@mu-crew/mu 3.11.3 → 3.13.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/chunk-3BARPJLY.js +44 -0
- package/dist/{chunk-R2SVBZAB.js → chunk-45KQLMEL.js} +193 -93
- package/dist/chunk-45KQLMEL.js.map +1 -0
- package/dist/{chunk-3L5KO3PX.js → chunk-54WJCMJR.js} +12 -10
- package/dist/chunk-54WJCMJR.js.map +1 -0
- package/dist/{chunk-LEORAGKV.js → chunk-EPCEEDHG.js} +4 -3
- package/dist/{chunk-LEORAGKV.js.map → chunk-EPCEEDHG.js.map} +1 -1
- package/dist/{chunk-77GBZ2KR.js → chunk-QITLNZEM.js} +3 -1
- package/dist/{chunk-77GBZ2KR.js.map → chunk-QITLNZEM.js.map} +1 -1
- package/dist/{chunk-F7ZBF6I7.js → chunk-TXV34VWT.js} +8616 -6030
- package/dist/chunk-TXV34VWT.js.map +1 -0
- package/dist/{chunk-L2ABBTYG.js → chunk-WI6AWCMK.js} +3 -1
- package/dist/{chunk-L2ABBTYG.js.map → chunk-WI6AWCMK.js.map} +1 -1
- package/dist/{cli-GJIO7GWD.js → cli-GMJUOXPW.js} +3582 -107
- package/dist/cli-GMJUOXPW.js.map +1 -0
- package/dist/cli.js +4 -3
- package/dist/cli.js.map +1 -1
- package/dist/devtools-LS4ZEPWM.js +3772 -0
- package/dist/devtools-LS4ZEPWM.js.map +1 -0
- package/dist/extension/mu-pi.js +294 -78
- package/dist/extension/mu-pi.js.map +1 -1
- package/dist/index.d.ts +311 -228
- package/dist/index.js +12 -5
- package/dist/tui-WSDDTA4K.js +40956 -0
- package/dist/tui-WSDDTA4K.js.map +1 -0
- package/dist/tui-sync-worker.js +4 -3
- package/dist/tui-sync-worker.js.map +1 -1
- package/dist/yank-XDOONQSL.js +13 -0
- package/docs/ARCHITECTURE.md +4 -1
- package/docs/ROADMAP.md +43 -38
- package/docs/VISION.md +8 -0
- package/docs/VOCABULARY.md +3 -1
- package/docs/architecture/control-socket.md +1 -1
- package/docs/architecture/dag.md +4 -0
- package/docs/architecture/sync.md +8 -0
- package/docs/architecture/tui.md +11 -2
- package/docs/guide/delegate.md +11 -4
- package/docs/guide/recovery.md +4 -0
- package/docs/reference/env.md +2 -2
- package/docs/reference/naming.md +1 -1
- package/package.json +2 -1
- package/skills/mu/SKILL.md +12 -2
- package/skills/mu/recipes/adversarial-review.md +3 -0
- package/skills/mu/recipes/backlog-triage.md +2 -0
- package/skills/mu/recipes/findings.md +7 -0
- package/skills/mu/recipes/long-run.md +3 -1
- package/skills/mu/recipes/loop-until-done.md +3 -1
- package/skills/mu/recipes/orchestrator-loop.md +4 -0
- package/skills/mu/recipes/ultrathink.md +3 -1
- package/skills/mu/recipes/watcher.md +6 -1
- package/dist/chunk-3L5KO3PX.js.map +0 -1
- package/dist/chunk-F7ZBF6I7.js.map +0 -1
- package/dist/chunk-PZ5AY32C.js +0 -10
- package/dist/chunk-R2SVBZAB.js.map +0 -1
- package/dist/cli-GJIO7GWD.js.map +0 -1
- package/dist/tui-WHCLVCYE.js +0 -6452
- package/dist/tui-WHCLVCYE.js.map +0 -1
- package/dist/yank-T3AYQTVR.js +0 -12
- /package/dist/{chunk-PZ5AY32C.js.map → chunk-3BARPJLY.js.map} +0 -0
- /package/dist/{yank-T3AYQTVR.js.map → yank-XDOONQSL.js.map} +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire as __muCreateRequire } from "node:module"; const require = __muCreateRequire(import.meta.url);
|
|
1
2
|
import {
|
|
2
3
|
GLYPH,
|
|
3
4
|
UNKNOWN_REASON,
|
|
@@ -7,10 +8,11 @@ import {
|
|
|
7
8
|
pc,
|
|
8
9
|
renderBoxTable,
|
|
9
10
|
renderOp
|
|
10
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-TXV34VWT.js";
|
|
11
12
|
import {
|
|
13
|
+
compareStrings,
|
|
12
14
|
formatPair
|
|
13
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-45KQLMEL.js";
|
|
14
16
|
|
|
15
17
|
// src/cli/format.ts
|
|
16
18
|
var STATUS_COLORS = {
|
|
@@ -104,7 +106,7 @@ function formatAgentsTable(agents) {
|
|
|
104
106
|
glyphCell,
|
|
105
107
|
nameCell,
|
|
106
108
|
a.cli,
|
|
107
|
-
a.state === "unknown" && a.reason !== void 0 ? `${a.state}: ${a.reason}` : a.state,
|
|
109
|
+
(a.state === "unknown" || a.state === "needs_permission") && a.reason !== void 0 ? `${a.state}: ${a.reason}` : a.state,
|
|
108
110
|
a.tab ?? a.name,
|
|
109
111
|
a.role === "read-only" ? pc.yellow("read-only") : ""
|
|
110
112
|
]);
|
|
@@ -337,7 +339,7 @@ function byRoiDesc(a, b) {
|
|
|
337
339
|
const rb = roiOf(b);
|
|
338
340
|
if (rb !== ra) return rb - ra;
|
|
339
341
|
if (a.effortDays !== b.effortDays) return a.effortDays - b.effortDays;
|
|
340
|
-
return a.name
|
|
342
|
+
return compareStrings(a.name, b.name);
|
|
341
343
|
}
|
|
342
344
|
function sortTasks(tasks, key) {
|
|
343
345
|
const out = tasks.slice();
|
|
@@ -346,16 +348,16 @@ function sortTasks(tasks, key) {
|
|
|
346
348
|
return out.sort(byRoiDesc);
|
|
347
349
|
case "recency":
|
|
348
350
|
return out.sort((a, b) => {
|
|
349
|
-
const byUpdated = b.updatedAt
|
|
350
|
-
return byUpdated !== 0 ? byUpdated : a.name
|
|
351
|
+
const byUpdated = compareStrings(b.updatedAt, a.updatedAt);
|
|
352
|
+
return byUpdated !== 0 ? byUpdated : compareStrings(a.name, b.name);
|
|
351
353
|
});
|
|
352
354
|
case "age":
|
|
353
355
|
return out.sort((a, b) => {
|
|
354
|
-
const byCreated = a.createdAt
|
|
355
|
-
return byCreated !== 0 ? byCreated : a.name
|
|
356
|
+
const byCreated = compareStrings(a.createdAt, b.createdAt);
|
|
357
|
+
return byCreated !== 0 ? byCreated : compareStrings(a.name, b.name);
|
|
356
358
|
});
|
|
357
359
|
case "id":
|
|
358
|
-
return out.sort((a, b) => a.name
|
|
360
|
+
return out.sort((a, b) => compareStrings(a.name, b.name));
|
|
359
361
|
}
|
|
360
362
|
}
|
|
361
363
|
function relTimeBasisForSort(key) {
|
|
@@ -388,4 +390,4 @@ export {
|
|
|
388
390
|
sortTasks,
|
|
389
391
|
relTimeBasisForSort
|
|
390
392
|
};
|
|
391
|
-
//# sourceMappingURL=chunk-
|
|
393
|
+
//# sourceMappingURL=chunk-54WJCMJR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli/format.ts","../src/tasks/sort.ts"],"sourcesContent":["// mu — pure rendering helpers used by every cli/*.ts verb wrapper.\n//\n// Extracted from src/cli.ts (review_cli_ts_past_refactor_signal): the\n// table renderers, status colourers, truncators, and relative-time\n// formatter were the bulk of cli.ts's bloat (~600 LOC over the 800-LOC\n// refactor signal). Co-locating them here keeps cli.ts focused on\n// argument parsing, workstream resolution, and program wiring.\n//\n// All helpers are pure (no I/O beyond returning a string) except\n// `printLogRow` which writes a single line to stdout — kept here so\n// `mu log` and the `recent events` section of `mu state` share one\n// renderer. cli.ts re-exports every symbol for back-compat with the\n// existing import surface (tests + cli/* importers).\n\nimport { type RuntimeState, UNKNOWN_REASON } from \"../agent-state.js\";\nimport type { LiveAgent } from \"../agents.js\";\nimport { agentStateGlyph, GLYPH } from \"../glyphs.js\";\nimport { parseOpKey, renderOp } from \"../log-render.js\";\nimport type { LogRow } from \"../logs.js\";\nimport { muTable, pc, renderBoxTable } from \"../output.js\";\nimport { formatPair, type TaskPair } from \"../tasks/status.js\";\nimport type { TaskRow } from \"../tasks.js\";\nimport type { Track } from \"../tracks.js\";\nimport type { WorkspaceRow } from \"../workspace.js\";\nimport type { TornDownWorkstream, WorkstreamSummary } from \"../workstream.js\";\n\n// ─── Status colours / icons ────────────────────────────────────────────\n\n/** Per-status colour for the table view. The glyph itself comes from\n * agentStateGlyph in src/glyphs.ts — single source of truth so the\n * CLI and TUI status surfaces never drift\n * (review_code_status_emoji_two_sources caught a 2-of-7 disagreement). */\nconst STATUS_COLORS: Record<RuntimeState, (s: string) => string> = {\n busy: pc.cyan,\n needs_input: pc.dim,\n needs_permission: pc.magenta,\n unknown: pc.dim,\n};\n\nexport function statusIcon(state: RuntimeState): string {\n return STATUS_COLORS[state](agentStateGlyph(state));\n}\n\n/**\n * Glyph used to flag the derived 'idle but assigned' state\n * (`AgentRow.idle === true`; idle_assigned_agent_detection). Same\n * warning glyph the doctor card paints for a `warn` check — one\n * symbol, one meaning, from src/glyphs.ts.\n */\nexport const IDLE_GLYPH = GLYPH.warn;\n\n/** Colour a task's (status, substate) pair, rendered by formatPair:\n * bare \"OPEN\" for a default substate, \"CLOSED/wontfix\" otherwise.\n * OPEN cyan, IN_PROGRESS yellow, CLOSED/done green, any other CLOSED\n * red (not done), OPEN/parked dim (out of the ready set). */\nexport function colorPair(pair: TaskPair): string {\n const text = formatPair(pair);\n switch (pair.status) {\n case \"OPEN\":\n return pair.substate === \"parked\" || pair.substate === \"triage\"\n ? pc.dim(text)\n : pc.cyan(text);\n case \"IN_PROGRESS\":\n return pc.yellow(text);\n case \"CLOSED\":\n return pair.substate === \"done\" ? pc.green(text) : pc.red(text);\n }\n}\n\n/** Colour a bare status. Only for callers with no substate in hand\n * (e.g. the TUI status-filter toggles); task rows use colorPair. */\nexport function colorStatus(status: TaskRow[\"status\"]): string {\n switch (status) {\n case \"OPEN\":\n return pc.cyan(status);\n case \"IN_PROGRESS\":\n return pc.yellow(status);\n case \"CLOSED\":\n return pc.green(status);\n }\n}\n\n/** Ink colour equivalent of colorPair(). The TUI must not embed\n * picocolors ANSI strings inside <Text>; rows pass this value to Ink's\n * color prop instead. */\nexport type InkColor = \"cyan\" | \"yellow\" | \"green\" | \"gray\" | \"red\";\n\n/** Ink colour for a task's pair; same mapping as colorPair(). */\nexport function inkColorForPair(pair: TaskPair): InkColor {\n switch (pair.status) {\n case \"OPEN\":\n return pair.substate === \"parked\" || pair.substate === \"triage\" ? \"gray\" : \"cyan\";\n case \"IN_PROGRESS\":\n return \"yellow\";\n case \"CLOSED\":\n return pair.substate === \"done\" ? \"green\" : \"red\";\n }\n}\n\n// ─── Width / truncation helpers ───────────────────────────────────────\n\n/**\n * Default fallback when stdout isn't a TTY (e.g. output is piped to\n * less/jq) and `process.stdout.columns` is undefined. 100 fits an 80-col\n * terminal with some breathing room; 100 is wide enough to keep most\n * rows on one line.\n */\nconst DEFAULT_TERMINAL_WIDTH = 100;\n\nexport function terminalWidth(): number {\n return process.stdout.columns ?? DEFAULT_TERMINAL_WIDTH;\n}\n\n/** Truncate `s` to fit `max` columns (counting display width as length;\n * good enough for ASCII titles, undercount for emoji/CJK — acceptable\n * trade-off given the terminal will visually clip anyway). Adds an\n * ellipsis when truncated. */\nexport function truncate(s: string, max: number): string {\n if (max <= 1) return s.slice(0, max);\n if (s.length <= max) return s;\n return `${s.slice(0, max - 1)}…`;\n}\n\n/** Front-truncate `s` to fit `max` columns, prepending an ellipsis when\n * truncated. Used for paths where the trailing `<workstream>/<agent>`\n * suffix is the only useful bit (the leading `~/.local/state/mu/...`\n * prefix is identical for every row). Surfaced live by `mu workspace\n * list` blowing the terminal width on the `path` column\n * (tables_truncate_long_cols_audit). */\nexport function truncateFront(s: string, max: number): string {\n if (max <= 1) return s.slice(-max);\n if (s.length <= max) return s;\n return `…${s.slice(-(max - 1))}`;\n}\n\n/** Format an elapsed duration (in ms) as a compact relative string:\n * '12s', '3m', '1h', '2d', '3w'. Used by the task list table when\n * `--sort recency`/`--sort age` is active so the timeframe the user\n * is sorting by is visible. Mirrors the helper in src/cli/state.ts\n * (TUI renderers); kept in sync (the TUI version omits weeks because it shows tighter\n * windows, but for task list a 5w-old item is real and worth a tag).\n */\nexport function relTime(ms: number): string {\n const sec = Math.max(0, Math.floor(ms / 1000));\n if (sec < 60) return `${sec}s`;\n const min = Math.floor(sec / 60);\n if (min < 60) return `${min}m`;\n const hr = Math.floor(min / 60);\n if (hr < 24) return `${hr}h`;\n const day = Math.floor(hr / 24);\n if (day < 7) return `${day}d`;\n return `${Math.floor(day / 7)}w`;\n}\n\n/** Like {@link relTime} but with a trailing \" ago\" suffix. The TUI's\n * Recent card / popup wants past-tense formatting (\"3m ago\") while\n * the In-progress card / popup wants the bare token (\"3m\"); both\n * buckets share the rest of the arithmetic. Hoisted out of the TUI\n * cluster (review_unify_format_when_since) so the two formatters\n * can't drift the way they were starting to. */\nexport function relTimeAgo(ms: number): string {\n return `${relTime(ms)} ago`;\n}\n\n// ─── Table renderers ──────────────────────────────────────────────────\n\nexport function formatAgentsTable(agents: readonly LiveAgent[]): string {\n if (agents.length === 0) return pc.dim(\" (no agents)\");\n // Cap the variable-width columns so a long tmux window name (or a\n // future free-text role) can't push the table past the terminal.\n // Other columns are bounded by their schemas (name 32-char cap,\n // cli is one of pi/codex/claude, status is one of OPEN/...).\n const table = muTable({\n head: [\n pc.bold(\"\"),\n pc.bold(\"name\"),\n pc.bold(\"cli\"),\n pc.bold(\"state\"),\n pc.bold(\"window\"),\n pc.bold(\"role\"),\n ],\n colWidths: [null, null, null, null, 32, 14],\n style: { head: [] },\n });\n for (const a of agents) {\n // Idle (alive + assigned + no recent progress): supplement the\n // status glyph with the yellow shared warning glyph, and yellow the\n // agent name itself so the row is visually obvious. The status column\n // stays the truth ('needs_input'); GLYPH.warn is the supplement.\n const idle = a.idle === true;\n const glyphCell = idle\n ? `${pc.yellow(IDLE_GLYPH)} ${statusIcon(a.state)}`\n : statusIcon(a.state);\n const nameCell = idle ? pc.yellow(a.name) : a.name;\n table.push([\n glyphCell,\n nameCell,\n a.cli,\n (a.state === \"unknown\" || a.state === \"needs_permission\") && a.reason !== undefined\n ? `${a.state}: ${a.reason}`\n : a.state,\n a.tab ?? a.name,\n a.role === \"read-only\" ? pc.yellow(\"read-only\") : \"\",\n ]);\n }\n const needsMurmur = agents.some(\n (a) =>\n a.state === \"unknown\" &&\n (a.reason === UNKNOWN_REASON.murmurMissing || a.reason === UNKNOWN_REASON.extensionMissing),\n );\n return needsMurmur\n ? `${table.toString()}\\n${pc.dim(\"agent state needs murmur (see mu doctor)\")}`\n : table.toString();\n}\n\nexport function formatReadyTable(tasks: readonly TaskRow[]): string {\n if (tasks.length === 0) return pc.dim(\" (no ready tasks)\");\n // Sort by ROI descending.\n const sorted = [...tasks].sort((a, b) => b.impact / b.effortDays - a.impact / a.effortDays);\n // Same title-truncation treatment as formatTaskListTable so the\n // ready-task table doesn't blow out terminal width.\n let idW = \"name\".length;\n let impactW = \"impact\".length;\n let effortW = \"effort\".length;\n let roiW = \"ROI\".length;\n let ownerW = \"owner\".length;\n for (const t of sorted) {\n idW = Math.max(idW, t.name.length);\n impactW = Math.max(impactW, String(t.impact).length);\n effortW = Math.max(effortW, String(t.effortDays).length);\n const roi = (t.impact / t.effortDays).toFixed(1);\n roiW = Math.max(roiW, roi.length);\n ownerW = Math.max(ownerW, (t.ownerName ?? \"\").length);\n }\n const padding = 6 * 3 + 1; // 6 cols\n const titleBudget = Math.max(\n 20,\n terminalWidth() - (idW + impactW + effortW + roiW + ownerW) - padding,\n );\n\n // Title is pre-truncated above; use muTable so wordWrap:false acts\n // as a safety belt for any other cell we don't pre-trim.\n const table = muTable({\n head: [\n pc.bold(\"name\"),\n pc.bold(\"title\"),\n pc.bold(\"impact\"),\n pc.bold(\"effort\"),\n pc.bold(\"ROI\"),\n pc.bold(\"owner\"),\n ],\n style: { head: [] },\n });\n for (const t of sorted) {\n const roi = (t.impact / t.effortDays).toFixed(1);\n table.push([\n t.name,\n truncate(t.title, titleBudget),\n String(t.impact),\n String(t.effortDays),\n roi,\n t.ownerName ?? \"\",\n ]);\n }\n return table.toString();\n}\n\nexport function formatTracks(tracks: readonly Track[]): string {\n if (tracks.length === 0) return pc.dim(\" (no open tracks)\");\n const lines: string[] = [];\n tracks.forEach((track, i) => {\n const rootNames = track.roots.map((r) => r.name).join(\", \");\n const verb = track.roots.length > 1 ? \"merged\" : \"track\";\n const parked = track.parked ? ` ${pc.dim(\"(parked)\")}` : \"\";\n lines.push(\n ` Track ${i + 1}: ${pc.bold(rootNames)} ${pc.dim(`(${track.taskIds.size} tasks, ${track.readyCount} ready, ${verb})`)}${parked}`,\n );\n });\n return lines.join(\"\\n\");\n}\n\n/** Workspaces table renderer. Used by `mu workspace list` and by\n * `mu state`'s Workspaces section — exported so cli/workspace.ts\n * can reuse it. The `behind` column shows how many commits the row's\n * parent_ref is behind the project's default branch HEAD; color-coded\n * green ≤2, yellow 3–9, red ≥10. Renders \"—\" when the count couldn't\n * be computed (no main resolvable, none-backend, missing parent_ref)\n * or wasn't asked for (no decorateWithStaleness call). Surfaced by\n * bug_workspace_stale_parent_silent_drift. */\nexport function formatWorkspacesTable(rows: readonly WorkspaceRow[]): string {\n // The path column is the one that bit operators today: an absolute\n // ~/.local/state/mu/workspaces/<ws>/worker-foo path runs ~70 chars\n // and pushed the table to ~200 cols. Front-truncate so the useful\n // trailing `<ws>/<agent>` suffix survives, then cap the column with\n // colWidths as a safety belt (tables_truncate_long_cols_audit).\n const PATH_BUDGET = 40;\n const table = muTable({\n head: [\"agent\", \"workstream\", \"backend\", \"path\", \"parent_ref\", \"behind\", \"created\"].map((h) =>\n pc.bold(h),\n ),\n colWidths: [null, null, null, PATH_BUDGET, null, null, null],\n });\n for (const r of rows) {\n table.push([\n r.agentName,\n r.workstreamName,\n r.backend,\n truncateFront(r.path, PATH_BUDGET - 2),\n r.parentRef ? pc.dim(r.parentRef.slice(0, 12)) : pc.dim(\"—\"),\n formatBehind(r.commitsBehindMain),\n pc.dim(r.createdAt),\n ]);\n }\n return table.toString();\n}\n\n/** Color-code the commits-behind-main count. Green ≤2 (fresh), yellow\n * 3–9 (drifting), red ≥10 (stale). Undefined / null renders as a dim\n * em-dash so the column stays well-typed even when staleness wasn't\n * computed. */\nfunction formatBehind(n: number | null | undefined): string {\n if (n === undefined || n === null) return pc.dim(\"—\");\n if (n <= 2) return pc.green(String(n));\n if (n <= 9) return pc.yellow(String(n));\n return pc.red(String(n));\n}\n\n/** One op, human-formatted. Used by `mu log` (read + tail) and by the\n * `recent events` section of `mu state`.\n *\n * All prose comes from `renderOp` (src/log-render.ts) — the ONE\n * formatter. This function only adds colour and column layout, so the\n * terminal and the TUI can never drift into two phrasings of the same\n * op. No raw JSON reaches the operator here. */\nexport function printLogRow(row: LogRow): void {\n const time = row.createdAt.replace(\"T\", \" \").replace(/\\.\\d+Z$/, \"Z\");\n const rendered = renderOp(row);\n const scope = parseOpKey(row.workstreamName).workstream;\n const where = scope === undefined ? pc.dim(\"—\") : scope;\n if (rendered === null) {\n // No intent: operator prose from `mu log \"<text>\"` or a `--kind`\n // ledger. Show it verbatim — inventing a verb for it would be\n // wrong, it is already exactly what the operator typed.\n console.log(\n `${pc.dim(`#${row.seq}`)} ${pc.dim(time)} ${pc.bold(row.source)} ${pc.yellow(row.kind)} [${where}] ${row.payload}`,\n );\n return;\n }\n const detail = rendered.detail === \"\" ? \"\" : ` ${pc.dim(rendered.detail)}`;\n console.log(\n `${pc.dim(`#${row.seq}`)} ${pc.dim(time)} ${pc.bold(row.source)} ${pc.cyan(rendered.verb)} ${rendered.subject}${detail} ${pc.dim(`[${where}]`)}`,\n );\n}\n\n/**\n * Workstreams summary table renderer. Used by `mu workstream list`\n * and the bare-`mu` empty-machine discovery fallback. Both\n * render the same shape; the helper lives here so cli/workstream.ts\n * and cli.ts can both import it without a lateral cli/* dependency.\n */\nexport function formatWorkstreamsTable(rows: WorkstreamSummary[]): string {\n // Workstream names are user-chosen free-form text; everything else\n // is a small int / fixed-shape token. Cap the name column so a long\n // workstream name doesn't push the row counts off-screen.\n const table = muTable({\n head: [\"name\", \"mux\", \"agents\", \"tasks\", \"edges\", \"notes\"].map((h) => pc.bold(h)),\n colWidths: [40, null, null, null, null, null],\n });\n for (const r of rows) {\n table.push([\n r.name,\n r.muxAlive ? pc.green(\"alive\") : pc.dim(\"—\"),\n String(r.agentCount),\n String(r.taskCount),\n String(r.edgeCount),\n String(r.noteCount),\n ]);\n }\n return table.toString();\n}\n\n/**\n * Past teardowns, newest first.\n *\n * `group` leads after the name because it is the only ACTIONABLE\n * column — the whole point of the list is to feed `mu undo <group>` —\n * and it is shown short (8 chars), which is what `mu undo` accepts.\n */\nexport function formatTornDownWorkstreamsTable(rows: readonly TornDownWorkstream[]): string {\n const table = muTable({\n head: [\"name\", \"group\", \"torn down\", \"tasks\", \"edges\", \"notes\", \"\"].map((h) => pc.bold(h)),\n colWidths: [32, null, null, null, null, null, null],\n });\n for (const r of rows) {\n // relTimeAgo takes an ELAPSED duration, not a timestamp.\n const at = Date.parse(r.at);\n table.push([\n r.name,\n r.group.slice(0, 8),\n Number.isNaN(at) ? pc.dim(\"—\") : relTimeAgo(Date.now() - at),\n String(r.tasks),\n String(r.edges),\n String(r.notes),\n r.recreated ? pc.dim(\"← recreated since\") : \"\",\n ]);\n }\n return table.toString();\n}\n\nexport function formatTaskListTable(\n tasks: readonly TaskRow[],\n opts: { withWorkstream?: boolean; relTimeBasis?: \"updatedAt\" | \"createdAt\" } = {},\n): string {\n if (tasks.length === 0) return pc.dim(\" (no tasks)\");\n // The optional relative-time column is appended at the end; header\n // mirrors the timestamp basis (\"updated\" / \"created\") so a glance\n // says which sort key is live.\n const timeHeader =\n opts.relTimeBasis === \"updatedAt\"\n ? \"updated\"\n : opts.relTimeBasis === \"createdAt\"\n ? \"created\"\n : null;\n const baseHead = opts.withWorkstream\n ? [\"name\", \"workstream\", \"status\", \"title\", \"impact\", \"effort\", \"ROI\", \"owner\"]\n : [\"name\", \"status\", \"title\", \"impact\", \"effort\", \"ROI\", \"owner\"];\n const head = timeHeader === null ? baseHead : [...baseHead, timeHeader];\n\n // Pre-compute the relative-time strings (relative to NOW) so column\n // width and row text agree.\n const now = Date.now();\n const timeCells: string[] = [];\n if (opts.relTimeBasis !== undefined) {\n const basis = opts.relTimeBasis;\n for (const t of tasks) {\n const ts = basis === \"updatedAt\" ? t.updatedAt : t.createdAt;\n const ms = now - new Date(ts).getTime();\n timeCells.push(relTime(ms));\n }\n }\n\n // Compute a budget for the title column so the table fits the terminal.\n // Other columns are mostly short fixed-shape values; figure out how\n // wide they actually are, sum them up, and give title the leftover.\n const otherCols = opts.withWorkstream\n ? ([\"localId\", \"workstream\", \"status\", \"impact\", \"effortDays\", \"roi\", \"owner\"] as const)\n : ([\"localId\", \"status\", \"impact\", \"effortDays\", \"roi\", \"owner\"] as const);\n const widths = new Map<string, number>();\n for (const col of otherCols) widths.set(col, col.length); // header is the floor\n for (const t of tasks) {\n widths.set(\"localId\", Math.max(widths.get(\"localId\") ?? 0, t.name.length));\n if (opts.withWorkstream) {\n widths.set(\"workstream\", Math.max(widths.get(\"workstream\") ?? 0, t.workstreamName.length));\n }\n widths.set(\"status\", Math.max(widths.get(\"status\") ?? 0, formatPair(t).length));\n widths.set(\"impact\", Math.max(widths.get(\"impact\") ?? 0, String(t.impact).length));\n widths.set(\"effortDays\", Math.max(widths.get(\"effortDays\") ?? 0, String(t.effortDays).length));\n const roi = t.effortDays > 0 ? (t.impact / t.effortDays).toFixed(1) : \"∞\";\n widths.set(\"roi\", Math.max(widths.get(\"roi\") ?? 0, roi.length));\n widths.set(\"owner\", Math.max(widths.get(\"owner\") ?? 0, (t.ownerName ?? \"—\").length));\n }\n let timeWidth = 0;\n if (timeHeader !== null) {\n timeWidth = timeHeader.length;\n for (const cell of timeCells) timeWidth = Math.max(timeWidth, cell.length);\n }\n // cli-table3 adds 2 chars of padding per cell + 1 char border per\n // column. Account for that to find the title budget.\n const numCols = head.length;\n const otherTotal = otherCols.reduce((acc, c) => acc + (widths.get(c) ?? 0), 0) + timeWidth;\n const padding = numCols * 3 + 1;\n const titleBudget = Math.max(20, terminalWidth() - otherTotal - padding);\n\n // Title is pre-truncated to titleBudget above. renderBoxTable, not\n // cli-table3: its layout pass is quadratic in rows\n // (f_tasklist_table_render).\n const rows: string[][] = [];\n for (let i = 0; i < tasks.length; i++) {\n const t = tasks[i];\n if (!t) continue; // noUncheckedIndexedAccess\n const roi = t.effortDays > 0 ? (t.impact / t.effortDays).toFixed(1) : \"∞\";\n const title = truncate(t.title, titleBudget);\n const baseRow = opts.withWorkstream\n ? [\n t.name,\n t.workstreamName,\n colorPair(t),\n title,\n String(t.impact),\n String(t.effortDays),\n roi,\n t.ownerName ?? pc.dim(\"—\"),\n ]\n : [\n t.name,\n colorPair(t),\n title,\n String(t.impact),\n String(t.effortDays),\n roi,\n t.ownerName ?? pc.dim(\"—\"),\n ];\n const row = timeHeader === null ? baseRow : [...baseRow, pc.dim(timeCells[i] ?? \"\")];\n rows.push(row);\n }\n return renderBoxTable(\n head.map((h) => pc.bold(h)),\n rows,\n );\n}\n","// Shared task sort keys and comparators.\n//\n// Used by the CLI task list/next verbs and by the TUI all-tasks popup.\n// Keep this below the CLI layer so TUI files never import from src/cli.ts\n// (that bundle cycle previously made `node dist/cli.js --help` exit\n// silently).\n\nimport { compareStrings } from \"../compare.js\";\nimport type { TaskRow } from \"./core.js\";\n\nexport const TASK_SORT_KEYS = [\"roi\", \"recency\", \"age\", \"id\"] as const;\nexport type TaskSortKey = (typeof TASK_SORT_KEYS)[number];\n\nexport function isTaskSortKey(s: string): s is TaskSortKey {\n return (TASK_SORT_KEYS as readonly string[]).includes(s);\n}\n\nfunction roiOf(t: TaskRow): number {\n return t.effortDays > 0 ? t.impact / t.effortDays : Number.POSITIVE_INFINITY;\n}\n\nexport function byRoiDesc(a: TaskRow, b: TaskRow): number {\n const ra = roiOf(a);\n const rb = roiOf(b);\n if (rb !== ra) return rb - ra;\n if (a.effortDays !== b.effortDays) return a.effortDays - b.effortDays;\n return compareStrings(a.name, b.name);\n}\n\n/** Sort a copy of `tasks` by `key`. Pure (does not mutate input). */\nexport function sortTasks(tasks: readonly TaskRow[], key: TaskSortKey): TaskRow[] {\n const out = tasks.slice();\n switch (key) {\n case \"roi\":\n return out.sort(byRoiDesc);\n case \"recency\":\n // updated_at DESC: most-recently-touched first.\n return out.sort((a, b) => {\n const byUpdated = compareStrings(b.updatedAt, a.updatedAt);\n return byUpdated !== 0 ? byUpdated : compareStrings(a.name, b.name);\n });\n case \"age\":\n // created_at ASC: oldest first (\"what's gone stale\").\n return out.sort((a, b) => {\n const byCreated = compareStrings(a.createdAt, b.createdAt);\n return byCreated !== 0 ? byCreated : compareStrings(a.name, b.name);\n });\n case \"id\":\n return out.sort((a, b) => compareStrings(a.name, b.name));\n }\n}\n\n/** Which timestamp basis the table's relative-time column should use\n * for the active sort, or `null` if no time column should be shown. */\nexport function relTimeBasisForSort(key: TaskSortKey): \"updatedAt\" | \"createdAt\" | null {\n if (key === \"recency\") return \"updatedAt\";\n if (key === \"age\") return \"createdAt\";\n return null;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { createRequire as __muCreateRequire } from "node:module"; const require = __muCreateRequire(import.meta.url);
|
|
1
2
|
import {
|
|
2
3
|
groupIdFromPrefix
|
|
3
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-TXV34VWT.js";
|
|
4
5
|
import {
|
|
5
6
|
LEGACY_LOG_ONLY_SQL_EXCLUSION,
|
|
6
7
|
localMachineId,
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
resolvePair,
|
|
10
11
|
resolveWorkstreamId,
|
|
11
12
|
withOpContext
|
|
12
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-45KQLMEL.js";
|
|
13
14
|
|
|
14
15
|
// src/undo.ts
|
|
15
16
|
var UndoGroupNotFoundError = class extends Error {
|
|
@@ -637,4 +638,4 @@ export {
|
|
|
637
638
|
planUndo,
|
|
638
639
|
undoGroup
|
|
639
640
|
};
|
|
640
|
-
//# sourceMappingURL=chunk-
|
|
641
|
+
//# sourceMappingURL=chunk-EPCEEDHG.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/undo.ts"],"sourcesContent":["// mu — undo: emit INVERSE OPS for one group.\n//\n// THE CHANGE IN SEMANTICS FROM THE OLD UNDO\n// ------------------------------\n// The old `mu undo --yes` swapped the whole DB file back to a snapshot. That\n// reverts your OTHER workstreams too — on the real dogfood DB, 812 tasks\n// across three workstreams — so it was a blunt instrument you hesitated\n// to reach for. It also did not sync, so a peer kept the mistake.\n//\n// `mu undo` emits inverse ops for one `group_id`:\n//\n// * GRANULAR — touches only the rows that action touched.\n// * COMPOSABLE — it is itself an ordinary op, in its own group, so it\n// syncs to peers and is ITSELF UNDOABLE. There is no\n// separate redo mechanism and no asymmetry between undo\n// and redo: undoing an undo is just undoing a group.\n// * NO FILES — no snapshots, no swap, nothing to prune.\n//\n// HOW AN INVERSE IS COMPUTED\n// --------------------------\n// Per op in the group, oldest-first:\n//\n// put that CREATED the row -> inverse is `del`\n// put that CHANGED fields -> inverse is a put restoring the PRIOR\n// value of exactly those fields\n// del -> inverse is a put restoring the row\n//\n// A field the group wrote more than once is restored once, from the value\n// before its FIRST write in the group: later writes' \"prior\" values are\n// the group's own intermediate states.\n//\n// \"Did this put create the row\" is answered by provenance, not by a flag:\n// a put created the row iff no op for that key precedes it. \"What was\n// this field's prior value\" is the newest op before this one that named\n// that field — the same shape of query src/apply.ts uses for per-field\n// LWW (its private `fieldHlc`), pointed backwards. `priorFieldValue`\n// below is the one place undo answers \"what was this field before\";\n// keep its predicates (json_type, the legacy-intent exclusion) in step\n// with apply's, or undo will quietly restore wrong values.\n//\n// WHY INVERSES GO THROUGH THE NORMAL WRITE PATH\n// ---------------------------------------------\n// This module MUTATES THE TABLES inside a `withOpContext` scope and lets\n// the capture triggers record the result. It does NOT hand-write rows\n// into `ops`, and it does NOT use applyOp (which is capture-SUPPRESSED,\n// being the ingest path). Consequences, all of them the point:\n//\n// * The inverse gets a fresh HLC from the same clock as any other edit,\n// so it is newest and therefore wins per-field LWW.\n// * It is captured like any other change, so it appears in `mu log`,\n// flushes to this machine's segment, and is visible to the drift\n// check. `mu doctor --deep` stays clean after an undo, which is the\n// strongest end-to-end proof available that undo did not corrupt the\n// projection.\n// * It lands in its own group, which is what makes undoing an undo work\n// with no extra machinery.\n//\n// THE SUPERSESSION DECISION\n// -------------------------\n// Undoing a group whose rows were changed AGAIN by a later group is the\n// hard case. Because the inverse gets a fresh (newest) HLC, it would WIN\n// per-field LWW and silently clobber that newer work. Three options:\n//\n// (a) clobber silently — destroys work the operator did after\n// the mistake. Unacceptable.\n// (b) skip superseded fields — \"undo\" that partially does nothing,\n// silently. Equally unacceptable: the\n// operator believes the action is undone.\n// (c) REFUSE, name the conflict, require an explicit flag.\n//\n// (c) is implemented. `planUndo` detects, per field, whether a LATER op\n// (from a different group) has written that field since, and reports it\n// as a `supersededBy` conflict. `updated_at` is the exception: almost\n// every write touches it (a `task note` touches its task), so a later\n// write to it is not a conflict; the undo just leaves the newer\n// timestamp. `undoGroup` refuses unless\n// `opts.force === true`. So the default is safe and loud, and the\n// override exists and says what it will destroy. Fail safe, never fail\n// silent — the same rule capture follows.\n//\n// FK ORDERING\n// -----------\n// A group can span entities: a cascade close writes N task ops, and a\n// workstream teardown writes tombstones for the whole tree. Restoring a\n// task before its workstream violates the FK, so inverses are applied in\n// DEPENDENCY ORDER (workstream -> task -> note/edge), not merely in\n// reverse emission order. See ENTITY_RESTORE_ORDER.\n\nimport { repairTaskPair } from \"./apply.js\";\nimport { type Db, resolveWorkstreamId } from \"./db.js\";\nimport { LEGACY_LOG_ONLY_SQL_EXCLUSION } from \"./legacy-ops.js\";\nimport { groupIdFromPrefix } from \"./logs.js\";\nimport { withOpContext } from \"./op-context.js\";\nimport type { HasNextSteps, NextStep } from \"./output.js\";\nimport { localMachineId } from \"./segments.js\";\nimport { mapLegacyStatus, resolvePair } from \"./tasks/status.js\";\n\n/** One op as stored, with the provenance we need to invert it. */\ninterface GroupOpRow {\n seq: number;\n hlc: string;\n group_id: string;\n machine_id: string;\n intent: string | null;\n actor: string | null;\n entity: string;\n key: string;\n op: string;\n payload: string;\n /** Ops for this key with a strictly older HLC. */\n prior: number;\n /** Kind of the NEWEST op preceding this one for the same key, or null\n * when there is none. 'del' means this put RESURRECTED the row, which\n * is a creation for undo purposes even though prior > 0. */\n priorKind: string | null;\n}\n\n/** What undoing one op will do. */\nexport interface InverseOp {\n /** Entity of the row being restored/removed. */\n entity: string;\n /** Natural key of that row. */\n key: string;\n /** The inverse action. */\n op: \"put\" | \"del\";\n /** Field -> prior value, for a `put`. Empty for a `del`. */\n fields: Record<string, string | number | null>;\n /** Human summary, for the dry-run listing. */\n summary: string;\n /** Fields that a LATER group has written since, with the group that\n * did. Non-empty means applying this inverse would clobber newer\n * work. */\n supersededBy: Array<{ field: string; groupId: string; intent: string | null }>;\n}\n\nexport interface UndoPlan {\n /** The group being undone. */\n groupId: string;\n /** The intent(s) of the ops in the group — what the operator did. */\n intents: readonly string[];\n /** When the group was written (ISO, from the oldest op). */\n when: string;\n /** Inverse ops, in the order they will be applied (FK-safe). */\n inverses: readonly InverseOp[];\n /** True iff any inverse would clobber a newer edit. */\n superseded: boolean;\n /** Ops in the group that need no inverse (already reverted, or a\n * no-op), for an honest count. */\n skipped: number;\n}\n\nexport interface UndoResult {\n plan: UndoPlan;\n /** Group id of the ops the UNDO itself wrote — pass this to\n * `mu undo` to redo. Null when no inverse changed a row: the undo\n * wrote no ops, so there is no group to redo. */\n undoGroupId: string | null;\n /** Inverse ops that actually changed a row. */\n applied: number;\n}\n\n/** Raised when the requested group does not exist. */\nexport class UndoGroupNotFoundError extends Error implements HasNextSteps {\n constructor(readonly groupId: string) {\n super(`no ops found for group ${JSON.stringify(groupId)}`);\n this.name = \"UndoGroupNotFoundError\";\n }\n\n errorNextSteps(): NextStep[] {\n return [\n { intent: \"List recent undoable groups\", command: \"mu undo\" },\n { intent: \"Inspect a group's ops\", command: \"mu log --group <id>\" },\n ];\n }\n}\n\n/** Raised when there is nothing to undo at all. */\nexport class NothingToUndoError extends Error implements HasNextSteps {\n constructor() {\n super(\"the ops log is empty; nothing to undo\");\n this.name = \"NothingToUndoError\";\n }\n\n errorNextSteps(): NextStep[] {\n return [{ intent: \"Make a change first\", command: \"mu task add <id> -t <title>\" }];\n }\n}\n\n/** Raised when the group's rows were changed by a later group, so\n * undoing would clobber that newer work. */\nexport class UndoSupersededError extends Error implements HasNextSteps {\n constructor(\n readonly groupId: string,\n readonly conflicts: readonly { key: string; field: string; groupId: string }[],\n ) {\n const shown = conflicts\n .slice(0, 5)\n .map((c) => `${c.key}.${c.field} (changed by ${c.groupId.slice(0, 8)})`)\n .join(\", \");\n super(\n `group ${groupId.slice(0, 8)} has been superseded: ${conflicts.length} field(s) were changed ` +\n `by a later action — ${shown}. Undoing would discard that newer work.`,\n );\n this.name = \"UndoSupersededError\";\n }\n\n errorNextSteps(): NextStep[] {\n return [\n {\n intent: \"Inspect what changed since\",\n command: `mu log --group ${this.groupId.slice(0, 8)}`,\n },\n {\n intent: \"Undo anyway, discarding the newer edits\",\n command: `mu undo ${this.groupId.slice(0, 8)} --yes --force`,\n },\n ];\n }\n}\n\n// ─── entity ordering ──────────────────────────────────────────────────\n\n/**\n * Dependency order for RESTORING rows. A workstream must exist before its\n * tasks, and a task before its notes and edges, or the FK rejects the\n * insert.\n *\n * Not simply \"reverse of emission order\": a teardown emits the workstream\n * tombstone FIRST (it is the row the operator deleted; the rest are FK\n * cascade victims), so reversing would try to restore notes before their\n * task. Sorting by entity depth is correct regardless of how the group\n * happened to be emitted.\n */\nconst ENTITY_RESTORE_ORDER: Record<string, number> = {\n workstream: 0,\n task: 1,\n note: 2,\n edge: 2,\n};\n\n/** Entity depth for ordering inverses. Restores sort by ascending rank\n * (parents first); deletes sort by descending rank, the mirror image\n * (children first, so the workstream goes last). */\nfunction restoreRank(entity: string): number {\n return ENTITY_RESTORE_ORDER[entity] ?? 3;\n}\n\n// ─── provenance ───────────────────────────────────────────────────────\n\n/**\n * The value `field` held for `key` immediately BEFORE `hlc`.\n *\n * This is the provenance query src/apply.ts uses for per-field LWW,\n * pointed backwards: the newest op strictly older than `hlc` that NAMED\n * this field. `json_type(...) IS NOT NULL` rather than\n * `json_extract(...) IS NOT NULL` for the same reason apply does it —\n * json_extract returns SQL NULL both for an absent key and for a\n * present-but-null one, so a set-to-NULL would look absent and we would\n * restore the wrong (older) value.\n *\n * Returns `{ found: false }` when no earlier op named the field, which\n * means the field had no value before this op — so there is nothing to\n * restore and the op must have been part of the row's creation.\n *\n * `machineId` limits the lookup to one machine's ops. Undo passes it for\n * `owner_id`, whose live value only this machine's ops ever wrote (apply\n * strips it from peers' ops).\n */\nexport function priorFieldValue(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n field: string,\n machineId?: string,\n): { found: true; value: string | number | null } | { found: false } {\n const row = db\n .prepare(\n `SELECT json_extract(payload, '$.' || @field) AS value\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'put'\n AND hlc < @hlc\n AND (@machineId IS NULL OR machine_id = @machineId)\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n AND json_type(payload, '$.' || @field) IS NOT NULL\n ORDER BY hlc DESC\n LIMIT 1`,\n )\n .get({ entity, key, hlc, field, machineId: machineId ?? null }) as\n | { value: string | number | null }\n | undefined;\n if (row === undefined) return { found: false };\n return { found: true, value: row.value };\n}\n\n/** Groups that wrote `field` of `key` AFTER `hlc`. Non-empty means the\n * field has been superseded since the group we are undoing. `machineId`\n * as in `priorFieldValue`. */\nfunction laterWriters(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n field: string,\n excludeGroup: string,\n machineId?: string,\n): Array<{ groupId: string; intent: string | null }> {\n return db\n .prepare(\n `SELECT DISTINCT group_id AS groupId, intent\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'put'\n AND hlc > @hlc\n AND group_id <> @excludeGroup\n AND (@machineId IS NULL OR machine_id = @machineId)\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n AND json_type(payload, '$.' || @field) IS NOT NULL`,\n )\n .all({ entity, key, hlc, field, excludeGroup, machineId: machineId ?? null }) as Array<{\n groupId: string;\n intent: string | null;\n }>;\n}\n\n/** Groups that wrote ANY field of `key` AFTER `hlc`, regardless of which\n * field. Used for the two whole-row inverses (restore-from-tombstone,\n * delete-the-row-this-group-created) where the question is \"did a later\n * group touch this key at all\", not \"did it set one specific field\". */\nfunction laterRowWriters(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n excludeGroup: string,\n): Array<{ groupId: string; intent: string | null }> {\n return db\n .prepare(\n `SELECT DISTINCT group_id AS groupId, intent\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'put'\n AND hlc > @hlc\n AND group_id <> @excludeGroup\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}`,\n )\n .all({ entity, key, hlc, excludeGroup }) as Array<{\n groupId: string;\n intent: string | null;\n }>;\n}\n\n/** True iff the newest op for `key` is a tombstone written by a group\n * other than `groupId`, i.e. the row is gone and not by this group's\n * own hand. */\nfunction isDeletedByLaterGroup(db: Db, entity: string, key: string, groupId: string): boolean {\n const row = db\n .prepare(\n `SELECT op, group_id AS groupId FROM ops\n WHERE entity = @entity AND key = @key\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc DESC LIMIT 1`,\n )\n .get({ entity, key }) as { op: string; groupId: string } | undefined;\n return row?.op === \"del\" && row.groupId !== groupId;\n}\n\n/** True iff a later group DELETED this key after `hlc`. Undoing a change\n * to a row that has since been deleted would resurrect it. */\nfunction laterDeleters(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n excludeGroup: string,\n): Array<{ groupId: string; intent: string | null }> {\n return db\n .prepare(\n `SELECT DISTINCT group_id AS groupId, intent\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'del'\n AND hlc > @hlc\n AND group_id <> @excludeGroup`,\n )\n .all({ entity, key, hlc, excludeGroup }) as Array<{ groupId: string; intent: string | null }>;\n}\n\n// ─── group discovery ──────────────────────────────────────────────────\n\nexport interface GroupSummary {\n groupId: string;\n /** Distinct intents in the group, in first-seen order. */\n intents: readonly string[];\n actor: string | null;\n /** Ops in the group. */\n ops: number;\n /** ISO timestamp of the group's oldest op. */\n when: string;\n /** Newest HLC in the group, for ordering. */\n hlc: string;\n}\n\n/**\n * Recent groups, newest first. This is how group ids become DISCOVERABLE:\n * `mu undo` with no argument lists these, so the operator never has to\n * know a uuid to use the verb.\n *\n * Only groups that touched a portable table are listed, because those are\n * the only ones with anything to invert.\n */\nexport function listRecentGroups(db: Db, limit = 10): GroupSummary[] {\n const rows = db\n .prepare(\n `SELECT group_id AS groupId,\n MAX(hlc) AS hlc,\n MIN(created_at) AS when_,\n COUNT(*) AS ops,\n MAX(actor) AS actor\n FROM ops\n WHERE entity IN ('workstream','task','note','edge')\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n GROUP BY group_id\n ORDER BY MAX(hlc) DESC\n LIMIT @limit`,\n )\n .all({ limit }) as Array<{\n groupId: string;\n hlc: string;\n when_: string;\n ops: number;\n actor: string | null;\n }>;\n\n return rows.map((row) => {\n const intents = (\n db\n .prepare(\n `SELECT DISTINCT intent FROM ops\n WHERE group_id = @groupId AND intent IS NOT NULL\n ORDER BY seq`,\n )\n .all({ groupId: row.groupId }) as { intent: string }[]\n ).map((r) => r.intent);\n return {\n groupId: row.groupId,\n intents,\n actor: row.actor,\n ops: row.ops,\n when: row.when_,\n hlc: row.hlc,\n };\n });\n}\n\n/** The most recent undoable group, or null when there is none. */\nexport function mostRecentGroup(db: Db): GroupSummary | null {\n return listRecentGroups(db, 1)[0] ?? null;\n}\n\n/**\n * Resolve a possibly-abbreviated group id to a full one, or raise.\n *\n * Delegates to `groupIdFromPrefix` (src/logs.ts) so `mu undo` and\n * `mu log --group` accept EXACTLY the same identifiers. They used to\n * disagree: undo resolved prefixes, `mu log --group` compared the column\n * literally and silently returned nothing\n * (bug_group_id_prefix_asymmetry). One rule, two verbs.\n *\n * Ambiguity surfaces as `GroupIdAmbiguousError` (exit 4, a conflict the\n * operator resolves) rather than being folded into not-found.\n */\nexport function resolveGroupId(db: Db, prefix: string): string {\n const resolved = groupIdFromPrefix(db, prefix);\n if (resolved === null) throw new UndoGroupNotFoundError(prefix);\n return resolved;\n}\n\n// ─── planning ─────────────────────────────────────────────────────────\n\nconst CAPTURED_TABLE_FOR: Record<string, string> = {\n workstream: \"workstreams\",\n task: \"tasks\",\n note: \"task_notes\",\n edge: \"task_edges\",\n};\n\n/** Fields never restored by an undo: surrogate ids and the columns\n * encoded in the natural key (`local_id` / `name`). */\nconst NEVER_RESTORE = new Set([\"id\", \"local_id\", \"name\", \"workstream_id\", \"task_id\"]);\n\n/** `tasks.owner_id` is an FK into machine-local `agents`. Undoing a\n * claim or release restores it with the status/substate pair it was\n * written with, but only from THIS machine's ops (a peer's value names\n * a peer's agent) and only when that agent still exists. No local op\n * before it means the row came from a peer and the owner was NULL. A whole-row\n * restore from a tombstone leaves it NULL, as apply does. */\nconst OWNER_FIELD = \"owner_id\";\n\n/** Excluded from whole-row reconstruction (tombstone restores). */\nconst NEVER_RECONSTRUCT = new Set([...NEVER_RESTORE, OWNER_FIELD]);\n\n/** Bookkeeping timestamp written by nearly every task change, including\n * the parent touch of `mu task note`. A later write to it alone is not\n * a supersession: undo then leaves the newer timestamp in place. */\nconst TOUCH_FIELD = \"updated_at\";\n\n/**\n * Compute what undoing `groupId` would do, WITHOUT doing it.\n *\n * Pure with respect to the DB: reads only. `mu undo <group>` calls this\n * for its dry run and `undoGroup` calls it again before applying, so the\n * preview and the action can never diverge.\n */\nexport function planUndo(db: Db, groupId: string): UndoPlan {\n const rows = db\n .prepare(\n `SELECT o.seq, o.hlc, o.group_id, o.machine_id, o.intent, o.actor, o.entity, o.key, o.op, o.payload,\n (SELECT COUNT(*) FROM ops p\n WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}) AS prior,\n (SELECT p.op FROM ops p\n WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY p.hlc DESC LIMIT 1) AS priorKind\n FROM ops o\n WHERE o.group_id = @groupId\n AND o.entity IN ('workstream','task','note','edge')\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY o.hlc`,\n )\n .all({ groupId }) as GroupOpRow[];\n\n if (rows.length === 0) throw new UndoGroupNotFoundError(groupId);\n\n const inverses: InverseOp[] = [];\n let skipped = 0;\n /** Task keys already handled by the legacy-note recovery below, which\n * works per TASK rather than per tombstone (see there). */\n const seenLegacyNoteTasks = new Set<string>();\n /** Per row key, the fields an EARLIER op of this group already\n * restores. A group can write one field twice (`task.park` writes\n * updated_at in two statements); only the first write's prior value\n * is the pre-group state, so later writes add no inverse for it. */\n const restoredFields = new Map<string, Set<string>>();\n let machine: string | undefined;\n const localMachine = (): string => {\n machine ??= localMachineId(db);\n return machine;\n };\n\n for (const row of rows) {\n if (row.op === \"del\") {\n // Inverse of a delete: restore the row from the state the log says\n // it had. Reconstruct it by replaying every put for the key that\n // preceded the tombstone, so a partial-update history still yields\n // a complete row.\n //\n // When that fold comes back empty, fall back to the tombstone's\n // OWN payload. Note tombstones are self-describing (see\n // fullOldPayload in src/capture.ts) precisely because a note's key\n // embeds a non-portable rowid: after a reprojection the puts sit\n // under the old key and the fold finds nothing, which used to make\n // the restore a silent no-op (drift-641).\n let fields = reconstructRow(db, row.entity, row.key, row.hlc);\n if (Object.keys(fields).length === 0) fields = payloadFields(row.payload);\n // Third tier, for LEGACY note tombstones only. A note key embeds a\n // non-portable rowid, so after a reprojection the puts sit under a\n // different key than the later del (drift-641). Tombstones written\n // since that fix carry the row, so tier 2 resolves them — but ones\n // written BEFORE it carry '{}', and then both tiers come back\n // empty and the note is silently not restored. Recover from the\n // puts for the same TASK instead of the same key.\n //\n // Ordinal position cannot be used to pair them: notes are a\n // grow-only SET, and before 3.1.1 it was keyed on (task, content),\n // so identical prose collapsed to one row. Observed 5 puts against 3 dels for one\n // task, the surplus being repeated '[reaper]' notes — the Nth put\n // is therefore not the Nth del. So this restores the DISTINCT\n // contents the log holds for the task and lets the set semantics\n // settle the count, which reaches the same end state the dedupe\n // produced. Consequence: the notes come back, but which tombstone\n // restored which note is not reconstructible, so this runs once\n // per task rather than once per tombstone.\n if (Object.keys(fields).length === 0 && row.entity === \"note\") {\n const recovered = recoverLegacyNoteContents(db, row.key, row.hlc, seenLegacyNoteTasks);\n for (const legacy of recovered) {\n inverses.push({\n entity: \"note\",\n key: legacy.key,\n op: \"put\",\n fields: legacy.fields,\n summary: `note ${legacy.key} (recovered from legacy history)`,\n supersededBy: [],\n });\n }\n skipped += 1;\n continue;\n }\n // History predating v10 carries retired lifecycle values that the\n // task_substates FK rejects, so a restore maps them onto the pair\n // they now mean, exactly as the apply path does. Without this,\n // undoing a pre-v10 `workstream teardown` aborted the whole\n // transaction on the first DEFERRED task.\n const status = fields.status;\n const legacy = typeof status === \"string\" ? mapLegacyStatus(status) : null;\n if (legacy) {\n fields.status = legacy.status;\n fields.substate = legacy.substate;\n }\n const conflicts = laterRowWriters(db, row.entity, row.key, row.hlc, groupId).map((w) => ({\n field: \"<row>\",\n groupId: w.groupId,\n intent: w.intent,\n }));\n inverses.push({\n entity: row.entity,\n key: row.key,\n op: \"put\",\n fields,\n summary: `${row.entity} ${row.key} (from tombstone)`,\n supersededBy: conflicts,\n });\n continue;\n }\n\n // A put. Did it CREATE the row, or CHANGE fields?\n //\n // Creation is \"no prior op at all\" OR \"the newest prior op was a\n // tombstone\" — the latter is a RESURRECTION, which creates the row\n // just as much as a first insert does. Testing only `prior === 0`\n // gets this wrong for the very common case of undoing an undo of a\n // delete: the undo's restoring put has prior ops (the original add\n // and the delete), so it would be treated as a field change and its\n // inverse would restore fields instead of deleting the row.\n if (row.prior === 0 || row.priorKind === \"del\") {\n // Created it: the inverse is a delete. If the newest op for the\n // key is a delete by another group (an earlier undo of this one,\n // or a later delete), the row is gone and there is nothing to\n // revert.\n if (isDeletedByLaterGroup(db, row.entity, row.key, groupId)) {\n skipped += 1;\n continue;\n }\n // A later group that wrote this key is a supersession — deleting\n // would discard that work.\n const later = [...laterRowWriters(db, row.entity, row.key, row.hlc, groupId)];\n inverses.push({\n entity: row.entity,\n key: row.key,\n op: \"del\",\n fields: {},\n summary: `${row.entity} ${row.key} (it was created by this group)`,\n supersededBy: later.map((w) => ({ field: \"<row>\", groupId: w.groupId, intent: w.intent })),\n });\n continue;\n }\n\n // Changed fields: restore the prior value of exactly those fields.\n const payload = JSON.parse(row.payload) as Record<string, unknown>;\n const fields: Record<string, string | number | null> = {};\n const supersededBy: InverseOp[\"supersededBy\"] = [];\n const rowKey = `${row.entity}\\u0000${row.key}`;\n const restored = restoredFields.get(rowKey) ?? new Set<string>();\n restoredFields.set(rowKey, restored);\n for (const field of Object.keys(payload)) {\n if (NEVER_RESTORE.has(field) || restored.has(field)) continue;\n restored.add(field);\n let machineId: string | undefined;\n if (field === OWNER_FIELD) {\n // A peer's owner_id was never applied here, so there is nothing\n // of it to undo.\n if (row.machine_id !== localMachine()) continue;\n machineId = localMachine();\n }\n const prior =\n field === OWNER_FIELD\n ? priorOwner(db, row.key, row.hlc, localMachine())\n : priorFieldValue(db, row.entity, row.key, row.hlc, field, machineId);\n if (!prior.found) {\n // No earlier op named this field, so it had no prior value to go\n // back to. Nothing to restore for it.\n continue;\n }\n // The prior owner's agent row is gone (ON DELETE SET NULL): its id\n // would violate the FK, so the owner stays as it is.\n if (field === OWNER_FIELD && prior.value !== null && !agentExists(db, prior.value)) continue;\n const writers = laterWriters(db, row.entity, row.key, row.hlc, field, groupId, machineId);\n if (field === TOUCH_FIELD && writers.length > 0) continue;\n fields[field] = prior.value;\n for (const writer of writers) {\n supersededBy.push({ field, groupId: writer.groupId, intent: writer.intent });\n }\n }\n // A later DELETE also supersedes: restoring fields on a row that has\n // since been removed would resurrect it.\n for (const deleter of laterDeleters(db, row.entity, row.key, row.hlc, groupId)) {\n supersededBy.push({ field: \"<row>\", groupId: deleter.groupId, intent: deleter.intent });\n }\n\n if (Object.keys(fields).length === 0) {\n skipped += 1;\n continue;\n }\n inverses.push({\n entity: row.entity,\n key: row.key,\n op: \"put\",\n fields,\n summary: `${row.entity} ${row.key} ${Object.entries(fields)\n .map(([f, v]) => `${f}=${v === null ? \"NULL\" : String(v)}`)\n .join(\" \")}`,\n supersededBy,\n });\n }\n\n // FK-safe ordering. Restores must go parents-first; deletes\n // children-first. Sorting by entity depth is correct regardless of the\n // order the group happened to be emitted in.\n const ordered = [...inverses].sort((a, b) => {\n if (a.op !== b.op) return a.op === \"put\" ? -1 : 1; // restores before deletes\n const rank =\n a.op === \"put\"\n ? restoreRank(a.entity) - restoreRank(b.entity)\n : restoreRank(b.entity) - restoreRank(a.entity);\n return rank;\n });\n\n const intents = [...new Set(rows.map((r) => r.intent).filter((i): i is string => i !== null))];\n\n return {\n groupId,\n intents,\n when: groupWhen(db, groupId),\n inverses: ordered,\n superseded: ordered.some((i) => i.supersededBy.length > 0),\n skipped,\n };\n}\n\n/**\n * The owner_id a task had just before `hlc`, from this machine's ops.\n *\n * Always found: a task row only ever starts unowned. Apply strips a\n * peer's owner_id and inserts the row with owner_id NULL without\n * capturing, so a task created (or recreated after a tombstone) by a\n * peer has no local op naming owner_id. A local op older than a\n * tombstone that won described a row that no longer exists, so it does\n * not count either. A tombstone lost (and deleted nothing) when a newer\n * put had already arrived, i.e. one with a higher hlc and a lower seq:\n * the same check applyDel makes.\n */\nfunction priorOwner(\n db: Db,\n key: string,\n hlc: string,\n machineId: string,\n): { found: true; value: string | number | null } {\n const local = priorFieldValue(db, \"task\", key, hlc, OWNER_FIELD, machineId);\n if (!local.found) return { found: true, value: null };\n const tomb = db\n .prepare(\n `SELECT 1 FROM ops d\n WHERE d.entity = 'task' AND d.key = @key AND d.op = 'del' AND d.hlc < @hlc\n AND d.hlc > (SELECT MAX(hlc) FROM ops\n WHERE entity = 'task' AND key = @key AND op = 'put'\n AND hlc < @hlc AND machine_id = @machineId\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n AND json_type(payload, '$.owner_id') IS NOT NULL)\n AND NOT EXISTS (SELECT 1 FROM ops p\n WHERE p.entity = 'task' AND p.key = @key AND p.op = 'put'\n AND p.hlc > d.hlc AND p.seq < d.seq)\n LIMIT 1`,\n )\n .get({ key, hlc, machineId });\n return tomb === undefined ? local : { found: true, value: null };\n}\n\nfunction agentExists(db: Db, id: string | number): boolean {\n return db.prepare(\"SELECT 1 FROM agents WHERE id = ?\").get(id) !== undefined;\n}\n\nfunction groupWhen(db: Db, groupId: string): string {\n const row = db\n .prepare(\"SELECT MIN(created_at) AS when_ FROM ops WHERE group_id = ?\")\n .get(groupId) as { when_: string | null } | undefined;\n return row?.when_ ?? \"\";\n}\n\n/**\n * Note contents the log holds for a task, for tombstones no other tier\n * can resolve (see the call site for why this is per-task).\n *\n * Returns one entry per DISTINCT content, skipping any whose content is\n * already live, and returns nothing at all the second time it is asked\n * about a task — `seen` makes the per-task work happen once even though\n * the caller iterates per tombstone.\n */\nfunction recoverLegacyNoteContents(\n db: Db,\n tombstoneKey: string,\n beforeHlc: string,\n seen: Set<string>,\n): Array<{ key: string; fields: Record<string, string | number | null> }> {\n const parsed = parseNoteKey(tombstoneKey);\n if (parsed === null) return [];\n if (seen.has(parsed.taskKey)) return [];\n seen.add(parsed.taskKey);\n\n const rows = db\n .prepare(\n `SELECT key, payload FROM ops\n WHERE entity = 'note'\n AND op = 'put'\n AND hlc < @hlc\n AND key LIKE @prefix\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc`,\n )\n .all({ hlc: beforeHlc, prefix: `${parsed.taskKey}#%` }) as Array<{\n key: string;\n payload: string;\n }>;\n\n const out: Array<{ key: string; fields: Record<string, string | number | null> }> = [];\n const contents = new Set<string>();\n for (const row of rows) {\n const fields = payloadFields(row.payload);\n const content = fields.content;\n if (typeof content !== \"string\" || content === \"\") continue;\n if (contents.has(content)) continue;\n contents.add(content);\n out.push({ key: row.key, fields });\n }\n return out;\n}\n\n/** The restorable fields carried by one op's payload.\n *\n * Shares `NEVER_RECONSTRUCT` and the scalar-only guard with\n * `reconstructRow`, so a fallback can never reintroduce a surrogate id\n * or an FK the fold would have dropped. */\nfunction payloadFields(payload: string): Record<string, string | number | null> {\n const fields: Record<string, string | number | null> = {};\n const parsed = JSON.parse(payload) as Record<string, unknown>;\n for (const [field, value] of Object.entries(parsed)) {\n if (NEVER_RECONSTRUCT.has(field)) continue;\n if (value === null || typeof value === \"string\" || typeof value === \"number\") {\n fields[field] = value;\n }\n }\n return fields;\n}\n\n/**\n * Rebuild the full field set a row had just before `beforeHlc`, by\n * folding every put for that key in HLC order.\n *\n * Necessary because ops are SEMANTIC PARTIAL UPDATES: most tombstones\n * carry no payload and the creating put may have been amended by later\n * partial puts, so no single op holds the whole row. Folding is the only\n * correct reconstruction, and it is the same fold the rebuild path does —\n * just bounded to one key and one point in time.\n */\nfunction reconstructRow(\n db: Db,\n entity: string,\n key: string,\n beforeHlc: string,\n): Record<string, string | number | null> {\n const puts = db\n .prepare(\n `SELECT payload FROM ops\n WHERE entity = @entity AND key = @key AND op = 'put' AND hlc < @hlc\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc`,\n )\n .all({ entity, key, hlc: beforeHlc }) as { payload: string }[];\n const fields: Record<string, string | number | null> = {};\n for (const put of puts) {\n const parsed = JSON.parse(put.payload) as Record<string, unknown>;\n for (const [field, value] of Object.entries(parsed)) {\n if (NEVER_RECONSTRUCT.has(field)) continue;\n if (value === null || typeof value === \"string\" || typeof value === \"number\") {\n fields[field] = value;\n }\n }\n }\n return fields;\n}\n\n// ─── applying ─────────────────────────────────────────────────────────\n\nexport interface UndoOptions {\n /** Apply even when the group has been superseded, discarding the\n * newer edits to those fields. */\n force?: boolean;\n /** Actor recorded on the undo's own ops. */\n actor?: string | undefined;\n}\n\n/**\n * Apply the inverse of `groupId`.\n *\n * Everything happens inside ONE `withOpContext` scope with\n * `group: \"new\"`, so:\n * * every inverse write lands in a single new group (making the undo\n * itself one undoable unit), and\n * * the capture triggers record each write with a fresh HLC, so the\n * undo is an ordinary op that syncs and shows up in `mu log`.\n *\n * Wrapped in one transaction: a half-applied undo is worse than none,\n * because the operator would not know which half.\n */\nexport function undoGroup(db: Db, groupId: string, opts: UndoOptions = {}): UndoResult {\n const plan = planUndo(db, groupId);\n\n if (plan.superseded && opts.force !== true) {\n const conflicts = plan.inverses.flatMap((inv) =>\n inv.supersededBy.map((s) => ({ key: inv.key, field: s.field, groupId: s.groupId })),\n );\n throw new UndoSupersededError(groupId, conflicts);\n }\n\n let applied = 0;\n let undoGroupId = \"\";\n\n const run = db.transaction(() => {\n withOpContext(\n db,\n { intent: \"undo\", group: \"new\", ...(opts.actor !== undefined ? { actor: opts.actor } : {}) },\n () => {\n const ctx = db.prepare(\"SELECT group_id FROM _op_ctx\").get() as\n | { group_id: string | null }\n | undefined;\n undoGroupId = ctx?.group_id ?? \"\";\n\n for (const inverse of plan.inverses) {\n if (applyInverse(db, inverse)) applied += 1;\n }\n },\n );\n });\n run.immediate();\n\n return { plan, undoGroupId: applied > 0 && undoGroupId !== \"\" ? undoGroupId : null, applied };\n}\n\n/** Apply one inverse by MUTATING THE TABLE, letting capture record it.\n * Returns true iff a row changed. */\nfunction applyInverse(db: Db, inverse: InverseOp): boolean {\n const table = CAPTURED_TABLE_FOR[inverse.entity];\n if (table === undefined) return false;\n\n if (inverse.op === \"del\") return deleteRow(db, inverse);\n return restoreRow(db, inverse, table);\n}\n\nfunction deleteRow(db: Db, inverse: InverseOp): boolean {\n switch (inverse.entity) {\n case \"workstream\":\n return db.prepare(\"DELETE FROM workstreams WHERE name = ?\").run(inverse.key).changes > 0;\n case \"task\": {\n const id = taskIdForKey(db, inverse.key);\n if (id === null) return false;\n return db.prepare(\"DELETE FROM tasks WHERE id = ?\").run(id).changes > 0;\n }\n case \"note\": {\n const parsed = parseNoteKey(inverse.key);\n if (parsed === null) return false;\n const taskId = taskIdForKey(db, parsed.taskKey);\n if (taskId === null) return false;\n // A note's identity is (task, author, content, created_at), as in\n // applyNotePut, so delete exactly the note the log says this was,\n // not every repeat of its text.\n const note = db\n .prepare(\n `SELECT json_extract(payload, '$.content') AS content,\n json_extract(payload, '$.author') AS author,\n json_extract(payload, '$.created_at') AS created_at FROM ops\n WHERE entity = 'note' AND key = ? AND op = 'put'\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc DESC LIMIT 1`,\n )\n .get(inverse.key) as\n | { content: string | null; author: string | null; created_at: string | null }\n | undefined;\n if (note?.content === undefined || note.content === null) return false;\n return (\n db\n .prepare(\n `DELETE FROM task_notes\n WHERE task_id = @taskId AND content = @content\n AND COALESCE(author, '') = COALESCE(@author, '')\n AND (@createdAt IS NULL OR created_at = @createdAt)`,\n )\n .run({\n taskId,\n content: note.content,\n author: note.author,\n createdAt: note.created_at,\n }).changes > 0\n );\n }\n case \"edge\": {\n const parsed = parseEdgeKey(inverse.key);\n if (parsed === null) return false;\n const from = taskIdForKey(db, parsed.blocker);\n const to = taskIdForKey(db, parsed.blocked);\n if (from === null || to === null) return false;\n return (\n db.prepare(\"DELETE FROM task_edges WHERE from_task_id = ? AND to_task_id = ?\").run(from, to)\n .changes > 0\n );\n }\n default:\n return false;\n }\n}\n\nfunction restoreRow(db: Db, inverse: InverseOp, table: string): boolean {\n switch (inverse.entity) {\n case \"workstream\": {\n const exists = db.prepare(\"SELECT id FROM workstreams WHERE name = ?\").get(inverse.key) as\n | { id: number }\n | undefined;\n if (!exists) {\n db.prepare(\"INSERT INTO workstreams (name, created_at) VALUES (?, ?)\").run(\n inverse.key,\n String(inverse.fields.created_at ?? new Date().toISOString()),\n );\n return true;\n }\n return updateFields(db, \"workstreams\", \"id\", exists.id, inverse.fields);\n }\n case \"task\": {\n const parsed = parseTaskKey(inverse.key);\n if (parsed === null) return false;\n const id = taskIdForKey(db, inverse.key);\n if (id === null) {\n // The task row is gone, so this is a restore-from-tombstone. The\n // parent workstream must exist first; it will have been restored\n // already by the entity ordering, but create it if the group\n // only deleted the task.\n const wsId = ensureWorkstream(db, parsed.workstream);\n const now = new Date().toISOString();\n // Insert a legal pair: the FK checks it at commit, and an\n // unknown status or substate must not abort the whole undo.\n const pair = resolvePair(\n String(inverse.fields.status ?? \"OPEN\"),\n inverse.fields.substate,\n ) ?? {\n status: \"OPEN\",\n substate: \"todo\",\n };\n db.prepare(\n `INSERT INTO tasks (workstream_id, local_id, title, status, substate, impact,\n effort_days, owner_id, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,\n ).run(\n wsId,\n parsed.localId,\n String(inverse.fields.title ?? parsed.localId),\n pair.status,\n pair.substate,\n Number(inverse.fields.impact ?? 50),\n Number(inverse.fields.effort_days ?? 1),\n String(inverse.fields.created_at ?? now),\n String(inverse.fields.updated_at ?? now),\n );\n return true;\n }\n // A partial restore (e.g. status only, from history that predates\n // substates) can leave an illegal pair; repair it before commit.\n const updated = updateFields(db, \"tasks\", \"id\", id, inverse.fields);\n return repairTaskPair(db, id) || updated;\n }\n case \"note\": {\n const parsed = parseNoteKey(inverse.key);\n if (parsed === null) return false;\n const taskId = taskIdForKey(db, parsed.taskKey);\n if (taskId === null) return false;\n const content = inverse.fields.content;\n if (content === undefined || content === null) return false;\n const author = inverse.fields.author;\n const createdAt = inverse.fields.created_at;\n // Same identity as applyNotePut, so restoring one of two repeats of\n // a text does not find the other and stop.\n const exists = db\n .prepare(\n `SELECT id FROM task_notes\n WHERE task_id = @taskId AND content = @content\n AND COALESCE(author, '') = COALESCE(@author, '')\n AND (@createdAt IS NULL OR created_at = @createdAt)`,\n )\n .get({\n taskId,\n content: String(content),\n author: author === undefined || author === null ? null : String(author),\n createdAt: createdAt === undefined || createdAt === null ? null : String(createdAt),\n }) as { id: number } | undefined;\n if (exists) return false; // grow-only: already there\n db.prepare(\n \"INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)\",\n ).run(\n taskId,\n inverse.fields.author === undefined || inverse.fields.author === null\n ? null\n : String(inverse.fields.author),\n String(content),\n String(inverse.fields.created_at ?? new Date().toISOString()),\n );\n return true;\n }\n case \"edge\": {\n const parsed = parseEdgeKey(inverse.key);\n if (parsed === null) return false;\n const from = taskIdForKey(db, parsed.blocker);\n const to = taskIdForKey(db, parsed.blocked);\n if (from === null || to === null) return false;\n const exists = db\n .prepare(\"SELECT 1 AS x FROM task_edges WHERE from_task_id = ? AND to_task_id = ?\")\n .get(from, to) as { x: number } | undefined;\n if (exists) return false;\n db.prepare(\n \"INSERT INTO task_edges (from_task_id, to_task_id, created_at) VALUES (?, ?, ?)\",\n ).run(from, to, String(inverse.fields.created_at ?? new Date().toISOString()));\n return true;\n }\n default:\n void table;\n return false;\n }\n}\n\n/**\n * Restore the named fields in ONE UPDATE.\n *\n * One statement, not one per field, and that matters: each UPDATE fires\n * the capture trigger once, so a per-field loop would emit N ops for a\n * single logical inverse. They would share the undo's group so undo/redo\n * still worked, but `mu log` would show one action as several and the\n * op count would misrepresent what happened. One statement, one op.\n *\n * Fields already holding the target value are dropped from the statement\n * first, so a fully-redundant inverse issues no UPDATE at all and\n * therefore produces no op (matching the capture WHEN guard). Returns\n * true iff a row actually changed, so `applied` never overstates the work.\n */\nfunction updateFields(\n db: Db,\n table: string,\n idColumn: string,\n id: number,\n fields: Record<string, string | number | null>,\n): boolean {\n const names = Object.keys(fields).filter((f) => !NEVER_RESTORE.has(f));\n if (names.length === 0) return false;\n\n const current = db.prepare(`SELECT * FROM ${table} WHERE ${idColumn} = ?`).get(id) as\n | Record<string, string | number | null>\n | undefined;\n\n // Only fields whose value actually differs. Comparing here rather than\n // relying on the trigger keeps `changed` honest for the caller.\n const pending = names.filter((name) => {\n const target = fields[name] ?? null;\n if (current === undefined) return true;\n return current[name] !== target;\n });\n if (pending.length === 0) return false;\n\n const setClause = pending.map((name) => `${name} = @${name}`).join(\", \");\n const params: Record<string, string | number | null> = { id };\n for (const name of pending) params[name] = fields[name] ?? null;\n db.prepare(`UPDATE ${table} SET ${setClause} WHERE ${idColumn} = @id`).run(params);\n return true;\n}\n\nfunction ensureWorkstream(db: Db, name: string): number {\n const existing = db.prepare(\"SELECT id FROM workstreams WHERE name = ?\").get(name) as\n | { id: number }\n | undefined;\n if (existing) return existing.id;\n db.prepare(\"INSERT INTO workstreams (name, created_at) VALUES (?, ?)\").run(\n name,\n new Date().toISOString(),\n );\n return resolveWorkstreamId(db, name);\n}\n\n// ─── key parsing (mirrors src/apply.ts) ───────────────────────────────\n\nfunction parseTaskKey(key: string): { workstream: string; localId: string } | null {\n const slash = key.indexOf(\"/\");\n if (slash <= 0 || slash === key.length - 1) return null;\n return { workstream: key.slice(0, slash), localId: key.slice(slash + 1) };\n}\n\nfunction parseNoteKey(key: string): { taskKey: string } | null {\n const hash = key.lastIndexOf(\"#\");\n if (hash <= 0) return null;\n return { taskKey: key.slice(0, hash) };\n}\n\nfunction parseEdgeKey(key: string): { blocker: string; blocked: string } | null {\n const arrow = key.indexOf(\"->\");\n if (arrow <= 0) return null;\n return { blocker: key.slice(0, arrow), blocked: key.slice(arrow + 2) };\n}\n\nfunction taskIdForKey(db: Db, key: string): number | null {\n const parsed = parseTaskKey(key);\n if (parsed === null) return null;\n const row = db\n .prepare(\n `SELECT t.id AS id FROM tasks t\n JOIN workstreams w ON w.id = t.workstream_id\n WHERE w.name = ? AND t.local_id = ?`,\n )\n .get(parsed.workstream, parsed.localId) as { id: number } | undefined;\n return row?.id ?? null;\n}\n"],"mappings":";;;;;;;;;;;;;;AAkKO,IAAM,yBAAN,cAAqC,MAA8B;AAAA,EACxE,YAAqB,SAAiB;AACpC,UAAM,0BAA0B,KAAK,UAAU,OAAO,CAAC,EAAE;AADtC;AAEnB,SAAK,OAAO;AAAA,EACd;AAAA,EAHqB;AAAA,EAKrB,iBAA6B;AAC3B,WAAO;AAAA,MACL,EAAE,QAAQ,+BAA+B,SAAS,UAAU;AAAA,MAC5D,EAAE,QAAQ,yBAAyB,SAAS,sBAAsB;AAAA,IACpE;AAAA,EACF;AACF;AAGO,IAAM,qBAAN,cAAiC,MAA8B;AAAA,EACpE,cAAc;AACZ,UAAM,uCAAuC;AAC7C,SAAK,OAAO;AAAA,EACd;AAAA,EAEA,iBAA6B;AAC3B,WAAO,CAAC,EAAE,QAAQ,uBAAuB,SAAS,8BAA8B,CAAC;AAAA,EACnF;AACF;AAIO,IAAM,sBAAN,cAAkC,MAA8B;AAAA,EACrE,YACW,SACA,WACT;AACA,UAAM,QAAQ,UACX,MAAM,GAAG,CAAC,EACV,IAAI,CAAC,MAAM,GAAG,EAAE,GAAG,IAAI,EAAE,KAAK,gBAAgB,EAAE,QAAQ,MAAM,GAAG,CAAC,CAAC,GAAG,EACtE,KAAK,IAAI;AACZ;AAAA,MACE,SAAS,QAAQ,MAAM,GAAG,CAAC,CAAC,yBAAyB,UAAU,MAAM,mDAC5C,KAAK;AAAA,IAChC;AAVS;AACA;AAUT,SAAK,OAAO;AAAA,EACd;AAAA,EAZW;AAAA,EACA;AAAA,EAaX,iBAA6B;AAC3B,WAAO;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS,kBAAkB,KAAK,QAAQ,MAAM,GAAG,CAAC,CAAC;AAAA,MACrD;AAAA,MACA;AAAA,QACE,QAAQ;AAAA,QACR,SAAS,WAAW,KAAK,QAAQ,MAAM,GAAG,CAAC,CAAC;AAAA,MAC9C;AAAA,IACF;AAAA,EACF;AACF;AAeA,IAAM,uBAA+C;AAAA,EACnD,YAAY;AAAA,EACZ,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AACR;AAKA,SAAS,YAAY,QAAwB;AAC3C,SAAO,qBAAqB,MAAM,KAAK;AACzC;AAuBO,SAAS,gBACd,IACA,QACA,KACA,KACA,OACA,WACmE;AACnE,QAAM,MAAM,GACT;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAOU,6BAA6B;AAAA;AAAA;AAAA;AAAA,EAIzC,EACC,IAAI,EAAE,QAAQ,KAAK,KAAK,OAAO,WAAW,aAAa,KAAK,CAAC;AAGhE,MAAI,QAAQ,OAAW,QAAO,EAAE,OAAO,MAAM;AAC7C,SAAO,EAAE,OAAO,MAAM,OAAO,IAAI,MAAM;AACzC;AAKA,SAAS,aACP,IACA,QACA,KACA,KACA,OACA,cACA,WACmD;AACnD,SAAO,GACJ;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAQU,6BAA6B;AAAA;AAAA,EAEzC,EACC,IAAI,EAAE,QAAQ,KAAK,KAAK,OAAO,cAAc,WAAW,aAAa,KAAK,CAAC;AAIhF;AAMA,SAAS,gBACP,IACA,QACA,KACA,KACA,cACmD;AACnD,SAAO,GACJ;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAOU,6BAA6B;AAAA,EACzC,EACC,IAAI,EAAE,QAAQ,KAAK,KAAK,aAAa,CAAC;AAI3C;AAKA,SAAS,sBAAsB,IAAQ,QAAgB,KAAa,SAA0B;AAC5F,QAAM,MAAM,GACT;AAAA,IACC;AAAA;AAAA,gBAEU,6BAA6B;AAAA;AAAA,EAEzC,EACC,IAAI,EAAE,QAAQ,IAAI,CAAC;AACtB,SAAO,KAAK,OAAO,SAAS,IAAI,YAAY;AAC9C;AAIA,SAAS,cACP,IACA,QACA,KACA,KACA,cACmD;AACnD,SAAO,GACJ;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOF,EACC,IAAI,EAAE,QAAQ,KAAK,KAAK,aAAa,CAAC;AAC3C;AAyBO,SAAS,iBAAiB,IAAQ,QAAQ,IAAoB;AACnE,QAAM,OAAO,GACV;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gBAOU,6BAA6B;AAAA;AAAA;AAAA;AAAA,EAIzC,EACC,IAAI,EAAE,MAAM,CAAC;AAQhB,SAAO,KAAK,IAAI,CAAC,QAAQ;AACvB,UAAM,UACJ,GACG;AAAA,MACC;AAAA;AAAA;AAAA,IAGF,EACC,IAAI,EAAE,SAAS,IAAI,QAAQ,CAAC,EAC/B,IAAI,CAAC,MAAM,EAAE,MAAM;AACrB,WAAO;AAAA,MACL,SAAS,IAAI;AAAA,MACb;AAAA,MACA,OAAO,IAAI;AAAA,MACX,KAAK,IAAI;AAAA,MACT,MAAM,IAAI;AAAA,MACV,KAAK,IAAI;AAAA,IACX;AAAA,EACF,CAAC;AACH;AAGO,SAAS,gBAAgB,IAA6B;AAC3D,SAAO,iBAAiB,IAAI,CAAC,EAAE,CAAC,KAAK;AACvC;AAcO,SAAS,eAAe,IAAQ,QAAwB;AAC7D,QAAM,WAAW,kBAAkB,IAAI,MAAM;AAC7C,MAAI,aAAa,KAAM,OAAM,IAAI,uBAAuB,MAAM;AAC9D,SAAO;AACT;AAIA,IAAM,qBAA6C;AAAA,EACjD,YAAY;AAAA,EACZ,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AACR;AAIA,IAAM,gBAAgB,oBAAI,IAAI,CAAC,MAAM,YAAY,QAAQ,iBAAiB,SAAS,CAAC;AAQpF,IAAM,cAAc;AAGpB,IAAM,oBAAoB,oBAAI,IAAI,CAAC,GAAG,eAAe,WAAW,CAAC;AAKjE,IAAM,cAAc;AASb,SAAS,SAAS,IAAQ,SAA2B;AAC1D,QAAM,OAAO,GACV;AAAA,IACC;AAAA;AAAA;AAAA,wBAGkB,6BAA6B;AAAA;AAAA;AAAA,wBAG7B,6BAA6B;AAAA;AAAA;AAAA;AAAA;AAAA,gBAKrC,6BAA6B;AAAA;AAAA,EAEzC,EACC,IAAI,EAAE,QAAQ,CAAC;AAElB,MAAI,KAAK,WAAW,EAAG,OAAM,IAAI,uBAAuB,OAAO;AAE/D,QAAM,WAAwB,CAAC;AAC/B,MAAI,UAAU;AAGd,QAAM,sBAAsB,oBAAI,IAAY;AAK5C,QAAM,iBAAiB,oBAAI,IAAyB;AACpD,MAAI;AACJ,QAAM,eAAe,MAAc;AACjC,gBAAY,eAAe,EAAE;AAC7B,WAAO;AAAA,EACT;AAEA,aAAW,OAAO,MAAM;AACtB,QAAI,IAAI,OAAO,OAAO;AAYpB,UAAIA,UAAS,eAAe,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,GAAG;AAC5D,UAAI,OAAO,KAAKA,OAAM,EAAE,WAAW,EAAG,CAAAA,UAAS,cAAc,IAAI,OAAO;AAmBxE,UAAI,OAAO,KAAKA,OAAM,EAAE,WAAW,KAAK,IAAI,WAAW,QAAQ;AAC7D,cAAM,YAAY,0BAA0B,IAAI,IAAI,KAAK,IAAI,KAAK,mBAAmB;AACrF,mBAAWC,WAAU,WAAW;AAC9B,mBAAS,KAAK;AAAA,YACZ,QAAQ;AAAA,YACR,KAAKA,QAAO;AAAA,YACZ,IAAI;AAAA,YACJ,QAAQA,QAAO;AAAA,YACf,SAAS,QAAQA,QAAO,GAAG;AAAA,YAC3B,cAAc,CAAC;AAAA,UACjB,CAAC;AAAA,QACH;AACA,mBAAW;AACX;AAAA,MACF;AAMA,YAAM,SAASD,QAAO;AACtB,YAAM,SAAS,OAAO,WAAW,WAAW,gBAAgB,MAAM,IAAI;AACtE,UAAI,QAAQ;AACV,QAAAA,QAAO,SAAS,OAAO;AACvB,QAAAA,QAAO,WAAW,OAAO;AAAA,MAC3B;AACA,YAAM,YAAY,gBAAgB,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,KAAK,OAAO,EAAE,IAAI,CAAC,OAAO;AAAA,QACvF,OAAO;AAAA,QACP,SAAS,EAAE;AAAA,QACX,QAAQ,EAAE;AAAA,MACZ,EAAE;AACF,eAAS,KAAK;AAAA,QACZ,QAAQ,IAAI;AAAA,QACZ,KAAK,IAAI;AAAA,QACT,IAAI;AAAA,QACJ,QAAAA;AAAA,QACA,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,GAAG;AAAA,QACjC,cAAc;AAAA,MAChB,CAAC;AACD;AAAA,IACF;AAWA,QAAI,IAAI,UAAU,KAAK,IAAI,cAAc,OAAO;AAK9C,UAAI,sBAAsB,IAAI,IAAI,QAAQ,IAAI,KAAK,OAAO,GAAG;AAC3D,mBAAW;AACX;AAAA,MACF;AAGA,YAAM,QAAQ,CAAC,GAAG,gBAAgB,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,KAAK,OAAO,CAAC;AAC5E,eAAS,KAAK;AAAA,QACZ,QAAQ,IAAI;AAAA,QACZ,KAAK,IAAI;AAAA,QACT,IAAI;AAAA,QACJ,QAAQ,CAAC;AAAA,QACT,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,GAAG;AAAA,QACjC,cAAc,MAAM,IAAI,CAAC,OAAO,EAAE,OAAO,SAAS,SAAS,EAAE,SAAS,QAAQ,EAAE,OAAO,EAAE;AAAA,MAC3F,CAAC;AACD;AAAA,IACF;AAGA,UAAM,UAAU,KAAK,MAAM,IAAI,OAAO;AACtC,UAAM,SAAiD,CAAC;AACxD,UAAM,eAA0C,CAAC;AACjD,UAAM,SAAS,GAAG,IAAI,MAAM,KAAS,IAAI,GAAG;AAC5C,UAAM,WAAW,eAAe,IAAI,MAAM,KAAK,oBAAI,IAAY;AAC/D,mBAAe,IAAI,QAAQ,QAAQ;AACnC,eAAW,SAAS,OAAO,KAAK,OAAO,GAAG;AACxC,UAAI,cAAc,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,EAAG;AACrD,eAAS,IAAI,KAAK;AAClB,UAAI;AACJ,UAAI,UAAU,aAAa;AAGzB,YAAI,IAAI,eAAe,aAAa,EAAG;AACvC,oBAAY,aAAa;AAAA,MAC3B;AACA,YAAM,QACJ,UAAU,cACN,WAAW,IAAI,IAAI,KAAK,IAAI,KAAK,aAAa,CAAC,IAC/C,gBAAgB,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,KAAK,OAAO,SAAS;AACxE,UAAI,CAAC,MAAM,OAAO;AAGhB;AAAA,MACF;AAGA,UAAI,UAAU,eAAe,MAAM,UAAU,QAAQ,CAAC,YAAY,IAAI,MAAM,KAAK,EAAG;AACpF,YAAM,UAAU,aAAa,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,KAAK,OAAO,SAAS,SAAS;AACxF,UAAI,UAAU,eAAe,QAAQ,SAAS,EAAG;AACjD,aAAO,KAAK,IAAI,MAAM;AACtB,iBAAW,UAAU,SAAS;AAC5B,qBAAa,KAAK,EAAE,OAAO,SAAS,OAAO,SAAS,QAAQ,OAAO,OAAO,CAAC;AAAA,MAC7E;AAAA,IACF;AAGA,eAAW,WAAW,cAAc,IAAI,IAAI,QAAQ,IAAI,KAAK,IAAI,KAAK,OAAO,GAAG;AAC9E,mBAAa,KAAK,EAAE,OAAO,SAAS,SAAS,QAAQ,SAAS,QAAQ,QAAQ,OAAO,CAAC;AAAA,IACxF;AAEA,QAAI,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AACpC,iBAAW;AACX;AAAA,IACF;AACA,aAAS,KAAK;AAAA,MACZ,QAAQ,IAAI;AAAA,MACZ,KAAK,IAAI;AAAA,MACT,IAAI;AAAA,MACJ;AAAA,MACA,SAAS,GAAG,IAAI,MAAM,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,MAAM,EACvD,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,OAAO,SAAS,OAAO,CAAC,CAAC,EAAE,EACzD,KAAK,GAAG,CAAC;AAAA,MACZ;AAAA,IACF,CAAC;AAAA,EACH;AAKA,QAAM,UAAU,CAAC,GAAG,QAAQ,EAAE,KAAK,CAAC,GAAG,MAAM;AAC3C,QAAI,EAAE,OAAO,EAAE,GAAI,QAAO,EAAE,OAAO,QAAQ,KAAK;AAChD,UAAM,OACJ,EAAE,OAAO,QACL,YAAY,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,IAC5C,YAAY,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM;AAClD,WAAO;AAAA,EACT,CAAC;AAED,QAAM,UAAU,CAAC,GAAG,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,MAAmB,MAAM,IAAI,CAAC,CAAC;AAE7F,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,MAAM,UAAU,IAAI,OAAO;AAAA,IAC3B,UAAU;AAAA,IACV,YAAY,QAAQ,KAAK,CAAC,MAAM,EAAE,aAAa,SAAS,CAAC;AAAA,IACzD;AAAA,EACF;AACF;AAcA,SAAS,WACP,IACA,KACA,KACA,WACgD;AAChD,QAAM,QAAQ,gBAAgB,IAAI,QAAQ,KAAK,KAAK,aAAa,SAAS;AAC1E,MAAI,CAAC,MAAM,MAAO,QAAO,EAAE,OAAO,MAAM,OAAO,KAAK;AACpD,QAAM,OAAO,GACV;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA,gCAK0B,6BAA6B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMzD,EACC,IAAI,EAAE,KAAK,KAAK,UAAU,CAAC;AAC9B,SAAO,SAAS,SAAY,QAAQ,EAAE,OAAO,MAAM,OAAO,KAAK;AACjE;AAEA,SAAS,YAAY,IAAQ,IAA8B;AACzD,SAAO,GAAG,QAAQ,mCAAmC,EAAE,IAAI,EAAE,MAAM;AACrE;AAEA,SAAS,UAAU,IAAQ,SAAyB;AAClD,QAAM,MAAM,GACT,QAAQ,6DAA6D,EACrE,IAAI,OAAO;AACd,SAAO,KAAK,SAAS;AACvB;AAWA,SAAS,0BACP,IACA,cACA,WACA,MACwE;AACxE,QAAM,SAAS,aAAa,YAAY;AACxC,MAAI,WAAW,KAAM,QAAO,CAAC;AAC7B,MAAI,KAAK,IAAI,OAAO,OAAO,EAAG,QAAO,CAAC;AACtC,OAAK,IAAI,OAAO,OAAO;AAEvB,QAAM,OAAO,GACV;AAAA,IACC;AAAA;AAAA;AAAA;AAAA;AAAA,gBAKU,6BAA6B;AAAA;AAAA,EAEzC,EACC,IAAI,EAAE,KAAK,WAAW,QAAQ,GAAG,OAAO,OAAO,KAAK,CAAC;AAKxD,QAAM,MAA8E,CAAC;AACrF,QAAM,WAAW,oBAAI,IAAY;AACjC,aAAW,OAAO,MAAM;AACtB,UAAM,SAAS,cAAc,IAAI,OAAO;AACxC,UAAM,UAAU,OAAO;AACvB,QAAI,OAAO,YAAY,YAAY,YAAY,GAAI;AACnD,QAAI,SAAS,IAAI,OAAO,EAAG;AAC3B,aAAS,IAAI,OAAO;AACpB,QAAI,KAAK,EAAE,KAAK,IAAI,KAAK,OAAO,CAAC;AAAA,EACnC;AACA,SAAO;AACT;AAOA,SAAS,cAAc,SAAyD;AAC9E,QAAM,SAAiD,CAAC;AACxD,QAAM,SAAS,KAAK,MAAM,OAAO;AACjC,aAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACnD,QAAI,kBAAkB,IAAI,KAAK,EAAG;AAClC,QAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,UAAU;AAC5E,aAAO,KAAK,IAAI;AAAA,IAClB;AAAA,EACF;AACA,SAAO;AACT;AAYA,SAAS,eACP,IACA,QACA,KACA,WACwC;AACxC,QAAM,OAAO,GACV;AAAA,IACC;AAAA;AAAA,gBAEU,6BAA6B;AAAA;AAAA,EAEzC,EACC,IAAI,EAAE,QAAQ,KAAK,KAAK,UAAU,CAAC;AACtC,QAAM,SAAiD,CAAC;AACxD,aAAW,OAAO,MAAM;AACtB,UAAM,SAAS,KAAK,MAAM,IAAI,OAAO;AACrC,eAAW,CAAC,OAAO,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACnD,UAAI,kBAAkB,IAAI,KAAK,EAAG;AAClC,UAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,OAAO,UAAU,UAAU;AAC5E,eAAO,KAAK,IAAI;AAAA,MAClB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAyBO,SAAS,UAAU,IAAQ,SAAiB,OAAoB,CAAC,GAAe;AACrF,QAAM,OAAO,SAAS,IAAI,OAAO;AAEjC,MAAI,KAAK,cAAc,KAAK,UAAU,MAAM;AAC1C,UAAM,YAAY,KAAK,SAAS;AAAA,MAAQ,CAAC,QACvC,IAAI,aAAa,IAAI,CAAC,OAAO,EAAE,KAAK,IAAI,KAAK,OAAO,EAAE,OAAO,SAAS,EAAE,QAAQ,EAAE;AAAA,IACpF;AACA,UAAM,IAAI,oBAAoB,SAAS,SAAS;AAAA,EAClD;AAEA,MAAI,UAAU;AACd,MAAI,cAAc;AAElB,QAAM,MAAM,GAAG,YAAY,MAAM;AAC/B;AAAA,MACE;AAAA,MACA,EAAE,QAAQ,QAAQ,OAAO,OAAO,GAAI,KAAK,UAAU,SAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC,EAAG;AAAA,MAC3F,MAAM;AACJ,cAAM,MAAM,GAAG,QAAQ,8BAA8B,EAAE,IAAI;AAG3D,sBAAc,KAAK,YAAY;AAE/B,mBAAW,WAAW,KAAK,UAAU;AACnC,cAAI,aAAa,IAAI,OAAO,EAAG,YAAW;AAAA,QAC5C;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACD,MAAI,UAAU;AAEd,SAAO,EAAE,MAAM,aAAa,UAAU,KAAK,gBAAgB,KAAK,cAAc,MAAM,QAAQ;AAC9F;AAIA,SAAS,aAAa,IAAQ,SAA6B;AACzD,QAAM,QAAQ,mBAAmB,QAAQ,MAAM;AAC/C,MAAI,UAAU,OAAW,QAAO;AAEhC,MAAI,QAAQ,OAAO,MAAO,QAAO,UAAU,IAAI,OAAO;AACtD,SAAO,WAAW,IAAI,SAAS,KAAK;AACtC;AAEA,SAAS,UAAU,IAAQ,SAA6B;AACtD,UAAQ,QAAQ,QAAQ;AAAA,IACtB,KAAK;AACH,aAAO,GAAG,QAAQ,wCAAwC,EAAE,IAAI,QAAQ,GAAG,EAAE,UAAU;AAAA,IACzF,KAAK,QAAQ;AACX,YAAM,KAAK,aAAa,IAAI,QAAQ,GAAG;AACvC,UAAI,OAAO,KAAM,QAAO;AACxB,aAAO,GAAG,QAAQ,gCAAgC,EAAE,IAAI,EAAE,EAAE,UAAU;AAAA,IACxE;AAAA,IACA,KAAK,QAAQ;AACX,YAAM,SAAS,aAAa,QAAQ,GAAG;AACvC,UAAI,WAAW,KAAM,QAAO;AAC5B,YAAM,SAAS,aAAa,IAAI,OAAO,OAAO;AAC9C,UAAI,WAAW,KAAM,QAAO;AAI5B,YAAM,OAAO,GACV;AAAA,QACC;AAAA;AAAA;AAAA;AAAA,oBAIU,6BAA6B;AAAA;AAAA,MAEzC,EACC,IAAI,QAAQ,GAAG;AAGlB,UAAI,MAAM,YAAY,UAAa,KAAK,YAAY,KAAM,QAAO;AACjE,aACE,GACG;AAAA,QACC;AAAA;AAAA;AAAA;AAAA,MAIF,EACC,IAAI;AAAA,QACH;AAAA,QACA,SAAS,KAAK;AAAA,QACd,QAAQ,KAAK;AAAA,QACb,WAAW,KAAK;AAAA,MAClB,CAAC,EAAE,UAAU;AAAA,IAEnB;AAAA,IACA,KAAK,QAAQ;AACX,YAAM,SAAS,aAAa,QAAQ,GAAG;AACvC,UAAI,WAAW,KAAM,QAAO;AAC5B,YAAM,OAAO,aAAa,IAAI,OAAO,OAAO;AAC5C,YAAM,KAAK,aAAa,IAAI,OAAO,OAAO;AAC1C,UAAI,SAAS,QAAQ,OAAO,KAAM,QAAO;AACzC,aACE,GAAG,QAAQ,kEAAkE,EAAE,IAAI,MAAM,EAAE,EACxF,UAAU;AAAA,IAEjB;AAAA,IACA;AACE,aAAO;AAAA,EACX;AACF;AAEA,SAAS,WAAW,IAAQ,SAAoB,OAAwB;AACtE,UAAQ,QAAQ,QAAQ;AAAA,IACtB,KAAK,cAAc;AACjB,YAAM,SAAS,GAAG,QAAQ,2CAA2C,EAAE,IAAI,QAAQ,GAAG;AAGtF,UAAI,CAAC,QAAQ;AACX,WAAG,QAAQ,0DAA0D,EAAE;AAAA,UACrE,QAAQ;AAAA,UACR,OAAO,QAAQ,OAAO,eAAc,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,QAC9D;AACA,eAAO;AAAA,MACT;AACA,aAAO,aAAa,IAAI,eAAe,MAAM,OAAO,IAAI,QAAQ,MAAM;AAAA,IACxE;AAAA,IACA,KAAK,QAAQ;AACX,YAAM,SAAS,aAAa,QAAQ,GAAG;AACvC,UAAI,WAAW,KAAM,QAAO;AAC5B,YAAM,KAAK,aAAa,IAAI,QAAQ,GAAG;AACvC,UAAI,OAAO,MAAM;AAKf,cAAM,OAAO,iBAAiB,IAAI,OAAO,UAAU;AACnD,cAAM,OAAM,oBAAI,KAAK,GAAE,YAAY;AAGnC,cAAM,OAAO;AAAA,UACX,OAAO,QAAQ,OAAO,UAAU,MAAM;AAAA,UACtC,QAAQ,OAAO;AAAA,QACjB,KAAK;AAAA,UACH,QAAQ;AAAA,UACR,UAAU;AAAA,QACZ;AACA,WAAG;AAAA,UACD;AAAA;AAAA;AAAA,QAGF,EAAE;AAAA,UACA;AAAA,UACA,OAAO;AAAA,UACP,OAAO,QAAQ,OAAO,SAAS,OAAO,OAAO;AAAA,UAC7C,KAAK;AAAA,UACL,KAAK;AAAA,UACL,OAAO,QAAQ,OAAO,UAAU,EAAE;AAAA,UAClC,OAAO,QAAQ,OAAO,eAAe,CAAC;AAAA,UACtC,OAAO,QAAQ,OAAO,cAAc,GAAG;AAAA,UACvC,OAAO,QAAQ,OAAO,cAAc,GAAG;AAAA,QACzC;AACA,eAAO;AAAA,MACT;AAGA,YAAM,UAAU,aAAa,IAAI,SAAS,MAAM,IAAI,QAAQ,MAAM;AAClE,aAAO,eAAe,IAAI,EAAE,KAAK;AAAA,IACnC;AAAA,IACA,KAAK,QAAQ;AACX,YAAM,SAAS,aAAa,QAAQ,GAAG;AACvC,UAAI,WAAW,KAAM,QAAO;AAC5B,YAAM,SAAS,aAAa,IAAI,OAAO,OAAO;AAC9C,UAAI,WAAW,KAAM,QAAO;AAC5B,YAAM,UAAU,QAAQ,OAAO;AAC/B,UAAI,YAAY,UAAa,YAAY,KAAM,QAAO;AACtD,YAAM,SAAS,QAAQ,OAAO;AAC9B,YAAM,YAAY,QAAQ,OAAO;AAGjC,YAAM,SAAS,GACZ;AAAA,QACC;AAAA;AAAA;AAAA;AAAA,MAIF,EACC,IAAI;AAAA,QACH;AAAA,QACA,SAAS,OAAO,OAAO;AAAA,QACvB,QAAQ,WAAW,UAAa,WAAW,OAAO,OAAO,OAAO,MAAM;AAAA,QACtE,WAAW,cAAc,UAAa,cAAc,OAAO,OAAO,OAAO,SAAS;AAAA,MACpF,CAAC;AACH,UAAI,OAAQ,QAAO;AACnB,SAAG;AAAA,QACD;AAAA,MACF,EAAE;AAAA,QACA;AAAA,QACA,QAAQ,OAAO,WAAW,UAAa,QAAQ,OAAO,WAAW,OAC7D,OACA,OAAO,QAAQ,OAAO,MAAM;AAAA,QAChC,OAAO,OAAO;AAAA,QACd,OAAO,QAAQ,OAAO,eAAc,oBAAI,KAAK,GAAE,YAAY,CAAC;AAAA,MAC9D;AACA,aAAO;AAAA,IACT;AAAA,IACA,KAAK,QAAQ;AACX,YAAM,SAAS,aAAa,QAAQ,GAAG;AACvC,UAAI,WAAW,KAAM,QAAO;AAC5B,YAAM,OAAO,aAAa,IAAI,OAAO,OAAO;AAC5C,YAAM,KAAK,aAAa,IAAI,OAAO,OAAO;AAC1C,UAAI,SAAS,QAAQ,OAAO,KAAM,QAAO;AACzC,YAAM,SAAS,GACZ,QAAQ,yEAAyE,EACjF,IAAI,MAAM,EAAE;AACf,UAAI,OAAQ,QAAO;AACnB,SAAG;AAAA,QACD;AAAA,MACF,EAAE,IAAI,MAAM,IAAI,OAAO,QAAQ,OAAO,eAAc,oBAAI,KAAK,GAAE,YAAY,CAAC,CAAC;AAC7E,aAAO;AAAA,IACT;AAAA,IACA;AACE,WAAK;AACL,aAAO;AAAA,EACX;AACF;AAgBA,SAAS,aACP,IACA,OACA,UACA,IACA,QACS;AACT,QAAM,QAAQ,OAAO,KAAK,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,cAAc,IAAI,CAAC,CAAC;AACrE,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,UAAU,GAAG,QAAQ,iBAAiB,KAAK,UAAU,QAAQ,MAAM,EAAE,IAAI,EAAE;AAMjF,QAAM,UAAU,MAAM,OAAO,CAAC,SAAS;AACrC,UAAM,SAAS,OAAO,IAAI,KAAK;AAC/B,QAAI,YAAY,OAAW,QAAO;AAClC,WAAO,QAAQ,IAAI,MAAM;AAAA,EAC3B,CAAC;AACD,MAAI,QAAQ,WAAW,EAAG,QAAO;AAEjC,QAAM,YAAY,QAAQ,IAAI,CAAC,SAAS,GAAG,IAAI,OAAO,IAAI,EAAE,EAAE,KAAK,IAAI;AACvE,QAAM,SAAiD,EAAE,GAAG;AAC5D,aAAW,QAAQ,QAAS,QAAO,IAAI,IAAI,OAAO,IAAI,KAAK;AAC3D,KAAG,QAAQ,UAAU,KAAK,QAAQ,SAAS,UAAU,QAAQ,QAAQ,EAAE,IAAI,MAAM;AACjF,SAAO;AACT;AAEA,SAAS,iBAAiB,IAAQ,MAAsB;AACtD,QAAM,WAAW,GAAG,QAAQ,2CAA2C,EAAE,IAAI,IAAI;AAGjF,MAAI,SAAU,QAAO,SAAS;AAC9B,KAAG,QAAQ,0DAA0D,EAAE;AAAA,IACrE;AAAA,KACA,oBAAI,KAAK,GAAE,YAAY;AAAA,EACzB;AACA,SAAO,oBAAoB,IAAI,IAAI;AACrC;AAIA,SAAS,aAAa,KAA6D;AACjF,QAAM,QAAQ,IAAI,QAAQ,GAAG;AAC7B,MAAI,SAAS,KAAK,UAAU,IAAI,SAAS,EAAG,QAAO;AACnD,SAAO,EAAE,YAAY,IAAI,MAAM,GAAG,KAAK,GAAG,SAAS,IAAI,MAAM,QAAQ,CAAC,EAAE;AAC1E;AAEA,SAAS,aAAa,KAAyC;AAC7D,QAAM,OAAO,IAAI,YAAY,GAAG;AAChC,MAAI,QAAQ,EAAG,QAAO;AACtB,SAAO,EAAE,SAAS,IAAI,MAAM,GAAG,IAAI,EAAE;AACvC;AAEA,SAAS,aAAa,KAA0D;AAC9E,QAAM,QAAQ,IAAI,QAAQ,IAAI;AAC9B,MAAI,SAAS,EAAG,QAAO;AACvB,SAAO,EAAE,SAAS,IAAI,MAAM,GAAG,KAAK,GAAG,SAAS,IAAI,MAAM,QAAQ,CAAC,EAAE;AACvE;AAEA,SAAS,aAAa,IAAQ,KAA4B;AACxD,QAAM,SAAS,aAAa,GAAG;AAC/B,MAAI,WAAW,KAAM,QAAO;AAC5B,QAAM,MAAM,GACT;AAAA,IACC;AAAA;AAAA;AAAA,EAGF,EACC,IAAI,OAAO,YAAY,OAAO,OAAO;AACxC,SAAO,KAAK,MAAM;AACpB;","names":["fields","legacy"]}
|
|
1
|
+
{"version":3,"sources":["../src/undo.ts"],"sourcesContent":["// mu — undo: emit INVERSE OPS for one group.\n//\n// THE CHANGE IN SEMANTICS FROM THE OLD UNDO\n// ------------------------------\n// The old `mu undo --yes` swapped the whole DB file back to a snapshot. That\n// reverts your OTHER workstreams too — on the real dogfood DB, 812 tasks\n// across three workstreams — so it was a blunt instrument you hesitated\n// to reach for. It also did not sync, so a peer kept the mistake.\n//\n// `mu undo` emits inverse ops for one `group_id`:\n//\n// * GRANULAR — touches only the rows that action touched.\n// * COMPOSABLE — it is itself an ordinary op, in its own group, so it\n// syncs to peers and is ITSELF UNDOABLE. There is no\n// separate redo mechanism and no asymmetry between undo\n// and redo: undoing an undo is just undoing a group.\n// * NO FILES — no snapshots, no swap, nothing to prune.\n//\n// HOW AN INVERSE IS COMPUTED\n// --------------------------\n// Per op in the group, oldest-first:\n//\n// put that CREATED the row -> inverse is `del`\n// put that CHANGED fields -> inverse is a put restoring the PRIOR\n// value of exactly those fields\n// del -> inverse is a put restoring the row\n//\n// A field the group wrote more than once is restored once, from the value\n// before its FIRST write in the group: later writes' \"prior\" values are\n// the group's own intermediate states.\n//\n// \"Did this put create the row\" is answered by provenance, not by a flag:\n// a put created the row iff no op for that key precedes it. \"What was\n// this field's prior value\" is the newest op before this one that named\n// that field — the same shape of query src/apply.ts uses for per-field\n// LWW (its private `fieldHlc`), pointed backwards. `priorFieldValue`\n// below is the one place undo answers \"what was this field before\";\n// keep its predicates (json_type, the legacy-intent exclusion) in step\n// with apply's, or undo will quietly restore wrong values.\n//\n// WHY INVERSES GO THROUGH THE NORMAL WRITE PATH\n// ---------------------------------------------\n// This module MUTATES THE TABLES inside a `withOpContext` scope and lets\n// the capture triggers record the result. It does NOT hand-write rows\n// into `ops`, and it does NOT use applyOp (which is capture-SUPPRESSED,\n// being the ingest path). Consequences, all of them the point:\n//\n// * The inverse gets a fresh HLC from the same clock as any other edit,\n// so it is newest and therefore wins per-field LWW.\n// * It is captured like any other change, so it appears in `mu log`,\n// flushes to this machine's segment, and is visible to the drift\n// check. `mu doctor --deep` stays clean after an undo, which is the\n// strongest end-to-end proof available that undo did not corrupt the\n// projection.\n// * It lands in its own group, which is what makes undoing an undo work\n// with no extra machinery.\n//\n// THE SUPERSESSION DECISION\n// -------------------------\n// Undoing a group whose rows were changed AGAIN by a later group is the\n// hard case. Because the inverse gets a fresh (newest) HLC, it would WIN\n// per-field LWW and silently clobber that newer work. Three options:\n//\n// (a) clobber silently — destroys work the operator did after\n// the mistake. Unacceptable.\n// (b) skip superseded fields — \"undo\" that partially does nothing,\n// silently. Equally unacceptable: the\n// operator believes the action is undone.\n// (c) REFUSE, name the conflict, require an explicit flag.\n//\n// (c) is implemented. `planUndo` detects, per field, whether a LATER op\n// (from a different group) has written that field since, and reports it\n// as a `supersededBy` conflict. `updated_at` is the exception: almost\n// every write touches it (a `task note` touches its task), so a later\n// write to it is not a conflict; the undo just leaves the newer\n// timestamp. `undoGroup` refuses unless\n// `opts.force === true`. So the default is safe and loud, and the\n// override exists and says what it will destroy. Fail safe, never fail\n// silent — the same rule capture follows.\n//\n// FK ORDERING\n// -----------\n// A group can span entities: a cascade close writes N task ops, and a\n// workstream teardown writes tombstones for the whole tree. Restoring a\n// task before its workstream violates the FK, so inverses are applied in\n// DEPENDENCY ORDER (workstream -> task -> note/edge), not merely in\n// reverse emission order. See ENTITY_RESTORE_ORDER.\n\nimport { repairTaskPair } from \"./apply.js\";\nimport { type Db, resolveWorkstreamId } from \"./db.js\";\nimport { LEGACY_LOG_ONLY_SQL_EXCLUSION } from \"./legacy-ops.js\";\nimport { groupIdFromPrefix } from \"./logs.js\";\nimport { withOpContext } from \"./op-context.js\";\nimport type { HasNextSteps, NextStep } from \"./output.js\";\nimport { localMachineId } from \"./segments.js\";\nimport { mapLegacyStatus, resolvePair } from \"./tasks/status.js\";\n\n/** One op as stored, with the provenance we need to invert it. */\ninterface GroupOpRow {\n seq: number;\n hlc: string;\n group_id: string;\n machine_id: string;\n intent: string | null;\n actor: string | null;\n entity: string;\n key: string;\n op: string;\n payload: string;\n /** Ops for this key with a strictly older HLC. */\n prior: number;\n /** Kind of the NEWEST op preceding this one for the same key, or null\n * when there is none. 'del' means this put RESURRECTED the row, which\n * is a creation for undo purposes even though prior > 0. */\n priorKind: string | null;\n}\n\n/** What undoing one op will do. */\nexport interface InverseOp {\n /** Entity of the row being restored/removed. */\n entity: string;\n /** Natural key of that row. */\n key: string;\n /** The inverse action. */\n op: \"put\" | \"del\";\n /** Field -> prior value, for a `put`. Empty for a `del`. */\n fields: Record<string, string | number | null>;\n /** Human summary, for the dry-run listing. */\n summary: string;\n /** Fields that a LATER group has written since, with the group that\n * did. Non-empty means applying this inverse would clobber newer\n * work. */\n supersededBy: Array<{ field: string; groupId: string; intent: string | null }>;\n}\n\nexport interface UndoPlan {\n /** The group being undone. */\n groupId: string;\n /** The intent(s) of the ops in the group — what the operator did. */\n intents: readonly string[];\n /** When the group was written (ISO, from the oldest op). */\n when: string;\n /** Inverse ops, in the order they will be applied (FK-safe). */\n inverses: readonly InverseOp[];\n /** True iff any inverse would clobber a newer edit. */\n superseded: boolean;\n /** Ops in the group that need no inverse (already reverted, or a\n * no-op), for an honest count. */\n skipped: number;\n}\n\nexport interface UndoResult {\n plan: UndoPlan;\n /** Group id of the ops the UNDO itself wrote — pass this to\n * `mu undo` to redo. Null when no inverse changed a row: the undo\n * wrote no ops, so there is no group to redo. */\n undoGroupId: string | null;\n /** Inverse ops that actually changed a row. */\n applied: number;\n}\n\n/** Raised when the requested group does not exist. */\nexport class UndoGroupNotFoundError extends Error implements HasNextSteps {\n constructor(readonly groupId: string) {\n super(`no ops found for group ${JSON.stringify(groupId)}`);\n this.name = \"UndoGroupNotFoundError\";\n }\n\n errorNextSteps(): NextStep[] {\n return [\n { intent: \"List recent undoable groups\", command: \"mu undo\" },\n { intent: \"Inspect a group's ops\", command: \"mu log --group <id>\" },\n ];\n }\n}\n\n/** Raised when there is nothing to undo at all. */\nexport class NothingToUndoError extends Error implements HasNextSteps {\n constructor() {\n super(\"the ops log is empty; nothing to undo\");\n this.name = \"NothingToUndoError\";\n }\n\n errorNextSteps(): NextStep[] {\n return [{ intent: \"Make a change first\", command: \"mu task add <id> -t <title>\" }];\n }\n}\n\n/** Raised when the group's rows were changed by a later group, so\n * undoing would clobber that newer work. */\nexport class UndoSupersededError extends Error implements HasNextSteps {\n constructor(\n readonly groupId: string,\n readonly conflicts: readonly { key: string; field: string; groupId: string }[],\n ) {\n const shown = conflicts\n .slice(0, 5)\n .map((c) => `${c.key}.${c.field} (changed by ${c.groupId.slice(0, 8)})`)\n .join(\", \");\n super(\n `group ${groupId.slice(0, 8)} has been superseded: ${conflicts.length} field(s) were changed ` +\n `by a later action — ${shown}. Undoing would discard that newer work.`,\n );\n this.name = \"UndoSupersededError\";\n }\n\n errorNextSteps(): NextStep[] {\n return [\n {\n intent: \"Inspect what changed since\",\n command: `mu log --group ${this.groupId.slice(0, 8)}`,\n },\n {\n intent: \"Undo anyway, discarding the newer edits\",\n command: `mu undo ${this.groupId.slice(0, 8)} --yes --force`,\n },\n ];\n }\n}\n\n// ─── entity ordering ──────────────────────────────────────────────────\n\n/**\n * Dependency order for RESTORING rows. A workstream must exist before its\n * tasks, and a task before its notes and edges, or the FK rejects the\n * insert.\n *\n * Not simply \"reverse of emission order\": a teardown emits the workstream\n * tombstone FIRST (it is the row the operator deleted; the rest are FK\n * cascade victims), so reversing would try to restore notes before their\n * task. Sorting by entity depth is correct regardless of how the group\n * happened to be emitted.\n */\nconst ENTITY_RESTORE_ORDER: Record<string, number> = {\n workstream: 0,\n task: 1,\n note: 2,\n edge: 2,\n};\n\n/** Entity depth for ordering inverses. Restores sort by ascending rank\n * (parents first); deletes sort by descending rank, the mirror image\n * (children first, so the workstream goes last). */\nfunction restoreRank(entity: string): number {\n return ENTITY_RESTORE_ORDER[entity] ?? 3;\n}\n\n// ─── provenance ───────────────────────────────────────────────────────\n\n/**\n * The value `field` held for `key` immediately BEFORE `hlc`.\n *\n * This is the provenance query src/apply.ts uses for per-field LWW,\n * pointed backwards: the newest op strictly older than `hlc` that NAMED\n * this field. `json_type(...) IS NOT NULL` rather than\n * `json_extract(...) IS NOT NULL` for the same reason apply does it —\n * json_extract returns SQL NULL both for an absent key and for a\n * present-but-null one, so a set-to-NULL would look absent and we would\n * restore the wrong (older) value.\n *\n * Returns `{ found: false }` when no earlier op named the field, which\n * means the field had no value before this op — so there is nothing to\n * restore and the op must have been part of the row's creation.\n *\n * `machineId` limits the lookup to one machine's ops. Undo passes it for\n * `owner_id`, whose live value only this machine's ops ever wrote (apply\n * strips it from peers' ops).\n */\nexport function priorFieldValue(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n field: string,\n machineId?: string,\n): { found: true; value: string | number | null } | { found: false } {\n const row = db\n .prepare(\n `SELECT json_extract(payload, '$.' || @field) AS value\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'put'\n AND hlc < @hlc\n AND (@machineId IS NULL OR machine_id = @machineId)\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n AND json_type(payload, '$.' || @field) IS NOT NULL\n ORDER BY hlc DESC\n LIMIT 1`,\n )\n .get({ entity, key, hlc, field, machineId: machineId ?? null }) as\n | { value: string | number | null }\n | undefined;\n if (row === undefined) return { found: false };\n return { found: true, value: row.value };\n}\n\n/** Groups that wrote `field` of `key` AFTER `hlc`. Non-empty means the\n * field has been superseded since the group we are undoing. `machineId`\n * as in `priorFieldValue`. */\nfunction laterWriters(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n field: string,\n excludeGroup: string,\n machineId?: string,\n): Array<{ groupId: string; intent: string | null }> {\n return db\n .prepare(\n `SELECT DISTINCT group_id AS groupId, intent\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'put'\n AND hlc > @hlc\n AND group_id <> @excludeGroup\n AND (@machineId IS NULL OR machine_id = @machineId)\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n AND json_type(payload, '$.' || @field) IS NOT NULL`,\n )\n .all({ entity, key, hlc, field, excludeGroup, machineId: machineId ?? null }) as Array<{\n groupId: string;\n intent: string | null;\n }>;\n}\n\n/** Groups that wrote ANY field of `key` AFTER `hlc`, regardless of which\n * field. Used for the two whole-row inverses (restore-from-tombstone,\n * delete-the-row-this-group-created) where the question is \"did a later\n * group touch this key at all\", not \"did it set one specific field\". */\nfunction laterRowWriters(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n excludeGroup: string,\n): Array<{ groupId: string; intent: string | null }> {\n return db\n .prepare(\n `SELECT DISTINCT group_id AS groupId, intent\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'put'\n AND hlc > @hlc\n AND group_id <> @excludeGroup\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}`,\n )\n .all({ entity, key, hlc, excludeGroup }) as Array<{\n groupId: string;\n intent: string | null;\n }>;\n}\n\n/** True iff the newest op for `key` is a tombstone written by a group\n * other than `groupId`, i.e. the row is gone and not by this group's\n * own hand. */\nfunction isDeletedByLaterGroup(db: Db, entity: string, key: string, groupId: string): boolean {\n const row = db\n .prepare(\n `SELECT op, group_id AS groupId FROM ops\n WHERE entity = @entity AND key = @key\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc DESC LIMIT 1`,\n )\n .get({ entity, key }) as { op: string; groupId: string } | undefined;\n return row?.op === \"del\" && row.groupId !== groupId;\n}\n\n/** True iff a later group DELETED this key after `hlc`. Undoing a change\n * to a row that has since been deleted would resurrect it. */\nfunction laterDeleters(\n db: Db,\n entity: string,\n key: string,\n hlc: string,\n excludeGroup: string,\n): Array<{ groupId: string; intent: string | null }> {\n return db\n .prepare(\n `SELECT DISTINCT group_id AS groupId, intent\n FROM ops\n WHERE entity = @entity\n AND key = @key\n AND op = 'del'\n AND hlc > @hlc\n AND group_id <> @excludeGroup`,\n )\n .all({ entity, key, hlc, excludeGroup }) as Array<{ groupId: string; intent: string | null }>;\n}\n\n// ─── group discovery ──────────────────────────────────────────────────\n\nexport interface GroupSummary {\n groupId: string;\n /** Distinct intents in the group, in first-seen order. */\n intents: readonly string[];\n actor: string | null;\n /** Ops in the group. */\n ops: number;\n /** ISO timestamp of the group's oldest op. */\n when: string;\n /** Newest HLC in the group, for ordering. */\n hlc: string;\n}\n\n/**\n * Recent groups, newest first. This is how group ids become DISCOVERABLE:\n * `mu undo` with no argument lists these, so the operator never has to\n * know a uuid to use the verb.\n *\n * Only groups that touched a portable table are listed, because those are\n * the only ones with anything to invert.\n */\nexport function listRecentGroups(db: Db, limit = 10): GroupSummary[] {\n const rows = db\n .prepare(\n `SELECT group_id AS groupId,\n MAX(hlc) AS hlc,\n MIN(created_at) AS when_,\n COUNT(*) AS ops,\n MAX(actor) AS actor\n FROM ops\n WHERE entity IN ('workstream','task','note','edge')\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n GROUP BY group_id\n ORDER BY MAX(hlc) DESC\n LIMIT @limit`,\n )\n .all({ limit }) as Array<{\n groupId: string;\n hlc: string;\n when_: string;\n ops: number;\n actor: string | null;\n }>;\n\n return rows.map((row) => {\n const intents = (\n db\n .prepare(\n `SELECT DISTINCT intent FROM ops\n WHERE group_id = @groupId AND intent IS NOT NULL\n ORDER BY seq`,\n )\n .all({ groupId: row.groupId }) as { intent: string }[]\n ).map((r) => r.intent);\n return {\n groupId: row.groupId,\n intents,\n actor: row.actor,\n ops: row.ops,\n when: row.when_,\n hlc: row.hlc,\n };\n });\n}\n\n/** The most recent undoable group, or null when there is none. */\nexport function mostRecentGroup(db: Db): GroupSummary | null {\n return listRecentGroups(db, 1)[0] ?? null;\n}\n\n/**\n * Resolve a possibly-abbreviated group id to a full one, or raise.\n *\n * Delegates to `groupIdFromPrefix` (src/logs.ts) so `mu undo` and\n * `mu log --group` accept EXACTLY the same identifiers. They used to\n * disagree: undo resolved prefixes, `mu log --group` compared the column\n * literally and silently returned nothing\n * (bug_group_id_prefix_asymmetry). One rule, two verbs.\n *\n * Ambiguity surfaces as `GroupIdAmbiguousError` (exit 4, a conflict the\n * operator resolves) rather than being folded into not-found.\n */\nexport function resolveGroupId(db: Db, prefix: string): string {\n const resolved = groupIdFromPrefix(db, prefix);\n if (resolved === null) throw new UndoGroupNotFoundError(prefix);\n return resolved;\n}\n\n// ─── planning ─────────────────────────────────────────────────────────\n\nconst CAPTURED_TABLE_FOR: Record<string, string> = {\n workstream: \"workstreams\",\n task: \"tasks\",\n note: \"task_notes\",\n edge: \"task_edges\",\n};\n\n/** Fields never restored by an undo: surrogate ids and the columns\n * encoded in the natural key (`local_id` / `name`). */\nconst NEVER_RESTORE = new Set([\"id\", \"local_id\", \"name\", \"workstream_id\", \"task_id\"]);\n\n/** `tasks.owner_id` is an FK into machine-local `agents`. Undoing a\n * claim or release restores it with the status/substate pair it was\n * written with, but only from THIS machine's ops (a peer's value names\n * a peer's agent) and only when that agent still exists. No local op\n * before it means the row came from a peer and the owner was NULL. A whole-row\n * restore from a tombstone leaves it NULL, as apply does. */\nconst OWNER_FIELD = \"owner_id\";\n\n/** Excluded from whole-row reconstruction (tombstone restores). */\nconst NEVER_RECONSTRUCT = new Set([...NEVER_RESTORE, OWNER_FIELD]);\n\n/** Bookkeeping timestamp written by nearly every task change, including\n * the parent touch of `mu task note`. A later write to it alone is not\n * a supersession: undo then leaves the newer timestamp in place. */\nconst TOUCH_FIELD = \"updated_at\";\n\n/**\n * Compute what undoing `groupId` would do, WITHOUT doing it.\n *\n * Pure with respect to the DB: reads only. `mu undo <group>` calls this\n * for its dry run and `undoGroup` calls it again before applying, so the\n * preview and the action can never diverge.\n */\nexport function planUndo(db: Db, groupId: string): UndoPlan {\n const rows = db\n .prepare(\n `SELECT o.seq, o.hlc, o.group_id, o.machine_id, o.intent, o.actor, o.entity, o.key, o.op, o.payload,\n (SELECT COUNT(*) FROM ops p\n WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}) AS prior,\n (SELECT p.op FROM ops p\n WHERE p.entity = o.entity AND p.key = o.key AND p.hlc < o.hlc\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY p.hlc DESC LIMIT 1) AS priorKind\n FROM ops o\n WHERE o.group_id = @groupId\n AND o.entity IN ('workstream','task','note','edge')\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY o.hlc`,\n )\n .all({ groupId }) as GroupOpRow[];\n\n if (rows.length === 0) throw new UndoGroupNotFoundError(groupId);\n\n const inverses: InverseOp[] = [];\n let skipped = 0;\n /** Task keys already handled by the legacy-note recovery below, which\n * works per TASK rather than per tombstone (see there). */\n const seenLegacyNoteTasks = new Set<string>();\n /** Per row key, the fields an EARLIER op of this group already\n * restores. A group can write one field twice (`task.park` writes\n * updated_at in two statements); only the first write's prior value\n * is the pre-group state, so later writes add no inverse for it. */\n const restoredFields = new Map<string, Set<string>>();\n let machine: string | undefined;\n const localMachine = (): string => {\n machine ??= localMachineId(db);\n return machine;\n };\n\n for (const row of rows) {\n if (row.op === \"del\") {\n // Inverse of a delete: restore the row from the state the log says\n // it had. Reconstruct it by replaying every put for the key that\n // preceded the tombstone, so a partial-update history still yields\n // a complete row.\n //\n // When that fold comes back empty, fall back to the tombstone's\n // OWN payload. Note tombstones are self-describing (see\n // fullOldPayload in src/capture.ts) precisely because a note's key\n // embeds a non-portable rowid: after a reprojection the puts sit\n // under the old key and the fold finds nothing, which used to make\n // the restore a silent no-op (drift-641).\n let fields = reconstructRow(db, row.entity, row.key, row.hlc);\n if (Object.keys(fields).length === 0) fields = payloadFields(row.payload);\n // Third tier, for LEGACY note tombstones only. A note key embeds a\n // non-portable rowid, so after a reprojection the puts sit under a\n // different key than the later del (drift-641). Tombstones written\n // since that fix carry the row, so tier 2 resolves them — but ones\n // written BEFORE it carry '{}', and then both tiers come back\n // empty and the note is silently not restored. Recover from the\n // puts for the same TASK instead of the same key.\n //\n // Ordinal position cannot be used to pair them: notes are a\n // grow-only SET, and before 3.1.1 it was keyed on (task, content),\n // so identical prose collapsed to one row. Observed 5 puts against 3 dels for one\n // task, the surplus being repeated '[reaper]' notes — the Nth put\n // is therefore not the Nth del. So this restores the DISTINCT\n // contents the log holds for the task and lets the set semantics\n // settle the count, which reaches the same end state the dedupe\n // produced. Consequence: the notes come back, but which tombstone\n // restored which note is not reconstructible, so this runs once\n // per task rather than once per tombstone.\n if (Object.keys(fields).length === 0 && row.entity === \"note\") {\n const recovered = recoverLegacyNoteContents(db, row.key, row.hlc, seenLegacyNoteTasks);\n for (const legacy of recovered) {\n inverses.push({\n entity: \"note\",\n key: legacy.key,\n op: \"put\",\n fields: legacy.fields,\n summary: `note ${legacy.key} (recovered from legacy history)`,\n supersededBy: [],\n });\n }\n skipped += 1;\n continue;\n }\n // History predating v10 carries retired lifecycle values that the\n // task_substates FK rejects, so a restore maps them onto the pair\n // they now mean, exactly as the apply path does. Without this,\n // undoing a pre-v10 `workstream teardown` aborted the whole\n // transaction on the first DEFERRED task.\n const status = fields.status;\n const legacy = typeof status === \"string\" ? mapLegacyStatus(status) : null;\n if (legacy) {\n fields.status = legacy.status;\n fields.substate = legacy.substate;\n }\n const conflicts = laterRowWriters(db, row.entity, row.key, row.hlc, groupId).map((w) => ({\n field: \"<row>\",\n groupId: w.groupId,\n intent: w.intent,\n }));\n inverses.push({\n entity: row.entity,\n key: row.key,\n op: \"put\",\n fields,\n summary: `${row.entity} ${row.key} (from tombstone)`,\n supersededBy: conflicts,\n });\n continue;\n }\n\n // A put. Did it CREATE the row, or CHANGE fields?\n //\n // Creation is \"no prior op at all\" OR \"the newest prior op was a\n // tombstone\" — the latter is a RESURRECTION, which creates the row\n // just as much as a first insert does. Testing only `prior === 0`\n // gets this wrong for the very common case of undoing an undo of a\n // delete: the undo's restoring put has prior ops (the original add\n // and the delete), so it would be treated as a field change and its\n // inverse would restore fields instead of deleting the row.\n if (row.prior === 0 || row.priorKind === \"del\") {\n // Created it: the inverse is a delete. If the newest op for the\n // key is a delete by another group (an earlier undo of this one,\n // or a later delete), the row is gone and there is nothing to\n // revert.\n if (isDeletedByLaterGroup(db, row.entity, row.key, groupId)) {\n skipped += 1;\n continue;\n }\n // A later group that wrote this key is a supersession — deleting\n // would discard that work.\n const later = [...laterRowWriters(db, row.entity, row.key, row.hlc, groupId)];\n inverses.push({\n entity: row.entity,\n key: row.key,\n op: \"del\",\n fields: {},\n summary: `${row.entity} ${row.key} (it was created by this group)`,\n supersededBy: later.map((w) => ({ field: \"<row>\", groupId: w.groupId, intent: w.intent })),\n });\n continue;\n }\n\n // Changed fields: restore the prior value of exactly those fields.\n const payload = JSON.parse(row.payload) as Record<string, unknown>;\n const fields: Record<string, string | number | null> = {};\n const supersededBy: InverseOp[\"supersededBy\"] = [];\n const rowKey = `${row.entity}\\u0000${row.key}`;\n const restored = restoredFields.get(rowKey) ?? new Set<string>();\n restoredFields.set(rowKey, restored);\n for (const field of Object.keys(payload)) {\n if (NEVER_RESTORE.has(field) || restored.has(field)) continue;\n restored.add(field);\n let machineId: string | undefined;\n if (field === OWNER_FIELD) {\n // A peer's owner_id was never applied here, so there is nothing\n // of it to undo.\n if (row.machine_id !== localMachine()) continue;\n machineId = localMachine();\n }\n const prior =\n field === OWNER_FIELD\n ? priorOwner(db, row.key, row.hlc, localMachine())\n : priorFieldValue(db, row.entity, row.key, row.hlc, field, machineId);\n if (!prior.found) {\n // No earlier op named this field, so it had no prior value to go\n // back to. Nothing to restore for it.\n continue;\n }\n // The prior owner's agent row is gone (ON DELETE SET NULL): its id\n // would violate the FK, so the owner stays as it is.\n if (field === OWNER_FIELD && prior.value !== null && !agentExists(db, prior.value)) continue;\n const writers = laterWriters(db, row.entity, row.key, row.hlc, field, groupId, machineId);\n if (field === TOUCH_FIELD && writers.length > 0) continue;\n fields[field] = prior.value;\n for (const writer of writers) {\n supersededBy.push({ field, groupId: writer.groupId, intent: writer.intent });\n }\n }\n // A later DELETE also supersedes: restoring fields on a row that has\n // since been removed would resurrect it.\n for (const deleter of laterDeleters(db, row.entity, row.key, row.hlc, groupId)) {\n supersededBy.push({ field: \"<row>\", groupId: deleter.groupId, intent: deleter.intent });\n }\n\n if (Object.keys(fields).length === 0) {\n skipped += 1;\n continue;\n }\n inverses.push({\n entity: row.entity,\n key: row.key,\n op: \"put\",\n fields,\n summary: `${row.entity} ${row.key} ${Object.entries(fields)\n .map(([f, v]) => `${f}=${v === null ? \"NULL\" : String(v)}`)\n .join(\" \")}`,\n supersededBy,\n });\n }\n\n // FK-safe ordering. Restores must go parents-first; deletes\n // children-first. Sorting by entity depth is correct regardless of the\n // order the group happened to be emitted in.\n const ordered = [...inverses].sort((a, b) => {\n if (a.op !== b.op) return a.op === \"put\" ? -1 : 1; // restores before deletes\n const rank =\n a.op === \"put\"\n ? restoreRank(a.entity) - restoreRank(b.entity)\n : restoreRank(b.entity) - restoreRank(a.entity);\n return rank;\n });\n\n const intents = [...new Set(rows.map((r) => r.intent).filter((i): i is string => i !== null))];\n\n return {\n groupId,\n intents,\n when: groupWhen(db, groupId),\n inverses: ordered,\n superseded: ordered.some((i) => i.supersededBy.length > 0),\n skipped,\n };\n}\n\n/**\n * The owner_id a task had just before `hlc`, from this machine's ops.\n *\n * Always found: a task row only ever starts unowned. Apply strips a\n * peer's owner_id and inserts the row with owner_id NULL without\n * capturing, so a task created (or recreated after a tombstone) by a\n * peer has no local op naming owner_id. A local op older than a\n * tombstone that won described a row that no longer exists, so it does\n * not count either. A tombstone lost (and deleted nothing) when a newer\n * put had already arrived, i.e. one with a higher hlc and a lower seq:\n * the same check applyDel makes.\n */\nfunction priorOwner(\n db: Db,\n key: string,\n hlc: string,\n machineId: string,\n): { found: true; value: string | number | null } {\n const local = priorFieldValue(db, \"task\", key, hlc, OWNER_FIELD, machineId);\n if (!local.found) return { found: true, value: null };\n const tomb = db\n .prepare(\n `SELECT 1 FROM ops d\n WHERE d.entity = 'task' AND d.key = @key AND d.op = 'del' AND d.hlc < @hlc\n AND d.hlc > (SELECT MAX(hlc) FROM ops\n WHERE entity = 'task' AND key = @key AND op = 'put'\n AND hlc < @hlc AND machine_id = @machineId\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n AND json_type(payload, '$.owner_id') IS NOT NULL)\n AND NOT EXISTS (SELECT 1 FROM ops p\n WHERE p.entity = 'task' AND p.key = @key AND p.op = 'put'\n AND p.hlc > d.hlc AND p.seq < d.seq)\n LIMIT 1`,\n )\n .get({ key, hlc, machineId });\n return tomb === undefined ? local : { found: true, value: null };\n}\n\nfunction agentExists(db: Db, id: string | number): boolean {\n return db.prepare(\"SELECT 1 FROM agents WHERE id = ?\").get(id) !== undefined;\n}\n\nfunction groupWhen(db: Db, groupId: string): string {\n const row = db\n .prepare(\"SELECT MIN(created_at) AS when_ FROM ops WHERE group_id = ?\")\n .get(groupId) as { when_: string | null } | undefined;\n return row?.when_ ?? \"\";\n}\n\n/**\n * Note contents the log holds for a task, for tombstones no other tier\n * can resolve (see the call site for why this is per-task).\n *\n * Returns one entry per DISTINCT content, skipping any whose content is\n * already live, and returns nothing at all the second time it is asked\n * about a task — `seen` makes the per-task work happen once even though\n * the caller iterates per tombstone.\n */\nfunction recoverLegacyNoteContents(\n db: Db,\n tombstoneKey: string,\n beforeHlc: string,\n seen: Set<string>,\n): Array<{ key: string; fields: Record<string, string | number | null> }> {\n const parsed = parseNoteKey(tombstoneKey);\n if (parsed === null) return [];\n if (seen.has(parsed.taskKey)) return [];\n seen.add(parsed.taskKey);\n\n const rows = db\n .prepare(\n `SELECT key, payload FROM ops\n WHERE entity = 'note'\n AND op = 'put'\n AND hlc < @hlc\n AND key LIKE @prefix\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc`,\n )\n .all({ hlc: beforeHlc, prefix: `${parsed.taskKey}#%` }) as Array<{\n key: string;\n payload: string;\n }>;\n\n const out: Array<{ key: string; fields: Record<string, string | number | null> }> = [];\n const contents = new Set<string>();\n for (const row of rows) {\n const fields = payloadFields(row.payload);\n const content = fields.content;\n if (typeof content !== \"string\" || content === \"\") continue;\n if (contents.has(content)) continue;\n contents.add(content);\n out.push({ key: row.key, fields });\n }\n return out;\n}\n\n/** The restorable fields carried by one op's payload.\n *\n * Shares `NEVER_RECONSTRUCT` and the scalar-only guard with\n * `reconstructRow`, so a fallback can never reintroduce a surrogate id\n * or an FK the fold would have dropped. */\nfunction payloadFields(payload: string): Record<string, string | number | null> {\n const fields: Record<string, string | number | null> = {};\n const parsed = JSON.parse(payload) as Record<string, unknown>;\n for (const [field, value] of Object.entries(parsed)) {\n if (NEVER_RECONSTRUCT.has(field)) continue;\n if (value === null || typeof value === \"string\" || typeof value === \"number\") {\n fields[field] = value;\n }\n }\n return fields;\n}\n\n/**\n * Rebuild the full field set a row had just before `beforeHlc`, by\n * folding every put for that key in HLC order.\n *\n * Necessary because ops are SEMANTIC PARTIAL UPDATES: most tombstones\n * carry no payload and the creating put may have been amended by later\n * partial puts, so no single op holds the whole row. Folding is the only\n * correct reconstruction, and it is the same fold the rebuild path does —\n * just bounded to one key and one point in time.\n */\nfunction reconstructRow(\n db: Db,\n entity: string,\n key: string,\n beforeHlc: string,\n): Record<string, string | number | null> {\n const puts = db\n .prepare(\n `SELECT payload FROM ops\n WHERE entity = @entity AND key = @key AND op = 'put' AND hlc < @hlc\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc`,\n )\n .all({ entity, key, hlc: beforeHlc }) as { payload: string }[];\n const fields: Record<string, string | number | null> = {};\n for (const put of puts) {\n const parsed = JSON.parse(put.payload) as Record<string, unknown>;\n for (const [field, value] of Object.entries(parsed)) {\n if (NEVER_RECONSTRUCT.has(field)) continue;\n if (value === null || typeof value === \"string\" || typeof value === \"number\") {\n fields[field] = value;\n }\n }\n }\n return fields;\n}\n\n// ─── applying ─────────────────────────────────────────────────────────\n\nexport interface UndoOptions {\n /** Apply even when the group has been superseded, discarding the\n * newer edits to those fields. */\n force?: boolean;\n /** Actor recorded on the undo's own ops. */\n actor?: string | undefined;\n}\n\n/**\n * Apply the inverse of `groupId`.\n *\n * Everything happens inside ONE `withOpContext` scope with\n * `group: \"new\"`, so:\n * * every inverse write lands in a single new group (making the undo\n * itself one undoable unit), and\n * * the capture triggers record each write with a fresh HLC, so the\n * undo is an ordinary op that syncs and shows up in `mu log`.\n *\n * Wrapped in one transaction: a half-applied undo is worse than none,\n * because the operator would not know which half.\n */\nexport function undoGroup(db: Db, groupId: string, opts: UndoOptions = {}): UndoResult {\n const plan = planUndo(db, groupId);\n\n if (plan.superseded && opts.force !== true) {\n const conflicts = plan.inverses.flatMap((inv) =>\n inv.supersededBy.map((s) => ({ key: inv.key, field: s.field, groupId: s.groupId })),\n );\n throw new UndoSupersededError(groupId, conflicts);\n }\n\n let applied = 0;\n let undoGroupId = \"\";\n\n const run = db.transaction(() => {\n withOpContext(\n db,\n { intent: \"undo\", group: \"new\", ...(opts.actor !== undefined ? { actor: opts.actor } : {}) },\n () => {\n const ctx = db.prepare(\"SELECT group_id FROM _op_ctx\").get() as\n | { group_id: string | null }\n | undefined;\n undoGroupId = ctx?.group_id ?? \"\";\n\n for (const inverse of plan.inverses) {\n if (applyInverse(db, inverse)) applied += 1;\n }\n },\n );\n });\n run.immediate();\n\n return { plan, undoGroupId: applied > 0 && undoGroupId !== \"\" ? undoGroupId : null, applied };\n}\n\n/** Apply one inverse by MUTATING THE TABLE, letting capture record it.\n * Returns true iff a row changed. */\nfunction applyInverse(db: Db, inverse: InverseOp): boolean {\n const table = CAPTURED_TABLE_FOR[inverse.entity];\n if (table === undefined) return false;\n\n if (inverse.op === \"del\") return deleteRow(db, inverse);\n return restoreRow(db, inverse, table);\n}\n\nfunction deleteRow(db: Db, inverse: InverseOp): boolean {\n switch (inverse.entity) {\n case \"workstream\":\n return db.prepare(\"DELETE FROM workstreams WHERE name = ?\").run(inverse.key).changes > 0;\n case \"task\": {\n const id = taskIdForKey(db, inverse.key);\n if (id === null) return false;\n return db.prepare(\"DELETE FROM tasks WHERE id = ?\").run(id).changes > 0;\n }\n case \"note\": {\n const parsed = parseNoteKey(inverse.key);\n if (parsed === null) return false;\n const taskId = taskIdForKey(db, parsed.taskKey);\n if (taskId === null) return false;\n // A note's identity is (task, author, content, created_at), as in\n // applyNotePut, so delete exactly the note the log says this was,\n // not every repeat of its text.\n const note = db\n .prepare(\n `SELECT json_extract(payload, '$.content') AS content,\n json_extract(payload, '$.author') AS author,\n json_extract(payload, '$.created_at') AS created_at FROM ops\n WHERE entity = 'note' AND key = ? AND op = 'put'\n AND ${LEGACY_LOG_ONLY_SQL_EXCLUSION}\n ORDER BY hlc DESC LIMIT 1`,\n )\n .get(inverse.key) as\n | { content: string | null; author: string | null; created_at: string | null }\n | undefined;\n if (note?.content === undefined || note.content === null) return false;\n return (\n db\n .prepare(\n `DELETE FROM task_notes\n WHERE task_id = @taskId AND content = @content\n AND COALESCE(author, '') = COALESCE(@author, '')\n AND (@createdAt IS NULL OR created_at = @createdAt)`,\n )\n .run({\n taskId,\n content: note.content,\n author: note.author,\n createdAt: note.created_at,\n }).changes > 0\n );\n }\n case \"edge\": {\n const parsed = parseEdgeKey(inverse.key);\n if (parsed === null) return false;\n const from = taskIdForKey(db, parsed.blocker);\n const to = taskIdForKey(db, parsed.blocked);\n if (from === null || to === null) return false;\n return (\n db.prepare(\"DELETE FROM task_edges WHERE from_task_id = ? AND to_task_id = ?\").run(from, to)\n .changes > 0\n );\n }\n default:\n return false;\n }\n}\n\nfunction restoreRow(db: Db, inverse: InverseOp, table: string): boolean {\n switch (inverse.entity) {\n case \"workstream\": {\n const exists = db.prepare(\"SELECT id FROM workstreams WHERE name = ?\").get(inverse.key) as\n | { id: number }\n | undefined;\n if (!exists) {\n db.prepare(\"INSERT INTO workstreams (name, created_at) VALUES (?, ?)\").run(\n inverse.key,\n String(inverse.fields.created_at ?? new Date().toISOString()),\n );\n return true;\n }\n return updateFields(db, \"workstreams\", \"id\", exists.id, inverse.fields);\n }\n case \"task\": {\n const parsed = parseTaskKey(inverse.key);\n if (parsed === null) return false;\n const id = taskIdForKey(db, inverse.key);\n if (id === null) {\n // The task row is gone, so this is a restore-from-tombstone. The\n // parent workstream must exist first; it will have been restored\n // already by the entity ordering, but create it if the group\n // only deleted the task.\n const wsId = ensureWorkstream(db, parsed.workstream);\n const now = new Date().toISOString();\n // Insert a legal pair: the FK checks it at commit, and an\n // unknown status or substate must not abort the whole undo.\n const pair = resolvePair(\n String(inverse.fields.status ?? \"OPEN\"),\n inverse.fields.substate,\n ) ?? {\n status: \"OPEN\",\n substate: \"todo\",\n };\n db.prepare(\n `INSERT INTO tasks (workstream_id, local_id, title, status, substate, impact,\n effort_days, owner_id, created_at, updated_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,\n ).run(\n wsId,\n parsed.localId,\n String(inverse.fields.title ?? parsed.localId),\n pair.status,\n pair.substate,\n Number(inverse.fields.impact ?? 50),\n Number(inverse.fields.effort_days ?? 1),\n String(inverse.fields.created_at ?? now),\n String(inverse.fields.updated_at ?? now),\n );\n return true;\n }\n // A partial restore (e.g. status only, from history that predates\n // substates) can leave an illegal pair; repair it before commit.\n const updated = updateFields(db, \"tasks\", \"id\", id, inverse.fields);\n return repairTaskPair(db, id) || updated;\n }\n case \"note\": {\n const parsed = parseNoteKey(inverse.key);\n if (parsed === null) return false;\n const taskId = taskIdForKey(db, parsed.taskKey);\n if (taskId === null) return false;\n const content = inverse.fields.content;\n if (content === undefined || content === null) return false;\n const author = inverse.fields.author;\n const createdAt = inverse.fields.created_at;\n // Same identity as applyNotePut, so restoring one of two repeats of\n // a text does not find the other and stop.\n const exists = db\n .prepare(\n `SELECT id FROM task_notes\n WHERE task_id = @taskId AND content = @content\n AND COALESCE(author, '') = COALESCE(@author, '')\n AND (@createdAt IS NULL OR created_at = @createdAt)`,\n )\n .get({\n taskId,\n content: String(content),\n author: author === undefined || author === null ? null : String(author),\n createdAt: createdAt === undefined || createdAt === null ? null : String(createdAt),\n }) as { id: number } | undefined;\n if (exists) return false; // grow-only: already there\n db.prepare(\n \"INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)\",\n ).run(\n taskId,\n inverse.fields.author === undefined || inverse.fields.author === null\n ? null\n : String(inverse.fields.author),\n String(content),\n String(inverse.fields.created_at ?? new Date().toISOString()),\n );\n return true;\n }\n case \"edge\": {\n const parsed = parseEdgeKey(inverse.key);\n if (parsed === null) return false;\n const from = taskIdForKey(db, parsed.blocker);\n const to = taskIdForKey(db, parsed.blocked);\n if (from === null || to === null) return false;\n const exists = db\n .prepare(\"SELECT 1 AS x FROM task_edges WHERE from_task_id = ? AND to_task_id = ?\")\n .get(from, to) as { x: number } | undefined;\n if (exists) return false;\n db.prepare(\n \"INSERT INTO task_edges (from_task_id, to_task_id, created_at) VALUES (?, ?, ?)\",\n ).run(from, to, String(inverse.fields.created_at ?? new Date().toISOString()));\n return true;\n }\n default:\n void table;\n return false;\n }\n}\n\n/**\n * Restore the named fields in ONE UPDATE.\n *\n * One statement, not one per field, and that matters: each UPDATE fires\n * the capture trigger once, so a per-field loop would emit N ops for a\n * single logical inverse. They would share the undo's group so undo/redo\n * still worked, but `mu log` would show one action as several and the\n * op count would misrepresent what happened. One statement, one op.\n *\n * Fields already holding the target value are dropped from the statement\n * first, so a fully-redundant inverse issues no UPDATE at all and\n * therefore produces no op (matching the capture WHEN guard). Returns\n * true iff a row actually changed, so `applied` never overstates the work.\n */\nfunction updateFields(\n db: Db,\n table: string,\n idColumn: string,\n id: number,\n fields: Record<string, string | number | null>,\n): boolean {\n const names = Object.keys(fields).filter((f) => !NEVER_RESTORE.has(f));\n if (names.length === 0) return false;\n\n const current = db.prepare(`SELECT * FROM ${table} WHERE ${idColumn} = ?`).get(id) as\n | Record<string, string | number | null>\n | undefined;\n\n // Only fields whose value actually differs. Comparing here rather than\n // relying on the trigger keeps `changed` honest for the caller.\n const pending = names.filter((name) => {\n const target = fields[name] ?? null;\n if (current === undefined) return true;\n return current[name] !== target;\n });\n if (pending.length === 0) return false;\n\n const setClause = pending.map((name) => `${name} = @${name}`).join(\", \");\n const params: Record<string, string | number | null> = { id };\n for (const name of pending) params[name] = fields[name] ?? null;\n db.prepare(`UPDATE ${table} SET ${setClause} WHERE ${idColumn} = @id`).run(params);\n return true;\n}\n\nfunction ensureWorkstream(db: Db, name: string): number {\n const existing = db.prepare(\"SELECT id FROM workstreams WHERE name = ?\").get(name) as\n | { id: number }\n | undefined;\n if (existing) return existing.id;\n db.prepare(\"INSERT INTO workstreams (name, created_at) VALUES (?, ?)\").run(\n name,\n new Date().toISOString(),\n );\n return resolveWorkstreamId(db, name);\n}\n\n// ─── key parsing (mirrors src/apply.ts) ───────────────────────────────\n\nfunction parseTaskKey(key: string): { workstream: string; localId: string } | null {\n const slash = key.indexOf(\"/\");\n if (slash <= 0 || slash === key.length - 1) return null;\n return { workstream: key.slice(0, slash), localId: key.slice(slash + 1) };\n}\n\nfunction parseNoteKey(key: string): { taskKey: string } | null {\n const hash = key.lastIndexOf(\"#\");\n if (hash <= 0) return null;\n return { taskKey: key.slice(0, hash) };\n}\n\nfunction parseEdgeKey(key: string): { blocker: string; blocked: string } | null {\n const arrow = key.indexOf(\"->\");\n if (arrow <= 0) return null;\n return { blocker: key.slice(0, arrow), blocked: key.slice(arrow + 2) };\n}\n\nfunction taskIdForKey(db: Db, key: string): number | null {\n const parsed = parseTaskKey(key);\n if (parsed === null) return null;\n const row = db\n .prepare(\n `SELECT t.id AS id FROM tasks t\n JOIN workstreams w ON w.id = t.workstream_id\n WHERE w.name = ? AND t.local_id = ?`,\n )\n .get(parsed.workstream, parsed.localId) as { id: number } | undefined;\n return row?.id ?? null;\n}\n"],"mappings":"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","names":["fields","legacy"]}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createRequire as __muCreateRequire } from "node:module"; const require = __muCreateRequire(import.meta.url);
|
|
2
|
+
|
|
1
3
|
// src/cli/tui/yank.ts
|
|
2
4
|
import { writeFile } from "fs/promises";
|
|
3
5
|
async function probeClipboardBackend(env = {}) {
|
|
@@ -82,4 +84,4 @@ export {
|
|
|
82
84
|
osc52Sequence,
|
|
83
85
|
yank
|
|
84
86
|
};
|
|
85
|
-
//# sourceMappingURL=chunk-
|
|
87
|
+
//# sourceMappingURL=chunk-QITLNZEM.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli/tui/yank.ts"],"sourcesContent":["// Clipboard probe + yank for the TUI's act-intent flow (R1: model\n// drives the CLI; the user yanks `mu …` commands rather than the TUI\n// executing them). See design_yank_flow + investigate_clipboard in\n// workstream `tui` for the matrix.\n//\n// Probe order:\n// darwin → pbcopy\n// linux + Wayland → wl-copy\n// linux + X11 → xclip → xsel\n// wsl → clip.exe\n// fallback → OSC-52 (works over SSH; needs terminal support)\n// final → null (UI shows \"[no clipboard]\" but the\n// command is still visible in the footer)\n//\n// The probe is sync-style (relies on which/command-v paths existing or\n// not) but execa returns Promises, so the API is async. Probe once per\n// TUI session and cache the result.\n\nimport { writeFile } from \"node:fs/promises\";\n\nexport type ClipboardBackend =\n | { kind: \"cli\"; cmd: string; args: string[] }\n | { kind: \"osc52\" }\n | null;\n\nexport interface YankResult {\n copied: boolean;\n backend: string | null;\n error?: string;\n}\n\n/** Injection seam for probeClipboardBackend tests — lets a test pin\n * process.platform / WAYLAND_DISPLAY / DISPLAY / WSL detection /\n * binary availability without monkey-patching globals. Defaults\n * exercise the real environment. */\nexport interface ProbeEnv {\n platform?: NodeJS.Platform;\n wayland?: boolean;\n x11?: boolean;\n isWsl?: () => Promise<boolean>;\n hasCommand?: (cmd: string) => Promise<boolean>;\n}\n\n/**\n * Probe the environment for an available clipboard backend. Returns\n * null when nothing works (the TUI falls back to displaying the\n * command in the footer for manual mouse-select).\n *\n * Caller is expected to memoise the result for the lifetime of the\n * TUI session.\n */\nexport async function probeClipboardBackend(env: ProbeEnv = {}): Promise<ClipboardBackend> {\n const platform = env.platform ?? process.platform;\n const hasWayland = env.wayland ?? process.env.WAYLAND_DISPLAY !== undefined;\n const hasX = env.x11 ?? process.env.DISPLAY !== undefined;\n const wsl = env.isWsl ?? isWsl;\n const has = env.hasCommand ?? hasCommand;\n\n // macOS\n if (platform === \"darwin\" && (await has(\"pbcopy\"))) {\n return { kind: \"cli\", cmd: \"pbcopy\", args: [] };\n }\n\n // WSL\n if (await wsl()) {\n if (await has(\"clip.exe\")) {\n return { kind: \"cli\", cmd: \"clip.exe\", args: [] };\n }\n }\n\n // Linux: prefer Wayland over X11 (XWayland sessions often have both).\n if (platform === \"linux\") {\n if (hasWayland && (await has(\"wl-copy\"))) {\n return { kind: \"cli\", cmd: \"wl-copy\", args: [\"--type\", \"text/plain\"] };\n }\n if (hasX) {\n if (await has(\"xclip\")) {\n return {\n kind: \"cli\",\n cmd: \"xclip\",\n args: [\"-selection\", \"clipboard\", \"-in\", \"-loops\", \"1\", \"-quiet\"],\n };\n }\n if (await has(\"xsel\")) {\n return { kind: \"cli\", cmd: \"xsel\", args: [\"--clipboard\", \"--input\"] };\n }\n }\n }\n\n // OSC-52 — best-effort over any terminal that supports it. Always\n // available as a fallback because we can write to /dev/tty without\n // any external dependency. The terminal may silently drop the\n // sequence (gnome-terminal, old tmux without `set-clipboard on`),\n // but we've done all we can — the footer line is the real fallback.\n return { kind: \"osc52\" };\n}\n\n/** Build the OSC-52 clipboard-set sequence for `text`. Exported for\n * test assertions: a regression that swaps the BEL terminator for\n * the (also-legal) ST `ESC \\` would still copy in some terminals\n * but break others; pinning the byte sequence catches both. */\nexport function osc52Sequence(text: string): string {\n return `\\x1b]52;c;${Buffer.from(text).toString(\"base64\")}\\x07`;\n}\n\n/** Check whether `cmd` exists on PATH. Returns false on any error. */\nasync function hasCommand(cmd: string): Promise<boolean> {\n try {\n const { execa } = await import(\"execa\");\n await execa(\"command\", [\"-v\", cmd], { shell: true, stdio: \"ignore\" });\n return true;\n } catch {\n return false;\n }\n}\n\n/** Detect WSL via /proc/version content. Returns false on any error. */\nasync function isWsl(): Promise<boolean> {\n try {\n const { readFile } = await import(\"node:fs/promises\");\n const v = await readFile(\"/proc/version\", \"utf-8\");\n return v.toLowerCase().includes(\"microsoft\");\n } catch {\n return false;\n }\n}\n\n/** Writer seam for the OSC-52 branch. Default writes to /dev/tty\n * (so the sequence reaches the controlling terminal even though\n * ink owns stdout). Tests inject a stub that captures the bytes. */\nexport type Osc52Writer = (data: string) => Promise<void>;\n\nconst defaultOsc52Writer: Osc52Writer = (data) => writeFile(\"/dev/tty\", data);\n\n/**\n * Copy `text` to the clipboard via the resolved backend. Returns a\n * structured result so the caller can render `[copied]` /\n * `[no clipboard]` in the footer/toast.\n */\nexport async function yank(\n text: string,\n backend: ClipboardBackend,\n opts: { osc52Writer?: Osc52Writer } = {},\n): Promise<YankResult> {\n if (backend === null) {\n return { copied: false, backend: null };\n }\n\n if (backend.kind === \"cli\") {\n try {\n const { execa } = await import(\"execa\");\n await execa(backend.cmd, backend.args, { input: text });\n return { copied: true, backend: backend.cmd };\n } catch (err) {\n return { copied: false, backend: backend.cmd, error: String(err) };\n }\n }\n\n // OSC-52: ESC ] 52 ; c ; <base64> BEL.\n const writer = opts.osc52Writer ?? defaultOsc52Writer;\n try {\n await writer(osc52Sequence(text));\n return { copied: true, backend: \"osc52\" };\n } catch (err) {\n return { copied: false, backend: \"osc52\", error: String(err) };\n }\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../src/cli/tui/yank.ts"],"sourcesContent":["// Clipboard probe + yank for the TUI's act-intent flow (R1: model\n// drives the CLI; the user yanks `mu …` commands rather than the TUI\n// executing them). See design_yank_flow + investigate_clipboard in\n// workstream `tui` for the matrix.\n//\n// Probe order:\n// darwin → pbcopy\n// linux + Wayland → wl-copy\n// linux + X11 → xclip → xsel\n// wsl → clip.exe\n// fallback → OSC-52 (works over SSH; needs terminal support)\n// final → null (UI shows \"[no clipboard]\" but the\n// command is still visible in the footer)\n//\n// The probe is sync-style (relies on which/command-v paths existing or\n// not) but execa returns Promises, so the API is async. Probe once per\n// TUI session and cache the result.\n\nimport { writeFile } from \"node:fs/promises\";\n\nexport type ClipboardBackend =\n | { kind: \"cli\"; cmd: string; args: string[] }\n | { kind: \"osc52\" }\n | null;\n\nexport interface YankResult {\n copied: boolean;\n backend: string | null;\n error?: string;\n}\n\n/** Injection seam for probeClipboardBackend tests — lets a test pin\n * process.platform / WAYLAND_DISPLAY / DISPLAY / WSL detection /\n * binary availability without monkey-patching globals. Defaults\n * exercise the real environment. */\nexport interface ProbeEnv {\n platform?: NodeJS.Platform;\n wayland?: boolean;\n x11?: boolean;\n isWsl?: () => Promise<boolean>;\n hasCommand?: (cmd: string) => Promise<boolean>;\n}\n\n/**\n * Probe the environment for an available clipboard backend. Returns\n * null when nothing works (the TUI falls back to displaying the\n * command in the footer for manual mouse-select).\n *\n * Caller is expected to memoise the result for the lifetime of the\n * TUI session.\n */\nexport async function probeClipboardBackend(env: ProbeEnv = {}): Promise<ClipboardBackend> {\n const platform = env.platform ?? process.platform;\n const hasWayland = env.wayland ?? process.env.WAYLAND_DISPLAY !== undefined;\n const hasX = env.x11 ?? process.env.DISPLAY !== undefined;\n const wsl = env.isWsl ?? isWsl;\n const has = env.hasCommand ?? hasCommand;\n\n // macOS\n if (platform === \"darwin\" && (await has(\"pbcopy\"))) {\n return { kind: \"cli\", cmd: \"pbcopy\", args: [] };\n }\n\n // WSL\n if (await wsl()) {\n if (await has(\"clip.exe\")) {\n return { kind: \"cli\", cmd: \"clip.exe\", args: [] };\n }\n }\n\n // Linux: prefer Wayland over X11 (XWayland sessions often have both).\n if (platform === \"linux\") {\n if (hasWayland && (await has(\"wl-copy\"))) {\n return { kind: \"cli\", cmd: \"wl-copy\", args: [\"--type\", \"text/plain\"] };\n }\n if (hasX) {\n if (await has(\"xclip\")) {\n return {\n kind: \"cli\",\n cmd: \"xclip\",\n args: [\"-selection\", \"clipboard\", \"-in\", \"-loops\", \"1\", \"-quiet\"],\n };\n }\n if (await has(\"xsel\")) {\n return { kind: \"cli\", cmd: \"xsel\", args: [\"--clipboard\", \"--input\"] };\n }\n }\n }\n\n // OSC-52 — best-effort over any terminal that supports it. Always\n // available as a fallback because we can write to /dev/tty without\n // any external dependency. The terminal may silently drop the\n // sequence (gnome-terminal, old tmux without `set-clipboard on`),\n // but we've done all we can — the footer line is the real fallback.\n return { kind: \"osc52\" };\n}\n\n/** Build the OSC-52 clipboard-set sequence for `text`. Exported for\n * test assertions: a regression that swaps the BEL terminator for\n * the (also-legal) ST `ESC \\` would still copy in some terminals\n * but break others; pinning the byte sequence catches both. */\nexport function osc52Sequence(text: string): string {\n return `\\x1b]52;c;${Buffer.from(text).toString(\"base64\")}\\x07`;\n}\n\n/** Check whether `cmd` exists on PATH. Returns false on any error. */\nasync function hasCommand(cmd: string): Promise<boolean> {\n try {\n const { execa } = await import(\"execa\");\n await execa(\"command\", [\"-v\", cmd], { shell: true, stdio: \"ignore\" });\n return true;\n } catch {\n return false;\n }\n}\n\n/** Detect WSL via /proc/version content. Returns false on any error. */\nasync function isWsl(): Promise<boolean> {\n try {\n const { readFile } = await import(\"node:fs/promises\");\n const v = await readFile(\"/proc/version\", \"utf-8\");\n return v.toLowerCase().includes(\"microsoft\");\n } catch {\n return false;\n }\n}\n\n/** Writer seam for the OSC-52 branch. Default writes to /dev/tty\n * (so the sequence reaches the controlling terminal even though\n * ink owns stdout). Tests inject a stub that captures the bytes. */\nexport type Osc52Writer = (data: string) => Promise<void>;\n\nconst defaultOsc52Writer: Osc52Writer = (data) => writeFile(\"/dev/tty\", data);\n\n/**\n * Copy `text` to the clipboard via the resolved backend. Returns a\n * structured result so the caller can render `[copied]` /\n * `[no clipboard]` in the footer/toast.\n */\nexport async function yank(\n text: string,\n backend: ClipboardBackend,\n opts: { osc52Writer?: Osc52Writer } = {},\n): Promise<YankResult> {\n if (backend === null) {\n return { copied: false, backend: null };\n }\n\n if (backend.kind === \"cli\") {\n try {\n const { execa } = await import(\"execa\");\n await execa(backend.cmd, backend.args, { input: text });\n return { copied: true, backend: backend.cmd };\n } catch (err) {\n return { copied: false, backend: backend.cmd, error: String(err) };\n }\n }\n\n // OSC-52: ESC ] 52 ; c ; <base64> BEL.\n const writer = opts.osc52Writer ?? defaultOsc52Writer;\n try {\n await writer(osc52Sequence(text));\n return { copied: true, backend: \"osc52\" };\n } catch (err) {\n return { copied: false, backend: \"osc52\", error: String(err) };\n }\n}\n"],"mappings":";;;AAkBA,SAAS,iBAAiB;AAiC1B,eAAsB,sBAAsB,MAAgB,CAAC,GAA8B;AACzF,QAAM,WAAW,IAAI,YAAY,QAAQ;AACzC,QAAM,aAAa,IAAI,WAAW,QAAQ,IAAI,oBAAoB;AAClE,QAAM,OAAO,IAAI,OAAO,QAAQ,IAAI,YAAY;AAChD,QAAM,MAAM,IAAI,SAAS;AACzB,QAAM,MAAM,IAAI,cAAc;AAG9B,MAAI,aAAa,YAAa,MAAM,IAAI,QAAQ,GAAI;AAClD,WAAO,EAAE,MAAM,OAAO,KAAK,UAAU,MAAM,CAAC,EAAE;AAAA,EAChD;AAGA,MAAI,MAAM,IAAI,GAAG;AACf,QAAI,MAAM,IAAI,UAAU,GAAG;AACzB,aAAO,EAAE,MAAM,OAAO,KAAK,YAAY,MAAM,CAAC,EAAE;AAAA,IAClD;AAAA,EACF;AAGA,MAAI,aAAa,SAAS;AACxB,QAAI,cAAe,MAAM,IAAI,SAAS,GAAI;AACxC,aAAO,EAAE,MAAM,OAAO,KAAK,WAAW,MAAM,CAAC,UAAU,YAAY,EAAE;AAAA,IACvE;AACA,QAAI,MAAM;AACR,UAAI,MAAM,IAAI,OAAO,GAAG;AACtB,eAAO;AAAA,UACL,MAAM;AAAA,UACN,KAAK;AAAA,UACL,MAAM,CAAC,cAAc,aAAa,OAAO,UAAU,KAAK,QAAQ;AAAA,QAClE;AAAA,MACF;AACA,UAAI,MAAM,IAAI,MAAM,GAAG;AACrB,eAAO,EAAE,MAAM,OAAO,KAAK,QAAQ,MAAM,CAAC,eAAe,SAAS,EAAE;AAAA,MACtE;AAAA,IACF;AAAA,EACF;AAOA,SAAO,EAAE,MAAM,QAAQ;AACzB;AAMO,SAAS,cAAc,MAAsB;AAClD,SAAO,aAAa,OAAO,KAAK,IAAI,EAAE,SAAS,QAAQ,CAAC;AAC1D;AAGA,eAAe,WAAW,KAA+B;AACvD,MAAI;AACF,UAAM,EAAE,MAAM,IAAI,MAAM,OAAO,OAAO;AACtC,UAAM,MAAM,WAAW,CAAC,MAAM,GAAG,GAAG,EAAE,OAAO,MAAM,OAAO,SAAS,CAAC;AACpE,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGA,eAAe,QAA0B;AACvC,MAAI;AACF,UAAM,EAAE,SAAS,IAAI,MAAM,OAAO,aAAkB;AACpD,UAAM,IAAI,MAAM,SAAS,iBAAiB,OAAO;AACjD,WAAO,EAAE,YAAY,EAAE,SAAS,WAAW;AAAA,EAC7C,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAOA,IAAM,qBAAkC,CAAC,SAAS,UAAU,YAAY,IAAI;AAO5E,eAAsB,KACpB,MACA,SACA,OAAsC,CAAC,GAClB;AACrB,MAAI,YAAY,MAAM;AACpB,WAAO,EAAE,QAAQ,OAAO,SAAS,KAAK;AAAA,EACxC;AAEA,MAAI,QAAQ,SAAS,OAAO;AAC1B,QAAI;AACF,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,OAAO;AACtC,YAAM,MAAM,QAAQ,KAAK,QAAQ,MAAM,EAAE,OAAO,KAAK,CAAC;AACtD,aAAO,EAAE,QAAQ,MAAM,SAAS,QAAQ,IAAI;AAAA,IAC9C,SAAS,KAAK;AACZ,aAAO,EAAE,QAAQ,OAAO,SAAS,QAAQ,KAAK,OAAO,OAAO,GAAG,EAAE;AAAA,IACnE;AAAA,EACF;AAGA,QAAM,SAAS,KAAK,eAAe;AACnC,MAAI;AACF,UAAM,OAAO,cAAc,IAAI,CAAC;AAChC,WAAO,EAAE,QAAQ,MAAM,SAAS,QAAQ;AAAA,EAC1C,SAAS,KAAK;AACZ,WAAO,EAAE,QAAQ,OAAO,SAAS,SAAS,OAAO,OAAO,GAAG,EAAE;AAAA,EAC/D;AACF;","names":[]}
|