@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.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.
Files changed (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unit tests for `managedRuntimeStrategy` in isolation.
3
+ *
4
+ * Strategy receives a `StrategyCtx` (overrides + platform + env). We
5
+ * inject a fake `exists` so no real filesystem is touched.
6
+ *
7
+ * See change: embed-managed-node-runtime (task 2.6).
8
+ */
9
+ import path from "node:path";
10
+ import { describe, expect, it } from "vitest";
11
+ import { managedRuntimeStrategy } from "../tool-registry/strategies.js";
12
+ import type { StrategyCtx } from "../tool-registry/types.js";
13
+
14
+ function ctx(opts: Partial<StrategyCtx> = {}): StrategyCtx {
15
+ return {
16
+ overrides: {},
17
+ platform: opts.platform ?? "linux",
18
+ env: opts.env ?? { homedir: "/fake/home" },
19
+ };
20
+ }
21
+
22
+ describe("managedRuntimeStrategy", () => {
23
+ it("Unix node: returns <managedDir>/node/bin/node when present", () => {
24
+ const expected = path.join("/fake/home", ".pi-dashboard", "node", "bin", "node");
25
+ const s = managedRuntimeStrategy("node", { exists: (p) => p === expected });
26
+ const r = s.run(ctx({ platform: "linux" }));
27
+ expect(r.ok).toBe(true);
28
+ if (r.ok) expect(r.path).toBe(expected);
29
+ });
30
+
31
+ it("Unix npm: returns <managedDir>/node/bin/npm when present", () => {
32
+ const expected = path.join("/fake/home", ".pi-dashboard", "node", "bin", "npm");
33
+ const s = managedRuntimeStrategy("npm", { exists: (p) => p === expected });
34
+ const r = s.run(ctx({ platform: "linux" }));
35
+ expect(r.ok).toBe(true);
36
+ if (r.ok) expect(r.path).toBe(expected);
37
+ });
38
+
39
+ it("Windows node: returns <managedDir>/node/node.exe when present", () => {
40
+ const expected = path.join("/fake/home", ".pi-dashboard", "node", "node.exe");
41
+ const s = managedRuntimeStrategy("node", { exists: (p) => p === expected });
42
+ const r = s.run(ctx({ platform: "win32" }));
43
+ expect(r.ok).toBe(true);
44
+ if (r.ok) expect(r.path).toBe(expected);
45
+ });
46
+
47
+ it("Windows npm: returns <managedDir>/node/npm.cmd when present", () => {
48
+ const expected = path.join("/fake/home", ".pi-dashboard", "node", "npm.cmd");
49
+ const s = managedRuntimeStrategy("npm", { exists: (p) => p === expected });
50
+ const r = s.run(ctx({ platform: "win32" }));
51
+ expect(r.ok).toBe(true);
52
+ if (r.ok) expect(r.path).toBe(expected);
53
+ });
54
+
55
+ it("Windows npx: returns <managedDir>/node/npx.cmd when present", () => {
56
+ const expected = path.join("/fake/home", ".pi-dashboard", "node", "npx.cmd");
57
+ const s = managedRuntimeStrategy("npx", { exists: (p) => p === expected });
58
+ const r = s.run(ctx({ platform: "win32" }));
59
+ expect(r.ok).toBe(true);
60
+ if (r.ok) expect(r.path).toBe(expected);
61
+ });
62
+
63
+ it("returns failure with reason when binary absent", () => {
64
+ const s = managedRuntimeStrategy("node", { exists: () => false });
65
+ const r = s.run(ctx({ platform: "linux" }));
66
+ expect(r.ok).toBe(false);
67
+ if (!r.ok) expect(r.reason).toMatch(/missing:/);
68
+ });
69
+
70
+ it("strategy is named 'managed' (classifies as managed source)", () => {
71
+ const s = managedRuntimeStrategy("node");
72
+ expect(s.name).toBe("managed");
73
+ });
74
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Contract test: the bridge auto-spawn code path (server-launcher.ts) must
3
+ * NOT import from `installable-list`. Only Electron seeds `installable.json`
4
+ * on first run; Bridge and Standalone starters must not produce or consume
5
+ * that file.
6
+ *
7
+ * This is a static source scan — no runtime execution. If this test fails,
8
+ * a dependency on installable-list was accidentally added to the bridge
9
+ * launcher which would break the "file-absent is a no-op" contract on
10
+ * Bridge/Standalone bootstraps.
11
+ *
12
+ * See change: simplify-electron-bootstrap-derived-state (task 5.7).
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import url from "node:url";
18
+
19
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
20
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
21
+
22
+ /** Files that form the bridge auto-spawn contract. */
23
+ const BRIDGE_SPAWN_FILES: readonly string[] = [
24
+ "packages/extension/src/server-launcher.ts",
25
+ "packages/extension/src/server-auto-start.ts",
26
+ "packages/extension/src/connection.ts",
27
+ ];
28
+
29
+ describe("bridge auto-spawn does not reference installable-list", () => {
30
+ for (const rel of BRIDGE_SPAWN_FILES) {
31
+ it(`${rel} does not import from installable-list`, () => {
32
+ const file = path.resolve(repoRoot, rel);
33
+ if (!fs.existsSync(file)) {
34
+ // File absent (optional extension file) — contract trivially satisfied.
35
+ return;
36
+ }
37
+ const source = fs.readFileSync(file, "utf-8");
38
+
39
+ // Strip line comments before checking so a commented-out import
40
+ // does not trigger a false positive.
41
+ const stripped = source
42
+ .replace(/\/\/[^\n]*/g, "")
43
+ .replace(/\/\*[\s\S]*?\*\//g, "");
44
+
45
+ expect(
46
+ stripped,
47
+ `${rel} must not import from "installable-list" — only Electron seeds installable.json. ` +
48
+ `Bridge/Standalone starters must not produce or consume that file.`,
49
+ ).not.toMatch(/installable-list/);
50
+ });
51
+ }
52
+ });
@@ -38,7 +38,12 @@ const GOVERNED_SKILLS = [
38
38
  ];
39
39
 
40
40
  describe("OpenSpec workflow skills must use effective-status.sh", () => {
41
- it("no raw `openspec status --json` outside the wrapper script", async () => {
41
+ // SKIPPED: the .pi/skills/openspec-*/SKILL.md files this lint targets are
42
+ // vendored from upstream and gitignored (see .pi/.gitignore) — they get
43
+ // overwritten on every install, so we can't enforce dashboard-local
44
+ // semantics on them from here. Re-enable if/when those skills are
45
+ // forked into the repo, or move the wrapper-enforcement upstream.
46
+ it.skip("no raw `openspec status --json` outside the wrapper script", async () => {
42
47
  const here = path.dirname(url.fileURLToPath(import.meta.url));
43
48
  const repoRoot = path.resolve(here, "..", "..", "..", "..");
44
49
  const skillsRoot = path.resolve(repoRoot, ".pi", "skills");
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Tests for packages/shared/src/platform/jj.ts — Recipe argv shapes
3
+ * and the pure `parseWorkspaceList` helper.
4
+ *
5
+ * Live integration tests (running real `jj` against a temp repo) are
6
+ * deferred to the integration-test phase; argv shape coverage here
7
+ * catches the most common refactor mistakes without requiring `jj`
8
+ * on the test runner's PATH.
9
+ *
10
+ * See change: add-jj-workspace-plugin.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ JJ_VERSION,
15
+ JJ_WORKSPACE_ROOT,
16
+ JJ_WORKSPACE_LIST,
17
+ JJ_WORKSPACE_ADD,
18
+ JJ_WORKSPACE_FORGET,
19
+ JJ_BOOKMARK_CREATE,
20
+ JJ_BOOKMARK_LIST,
21
+ JJ_GIT_INIT_COLOCATE,
22
+ JJ_GIT_PUSH,
23
+ JJ_DIFF,
24
+ JJ_RESOLVE_LIST,
25
+ JJ_OP_LOG_HEAD,
26
+ JJ_OP_RESTORE,
27
+ JJ_REBASE,
28
+ JJ_LOG_REVSET,
29
+ JJ_RECIPES,
30
+ parseWorkspaceList,
31
+ findWorkspaceByName,
32
+ } from "../platform/jj.js";
33
+
34
+ // ── Argv shapes ─────────────────────────────────────────────────────────────
35
+
36
+ describe("JJ_VERSION.argv", () => {
37
+ it("is `jj --version`", () => {
38
+ expect(JJ_VERSION.argv({})).toEqual(["jj", "--version"]);
39
+ });
40
+
41
+ it("parses `jj 0.18.0` into `0.18.0`", () => {
42
+ expect(JJ_VERSION.parse("jj 0.18.0\n", {})).toBe("0.18.0");
43
+ });
44
+
45
+ it("falls back to trimmed string when version regex fails", () => {
46
+ expect(JJ_VERSION.parse("unknown-format\n", {})).toBe("unknown-format");
47
+ });
48
+ });
49
+
50
+ describe("JJ_WORKSPACE_ROOT.argv", () => {
51
+ it("is `jj workspace root`", () => {
52
+ expect(JJ_WORKSPACE_ROOT.argv({ cwd: "/tmp" })).toEqual([
53
+ "jj", "workspace", "root",
54
+ ]);
55
+ });
56
+ });
57
+
58
+ describe("JJ_WORKSPACE_LIST.argv", () => {
59
+ it("includes --no-pager", () => {
60
+ expect(JJ_WORKSPACE_LIST.argv({ cwd: "/tmp" })).toEqual([
61
+ "jj", "workspace", "list", "--no-pager",
62
+ ]);
63
+ });
64
+ });
65
+
66
+ describe("JJ_WORKSPACE_ADD.argv", () => {
67
+ it("without baseRev", () => {
68
+ expect(JJ_WORKSPACE_ADD.argv({ cwd: "/repo", destPath: "/repo/.shadow/agent-1" })).toEqual([
69
+ "jj", "workspace", "add", "/repo/.shadow/agent-1",
70
+ ]);
71
+ });
72
+
73
+ it("with baseRev", () => {
74
+ expect(JJ_WORKSPACE_ADD.argv({
75
+ cwd: "/repo",
76
+ destPath: "/repo/.shadow/agent-1",
77
+ baseRev: "develop",
78
+ })).toEqual([
79
+ "jj", "workspace", "add", "/repo/.shadow/agent-1", "-r", "develop",
80
+ ]);
81
+ });
82
+
83
+ it("path with spaces is passed verbatim (argv-array, no shell)", () => {
84
+ expect(JJ_WORKSPACE_ADD.argv({ cwd: "/repo", destPath: "/repo/my workspace" })).toEqual([
85
+ "jj", "workspace", "add", "/repo/my workspace",
86
+ ]);
87
+ });
88
+ });
89
+
90
+ describe("JJ_WORKSPACE_FORGET.argv", () => {
91
+ it("is `jj workspace forget <name>`", () => {
92
+ expect(JJ_WORKSPACE_FORGET.argv({ cwd: "/repo", name: "agent-1" })).toEqual([
93
+ "jj", "workspace", "forget", "agent-1",
94
+ ]);
95
+ });
96
+ });
97
+
98
+ describe("JJ_BOOKMARK_CREATE.argv", () => {
99
+ it("is `jj bookmark create <name> -r <rev>`", () => {
100
+ expect(JJ_BOOKMARK_CREATE.argv({ cwd: "/repo", name: "feat", rev: "@" })).toEqual([
101
+ "jj", "bookmark", "create", "feat", "-r", "@",
102
+ ]);
103
+ });
104
+ });
105
+
106
+ describe("JJ_BOOKMARK_LIST.argv", () => {
107
+ it("includes name template and --no-pager", () => {
108
+ const argv = JJ_BOOKMARK_LIST.argv({ cwd: "/repo" });
109
+ expect(argv[0]).toBe("jj");
110
+ expect(argv).toContain("bookmark");
111
+ expect(argv).toContain("list");
112
+ expect(argv).toContain("-T");
113
+ expect(argv).toContain("--no-pager");
114
+ });
115
+ });
116
+
117
+ describe("JJ_GIT_INIT_COLOCATE.argv", () => {
118
+ it("is `jj git init --colocate`", () => {
119
+ expect(JJ_GIT_INIT_COLOCATE.argv({ cwd: "/repo" })).toEqual([
120
+ "jj", "git", "init", "--colocate",
121
+ ]);
122
+ });
123
+ });
124
+
125
+ describe("JJ_GIT_PUSH.argv", () => {
126
+ it("includes --bookmark <name>", () => {
127
+ expect(JJ_GIT_PUSH.argv({ cwd: "/repo", bookmark: "feat/agent-1" })).toEqual([
128
+ "jj", "git", "push", "--bookmark", "feat/agent-1",
129
+ ]);
130
+ });
131
+ });
132
+
133
+ describe("JJ_DIFF.argv", () => {
134
+ it("default invocation has no --from/--to", () => {
135
+ expect(JJ_DIFF.argv({ cwd: "/repo" })).toEqual([
136
+ "jj", "diff", "--no-pager",
137
+ ]);
138
+ });
139
+
140
+ it("with --from and --to", () => {
141
+ expect(JJ_DIFF.argv({ cwd: "/repo", fromRev: "develop", toRev: "@" })).toEqual([
142
+ "jj", "diff", "--no-pager", "--from", "develop", "--to", "@",
143
+ ]);
144
+ });
145
+
146
+ it("with path filter", () => {
147
+ expect(JJ_DIFF.argv({
148
+ cwd: "/repo",
149
+ fromRev: "develop",
150
+ toRev: "@",
151
+ path: "src/auth.ts",
152
+ })).toEqual([
153
+ "jj", "diff", "--no-pager",
154
+ "--from", "develop",
155
+ "--to", "@",
156
+ "--", "src/auth.ts",
157
+ ]);
158
+ });
159
+
160
+ it("path-only diff (working copy)", () => {
161
+ expect(JJ_DIFF.argv({ cwd: "/repo", path: "src/auth.ts" })).toEqual([
162
+ "jj", "diff", "--no-pager", "--", "src/auth.ts",
163
+ ]);
164
+ });
165
+ });
166
+
167
+ describe("JJ_RESOLVE_LIST.argv", () => {
168
+ it("is `jj resolve --list`", () => {
169
+ expect(JJ_RESOLVE_LIST.argv({ cwd: "/repo" })).toEqual([
170
+ "jj", "resolve", "--list", "--no-pager",
171
+ ]);
172
+ });
173
+
174
+ it("tolerates exit code 1 (no conflicts)", () => {
175
+ expect(JJ_RESOLVE_LIST.tolerate).toContain(1);
176
+ });
177
+ });
178
+
179
+ describe("JJ_OP_LOG_HEAD.argv", () => {
180
+ it("includes --limit 1 and id.short() template", () => {
181
+ const argv = JJ_OP_LOG_HEAD.argv({ cwd: "/repo" });
182
+ expect(argv).toContain("op");
183
+ expect(argv).toContain("log");
184
+ expect(argv).toContain("--limit");
185
+ expect(argv).toContain("1");
186
+ expect(argv).toContain("-T");
187
+ });
188
+
189
+ it("parses single-line short id output", () => {
190
+ expect(JJ_OP_LOG_HEAD.parse("abc1234\n", { cwd: "/repo" })).toBe("abc1234");
191
+ });
192
+
193
+ it("returns undefined for empty output", () => {
194
+ expect(JJ_OP_LOG_HEAD.parse("\n", { cwd: "/repo" })).toBeUndefined();
195
+ });
196
+ });
197
+
198
+ describe("JJ_OP_RESTORE.argv", () => {
199
+ it("is `jj op restore <op-id>`", () => {
200
+ expect(JJ_OP_RESTORE.argv({ cwd: "/repo", opId: "abc1234" })).toEqual([
201
+ "jj", "op", "restore", "abc1234",
202
+ ]);
203
+ });
204
+ });
205
+
206
+ describe("JJ_REBASE.argv", () => {
207
+ it("is `jj rebase -d <dest> -s <src>`", () => {
208
+ expect(JJ_REBASE.argv({ cwd: "/repo", dest: "main", src: "agent-1" })).toEqual([
209
+ "jj", "rebase", "-d", "main", "-s", "agent-1",
210
+ ]);
211
+ });
212
+ });
213
+
214
+ describe("JJ_LOG_REVSET.argv", () => {
215
+ it("uses default change_id template", () => {
216
+ const argv = JJ_LOG_REVSET.argv({ cwd: "/repo", revset: "trunk()..@" });
217
+ expect(argv).toContain("log");
218
+ expect(argv).toContain("-r");
219
+ expect(argv).toContain("trunk()..@");
220
+ expect(argv).toContain("--no-graph");
221
+ });
222
+
223
+ it("respects custom template", () => {
224
+ const argv = JJ_LOG_REVSET.argv({
225
+ cwd: "/repo",
226
+ revset: "@",
227
+ template: 'description ++ "\\n"',
228
+ });
229
+ expect(argv).toContain('description ++ "\\n"');
230
+ });
231
+ });
232
+
233
+ describe("JJ_RECIPES registry", () => {
234
+ it("enumerates all exported recipes", () => {
235
+ const keys = Object.keys(JJ_RECIPES).sort();
236
+ expect(keys).toEqual([
237
+ "JJ_BOOKMARK_CREATE",
238
+ "JJ_BOOKMARK_LIST",
239
+ "JJ_DIFF",
240
+ "JJ_GIT_INIT_COLOCATE",
241
+ "JJ_GIT_PUSH",
242
+ "JJ_LOG_REVSET",
243
+ "JJ_OP_LOG_HEAD",
244
+ "JJ_OP_RESTORE",
245
+ "JJ_REBASE",
246
+ "JJ_RESOLVE_LIST",
247
+ "JJ_VERSION",
248
+ "JJ_WORKSPACE_ADD",
249
+ "JJ_WORKSPACE_FORGET",
250
+ "JJ_WORKSPACE_LIST",
251
+ "JJ_WORKSPACE_ROOT",
252
+ ]);
253
+ });
254
+
255
+ it("every recipe has argv and parse functions", () => {
256
+ for (const [name, recipe] of Object.entries(JJ_RECIPES)) {
257
+ expect(typeof recipe.argv, `${name}.argv`).toBe("function");
258
+ expect(typeof recipe.parse, `${name}.parse`).toBe("function");
259
+ }
260
+ });
261
+
262
+ it("every recipe's argv starts with `jj`", () => {
263
+ for (const [name, recipe] of Object.entries(JJ_RECIPES)) {
264
+ // Use a forgiving input shape — we only care about the binary name.
265
+ const argv = (recipe.argv as (i: any) => readonly string[])({
266
+ cwd: "/tmp",
267
+ destPath: "/x",
268
+ baseRev: "@",
269
+ name: "x",
270
+ rev: "@",
271
+ bookmark: "x",
272
+ opId: "x",
273
+ dest: "x",
274
+ src: "x",
275
+ revset: "@",
276
+ path: "x",
277
+ });
278
+ expect(argv[0], `${name} first arg`).toBe("jj");
279
+ }
280
+ });
281
+ });
282
+
283
+ // ── parseWorkspaceList ──────────────────────────────────────────────────────
284
+
285
+ describe("parseWorkspaceList", () => {
286
+ it("parses standard two-workspace output", () => {
287
+ const out = `default: rxnxoqlk 4f2c1234 (no description set)
288
+ agent-1: tmysxysu 0c4b5678 (empty) (no description set)
289
+ `;
290
+ expect(parseWorkspaceList(out)).toEqual([
291
+ { name: "default", changeIdShort: "rxnxoqlk", commitIdShort: "4f2c1234" },
292
+ { name: "agent-1", changeIdShort: "tmysxysu", commitIdShort: "0c4b5678" },
293
+ ]);
294
+ });
295
+
296
+ it("captures non-default descriptions", () => {
297
+ const out = `default: rxnxoqlk 4f2c1234 work in progress on auth\n`;
298
+ expect(parseWorkspaceList(out)).toEqual([
299
+ {
300
+ name: "default",
301
+ changeIdShort: "rxnxoqlk",
302
+ commitIdShort: "4f2c1234",
303
+ description: "work in progress on auth",
304
+ },
305
+ ]);
306
+ });
307
+
308
+ it("ignores blank and malformed lines", () => {
309
+ const out = `\ndefault: rxnxoqlk 4f2c1234 (no description set)\nrandom garbage\n: missing-name 1234 5678\n`;
310
+ const entries = parseWorkspaceList(out);
311
+ expect(entries.map((e) => e.name)).toEqual(["default"]);
312
+ });
313
+
314
+ it("yields entry without ids when format is unexpected", () => {
315
+ const out = `weird-name: this is not an id pair\n`;
316
+ const entries = parseWorkspaceList(out);
317
+ expect(entries).toHaveLength(1);
318
+ expect(entries[0]?.name).toBe("weird-name");
319
+ expect(entries[0]?.changeIdShort).toBeUndefined();
320
+ });
321
+
322
+ it("returns empty array for empty input", () => {
323
+ expect(parseWorkspaceList("")).toEqual([]);
324
+ });
325
+ });
326
+
327
+ describe("findWorkspaceByName", () => {
328
+ const fixtures = parseWorkspaceList(
329
+ `default: aaaa 1111 (no description set)\nagent-1: bbbb 2222 (no description set)\n`,
330
+ );
331
+
332
+ it("returns the matching entry by name", () => {
333
+ expect(findWorkspaceByName(fixtures, "agent-1")?.changeIdShort).toBe("bbbb");
334
+ });
335
+
336
+ it("returns undefined for unknown name", () => {
337
+ expect(findWorkspaceByName(fixtures, "ghost")).toBeUndefined();
338
+ });
339
+ });
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseSkillBlock, buildSkillBlock } from "../skill-block-parser.js";
3
+
4
+ describe("parseSkillBlock", () => {
5
+ it("matches a well-formed wrapper with args", () => {
6
+ const text =
7
+ `<skill name="foo" location="/x/SKILL.md">\nReferences are relative to /x.\n\nbody\n</skill>\n\nargs here`;
8
+ const block = parseSkillBlock(text);
9
+ expect(block).not.toBeNull();
10
+ expect(block!.name).toBe("foo");
11
+ expect(block!.location).toBe("/x/SKILL.md");
12
+ expect(block!.body).toBe("body");
13
+ expect(block!.args).toBe("args here");
14
+ expect(block!.condensed).toBe("/skill:foo args here");
15
+ });
16
+
17
+ it("strips the 'References are relative to <baseDir>.\\n\\n' preamble from body", () => {
18
+ const text =
19
+ `<skill name="foo" location="/x/SKILL.md">\nReferences are relative to /x.\n\nfirst body line\nsecond body line\n</skill>`;
20
+ const block = parseSkillBlock(text);
21
+ expect(block!.body).toBe("first body line\nsecond body line");
22
+ expect(block!.body.startsWith("References are relative")).toBe(false);
23
+ });
24
+
25
+ it("falls back to verbatim content when preamble is absent", () => {
26
+ // Hand-crafted wrapper without the standard preamble (defensive: pi may evolve).
27
+ const text = `<skill name="foo" location="/x">\nbody only\n</skill>`;
28
+ expect(parseSkillBlock(text)!.body).toBe("body only");
29
+ });
30
+
31
+ it("matches a wrapper without args (no preamble)", () => {
32
+ const text = `<skill name="foo" location="/x">\nbody\n</skill>`;
33
+ const block = parseSkillBlock(text);
34
+ expect(block).not.toBeNull();
35
+ expect(block!.args).toBeUndefined();
36
+ expect(block!.condensed).toBe("/skill:foo");
37
+ expect(block!.body).toBe("body");
38
+ });
39
+
40
+ it("preserves multi-line user args verbatim", () => {
41
+ const text =
42
+ `<skill name="foo" location="/x">\nbody\n</skill>\n\nline1\nline2\nline3`;
43
+ const block = parseSkillBlock(text);
44
+ expect(block!.args).toBe("line1\nline2\nline3");
45
+ expect(block!.condensed).toBe("/skill:foo line1\nline2\nline3");
46
+ });
47
+
48
+ it("returns null for plain text", () => {
49
+ expect(parseSkillBlock("Hello, this is just text.")).toBeNull();
50
+ expect(parseSkillBlock("")).toBeNull();
51
+ expect(parseSkillBlock("/skill:foo args (unexpanded)")).toBeNull();
52
+ });
53
+
54
+ it("returns null for mid-document <skill> (anchor enforcement)", () => {
55
+ const text = `prefix\n<skill name="foo" location="/x">\nbody\n</skill>`;
56
+ expect(parseSkillBlock(text)).toBeNull();
57
+ });
58
+
59
+ it("returns null for trailing whitespace after </skill>", () => {
60
+ // Anchor end-of-string: a stray newline after </skill> with no args fails the optional-args group.
61
+ const text = `<skill name="foo" location="/x">\nbody\n</skill>\n`;
62
+ expect(parseSkillBlock(text)).toBeNull();
63
+ });
64
+
65
+ it("does not terminate prematurely on body containing literal <skill> text", () => {
66
+ const text =
67
+ `<skill name="real" location="/x">\nReferences are relative to /x.\n\nDocumented like: <skill name="example">…</skill>\nThat ended.\n</skill>`;
68
+ const block = parseSkillBlock(text);
69
+ expect(block).not.toBeNull();
70
+ expect(block!.name).toBe("real");
71
+ expect(block!.body).toContain('Documented like: <skill name="example">…</skill>');
72
+ expect(block!.body).toContain("That ended.");
73
+ // preamble was stripped — body starts with the user-visible content
74
+ expect(block!.body.startsWith("Documented")).toBe(true);
75
+ });
76
+
77
+ it("handles an empty body (no preamble)", () => {
78
+ const text = `<skill name="empty" location="/x">\n\n</skill>`;
79
+ const block = parseSkillBlock(text);
80
+ expect(block).not.toBeNull();
81
+ expect(block!.body).toBe("");
82
+ });
83
+
84
+ it("condensed form has a single space between name and args", () => {
85
+ const text = `<skill name="x" location="/p">\nb\n</skill>\n\nfoo`;
86
+ expect(parseSkillBlock(text)!.condensed).toBe("/skill:x foo");
87
+ });
88
+
89
+ it("condensed form has no trailing space when args is absent", () => {
90
+ const text = `<skill name="x" location="/p">\nb\n</skill>`;
91
+ expect(parseSkillBlock(text)!.condensed).toBe("/skill:x");
92
+ });
93
+ });
94
+
95
+ describe("buildSkillBlock", () => {
96
+ it("emits pi's exact wrapper format with args", () => {
97
+ const out = buildSkillBlock({
98
+ name: "openspec-explore",
99
+ filePath: "/x/openspec-explore/SKILL.md",
100
+ baseDir: "/x/openspec-explore",
101
+ body: "Enter explore mode.",
102
+ userArgs: "continue with X",
103
+ });
104
+ expect(out).toBe(
105
+ `<skill name="openspec-explore" location="/x/openspec-explore/SKILL.md">\n` +
106
+ `References are relative to /x/openspec-explore.\n\n` +
107
+ `Enter explore mode.\n` +
108
+ `</skill>\n\n` +
109
+ `continue with X`,
110
+ );
111
+ });
112
+
113
+ it("emits wrapper without trailing args block when userArgs is absent", () => {
114
+ const out = buildSkillBlock({
115
+ name: "foo",
116
+ filePath: "/x/foo/SKILL.md",
117
+ baseDir: "/x/foo",
118
+ body: "body line",
119
+ });
120
+ expect(out.endsWith("</skill>")).toBe(true);
121
+ expect(out).not.toContain("</skill>\n\n");
122
+ });
123
+ });
124
+
125
+ describe("buildSkillBlock + parseSkillBlock round-trip", () => {
126
+ it("round-trips name / body / args (with args)", () => {
127
+ const built = buildSkillBlock({
128
+ name: "round",
129
+ filePath: "/p/SKILL.md",
130
+ baseDir: "/p",
131
+ body: "Some body\nwith multiple lines",
132
+ userArgs: "the args here",
133
+ });
134
+ const parsed = parseSkillBlock(built);
135
+ expect(parsed).not.toBeNull();
136
+ expect(parsed!.name).toBe("round");
137
+ expect(parsed!.location).toBe("/p/SKILL.md");
138
+ expect(parsed!.body).toBe("Some body\nwith multiple lines");
139
+ expect(parsed!.args).toBe("the args here");
140
+ });
141
+
142
+ it("round-trips with no args", () => {
143
+ const built = buildSkillBlock({
144
+ name: "noargs",
145
+ filePath: "/p/SKILL.md",
146
+ baseDir: "/p",
147
+ body: "Body only",
148
+ });
149
+ const parsed = parseSkillBlock(built);
150
+ expect(parsed!.args).toBeUndefined();
151
+ expect(parsed!.condensed).toBe("/skill:noargs");
152
+ });
153
+ });
@@ -179,13 +179,29 @@ describe("openspec binary definition", () => {
179
179
  });
180
180
 
181
181
  describe("registered tool set", () => {
182
- it("registers pi, pi-coding-agent, openspec, npm, node, git, zrok, wt", () => {
182
+ it("registers pi, pi-coding-agent, openspec, npm, node, git, jj, zrok, wt", () => {
183
183
  const r = freshRegistry({});
184
- for (const name of ["pi", "pi-coding-agent", "openspec", "npm", "node", "git", "zrok", "wt"]) {
184
+ for (const name of ["pi", "pi-coding-agent", "openspec", "npm", "node", "git", "jj", "zrok", "wt"]) {
185
185
  expect(r.has(name)).toBe(true);
186
186
  }
187
187
  });
188
188
 
189
+ it("jj resolves via where when found", () => {
190
+ const r = freshRegistry({
191
+ which: (name) => (name === "jj" ? "/usr/local/bin/jj" : null),
192
+ });
193
+ const res = r.resolve("jj");
194
+ expect(res.ok).toBe(true);
195
+ expect(res.path).toBe("/usr/local/bin/jj");
196
+ expect(res.source).toBe("system");
197
+ });
198
+
199
+ it("jj unavailable returns ok:false without throwing", () => {
200
+ const r = freshRegistry({ which: () => null });
201
+ const res = r.resolve("jj");
202
+ expect(res.ok).toBe(false);
203
+ });
204
+
189
205
  it("wt resolves via where when found", () => {
190
206
  const r = freshRegistry({
191
207
  platform: "win32",