@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.4.6

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.
Files changed (37) hide show
  1. package/AGENTS.md +10 -84
  2. package/README.md +20 -2
  3. package/docs/architecture.md +28 -2
  4. package/package.json +4 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  7. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  8. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  9. package/packages/extension/src/bridge-context.ts +7 -0
  10. package/packages/extension/src/bridge.ts +32 -3
  11. package/packages/extension/src/model-tracker.ts +35 -1
  12. package/packages/extension/src/prompt-bus.ts +4 -3
  13. package/packages/extension/src/session-sync.ts +1 -1
  14. package/packages/extension/src/vcs-info.ts +184 -0
  15. package/packages/server/package.json +4 -4
  16. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  17. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  18. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  19. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  20. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  21. package/packages/server/src/cli.ts +1 -0
  22. package/packages/server/src/event-wiring.ts +9 -0
  23. package/packages/server/src/openspec-tasks.ts +50 -19
  24. package/packages/server/src/routes/jj-routes.ts +386 -0
  25. package/packages/server/src/routes/session-routes.ts +12 -3
  26. package/packages/server/src/server.ts +8 -2
  27. package/packages/server/src/session-diff.ts +118 -1
  28. package/packages/shared/package.json +1 -1
  29. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  30. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  31. package/packages/shared/src/config.ts +14 -0
  32. package/packages/shared/src/diff-types.ts +17 -0
  33. package/packages/shared/src/platform/jj.ts +405 -0
  34. package/packages/shared/src/protocol.ts +14 -0
  35. package/packages/shared/src/tool-registry/definitions.ts +1 -0
  36. package/packages/shared/src/types.ts +34 -0
  37. package/packages/extension/src/git-info.ts +0 -55
@@ -34,7 +34,7 @@ import { scanChildProcesses } from "./process-scanner.js";
34
34
  import type { BridgeContext } from "./bridge-context.js";
35
35
  import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from "./bridge-context.js";
36
36
  import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
37
- import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged } from "./model-tracker.js";
37
+ import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
38
38
  import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
39
39
  import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "./ui-modules.js";
40
40
 
@@ -180,6 +180,7 @@ function initBridge(pi: ExtensionAPI) {
180
180
  const trackedPgids = new Set<number>(); // PGIDs captured during bash tool calls
181
181
  let lastGitBranch: string | undefined;
182
182
  let lastGitPrNumber: number | undefined;
183
+ let lastJjStateJson: string | undefined; // see change: add-jj-workspace-plugin
183
184
  let lastSessionName: string | undefined;
184
185
  let cachedHasUI: boolean | undefined = prev.hasUI;
185
186
  let cachedModelRegistry: any | undefined = prev.modelRegistry;
@@ -469,7 +470,23 @@ function initBridge(pi: ExtensionAPI) {
469
470
  }),
470
471
  onReconnect: safe(() => {
471
472
  if (!isActive()) return; // Stale listener guard
473
+ // Reset caches that aren't persisted server-side so the upcoming
474
+ // 30s tick (and the inline calls below) re-emit the live state.
475
+ // See change: add-jj-workspace-plugin.
476
+ const _bc = syncBc();
477
+ _resetReconnectCaches(_bc);
478
+ applyBc(_bc);
472
479
  sendStateSync();
480
+ // Force-emit jj/git state for the active session’s cwd. The bridge
481
+ // doesn't have direct ctx here, so we walk the active session.
482
+ try {
483
+ const activeId = (pi as any).getCurrentSessionId?.();
484
+ const activeCtx = activeId ? (pi as any).getCtx?.(activeId) : (cachedCtx as any);
485
+ if (activeCtx?.cwd) {
486
+ sendGitInfoIfChanged(activeCtx.cwd);
487
+ sendJjStateIfChanged(activeCtx.cwd);
488
+ }
489
+ } catch { /* probe failure non-fatal */ }
473
490
  replaySessionEntries();
474
491
  // Re-send pending PromptBus requests so dashboard dialogs survive browser refresh.
475
492
  // Synchronous within this tick to prevent TUI respond() from interleaving.
@@ -611,6 +628,7 @@ function initBridge(pi: ExtensionAPI) {
611
628
  lastModel, lastThinkingLevel,
612
629
  lastSessionFile, lastSessionDir, lastFirstMessage,
613
630
  lastGitBranch, lastGitPrNumber, lastSessionName,
631
+ lastJjStateJson,
614
632
  hasRegisteredOnce,
615
633
  };
616
634
  }
@@ -628,6 +646,7 @@ function initBridge(pi: ExtensionAPI) {
628
646
  lastGitBranch = bc.lastGitBranch;
629
647
  lastGitPrNumber = bc.lastGitPrNumber;
630
648
  lastSessionName = bc.lastSessionName;
649
+ lastJjStateJson = bc.lastJjStateJson;
631
650
  hasRegisteredOnce = bc.hasRegisteredOnce;
632
651
  }
633
652
 
@@ -637,6 +656,7 @@ function initBridge(pi: ExtensionAPI) {
637
656
  function sendModelUpdateIfChanged() { const bc = syncBc(); _sendModelUpdateIfChanged(bc); applyBc(bc); }
638
657
  function sendSessionNameIfChanged() { const bc = syncBc(); _sendSessionNameIfChanged(bc); applyBc(bc); }
639
658
  function sendGitInfoIfChanged(cwd: string) { const bc = syncBc(); _sendGitInfoIfChanged(bc, cwd); applyBc(bc); }
659
+ function sendJjStateIfChanged(cwd: string) { const bc = syncBc(); _sendJjStateIfChanged(bc, cwd); applyBc(bc); }
640
660
 
641
661
  // Forward all pi core events to the dashboard.
642
662
  // Events with special enrichment logic:
@@ -836,7 +856,13 @@ function initBridge(pi: ExtensionAPI) {
836
856
  // ── PromptBus setup ──
837
857
  // Create bus with dashboard connection wiring.
838
858
  // Replaces the old ui-proxy race pattern.
859
+ // Convert seconds → milliseconds for PromptBus.
860
+ // Values <= 0 (e.g. -1) are passed through as-is to signal infinite wait.
861
+ const askUserTimeoutMs = config.askUserPromptTimeoutSeconds > 0
862
+ ? config.askUserPromptTimeoutSeconds * 1000
863
+ : -1;
839
864
  promptBus = new PromptBus({
865
+ timeoutMs: askUserTimeoutMs,
840
866
  onDashboardRequest: (prompt, component, placement) => {
841
867
  connection.send({
842
868
  type: "prompt_request" as any,
@@ -1225,8 +1251,9 @@ function initBridge(pi: ExtensionAPI) {
1225
1251
  }
1226
1252
  }).catch(() => { stopSpinner(); });
1227
1253
 
1228
- // Send initial git info
1254
+ // Send initial git + jj info
1229
1255
  sendGitInfoIfChanged(ctx.cwd);
1256
+ sendJjStateIfChanged(ctx.cwd);
1230
1257
 
1231
1258
  // Start metrics monitor and heartbeat
1232
1259
  startMetricsMonitor();
@@ -1240,10 +1267,11 @@ function initBridge(pi: ExtensionAPI) {
1240
1267
  }, HEARTBEAT_INTERVAL);
1241
1268
  getBridgeState().timers!.push(heartbeatTimer);
1242
1269
 
1243
- // Start git info + name/model polling
1270
+ // Start git + jj + name/model polling
1244
1271
  gitPollTimer = setInterval(() => {
1245
1272
  if (!isActive()) return;
1246
1273
  sendGitInfoIfChanged(ctx.cwd);
1274
+ sendJjStateIfChanged(ctx.cwd);
1247
1275
  sendSessionNameIfChanged();
1248
1276
  sendModelUpdateIfChanged();
1249
1277
  }, GIT_POLL_INTERVAL);
@@ -1287,6 +1315,7 @@ function initBridge(pi: ExtensionAPI) {
1287
1315
  if (gitPollTimer) clearInterval(gitPollTimer);
1288
1316
  gitPollTimer = setInterval(() => {
1289
1317
  sendGitInfoIfChanged(ctx.cwd);
1318
+ sendJjStateIfChanged(ctx.cwd);
1290
1319
  }, GIT_POLL_INTERVAL);
1291
1320
  }
1292
1321
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { BridgeContext } from "./bridge-context.js";
6
6
  import { getCurrentModelString } from "./bridge-context.js";
7
- import { gatherGitInfo } from "./git-info.js";
7
+ import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
8
8
 
9
9
  /**
10
10
  * Send model_update if model or thinking level has changed since last send.
@@ -54,3 +54,37 @@ export function sendGitInfoIfChanged(bc: BridgeContext, cwd: string): void {
54
54
  ...info,
55
55
  });
56
56
  }
57
+
58
+ /**
59
+ * Reset the change-detection caches that aren't persisted on the server
60
+ * side, so a server-restart-driven reconnect re-sends them. `gitBranch`
61
+ * is already persisted to `.meta.json` so it's tolerable for a tick of
62
+ * staleness; `jjState` is intentionally NOT persisted (live tool state)
63
+ * and must be re-emitted on every reconnect.
64
+ * See change: add-jj-workspace-plugin.
65
+ */
66
+ export function resetReconnectCaches(bc: BridgeContext): void {
67
+ bc.lastJjStateJson = undefined;
68
+ // Defensive: also reset git so a reconnect through a stale state cache
69
+ // doesn't surface stale branch info if .meta.json wasn't persisted yet.
70
+ bc.lastGitBranch = undefined;
71
+ bc.lastGitPrNumber = undefined;
72
+ }
73
+
74
+ /**
75
+ * Send jj_state_update if the cwd's jj state has changed since last send.
76
+ * Sends `null` to clear when the session leaves a jj repo (cwd switch).
77
+ * No-op when there's nothing to clear and nothing to send.
78
+ * See change: add-jj-workspace-plugin.
79
+ */
80
+ export function sendJjStateIfChanged(bc: BridgeContext, cwd: string): void {
81
+ const state = gatherJjInfo(cwd);
82
+ const nextJson = state ? JSON.stringify(state) : "";
83
+ if (nextJson === (bc.lastJjStateJson ?? "")) return;
84
+ bc.lastJjStateJson = nextJson;
85
+ bc.connection.send({
86
+ type: "jj_state_update",
87
+ sessionId: bc.sessionId,
88
+ jjState: state ?? null,
89
+ });
90
+ }
@@ -112,9 +112,10 @@ export class PromptBus {
112
112
 
113
113
  return new Promise<PromptResponse>((resolve) => {
114
114
  const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
115
- const timer = setTimeout(() => {
116
- this.cancel(id);
117
- }, timeoutMs);
115
+ // timeoutMs <= 0 means infinite — never fire a cancellation timer.
116
+ const timer = timeoutMs > 0
117
+ ? setTimeout(() => { this.cancel(id); }, timeoutMs)
118
+ : (null as unknown as ReturnType<typeof setTimeout>);
118
119
 
119
120
  // Distribute to all adapters and collect claims
120
121
  const claims: PendingPrompt["claims"] = [];
@@ -6,7 +6,7 @@ import type { BridgeContext } from "./bridge-context.js";
6
6
  import { getCurrentModelString, extractFirstMessage, filterHiddenCommands } from "./bridge-context.js";
7
7
  import { detectSessionSource } from "./source-detector.js";
8
8
  import { replayEntriesAsEvents } from "@blackbelt-technology/pi-dashboard-shared/state-replay.js";
9
- import { gatherGitInfo } from "./git-info.js";
9
+ import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
10
10
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
11
 
12
12
  /**
@@ -0,0 +1,184 @@
1
+ /**
2
+ * VCS info gathering — detects git branch/remote/PR AND jj workspace state.
3
+ * Delegates to shared platform tool modules so there's no inline execSync
4
+ * and every call benefits from the runner's safety defaults (windowsHide,
5
+ * timeout, tolerated exit codes).
6
+ *
7
+ * jj probing is fast-path-gated: when `<cwd>/.jj/` doesn't exist, NO `jj`
8
+ * subprocess is spawned. Only sessions inside a jj repo pay the probe cost.
9
+ *
10
+ * See changes: platform-command-executor, add-jj-workspace-plugin.
11
+ */
12
+ import { existsSync } from "node:fs";
13
+ import path from "node:path";
14
+ import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
15
+ import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
16
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
17
+ import type { JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
18
+ import { buildGitLinks, type GitLinks } from "./git-link-builder.js";
19
+
20
+ export interface GitInfo {
21
+ gitBranch: string;
22
+ gitBranchUrl?: string;
23
+ gitPrNumber?: number;
24
+ gitPrUrl?: string;
25
+ }
26
+
27
+ /** Detect the current git branch. Returns short SHA for detached HEAD. */
28
+ export function detectBranch(cwd: string): string | undefined {
29
+ const ref = git.currentBranchOr({ cwd });
30
+ if (!ref) return undefined;
31
+ if (ref === "HEAD") {
32
+ // Detached HEAD — return short commit SHA
33
+ return git.headShaOr({ cwd, short: true }) ?? "HEAD";
34
+ }
35
+ return ref;
36
+ }
37
+
38
+ /** Detect the remote origin URL. */
39
+ export function detectRemoteUrl(cwd: string): string | undefined {
40
+ return git.remoteUrlOr({ cwd });
41
+ }
42
+
43
+ /** Detect the PR number via gh CLI (best effort). */
44
+ export function detectPrNumber(cwd: string): number | undefined {
45
+ return git.prNumberOr({ cwd });
46
+ }
47
+
48
+ /** Gather all git info for a directory. Returns undefined if not a git repo. */
49
+ export function gatherGitInfo(cwd: string): GitInfo | undefined {
50
+ const branch = detectBranch(cwd);
51
+ if (!branch) return undefined;
52
+
53
+ const remoteUrl = detectRemoteUrl(cwd);
54
+ const prNumber = detectPrNumber(cwd);
55
+
56
+ const links: GitLinks = remoteUrl ? buildGitLinks(remoteUrl, branch, prNumber) : {};
57
+
58
+ return {
59
+ gitBranch: branch,
60
+ gitBranchUrl: links.branchUrl,
61
+ gitPrNumber: prNumber,
62
+ gitPrUrl: links.prUrl,
63
+ };
64
+ }
65
+
66
+ // ── Jujutsu probing ────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Module-level cache: result of resolving `jj` once per process. The tool
70
+ * registry already memoizes resolutions, but reading it on every probe tick
71
+ * adds noise to traces. Single read on first probe, sticky for the process.
72
+ */
73
+ let jjAvailable: boolean | undefined;
74
+
75
+ function isJjResolvable(): boolean {
76
+ if (jjAvailable !== undefined) return jjAvailable;
77
+ try {
78
+ const reg = getDefaultRegistry();
79
+ const res = reg.resolve("jj");
80
+ jjAvailable = res.ok;
81
+ } catch {
82
+ jjAvailable = false;
83
+ }
84
+ return jjAvailable;
85
+ }
86
+
87
+ /**
88
+ * Test-only hook to reset the jj-availability cache.
89
+ * Production code MUST NOT call this.
90
+ */
91
+ export function _resetJjAvailableForTests(): void {
92
+ jjAvailable = undefined;
93
+ }
94
+
95
+ /**
96
+ * Gather jj workspace state for a directory.
97
+ * Returns `undefined` when:
98
+ * - `jj` is not resolvable via the tool registry, OR
99
+ * - `.jj/` does not exist in cwd (fast path, no subprocess spawn).
100
+ *
101
+ * Returns a populated `JjState` when both conditions are met. Errors during
102
+ * `jj` invocation surface in `lastError` rather than throwing; the rest of
103
+ * the fields fall back to undefined / empty.
104
+ *
105
+ * Per spec scenario "Non-jj cwd incurs no jj subprocess cost".
106
+ */
107
+ export function gatherJjInfo(cwd: string): JjState | undefined {
108
+ if (!isJjResolvable()) return undefined;
109
+ if (!existsSync(path.join(cwd, ".jj"))) return undefined;
110
+
111
+ const isColocated = existsSync(path.join(cwd, ".git"));
112
+
113
+ // Resolve workspace name + root. Errors are caught and surfaced via
114
+ // lastError so callers always get *some* JjState rather than nothing.
115
+ let workspaceName: string | undefined;
116
+ let workspaceRoot: string | undefined;
117
+ let lastError: string | undefined;
118
+
119
+ const rootResult = jj.workspaceRoot({ cwd });
120
+ if (rootResult.ok) {
121
+ workspaceRoot = rootResult.value;
122
+ } else if (rootResult.error.kind !== "not-found") {
123
+ lastError = describeJjError(rootResult.error);
124
+ }
125
+
126
+ // Workspace name: parse `jj workspace list` and match by working-copy
127
+ // path. The CLI does not include the path in list output, so we fall
128
+ // back to identifying the workspace via the `<name>@` revset matching
129
+ // the workspace root. For now: if there's only one workspace, use
130
+ // its name; otherwise default to "default" if found, else first entry.
131
+ // (Multi-workspace name disambiguation tracked as Phase 4 follow-up.)
132
+ const listResult = jj.workspaceList({ cwd });
133
+ if (listResult.ok) {
134
+ const entries = jj.parseWorkspaceList(listResult.value);
135
+ if (entries.length === 1) {
136
+ workspaceName = entries[0]?.name;
137
+ } else if (entries.length > 1) {
138
+ workspaceName = entries.find((e) => e.name === "default")?.name
139
+ ?? entries[0]?.name;
140
+ }
141
+ } else if (listResult.error.kind !== "not-found" && !lastError) {
142
+ lastError = describeJjError(listResult.error);
143
+ }
144
+
145
+ return {
146
+ isJjRepo: true,
147
+ isColocated,
148
+ workspaceName,
149
+ workspaceRoot,
150
+ lastError,
151
+ };
152
+ }
153
+
154
+ function describeJjError(
155
+ error: { kind: string; [k: string]: unknown },
156
+ ): string {
157
+ if (error.kind === "timeout") return "jj probe timed out";
158
+ if (error.kind === "exit") {
159
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
160
+ return stderr.split("\n")[0] || `jj exited ${String(error.code)}`;
161
+ }
162
+ if (error.kind === "spawn-failure") {
163
+ return typeof error.message === "string" ? error.message : "spawn failed";
164
+ }
165
+ return error.kind;
166
+ }
167
+
168
+ // ── Combined VCS gather ────────────────────────────────────────────────────
169
+
170
+ export interface VcsInfo {
171
+ git?: GitInfo;
172
+ jj?: JjState;
173
+ }
174
+
175
+ /**
176
+ * Convenience helper that gathers both git and jj info in one call.
177
+ * Used by the bridge's per-session 30 s probe tick.
178
+ */
179
+ export function gatherVcsInfo(cwd: string): VcsInfo {
180
+ return {
181
+ git: gatherGitInfo(cwd),
182
+ jj: gatherJjInfo(cwd),
183
+ };
184
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "postinstall": "node scripts/fix-pty-permissions.cjs"
32
32
  },
33
33
  "dependencies": {
34
- "@blackbelt-technology/dashboard-plugin-runtime": "^0.4.5",
35
- "@blackbelt-technology/pi-dashboard-extension": "^0.4.5",
36
- "@blackbelt-technology/pi-dashboard-shared": "^0.4.5",
34
+ "@blackbelt-technology/dashboard-plugin-runtime": "^0.4.6",
35
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.6",
36
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.6",
37
37
  "@fastify/compress": "^8.3.1",
38
38
  "@fastify/cookie": "^11.0.2",
39
39
  "@fastify/cors": "^11.0.0",
@@ -67,8 +67,10 @@ describe("isUnreadTrigger", () => {
67
67
  isUnreadTrigger(
68
68
  "agent_end",
69
69
  { status: "streaming", currentTool: null },
70
- // @ts-expect-error simulating the broader status union
71
- { status: "ended", currentTool: null },
70
+ // The status union is currently "streaming" | "idle" | "active";
71
+ // "ended" is not in the union but is a valid runtime value
72
+ // upstream, so we cast for the simulation.
73
+ { status: "ended" as unknown as "idle", currentTool: null },
72
74
  ),
73
75
  ).toBe(false);
74
76
  });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Unit tests for the jj REST routes module — focused on the pure helpers
3
+ * (`checkInitColocatedPreconditions`) and validation logic. Full route
4
+ * integration tests are deferred until the test harness wires up a live
5
+ * Fastify instance + browserGateway mock.
6
+ *
7
+ * See change: add-jj-workspace-plugin.
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import fs from "node:fs";
13
+
14
+ const { statusPorcelain } = vi.hoisted(() => ({
15
+ statusPorcelain: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/git.js", async () => {
19
+ const real = await vi.importActual<
20
+ typeof import("@blackbelt-technology/pi-dashboard-shared/platform/git.js")
21
+ >("@blackbelt-technology/pi-dashboard-shared/platform/git.js");
22
+ return { ...real, statusPorcelain };
23
+ });
24
+
25
+ import { checkInitColocatedPreconditions } from "../routes/jj-routes.js";
26
+
27
+ describe("checkInitColocatedPreconditions", () => {
28
+ let tmpDir: string;
29
+
30
+ beforeEach(() => {
31
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "jj-routes-test-"));
32
+ statusPorcelain.mockReset();
33
+ });
34
+
35
+ afterEach(() => {
36
+ fs.rmSync(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ it("returns INVALID_CWD when cwd is empty", () => {
40
+ expect(checkInitColocatedPreconditions("")?.code).toBe("INVALID_CWD");
41
+ });
42
+
43
+ it("returns INVALID_CWD when cwd does not exist", () => {
44
+ expect(checkInitColocatedPreconditions("/nonexistent/path/12345")?.code).toBe("INVALID_CWD");
45
+ });
46
+
47
+ it("returns ALREADY_JJ when .jj/ exists", () => {
48
+ fs.mkdirSync(path.join(tmpDir, ".jj"));
49
+ expect(checkInitColocatedPreconditions(tmpDir)?.code).toBe("ALREADY_JJ");
50
+ });
51
+
52
+ it("returns NOT_GIT_REPO when neither .jj/ nor .git/ exist", () => {
53
+ expect(checkInitColocatedPreconditions(tmpDir)?.code).toBe("NOT_GIT_REPO");
54
+ });
55
+
56
+ it("returns DIRTY_INDEX when git status has staged entries", () => {
57
+ fs.mkdirSync(path.join(tmpDir, ".git"));
58
+ statusPorcelain.mockReturnValue({
59
+ ok: true,
60
+ value: "M src/foo.ts\nA src/bar.ts\n",
61
+ });
62
+ const result = checkInitColocatedPreconditions(tmpDir);
63
+ expect(result?.code).toBe("DIRTY_INDEX");
64
+ expect(result?.message).toContain("2 entries");
65
+ });
66
+
67
+ it("returns null on clean .git/ tree (working-tree dirt is fine)", () => {
68
+ fs.mkdirSync(path.join(tmpDir, ".git"));
69
+ // Lines starting with " M" (space then M) are working-tree-only changes.
70
+ // Lines starting with "??" are untracked files. Both are SAFE per spec
71
+ // scenario "Init allowed on unstaged dirty working tree".
72
+ statusPorcelain.mockReturnValue({
73
+ ok: true,
74
+ value: " M src/working-tree-mod.ts\n?? src/new-untracked.ts\n",
75
+ });
76
+ expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
77
+ });
78
+
79
+ it("returns null on totally clean tree", () => {
80
+ fs.mkdirSync(path.join(tmpDir, ".git"));
81
+ statusPorcelain.mockReturnValue({ ok: true, value: "" });
82
+ expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
83
+ });
84
+
85
+ it("returns null when statusPorcelain itself fails (defensive: don't refuse on probe error)", () => {
86
+ fs.mkdirSync(path.join(tmpDir, ".git"));
87
+ statusPorcelain.mockReturnValue({
88
+ ok: false,
89
+ error: { kind: "not-found", binary: "git" },
90
+ });
91
+ expect(checkInitColocatedPreconditions(tmpDir)).toBeNull();
92
+ });
93
+ });
@@ -69,6 +69,53 @@ describe("parseTasksMarkdown", () => {
69
69
  const tasks = parseTasksMarkdown("- [ ] 1.1 Loose task");
70
70
  expect(tasks[0].group).toBe("");
71
71
  });
72
+
73
+ // ─── relax-tasks-parser-id-optional ───────────────────────────────────────
74
+ // The parser MUST accept top-level checkboxes with or without a `1.1`-style
75
+ // numeric id prefix. Id-less lines get a synthesized `L<line>` id.
76
+
77
+ it("parses id-less checkboxes with synthesized L<line> ids", () => {
78
+ const md = [
79
+ "## 1. Workflow matrix", // line 1
80
+ "", // line 2
81
+ "- [ ] Verify runner image", // line 3
82
+ "- [x] Add matrix row", // line 4
83
+ ].join("\n");
84
+ const tasks = parseTasksMarkdown(md);
85
+ expect(tasks).toEqual([
86
+ { id: "L3", text: "Verify runner image", done: false, line: 3, group: "1. Workflow matrix" },
87
+ { id: "L4", text: "Add matrix row", done: true, line: 4, group: "1. Workflow matrix" },
88
+ ]);
89
+ });
90
+
91
+ it("parses files mixing id-ed and id-less checkboxes", () => {
92
+ const md = [
93
+ "## 1. Mix", // 1
94
+ "", // 2
95
+ "- [ ] 1.1 Has id", // 3
96
+ "- [x] No id here", // 4
97
+ "- [ ] 1.3 Skipped 1.2 on purpose", // 5
98
+ ].join("\n");
99
+ const tasks = parseTasksMarkdown(md);
100
+ expect(tasks).toEqual([
101
+ { id: "1.1", text: "Has id", done: false, line: 3, group: "1. Mix" },
102
+ { id: "L4", text: "No id here", done: true, line: 4, group: "1. Mix" },
103
+ { id: "1.3", text: "Skipped 1.2 on purpose", done: false, line: 5, group: "1. Mix" },
104
+ ]);
105
+ });
106
+
107
+ it("still ignores indented checkboxes (id-less or id-ed)", () => {
108
+ const md = [
109
+ "## G",
110
+ " - [ ] indented id-less",
111
+ " - [ ] 1.1 indented id-ed",
112
+ "- [ ] top-level id-less",
113
+ ].join("\n");
114
+ const tasks = parseTasksMarkdown(md);
115
+ expect(tasks).toHaveLength(1);
116
+ expect(tasks[0].id).toBe("L4");
117
+ expect(tasks[0].text).toBe("top-level id-less");
118
+ });
72
119
  });
73
120
 
74
121
  describe("readTasks + toggleTask (writer)", () => {
@@ -175,4 +222,71 @@ describe("readTasks + toggleTask (writer)", () => {
175
222
  const after = fs.readFileSync(tasksFile, "utf-8");
176
223
  expect(after).toBe(weirdMd.replace("- [ ] 1.1 Task one", "- [x] 1.1 Task one"));
177
224
  });
225
+
226
+ // ─── relax-tasks-parser-id-optional ───────────────────────────────────────
227
+
228
+ describe("id-less round-trip", () => {
229
+ const idlessMd = [
230
+ "## 1. Workflow matrix", // 1
231
+ "", // 2
232
+ "- [ ] Verify runner image", // 3
233
+ "- [x] Add matrix row", // 4
234
+ "", // 5
235
+ "## 2. Verify rename behavior", // 6
236
+ "- [ ] Inspect releases", // 7
237
+ ].join("\n");
238
+
239
+ beforeEach(() => {
240
+ fs.writeFileSync(tasksFile, idlessMd, "utf-8");
241
+ });
242
+
243
+ it("toggle ticks an id-less task addressed by L<line>, no synthetic id leaks into the file", async () => {
244
+ const result = await toggleTask(tmpDir, CWD_CHANGE[1], "L3", true, 3);
245
+ expect(result).toEqual({
246
+ id: "L3",
247
+ text: "Verify runner image",
248
+ done: true,
249
+ line: 3,
250
+ group: "1. Workflow matrix",
251
+ });
252
+ const after = fs.readFileSync(tasksFile, "utf-8");
253
+ // CRITICAL: line shape preserved — no "L3" appears in the file body
254
+ expect(after).toBe(idlessMd.replace("- [ ] Verify runner image", "- [x] Verify runner image"));
255
+ expect(after).not.toContain("L3");
256
+ });
257
+
258
+ it("toggle unticks an id-less task addressed by L<line>", async () => {
259
+ const result = await toggleTask(tmpDir, CWD_CHANGE[1], "L4", false, 4);
260
+ expect(result.done).toBe(false);
261
+ const after = fs.readFileSync(tasksFile, "utf-8");
262
+ expect(after).toBe(idlessMd.replace("- [x] Add matrix row", "- [ ] Add matrix row"));
263
+ expect(after).not.toContain("L4");
264
+ });
265
+
266
+ it("toggle of id-less line with wrong synthesized id throws LineMismatchError", async () => {
267
+ // Line 3 is id-less; passing L99 (or any other L<n>) must reject
268
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "L99", true, 3)).rejects.toBeInstanceOf(
269
+ LineMismatchError,
270
+ );
271
+ expect(fs.readFileSync(tasksFile, "utf-8")).toBe(idlessMd);
272
+ });
273
+
274
+ it("toggle of id-less line with a numeric-style id throws LineMismatchError", async () => {
275
+ // Line 3 has no numeric id; passing "1.1" must reject
276
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "1.1", true, 3)).rejects.toBeInstanceOf(
277
+ LineMismatchError,
278
+ );
279
+ expect(fs.readFileSync(tasksFile, "utf-8")).toBe(idlessMd);
280
+ });
281
+
282
+ it("toggle of id-ed line addressed by L<n> throws LineMismatchError", async () => {
283
+ // Switch fixture to id-ed for this case
284
+ fs.writeFileSync(tasksFile, initialMd, "utf-8");
285
+ // Line 3 has id "1.1"; addressing it as "L3" must reject
286
+ await expect(toggleTask(tmpDir, CWD_CHANGE[1], "L3", true, 3)).rejects.toBeInstanceOf(
287
+ LineMismatchError,
288
+ );
289
+ expect(fs.readFileSync(tasksFile, "utf-8")).toBe(initialMd);
290
+ });
291
+ });
178
292
  });