Skip to content
Merged
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Between releases: see `git log` for merged work and [`ROADMAP.md`](ROADMAP.md) f

## [Unreleased]

### Changed (hook crash presentation in the Summary — #105)

Closes [#105](https://github.com/breferrari/shardmind/issues/105).

- **A crashed hook no longer buries the outcome.** Hooks are non-fatal (Helm semantics — the vault is already written when a hook runs, so a failing hook never rolls back), but the Summary dumped the hook's raw multi-line stderr verbatim, so a *successful* install with a *crashed* hook looked indistinguishable from an engine failure. The crash now renders as `"<hook> exited with code N. Non-fatal; your vault is ready."` with the "operation succeeded" detail on its own dim line, and the captured stdout/stderr render **dimmed + indented**, truncated to the first 5 lines (`HOOK_OUTPUT_VISIBLE_LINES`) with a `"… N more lines (full log at .shardmind/logs/<slot>.log)"` pointer.

- **Full output is persisted to a vault-local log.** When a hook crashes (non-zero exit / failure) or its output is long enough to truncate, the orchestrator writes the complete captured stdout/stderr to `.shardmind/logs/<slot>.log` (new `HOOK_LOGS_DIR`). The write is **non-fatal** — a failure (read-only vault, ENOSPC) just omits the on-screen pointer; it never breaks an install whose hook is already non-fatal by contract. Short, clean hooks write nothing. The log lives under `.shardmind/`, so it is excluded from Invariant 1 byte-equivalence.

- **Tests:** `headLines` unit matrix; orchestrator log-persistence cases (crash writes a log, long-clean writes a log, short-clean writes nothing, dry-run writes nothing, unwritable `logs/` is non-fatal); `HookSummarySection` truncation + pointer + crash-header rendering; L2 PTY scenario 27 asserts the truncated on-screen UI + the full marker in the on-disk log. Invariant 1 + the contract four-branch hook tests stay green. **Docs:** `docs/SHARD-LAYOUT.md`, `docs/ARCHITECTURE.md §9.3`, `docs/AUTHORING.md §6`.

### Fixed (update merge rendered copy-origin files — #132)

Closes [#132](https://github.com/breferrari/shardmind/issues/132); root cause **reported via [#129](https://github.com/breferrari/shardmind/issues/129)** (thanks!).
Expand Down
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,15 @@ UX gaps surfaced during real obsidian-mind v6 install + adopt runs. None block t
- [x] `multiselect` value type for module-set questions ([#101](https://github.com/breferrari/shardmind/issues/101)) — pairs with #100 (shortens module list). First-class value type + scrollable widget + per-option `default` + min/max; value→module gating stays #80.
- [x] Adopt: replace step-by-step install wizard with confirm-or-override values flow ([#104](https://github.com/breferrari/shardmind/issues/104))
- [x] Adopt batch operations (keep all mine / use all theirs / auto-merge non-conflicting) ([#120](https://github.com/breferrari/shardmind/issues/120)) — pairs with #104.
- [ ] Hook stderr presentation in Summary (truncate, dim, label as non-fatal) ([#105](https://github.com/breferrari/shardmind/issues/105))
- [x] Hook stderr presentation in Summary (truncate, dim, label as non-fatal) ([#105](https://github.com/breferrari/shardmind/issues/105))

### 0.1.x — Done gate

v0.1.x ships when:
- [ ] Foundation closed: [#111](https://github.com/breferrari/shardmind/issues/111) ✅, [#112](https://github.com/breferrari/shardmind/issues/112) ✅.
- [ ] Parallel closed: [#113](https://github.com/breferrari/shardmind/issues/113) (self-update notifier), [#119](https://github.com/breferrari/shardmind/issues/119) (release cadence policy).
- [x] Foundation closed: [#111](https://github.com/breferrari/shardmind/issues/111) ✅, [#112](https://github.com/breferrari/shardmind/issues/112) ✅.
- [x] Parallel closed: [#113](https://github.com/breferrari/shardmind/issues/113) (self-update notifier), [#119](https://github.com/breferrari/shardmind/issues/119) (release cadence policy).
- [ ] Hook lifecycle (#102) shipped with [#121](https://github.com/breferrari/shardmind/issues/121) (version-compatibility check) and obsidian-mind hook migration in the same release window.
- [ ] Flagship-UX closed: [#100](https://github.com/breferrari/shardmind/issues/100), [#101](https://github.com/breferrari/shardmind/issues/101), [#104](https://github.com/breferrari/shardmind/issues/104), [#105](https://github.com/breferrari/shardmind/issues/105), [#120](https://github.com/breferrari/shardmind/issues/120).
- [x] Flagship-UX closed: [#100](https://github.com/breferrari/shardmind/issues/100), [#101](https://github.com/breferrari/shardmind/issues/101), [#104](https://github.com/breferrari/shardmind/issues/104), [#105](https://github.com/breferrari/shardmind/issues/105), [#120](https://github.com/breferrari/shardmind/issues/120).
- [ ] Research-wiki shard ([#15](https://github.com/breferrari/shardmind/issues/15)) shipped with E2E tests + registry-mode end-to-end proof.
- [ ] v6 docs polish ([#85](https://github.com/breferrari/shardmind/issues/85)) closed.
- [ ] Smoke gate green against both shards (obsidian-mind + research-wiki).
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ export default async function(ctx: BootstrapContext): Promise<void>;

**Post-hook re-hash**: after the hook phase exits — success OR failure — the engine re-reads each managed file in `state.files` and recomputes `rendered_hash`, then writes the updated `state.json`. This ensures the engine's view of disk reflects actual content even when a hook only partially completed (the hook contract is non-fatal, so a hook that broke can't corrupt the engine). Per-file ENOENT and other I/O errors are tolerated; drift detection picks up any discrepancy on the next status run. Implementation: `source/core/state.ts::rehashManagedFiles`, called by the orchestrator after the hook subprocess returns.

**If a hook throws**: ShardMind logs the error, shows a warning ("<slot> hook exited with code N. Install succeeded; the hook's work may be incomplete."), does NOT rollback. Non-fatal. Same pattern as Helm hooks. The post-hook re-hash still runs, and subsequent independent slots still attempt.
**If a hook throws**: ShardMind shows a warning ("<slot> exited with code N. Non-fatal; your vault is ready." with "The operation succeeded; the hook's work may be incomplete." on its own dim line), does NOT rollback. Non-fatal. Same pattern as Helm hooks. The post-hook re-hash still runs, and subsequent independent slots still attempt. So a crashed hook's stack trace can't visually dominate the Summary, the captured stdout/stderr render dimmed + indented and **truncated to the first `HOOK_OUTPUT_VISIBLE_LINES` lines**; when a hook crashes or its output is long enough to truncate, the orchestrator persists the complete output to `.shardmind/logs/<slot>.log` and the on-screen block points there. The log write is itself non-fatal (a write failure just omits the pointer). See `source/components/HookSummarySection.tsx` and `attachHookLog` in `hook-orchestrator.ts` (#105).

**Legacy `post-install`**: a shard declaring the deprecated `hooks.post-install` (and neither new slot) runs it once on install/adopt with the legacy flat `HookContext` (including `valuesAreDefaults`, `newFiles: []`, `removedFiles: []` for source compatibility) and no boundary enforcement, plus a `HOOK_POST_INSTALL_DEPRECATED` warning. Declaring it alongside `bootstrap`/`personalize` is a parse-time `HOOK_SLOT_CONFLICT`. Honored ≥1 minor (deprecate 0.2.0, remove ≥0.3.0).

Expand Down
2 changes: 2 additions & 0 deletions docs/AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ The old single `post-install` hook bundled three jobs with different lifecycles
- **You no longer write `if (!ctx.valuesAreDefaults) …`.** The engine simply doesn't invoke `personalize` on a defaults install. `PersonalizeContext` has no `valuesAreDefaults` field — if your `personalize` hook runs, values are non-default by construction.
- **The write boundary is checked.** If `bootstrap` edits a managed file or `personalize` creates an unmanaged one, the engine surfaces a non-fatal warning naming the paths (it does not undo the write). This catches a mis-placed responsibility during your dev loop instead of silently breaking Invariant 1 in the field.

**If your hook crashes**, the install/update/adopt still succeeds (hooks are non-fatal). The Summary shows a dimmed, truncated head of the hook's output under a "Non-fatal; your vault is ready." warning, and writes the **full** captured stdout/stderr to `.shardmind/logs/<slot>.log` in the installed vault — point users there (or `cat` it yourself) to debug a failing hook.

### Per-slot context

- **`BootstrapContext`** → `{ slot: 'bootstrap', vaultRoot, values, modules, shard, previousVersion? }`. No `valuesAreDefaults`, no file lists.
Expand Down
6 changes: 4 additions & 2 deletions docs/SHARD-LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ my-vault/
│ ├── state.json ← ownership hashes + module/agent selections + version + resolved ref
│ ├── shard.yaml ← cached manifest (runtime reads without re-extracting the tarball)
│ ├── shard-schema.yaml ← cached values schema
│ └── templates/ ← cached source files; merge base for three-way merge on update
│ ├── templates/ ← cached source files; merge base for three-way merge on update
│ └── logs/ ← full output of a crashed or verbose hook (<slot>.log); written
│ on demand so the Summary can truncate on screen and point here
├── shard-values.yaml ← user's answers from the wizard; vault-root (not under .shardmind/);
│ named separately from .shardmind/ per VISION's
Expand All @@ -83,7 +85,7 @@ my-vault/
└── (no .github/, no CONTRIBUTING.md, no translations, no demo media)
```

The installed-side path constants are authoritative in [`source/runtime/vault-paths.ts`](../source/runtime/vault-paths.ts): `STATE_FILE`, `CACHED_MANIFEST`, `CACHED_SCHEMA`, `CACHED_TEMPLATES` all live under `.shardmind/`; `VALUES_FILE` lives at vault root.
The installed-side path constants are authoritative in [`source/runtime/vault-paths.ts`](../source/runtime/vault-paths.ts): `STATE_FILE`, `CACHED_MANIFEST`, `CACHED_SCHEMA`, `CACHED_TEMPLATES`, `HOOK_LOGS_DIR` all live under `.shardmind/`; `VALUES_FILE` lives at vault root. Everything under `.shardmind/` (including `logs/`) is engine metadata and is excluded from the Invariant 1 byte-equivalence comparison.

## Personalization model

Expand Down
57 changes: 41 additions & 16 deletions source/components/HookSummarySection.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ReactElement } from 'react';
import { Box, Text } from 'ink';
import { StatusMessage } from './ui.js';
import type { HookStage, HookSummary } from '../core/hook.js';
import { headLines, type HookStage, type HookSummary } from '../core/hook.js';
import type { HookOutcome } from '../core/hook-orchestrator.js';
import { assertNever } from '../runtime/types.js';

Expand Down Expand Up @@ -80,9 +80,12 @@ function renderOutcome(
{succeeded ? (
<Text color="green">{name} completed.</Text>
) : (
<StatusMessage variant="warning">
{name} exited with code {exitCode}. The operation succeeded; the hook's work may be incomplete.
</StatusMessage>
<Box flexDirection="column">
<StatusMessage variant="warning">
{name} exited with code {exitCode}. Non-fatal; your vault is ready.
</StatusMessage>
<Text dimColor>The operation succeeded; the hook's work may be incomplete.</Text>
</Box>
)}
{summary.deprecated && (
<StatusMessage variant="warning">
Expand All @@ -92,18 +95,40 @@ function renderOutcome(
{summary.violation && (
<StatusMessage variant="warning">{violationMessage(stage, summary.violation)}</StatusMessage>
)}
{stdout && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Hook stdout:</Text>
<Text>{stdout}</Text>
</Box>
)}
{stderr && (
<Box flexDirection="column" marginTop={1}>
<Text bold>Hook stderr:</Text>
<Text>{stderr}</Text>
</Box>
)}
{stdout && outputBlock('Hook stdout:', stdout, summary.logPath, 'stdout')}
{stderr && outputBlock('Hook stderr:', stderr, summary.logPath, 'stderr')}
</Box>
);
}

/**
* One captured-output block: a bold label, then the first
* `HOOK_OUTPUT_VISIBLE_LINES` lines dimmed + indented so the hook's output
* never reads as the primary outcome. When the output is longer, a dim
* "… N more lines" pointer follows — naming the full log on disk when one was
* written (`summary.logPath`). A long Node stack trace from a crashed hook
* thus collapses to a few dim lines plus a path, instead of dominating the
* Summary. See #105.
*/
function outputBlock(
label: string,
text: string,
logPath: string | undefined,
key: string,
): ReactElement {
const { head, hidden } = headLines(text);
return (
<Box key={key} flexDirection="column" marginTop={1}>
<Text bold>{label}</Text>
<Box flexDirection="column" marginLeft={2}>
<Text dimColor>{head}</Text>
{hidden > 0 && (
<Text dimColor>
… {hidden} more line{hidden === 1 ? '' : 's'}
{logPath ? ` (full log at ${logPath})` : ''}
</Text>
)}
</Box>
</Box>
);
}
Expand Down
61 changes: 60 additions & 1 deletion source/core/hook-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@
* Spec: docs/SHARD-LAYOUT.md §Hook lifecycle; docs/IMPLEMENTATION.md §4.16a.
*/

import fsp from 'node:fs/promises';
import path from 'node:path';
import type {
AnyHookContext,
ModuleSelections,
ShardManifest,
ShardSchema,
ShardState,
} from '../runtime/types.js';
import { HOOK_LOGS_DIR, hookLogRelPath } from '../runtime/vault-paths.js';
import { DEFAULT_HOOK_TIMEOUT_MS } from './manifest.js';
import {
runHook,
summarizeHook,
headLines,
type HookResult,
type HookStage,
type HookSummary,
Expand Down Expand Up @@ -208,7 +212,11 @@ export async function runHooks(plan: HookRunPlan, ui: HookRunUi): Promise<HookRu
violation = detectUnmanagedCreates(after, unmanagedBefore, state);
}

outcomes.push({ slot: job.slot, summary: summarize(result, { violation, deprecated: job.deprecated }) });
const summary = summarize(result, { violation, deprecated: job.deprecated });
// Persist the full output (and attach the pointer) when the hook crashed or
// its output is long enough that the Summary will truncate it.
const withLog = summary ? await attachHookLog(plan.vaultRoot, job.slot, summary) : summary;
outcomes.push({ slot: job.slot, summary: withLog });

// Persist the bootstrap fingerprint only after a SUCCESSFUL bootstrap
// (exit 0), so the next update compares against it (Invariant 4). A
Expand Down Expand Up @@ -349,6 +357,57 @@ function summarize(
return merged;
}

/**
* Persist a hook's full captured output to `.shardmind/logs/<slot>.log` when it
* is worth pointing at — the hook crashed (non-zero exit / failure) or either
* stream is long enough that the Summary will truncate it — and return the
* summary with `logPath` set. A short, clean hook writes nothing (no clutter,
* no perturbation of clean-path E2E) and is returned unchanged.
*
* Non-fatal: a log-write failure (read-only vault, ENOSPC) just omits the
* pointer; it must never break an install whose hook is already non-fatal by
* contract (ARCHITECTURE.md §9.3). The log lives under `.shardmind/`, so it is
* excluded from Invariant 1 byte-equivalence.
*/
export async function attachHookLog(
vaultRoot: string,
slot: HookStage,
summary: HookSummary,
): Promise<HookSummary> {
const stdout = summary.stdout ?? '';
const stderr = summary.stderr ?? '';
if (stdout === '' && stderr === '') return summary;

const crashed = (summary.exitCode ?? 0) !== 0;
const willTruncate =
headLines(stdout.trim()).hidden > 0 || headLines(stderr.trim()).hidden > 0;
if (!crashed && !willTruncate) return summary;

try {
await fsp.mkdir(path.join(vaultRoot, HOOK_LOGS_DIR), { recursive: true });
const relPath = hookLogRelPath(slot);
await fsp.writeFile(path.join(vaultRoot, relPath), formatHookLog(slot, summary), 'utf-8');
return { ...summary, logPath: relPath };
} catch {
return summary;
}
}

/** Readable full-output dump written to `.shardmind/logs/<slot>.log`. */
function formatHookLog(slot: HookStage, summary: HookSummary): string {
return [
`# ShardMind ${slot} hook — full captured output`,
`# exit code: ${summary.exitCode ?? 0}`,
'',
'=== stdout ===',
summary.stdout ?? '',
'',
'=== stderr ===',
summary.stderr ?? '',
'',
].join('\n');
}

function valuesAreDefaultsSafe(values: Record<string, unknown>, schema: ShardSchema): boolean {
try {
return valuesAreDefaults(values, schema);
Expand Down
39 changes: 39 additions & 0 deletions source/core/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,45 @@ export interface HookSummary {
stdout?: string;
stderr?: string;
exitCode?: number;
/**
* Vault-relative path of the full captured output on disk (e.g.
* `.shardmind/logs/bootstrap.log`), written by the orchestrator when a hook
* crashes or its output is long enough to truncate on screen. The Summary
* shows the first `HOOK_OUTPUT_VISIBLE_LINES` lines and points here for the
* rest. Absent when nothing was written (short clean hook, or the log write
* failed — non-fatal). See `hook-orchestrator.ts`.
*/
logPath?: string;
}

/**
* How many lines of a hook's stdout / stderr the Summary renders inline before
* truncating with a "… N more lines — full log at <path>" pointer. Shared by
* the orchestrator (which decides whether a log file is worth writing) and
* `HookSummarySection.tsx` (which does the on-screen truncation) so the two
* never disagree on the threshold.
*/
export const HOOK_OUTPUT_VISIBLE_LINES = 5;

/**
* Split `text` into the first `max` lines plus a count of how many were
* dropped. A single trailing newline is not counted as an extra empty line
* (so `"a\nb\n"` is two lines, not three). Pure — used both to decide whether
* a hook log is worth persisting and to render the truncated on-screen block.
*/
export function headLines(
text: string,
max: number = HOOK_OUTPUT_VISIBLE_LINES,
): { head: string; hidden: number } {
// Split on CRLF or LF — a Windows hook (Git for Windows, some Node scripts)
// emits CRLF, and `split('\n')` alone would leave a trailing `\r` on each
// line that renders as a visible control character in the Summary. The head
// is re-joined with LF (the canonical display ending), matching the rest of
// the engine.
const lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
if (lines.length <= max) return { head: lines.join('\n'), hidden: 0 };
return { head: lines.slice(0, max).join('\n'), hidden: lines.length - max };
}

/**
Expand Down
18 changes: 18 additions & 0 deletions source/runtime/vault-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ export const CACHED_MANIFEST = path.join(SHARDMIND_DIR, 'shard.yaml');
export const CACHED_SCHEMA = path.join(SHARDMIND_DIR, 'shard-schema.yaml');
export const CACHED_TEMPLATES = path.join(SHARDMIND_DIR, 'templates');

/**
* Where a crashed (or verbose) hook's full captured output is persisted so
* the Summary can truncate the on-screen block and point the user at the
* complete log. Under `.shardmind/`, so it is excluded from Invariant 1
* byte-equivalence ("modulo `.shardmind/` metadata"). See `hook-orchestrator.ts`.
*/
export const HOOK_LOGS_DIR = path.join(SHARDMIND_DIR, 'logs');

/**
* Vault-relative path of one slot's full hook log, e.g.
* `.shardmind/logs/bootstrap.log`. Returned with forward slashes — it doubles
* as a user-facing pointer in the Summary, and `path.join(vaultRoot, …)`
* resolves a posix-style relative path correctly on every platform.
*/
export function hookLogRelPath(slot: string): string {
return `${SHARDMIND_DIR}/logs/${slot}.log`;
}

/** User-authored values file. Engine creates it on install, never overwrites. */
export const VALUES_FILE = 'shard-values.yaml';

Expand Down
Loading
Loading