@unbrained/pm-cli 2026.3.12 → 2026.5.1-2

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 (285) hide show
  1. package/.agents/pm/extensions/.managed-extensions.json +42 -0
  2. package/.agents/pm/extensions/beads/index.js +109 -0
  3. package/.agents/pm/extensions/beads/manifest.json +7 -0
  4. package/{dist/cli/commands/beads.js → .agents/pm/extensions/beads/runtime.js} +31 -21
  5. package/.agents/pm/extensions/beads/runtime.ts +702 -0
  6. package/.agents/pm/extensions/todos/index.js +126 -0
  7. package/.agents/pm/extensions/todos/manifest.json +7 -0
  8. package/{dist/extensions/builtins/todos/import-export.js → .agents/pm/extensions/todos/runtime.js} +39 -29
  9. package/.agents/pm/extensions/todos/runtime.ts +568 -0
  10. package/AGENTS.md +196 -92
  11. package/CHANGELOG.md +404 -0
  12. package/CODE_OF_CONDUCT.md +42 -0
  13. package/CONTRIBUTING.md +144 -0
  14. package/PRD.md +512 -164
  15. package/README.md +1053 -2
  16. package/SECURITY.md +51 -0
  17. package/dist/cli/commands/activity.d.ts +5 -0
  18. package/dist/cli/commands/activity.js +66 -3
  19. package/dist/cli/commands/activity.js.map +1 -1
  20. package/dist/cli/commands/aggregate.d.ts +54 -0
  21. package/dist/cli/commands/aggregate.js +181 -0
  22. package/dist/cli/commands/aggregate.js.map +1 -0
  23. package/dist/cli/commands/append.js +4 -1
  24. package/dist/cli/commands/append.js.map +1 -1
  25. package/dist/cli/commands/calendar.d.ts +109 -0
  26. package/dist/cli/commands/calendar.js +797 -0
  27. package/dist/cli/commands/calendar.js.map +1 -0
  28. package/dist/cli/commands/claim.d.ts +5 -1
  29. package/dist/cli/commands/claim.js +42 -21
  30. package/dist/cli/commands/claim.js.map +1 -1
  31. package/dist/cli/commands/close.d.ts +1 -0
  32. package/dist/cli/commands/close.js +54 -5
  33. package/dist/cli/commands/close.js.map +1 -1
  34. package/dist/cli/commands/comments-audit.d.ts +91 -0
  35. package/dist/cli/commands/comments-audit.js +195 -0
  36. package/dist/cli/commands/comments-audit.js.map +1 -0
  37. package/dist/cli/commands/comments.d.ts +1 -0
  38. package/dist/cli/commands/comments.js +70 -21
  39. package/dist/cli/commands/comments.js.map +1 -1
  40. package/dist/cli/commands/completion.d.ts +10 -4
  41. package/dist/cli/commands/completion.js +1184 -137
  42. package/dist/cli/commands/completion.js.map +1 -1
  43. package/dist/cli/commands/config.d.ts +35 -3
  44. package/dist/cli/commands/config.js +968 -13
  45. package/dist/cli/commands/config.js.map +1 -1
  46. package/dist/cli/commands/context.d.ts +86 -0
  47. package/dist/cli/commands/context.js +299 -0
  48. package/dist/cli/commands/context.js.map +1 -0
  49. package/dist/cli/commands/contracts.d.ts +78 -0
  50. package/dist/cli/commands/contracts.js +920 -0
  51. package/dist/cli/commands/contracts.js.map +1 -0
  52. package/dist/cli/commands/create.d.ts +48 -14
  53. package/dist/cli/commands/create.js +1331 -160
  54. package/dist/cli/commands/create.js.map +1 -1
  55. package/dist/cli/commands/dedupe-audit.d.ts +81 -0
  56. package/dist/cli/commands/dedupe-audit.js +330 -0
  57. package/dist/cli/commands/dedupe-audit.js.map +1 -0
  58. package/dist/cli/commands/deps.d.ts +52 -0
  59. package/dist/cli/commands/deps.js +204 -0
  60. package/dist/cli/commands/deps.js.map +1 -0
  61. package/dist/cli/commands/docs.d.ts +19 -0
  62. package/dist/cli/commands/docs.js +212 -13
  63. package/dist/cli/commands/docs.js.map +1 -1
  64. package/dist/cli/commands/extension.d.ts +122 -0
  65. package/dist/cli/commands/extension.js +1850 -0
  66. package/dist/cli/commands/extension.js.map +1 -0
  67. package/dist/cli/commands/files.d.ts +52 -1
  68. package/dist/cli/commands/files.js +455 -13
  69. package/dist/cli/commands/files.js.map +1 -1
  70. package/dist/cli/commands/gc.d.ts +11 -1
  71. package/dist/cli/commands/gc.js +89 -11
  72. package/dist/cli/commands/gc.js.map +1 -1
  73. package/dist/cli/commands/get.d.ts +13 -0
  74. package/dist/cli/commands/get.js +35 -3
  75. package/dist/cli/commands/get.js.map +1 -1
  76. package/dist/cli/commands/health.d.ts +10 -2
  77. package/dist/cli/commands/health.js +774 -23
  78. package/dist/cli/commands/health.js.map +1 -1
  79. package/dist/cli/commands/history.d.ts +20 -0
  80. package/dist/cli/commands/history.js +152 -6
  81. package/dist/cli/commands/history.js.map +1 -1
  82. package/dist/cli/commands/index.d.ts +16 -3
  83. package/dist/cli/commands/index.js +16 -3
  84. package/dist/cli/commands/index.js.map +1 -1
  85. package/dist/cli/commands/init.d.ts +7 -2
  86. package/dist/cli/commands/init.js +137 -5
  87. package/dist/cli/commands/init.js.map +1 -1
  88. package/dist/cli/commands/learnings.d.ts +17 -0
  89. package/dist/cli/commands/learnings.js +129 -0
  90. package/dist/cli/commands/learnings.js.map +1 -0
  91. package/dist/cli/commands/list.d.ts +29 -1
  92. package/dist/cli/commands/list.js +289 -53
  93. package/dist/cli/commands/list.js.map +1 -1
  94. package/dist/cli/commands/normalize.d.ts +51 -0
  95. package/dist/cli/commands/normalize.js +298 -0
  96. package/dist/cli/commands/normalize.js.map +1 -0
  97. package/dist/cli/commands/notes.d.ts +17 -0
  98. package/dist/cli/commands/notes.js +129 -0
  99. package/dist/cli/commands/notes.js.map +1 -0
  100. package/dist/cli/commands/reindex.d.ts +1 -0
  101. package/dist/cli/commands/reindex.js +208 -32
  102. package/dist/cli/commands/reindex.js.map +1 -1
  103. package/dist/cli/commands/restore.js +164 -30
  104. package/dist/cli/commands/restore.js.map +1 -1
  105. package/dist/cli/commands/search.d.ts +14 -1
  106. package/dist/cli/commands/search.js +475 -81
  107. package/dist/cli/commands/search.js.map +1 -1
  108. package/dist/cli/commands/stats.js +26 -10
  109. package/dist/cli/commands/stats.js.map +1 -1
  110. package/dist/cli/commands/templates.d.ts +26 -0
  111. package/dist/cli/commands/templates.js +179 -0
  112. package/dist/cli/commands/templates.js.map +1 -0
  113. package/dist/cli/commands/test-all.d.ts +19 -1
  114. package/dist/cli/commands/test-all.js +161 -13
  115. package/dist/cli/commands/test-all.js.map +1 -1
  116. package/dist/cli/commands/test-runs.d.ts +63 -0
  117. package/dist/cli/commands/test-runs.js +179 -0
  118. package/dist/cli/commands/test-runs.js.map +1 -0
  119. package/dist/cli/commands/test.d.ts +75 -1
  120. package/dist/cli/commands/test.js +1360 -41
  121. package/dist/cli/commands/test.js.map +1 -1
  122. package/dist/cli/commands/update-many.d.ts +57 -0
  123. package/dist/cli/commands/update-many.js +631 -0
  124. package/dist/cli/commands/update-many.js.map +1 -0
  125. package/dist/cli/commands/update.d.ts +30 -0
  126. package/dist/cli/commands/update.js +1393 -84
  127. package/dist/cli/commands/update.js.map +1 -1
  128. package/dist/cli/commands/validate.d.ts +30 -0
  129. package/dist/cli/commands/validate.js +1151 -0
  130. package/dist/cli/commands/validate.js.map +1 -0
  131. package/dist/cli/error-guidance.d.ts +33 -0
  132. package/dist/cli/error-guidance.js +337 -0
  133. package/dist/cli/error-guidance.js.map +1 -0
  134. package/dist/cli/extension-command-options.d.ts +1 -0
  135. package/dist/cli/extension-command-options.js +92 -0
  136. package/dist/cli/extension-command-options.js.map +1 -1
  137. package/dist/cli/help-content.d.ts +20 -0
  138. package/dist/cli/help-content.js +543 -0
  139. package/dist/cli/help-content.js.map +1 -0
  140. package/dist/cli/main.js +3625 -445
  141. package/dist/cli/main.js.map +1 -1
  142. package/dist/core/extensions/index.d.ts +13 -1
  143. package/dist/core/extensions/index.js +108 -1
  144. package/dist/core/extensions/index.js.map +1 -1
  145. package/dist/core/extensions/item-fields.d.ts +2 -0
  146. package/dist/core/extensions/item-fields.js +79 -0
  147. package/dist/core/extensions/item-fields.js.map +1 -0
  148. package/dist/core/extensions/loader.d.ts +322 -9
  149. package/dist/core/extensions/loader.js +911 -20
  150. package/dist/core/extensions/loader.js.map +1 -1
  151. package/dist/core/extensions/runtime-registrations.d.ts +5 -0
  152. package/dist/core/extensions/runtime-registrations.js +51 -0
  153. package/dist/core/extensions/runtime-registrations.js.map +1 -0
  154. package/dist/core/history/history-stream-policy.d.ts +20 -0
  155. package/dist/core/history/history-stream-policy.js +53 -0
  156. package/dist/core/history/history-stream-policy.js.map +1 -0
  157. package/dist/core/history/history.js +90 -1
  158. package/dist/core/history/history.js.map +1 -1
  159. package/dist/core/item/id.js +4 -1
  160. package/dist/core/item/id.js.map +1 -1
  161. package/dist/core/item/index.d.ts +1 -0
  162. package/dist/core/item/index.js +1 -0
  163. package/dist/core/item/index.js.map +1 -1
  164. package/dist/core/item/item-format.d.ts +11 -5
  165. package/dist/core/item/item-format.js +507 -24
  166. package/dist/core/item/item-format.js.map +1 -1
  167. package/dist/core/item/parent-reference-policy.d.ts +6 -0
  168. package/dist/core/item/parent-reference-policy.js +32 -0
  169. package/dist/core/item/parent-reference-policy.js.map +1 -0
  170. package/dist/core/item/parse.d.ts +5 -0
  171. package/dist/core/item/parse.js +216 -19
  172. package/dist/core/item/parse.js.map +1 -1
  173. package/dist/core/item/sprint-release-format.d.ts +6 -0
  174. package/dist/core/item/sprint-release-format.js +33 -0
  175. package/dist/core/item/sprint-release-format.js.map +1 -0
  176. package/dist/core/item/status.d.ts +3 -0
  177. package/dist/core/item/status.js +24 -0
  178. package/dist/core/item/status.js.map +1 -0
  179. package/dist/core/item/type-registry.d.ts +37 -0
  180. package/dist/core/item/type-registry.js +706 -0
  181. package/dist/core/item/type-registry.js.map +1 -0
  182. package/dist/core/lock/lock.d.ts +1 -1
  183. package/dist/core/lock/lock.js +101 -12
  184. package/dist/core/lock/lock.js.map +1 -1
  185. package/dist/core/output/command-aware.d.ts +1 -0
  186. package/dist/core/output/command-aware.js +394 -0
  187. package/dist/core/output/command-aware.js.map +1 -0
  188. package/dist/core/output/output.d.ts +3 -0
  189. package/dist/core/output/output.js +124 -6
  190. package/dist/core/output/output.js.map +1 -1
  191. package/dist/core/schema/runtime-field-filters.d.ts +3 -0
  192. package/dist/core/schema/runtime-field-filters.js +39 -0
  193. package/dist/core/schema/runtime-field-filters.js.map +1 -0
  194. package/dist/core/schema/runtime-field-values.d.ts +8 -0
  195. package/dist/core/schema/runtime-field-values.js +154 -0
  196. package/dist/core/schema/runtime-field-values.js.map +1 -0
  197. package/dist/core/schema/runtime-schema.d.ts +68 -0
  198. package/dist/core/schema/runtime-schema.js +554 -0
  199. package/dist/core/schema/runtime-schema.js.map +1 -0
  200. package/dist/core/search/cache.d.ts +13 -1
  201. package/dist/core/search/cache.js +123 -14
  202. package/dist/core/search/cache.js.map +1 -1
  203. package/dist/core/search/semantic-defaults.d.ts +6 -0
  204. package/dist/core/search/semantic-defaults.js +120 -0
  205. package/dist/core/search/semantic-defaults.js.map +1 -0
  206. package/dist/core/search/vector-stores.js +3 -1
  207. package/dist/core/search/vector-stores.js.map +1 -1
  208. package/dist/core/shared/command-types.d.ts +2 -0
  209. package/dist/core/shared/conflict-markers.d.ts +7 -0
  210. package/dist/core/shared/conflict-markers.js +27 -0
  211. package/dist/core/shared/conflict-markers.js.map +1 -0
  212. package/dist/core/shared/constants.d.ts +15 -4
  213. package/dist/core/shared/constants.js +141 -1
  214. package/dist/core/shared/constants.js.map +1 -1
  215. package/dist/core/shared/errors.d.ts +10 -1
  216. package/dist/core/shared/errors.js +3 -1
  217. package/dist/core/shared/errors.js.map +1 -1
  218. package/dist/core/shared/text-normalization.d.ts +4 -0
  219. package/dist/core/shared/text-normalization.js +33 -0
  220. package/dist/core/shared/text-normalization.js.map +1 -0
  221. package/dist/core/shared/time.d.ts +1 -2
  222. package/dist/core/shared/time.js +98 -11
  223. package/dist/core/shared/time.js.map +1 -1
  224. package/dist/core/store/index.d.ts +1 -0
  225. package/dist/core/store/index.js +1 -0
  226. package/dist/core/store/index.js.map +1 -1
  227. package/dist/core/store/item-format-migration.d.ts +9 -0
  228. package/dist/core/store/item-format-migration.js +87 -0
  229. package/dist/core/store/item-format-migration.js.map +1 -0
  230. package/dist/core/store/item-store.d.ts +13 -4
  231. package/dist/core/store/item-store.js +238 -51
  232. package/dist/core/store/item-store.js.map +1 -1
  233. package/dist/core/store/paths.d.ts +21 -3
  234. package/dist/core/store/paths.js +59 -4
  235. package/dist/core/store/paths.js.map +1 -1
  236. package/dist/core/store/settings.d.ts +14 -1
  237. package/dist/core/store/settings.js +463 -7
  238. package/dist/core/store/settings.js.map +1 -1
  239. package/dist/core/telemetry/consent.d.ts +2 -0
  240. package/dist/core/telemetry/consent.js +79 -0
  241. package/dist/core/telemetry/consent.js.map +1 -0
  242. package/dist/core/telemetry/runtime.d.ts +38 -0
  243. package/dist/core/telemetry/runtime.js +733 -0
  244. package/dist/core/telemetry/runtime.js.map +1 -0
  245. package/dist/core/test/background-runs.d.ts +117 -0
  246. package/dist/core/test/background-runs.js +760 -0
  247. package/dist/core/test/background-runs.js.map +1 -0
  248. package/dist/core/test/item-test-run-tracking.d.ts +9 -0
  249. package/dist/core/test/item-test-run-tracking.js +50 -0
  250. package/dist/core/test/item-test-run-tracking.js.map +1 -0
  251. package/dist/sdk/cli-contracts.d.ts +92 -0
  252. package/dist/sdk/cli-contracts.js +2357 -0
  253. package/dist/sdk/cli-contracts.js.map +1 -0
  254. package/dist/sdk/index.d.ts +34 -0
  255. package/dist/sdk/index.js +23 -0
  256. package/dist/sdk/index.js.map +1 -0
  257. package/dist/types.d.ts +197 -3
  258. package/dist/types.js +48 -1
  259. package/dist/types.js.map +1 -1
  260. package/docs/ARCHITECTURE.md +368 -39
  261. package/docs/EXTENSIONS.md +454 -49
  262. package/docs/RELEASING.md +70 -19
  263. package/docs/SDK.md +123 -0
  264. package/docs/examples/starter-extension/README.md +48 -0
  265. package/docs/examples/starter-extension/index.js +191 -0
  266. package/docs/examples/starter-extension/manifest.json +17 -0
  267. package/docs/examples/starter-extension/package.json +10 -0
  268. package/package.json +41 -14
  269. package/.pi/extensions/pm-cli/index.ts +0 -778
  270. package/dist/cli/commands/beads.d.ts +0 -16
  271. package/dist/cli/commands/beads.js.map +0 -1
  272. package/dist/cli/commands/install.d.ts +0 -18
  273. package/dist/cli/commands/install.js +0 -87
  274. package/dist/cli/commands/install.js.map +0 -1
  275. package/dist/core/extensions/builtins.d.ts +0 -3
  276. package/dist/core/extensions/builtins.js +0 -47
  277. package/dist/core/extensions/builtins.js.map +0 -1
  278. package/dist/extensions/builtins/beads/index.d.ts +0 -8
  279. package/dist/extensions/builtins/beads/index.js +0 -33
  280. package/dist/extensions/builtins/beads/index.js.map +0 -1
  281. package/dist/extensions/builtins/todos/import-export.d.ts +0 -26
  282. package/dist/extensions/builtins/todos/import-export.js.map +0 -1
  283. package/dist/extensions/builtins/todos/index.d.ts +0 -8
  284. package/dist/extensions/builtins/todos/index.js +0 -38
  285. package/dist/extensions/builtins/todos/index.js.map +0 -1
@@ -1,24 +1,150 @@
1
- import { exec as execCb } from "node:child_process";
2
- import { mkdtemp, rm } from "node:fs/promises";
1
+ import { spawn } from "node:child_process";
2
+ import { cp, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import path from "node:path";
5
- import { promisify } from "node:util";
5
+ import { getActiveExtensionRegistrations } from "../../core/extensions/index.js";
6
6
  import { pathExists } from "../../core/fs/fs-utils.js";
7
- import { parseCsvKv, parseOptionalNumber } from "../../core/item/parse.js";
7
+ import { resolveItemTypeRegistry } from "../../core/item/type-registry.js";
8
+ import { createStdinTokenResolver, parseCsvKv, parseOptionalNumber } from "../../core/item/parse.js";
8
9
  import { EXIT_CODE } from "../../core/shared/constants.js";
9
10
  import { PmCliError } from "../../core/shared/errors.js";
11
+ import { nowIso } from "../../core/shared/time.js";
10
12
  import { locateItem, mutateItem, readLocatedItem } from "../../core/store/item-store.js";
11
- import { getSettingsPath, resolvePmRoot } from "../../core/store/paths.js";
13
+ import { getSettingsPath, ITEM_FILE_EXTENSIONS, resolveGlobalPmRoot, resolvePmRoot } from "../../core/store/paths.js";
12
14
  import { readSettings } from "../../core/store/settings.js";
15
+ import { appendTrackedTestRunSummary } from "../../core/test/item-test-run-tracking.js";
13
16
  import { runInit } from "./init.js";
14
17
  import { SCOPE_VALUES } from "../../types/index.js";
15
- const exec = promisify(execCb);
16
18
  const TEST_OUTPUT_MAX_BUFFER_BYTES = 20 * 1024 * 1024;
19
+ const DEFAULT_LINKED_TEST_TIMEOUT_FORCE_KILL_DELAY_MS = 3000;
20
+ const DEFAULT_LINKED_TEST_HEARTBEAT_INTERVAL_MS = 10000;
21
+ const MAX_LINKED_TEST_COMMAND_LABEL_LENGTH = 120;
22
+ const LINKED_TEST_PROTECTED_ENV_KEYS = new Set(["PM_PATH", "PM_GLOBAL_PATH", "FORCE_COLOR"]);
23
+ const LINKED_TEST_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ const PM_CONTEXT_MODE_VALUES = ["schema", "tracker", "auto"];
25
+ const LINKED_TEST_TRACKER_DIRS_TO_SKIP = new Set(["locks", "extensions"]);
26
+ const LINKED_TEST_ITEM_COUNT_DIRS_TO_SKIP = new Set(["history", "index", "search", "extensions", "locks"]);
27
+ const LINKED_TEST_INFRA_COLLISION_PATTERNS = [
28
+ /eaddrinuse/i,
29
+ /address already in use/i,
30
+ /port\s+\d+\s+is already in use/i,
31
+ /web server[^.\n]*already running/i,
32
+ /failed to listen on/i,
33
+ ];
34
+ const PM_SUBCOMMANDS_WITH_ITEM_REFERENCE = new Set([
35
+ "get",
36
+ "history",
37
+ "restore",
38
+ "update",
39
+ "close",
40
+ "delete",
41
+ "append",
42
+ "claim",
43
+ "release",
44
+ "comments",
45
+ "notes",
46
+ "learnings",
47
+ "files",
48
+ "docs",
49
+ "deps",
50
+ "test",
51
+ ]);
52
+ const PM_TRACKER_READ_SUBCOMMANDS = new Set([
53
+ "activity",
54
+ "calendar",
55
+ "context",
56
+ "ctx",
57
+ "deps",
58
+ "get",
59
+ "health",
60
+ "history",
61
+ "list",
62
+ "list-all",
63
+ "list-blocked",
64
+ "list-canceled",
65
+ "list-closed",
66
+ "list-draft",
67
+ "list-in-progress",
68
+ "list-open",
69
+ "search",
70
+ "stats",
71
+ "test-all",
72
+ "validate",
73
+ ]);
74
+ function readPositiveIntegerEnv(name, fallback) {
75
+ const raw = process.env[name];
76
+ if (typeof raw !== "string" || raw.trim().length === 0) {
77
+ return fallback;
78
+ }
79
+ const parsed = Number.parseInt(raw, 10);
80
+ if (!Number.isFinite(parsed) || parsed <= 0) {
81
+ return fallback;
82
+ }
83
+ return parsed;
84
+ }
85
+ function linkedTestTimeoutForceKillDelayMs() {
86
+ return readPositiveIntegerEnv("PM_LINKED_TEST_TIMEOUT_FORCE_KILL_DELAY_MS", DEFAULT_LINKED_TEST_TIMEOUT_FORCE_KILL_DELAY_MS);
87
+ }
88
+ function linkedTestHeartbeatIntervalMs() {
89
+ return readPositiveIntegerEnv("PM_LINKED_TEST_HEARTBEAT_INTERVAL_MS", DEFAULT_LINKED_TEST_HEARTBEAT_INTERVAL_MS);
90
+ }
17
91
  function resolveAuthor(candidate, fallback) {
18
92
  const resolved = candidate ?? process.env.PM_AUTHOR ?? fallback;
19
93
  const trimmed = resolved.trim();
20
94
  return trimmed || "unknown";
21
95
  }
96
+ function resolveTrackedRunId(kind) {
97
+ const fromEnv = process.env.PM_BACKGROUND_TEST_RUN_ID?.trim();
98
+ if (fromEnv && fromEnv.length > 0) {
99
+ return fromEnv;
100
+ }
101
+ return `${kind}-local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
102
+ }
103
+ function summarizeRunResultStatuses(results) {
104
+ let passed = 0;
105
+ let failed = 0;
106
+ let skipped = 0;
107
+ for (const entry of results) {
108
+ if (entry.status === "passed") {
109
+ passed += 1;
110
+ continue;
111
+ }
112
+ if (entry.status === "failed") {
113
+ failed += 1;
114
+ continue;
115
+ }
116
+ skipped += 1;
117
+ }
118
+ return { passed, failed, skipped };
119
+ }
120
+ export function summarizeContextPreflight(runResults) {
121
+ let checkedPmCommands = 0;
122
+ let trackerReadCommands = 0;
123
+ let mismatches = 0;
124
+ let autoRemediated = 0;
125
+ for (const result of runResults) {
126
+ const context = result.execution_context;
127
+ if (!context || context.is_pm_command !== true) {
128
+ continue;
129
+ }
130
+ checkedPmCommands += 1;
131
+ if (context.is_pm_tracker_read_command === true) {
132
+ trackerReadCommands += 1;
133
+ }
134
+ if (context.mismatch_detected === true) {
135
+ mismatches += 1;
136
+ }
137
+ if (context.auto_pm_context_applied === true) {
138
+ autoRemediated += 1;
139
+ }
140
+ }
141
+ return {
142
+ checked_pm_commands: checkedPmCommands,
143
+ tracker_read_commands: trackerReadCommands,
144
+ mismatches,
145
+ auto_remediated: autoRemediated,
146
+ };
147
+ }
22
148
  function ensureScope(raw) {
23
149
  const value = (raw ?? "project");
24
150
  if (!SCOPE_VALUES.includes(value)) {
@@ -26,6 +152,242 @@ function ensureScope(raw) {
26
152
  }
27
153
  return value;
28
154
  }
155
+ function parseLinkedTestBooleanValue(raw, optionName, fieldLabel) {
156
+ if (!raw || raw.trim().length === 0) {
157
+ return undefined;
158
+ }
159
+ const normalized = raw.trim().toLowerCase();
160
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
161
+ return true;
162
+ }
163
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
164
+ return false;
165
+ }
166
+ throw new PmCliError(`${optionName} ${fieldLabel} must be one of true|false|1|0|yes|no`, EXIT_CODE.USAGE);
167
+ }
168
+ function parseLinkedTestEnvSetValue(raw, optionName) {
169
+ if (!raw || raw.trim().length === 0) {
170
+ return undefined;
171
+ }
172
+ const assignments = raw
173
+ .split(/[;\n]/)
174
+ .map((entry) => entry.trim())
175
+ .filter((entry) => entry.length > 0);
176
+ if (assignments.length === 0) {
177
+ throw new PmCliError(`${optionName} env_set must include at least one KEY=VALUE assignment`, EXIT_CODE.USAGE);
178
+ }
179
+ const envSet = {};
180
+ for (const assignment of assignments) {
181
+ const separatorIndex = assignment.indexOf("=");
182
+ if (separatorIndex <= 0) {
183
+ throw new PmCliError(`${optionName} env_set entries must use KEY=VALUE and be separated by semicolons. Example: env_set=PORT=0;PLAYWRIGHT_BASE_URL=http://127.0.0.1:4173`, EXIT_CODE.USAGE);
184
+ }
185
+ const key = assignment.slice(0, separatorIndex).trim();
186
+ const value = assignment.slice(separatorIndex + 1);
187
+ if (!LINKED_TEST_ENV_NAME_PATTERN.test(key)) {
188
+ throw new PmCliError(`${optionName} env_set key "${key}" is invalid`, EXIT_CODE.USAGE);
189
+ }
190
+ if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
191
+ throw new PmCliError(`${optionName} env_set key "${key}" is reserved for sandbox safety`, EXIT_CODE.USAGE);
192
+ }
193
+ envSet[key] = value;
194
+ }
195
+ return Object.keys(envSet).length > 0 ? envSet : undefined;
196
+ }
197
+ function parseLinkedTestEnvClearValue(raw, optionName) {
198
+ if (!raw || raw.trim().length === 0) {
199
+ return undefined;
200
+ }
201
+ const values = [...new Set(raw.split(/[;,\n]/).map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
202
+ if (values.length === 0) {
203
+ throw new PmCliError(`${optionName} env_clear must include at least one environment variable name`, EXIT_CODE.USAGE);
204
+ }
205
+ for (const key of values) {
206
+ if (!LINKED_TEST_ENV_NAME_PATTERN.test(key)) {
207
+ throw new PmCliError(`${optionName} env_clear key "${key}" is invalid`, EXIT_CODE.USAGE);
208
+ }
209
+ if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
210
+ throw new PmCliError(`${optionName} env_clear key "${key}" is reserved for sandbox safety`, EXIT_CODE.USAGE);
211
+ }
212
+ }
213
+ return values;
214
+ }
215
+ function parseLinkedTestStringList(raw) {
216
+ if (!raw || raw.trim().length === 0) {
217
+ return undefined;
218
+ }
219
+ const values = [...new Set(raw.split(/[;\n]/).map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
220
+ return values.length > 0 ? values : undefined;
221
+ }
222
+ function parseLinkedTestRegexList(raw, optionName, fieldLabel) {
223
+ const values = parseLinkedTestStringList(raw);
224
+ if (!values || values.length === 0) {
225
+ return undefined;
226
+ }
227
+ for (const pattern of values) {
228
+ try {
229
+ // Validate syntax when linked-test metadata is added.
230
+ new RegExp(pattern, "m");
231
+ }
232
+ catch (error) {
233
+ throw new PmCliError(`${optionName} ${fieldLabel} includes invalid regex "${pattern}": ${error instanceof Error ? error.message : String(error)}`, EXIT_CODE.USAGE);
234
+ }
235
+ }
236
+ return values;
237
+ }
238
+ function parseLinkedTestMinLines(raw, optionName) {
239
+ if (!raw || raw.trim().length === 0) {
240
+ return undefined;
241
+ }
242
+ const parsed = parseOptionalNumber(raw, "assert_stdout_min_lines");
243
+ if (!Number.isInteger(parsed) || parsed < 0) {
244
+ throw new PmCliError(`${optionName} assert_stdout_min_lines must be an integer >= 0`, EXIT_CODE.USAGE);
245
+ }
246
+ return parsed;
247
+ }
248
+ function parseLinkedTestAssertionEqualsMap(raw, optionName) {
249
+ if (!raw || raw.trim().length === 0) {
250
+ return undefined;
251
+ }
252
+ const assignments = raw
253
+ .split(/[;\n]/)
254
+ .map((entry) => entry.trim())
255
+ .filter((entry) => entry.length > 0);
256
+ if (assignments.length === 0) {
257
+ throw new PmCliError(`${optionName} assert_json_field_equals must include at least one path=value assignment`, EXIT_CODE.USAGE);
258
+ }
259
+ const values = {};
260
+ for (const assignment of assignments) {
261
+ const separatorIndex = assignment.indexOf("=");
262
+ if (separatorIndex <= 0) {
263
+ throw new PmCliError(`${optionName} assert_json_field_equals entries must use path=value and be separated by semicolons`, EXIT_CODE.USAGE);
264
+ }
265
+ const key = assignment.slice(0, separatorIndex).trim();
266
+ const value = assignment.slice(separatorIndex + 1).trim();
267
+ if (key.length === 0 || value.length === 0) {
268
+ throw new PmCliError(`${optionName} assert_json_field_equals entries must include non-empty path and value`, EXIT_CODE.USAGE);
269
+ }
270
+ values[key] = value;
271
+ }
272
+ return Object.keys(values).length > 0 ? values : undefined;
273
+ }
274
+ function parseLinkedTestAssertionGteMap(raw, optionName) {
275
+ if (!raw || raw.trim().length === 0) {
276
+ return undefined;
277
+ }
278
+ const assignments = raw
279
+ .split(/[;\n]/)
280
+ .map((entry) => entry.trim())
281
+ .filter((entry) => entry.length > 0);
282
+ if (assignments.length === 0) {
283
+ throw new PmCliError(`${optionName} assert_json_field_gte must include at least one path=value assignment`, EXIT_CODE.USAGE);
284
+ }
285
+ const values = {};
286
+ for (const assignment of assignments) {
287
+ const separatorIndex = assignment.indexOf("=");
288
+ if (separatorIndex <= 0) {
289
+ throw new PmCliError(`${optionName} assert_json_field_gte entries must use path=value and be separated by semicolons`, EXIT_CODE.USAGE);
290
+ }
291
+ const key = assignment.slice(0, separatorIndex).trim();
292
+ const valueRaw = assignment.slice(separatorIndex + 1).trim();
293
+ if (key.length === 0 || valueRaw.length === 0) {
294
+ throw new PmCliError(`${optionName} assert_json_field_gte entries must include non-empty path and value`, EXIT_CODE.USAGE);
295
+ }
296
+ const value = Number.parseFloat(valueRaw);
297
+ if (!Number.isFinite(value)) {
298
+ throw new PmCliError(`${optionName} assert_json_field_gte value for "${key}" must be numeric`, EXIT_CODE.USAGE);
299
+ }
300
+ values[key] = value;
301
+ }
302
+ return Object.keys(values).length > 0 ? values : undefined;
303
+ }
304
+ function parseLinkedTestContextModeValue(raw, optionName) {
305
+ if (!raw || raw.trim().length === 0) {
306
+ return undefined;
307
+ }
308
+ const normalized = raw.trim().toLowerCase();
309
+ if (PM_CONTEXT_MODE_VALUES.includes(normalized)) {
310
+ return normalized;
311
+ }
312
+ throw new PmCliError(`${optionName} pm_context_mode must be one of: ${PM_CONTEXT_MODE_VALUES.join(", ")}`, EXIT_CODE.USAGE);
313
+ }
314
+ function parsePmContextMode(raw) {
315
+ if (!raw) {
316
+ return "schema";
317
+ }
318
+ const normalized = raw.trim().toLowerCase();
319
+ if (PM_CONTEXT_MODE_VALUES.includes(normalized)) {
320
+ return normalized;
321
+ }
322
+ throw new PmCliError(`Invalid --pm-context value "${raw}". Expected one of: ${PM_CONTEXT_MODE_VALUES.join(", ")}`, EXIT_CODE.USAGE);
323
+ }
324
+ function resolveLinkedTestRequestedContextMode(linkedTest, runLevelMode, overrideLinkedPmContext) {
325
+ if (overrideLinkedPmContext) {
326
+ return runLevelMode;
327
+ }
328
+ if (typeof linkedTest.pm_context_mode !== "string" || linkedTest.pm_context_mode.trim().length === 0) {
329
+ return runLevelMode;
330
+ }
331
+ return parsePmContextMode(linkedTest.pm_context_mode);
332
+ }
333
+ function resolveLinkedTestEffectiveContextMode(requestedMode, isPmTrackerReadCommand) {
334
+ if (requestedMode === "auto") {
335
+ return isPmTrackerReadCommand ? "tracker" : "schema";
336
+ }
337
+ return requestedMode;
338
+ }
339
+ function hasLinkedTestAssertions(linkedTest) {
340
+ return ((linkedTest.assert_stdout_contains?.length ?? 0) > 0 ||
341
+ (linkedTest.assert_stdout_regex?.length ?? 0) > 0 ||
342
+ (linkedTest.assert_stderr_contains?.length ?? 0) > 0 ||
343
+ (linkedTest.assert_stderr_regex?.length ?? 0) > 0 ||
344
+ typeof linkedTest.assert_stdout_min_lines === "number" ||
345
+ Object.keys(linkedTest.assert_json_field_equals ?? {}).length > 0 ||
346
+ Object.keys(linkedTest.assert_json_field_gte ?? {}).length > 0);
347
+ }
348
+ function buildPmContextMismatchHint(params) {
349
+ const { executionContext, runLevelPmContextMode, linkedOverridePmContextMode } = params;
350
+ if (!executionContext.is_pm_tracker_read_command || !executionContext.mismatch_detected) {
351
+ return "";
352
+ }
353
+ if (runLevelPmContextMode === "tracker" && linkedOverridePmContextMode === "schema") {
354
+ return (" Linked test metadata pm_context_mode=schema overrides run-level --pm-context tracker." +
355
+ " Set pm_context_mode=tracker (or auto) on the linked test, or remove the override, to run against seeded tracker data.");
356
+ }
357
+ if (executionContext.pm_context_mode === "schema") {
358
+ return " Use --pm-context tracker to run PM tracker-read commands against seeded tracker data.";
359
+ }
360
+ return "";
361
+ }
362
+ function mergeEnvSetDirectives(entries, optionName) {
363
+ const merged = {};
364
+ if (!entries) {
365
+ return merged;
366
+ }
367
+ for (const entry of entries) {
368
+ const parsed = parseLinkedTestEnvSetValue(entry, optionName);
369
+ if (!parsed) {
370
+ continue;
371
+ }
372
+ for (const [key, value] of Object.entries(parsed)) {
373
+ merged[key] = value;
374
+ }
375
+ }
376
+ return merged;
377
+ }
378
+ function mergeEnvClearDirectives(entries, optionName) {
379
+ if (!entries) {
380
+ return [];
381
+ }
382
+ const values = [];
383
+ for (const entry of entries) {
384
+ const parsed = parseLinkedTestEnvClearValue(entry, optionName);
385
+ if (parsed) {
386
+ values.push(...parsed);
387
+ }
388
+ }
389
+ return [...new Set(values)];
390
+ }
29
391
  const PM_GLOBAL_FLAGS_WITH_VALUE = new Set(["--path"]);
30
392
  const NPX_FLAGS_WITH_VALUE = new Set(["-p", "--package", "-c", "--call"]);
31
393
  const PNPM_GLOBAL_FLAGS_WITH_VALUE = new Set([
@@ -180,6 +542,124 @@ function parseNpmExecCommand(tokens) {
180
542
  }
181
543
  return parseNpxCommand(parsed.args);
182
544
  }
545
+ function resolvePmSubcommandContext(args) {
546
+ let index = 0;
547
+ while (index < args.length) {
548
+ const token = args[index];
549
+ if (token === "--") {
550
+ index += 1;
551
+ continue;
552
+ }
553
+ if (token.startsWith("-")) {
554
+ if (PM_GLOBAL_FLAGS_WITH_VALUE.has(token)) {
555
+ index += 2;
556
+ continue;
557
+ }
558
+ index += 1;
559
+ continue;
560
+ }
561
+ return {
562
+ subcommand: token,
563
+ remaining: args.slice(index + 1),
564
+ };
565
+ }
566
+ return null;
567
+ }
568
+ function firstPositionalToken(tokens) {
569
+ for (const token of tokens) {
570
+ if (!token.startsWith("-")) {
571
+ return token;
572
+ }
573
+ }
574
+ return undefined;
575
+ }
576
+ function looksLikePrefixedItemId(token, idPrefix) {
577
+ const normalizedPrefix = idPrefix.trim().toLowerCase().replace(/-+$/, "");
578
+ if (normalizedPrefix.length === 0) {
579
+ return false;
580
+ }
581
+ const normalized = token.trim().toLowerCase();
582
+ if (!normalized.startsWith(`${normalizedPrefix}-`)) {
583
+ return false;
584
+ }
585
+ return normalized.length > normalizedPrefix.length + 1;
586
+ }
587
+ function extractPmInvocationArgsFromSegment(segment) {
588
+ const rawTokens = segment.split(" ").filter((token) => token.length > 0);
589
+ const tokens = stripLeadingEnvAssignments(rawTokens);
590
+ if (tokens.length === 0) {
591
+ return null;
592
+ }
593
+ const [executable, ...args] = tokens;
594
+ if (isPmExecutableToken(executable) || isPmCliScriptToken(executable)) {
595
+ return args;
596
+ }
597
+ if (executable === "node" && args.length > 0 && isPmCliScriptToken(args[0])) {
598
+ return args.slice(1);
599
+ }
600
+ if (executable === "npx") {
601
+ const parsed = parseNpxCommand(args);
602
+ if (parsed && (isPmExecutableToken(parsed.command) || isPmCliPackageToken(parsed.command))) {
603
+ return parsed.args;
604
+ }
605
+ }
606
+ if (executable === "pnpm") {
607
+ const parsed = parsePnpmDlxCommand(args);
608
+ if (parsed && (isPmExecutableToken(parsed.command) || isPmCliPackageToken(parsed.command))) {
609
+ return parsed.args;
610
+ }
611
+ }
612
+ if (executable === "npm") {
613
+ const parsed = parseNpmExecCommand(args);
614
+ if (parsed && (isPmExecutableToken(parsed.command) || isPmCliPackageToken(parsed.command))) {
615
+ return parsed.args;
616
+ }
617
+ }
618
+ return null;
619
+ }
620
+ function commandInvokesPmCli(command) {
621
+ const normalizedCommand = normalizeCommandForValidation(command);
622
+ return splitNormalizedCommandSegments(normalizedCommand).some((segment) => extractPmInvocationArgsFromSegment(segment) !== null);
623
+ }
624
+ function commandInvokesPmTrackerReadCommand(command) {
625
+ const normalizedCommand = normalizeCommandForValidation(command);
626
+ return splitNormalizedCommandSegments(normalizedCommand).some((segment) => {
627
+ const invocationArgs = extractPmInvocationArgsFromSegment(segment);
628
+ if (!invocationArgs) {
629
+ return false;
630
+ }
631
+ const context = resolvePmSubcommandContext(invocationArgs);
632
+ if (!context) {
633
+ return false;
634
+ }
635
+ return PM_TRACKER_READ_SUBCOMMANDS.has(context.subcommand);
636
+ });
637
+ }
638
+ export function extractReferencedPmItemIdsFromCommand(command, idPrefix = "pm") {
639
+ const normalizedCommand = normalizeCommandForValidation(command);
640
+ const ids = new Set();
641
+ for (const segment of splitNormalizedCommandSegments(normalizedCommand)) {
642
+ const invocationArgs = extractPmInvocationArgsFromSegment(segment);
643
+ if (!invocationArgs) {
644
+ continue;
645
+ }
646
+ const context = resolvePmSubcommandContext(invocationArgs);
647
+ if (!context) {
648
+ continue;
649
+ }
650
+ if (!PM_SUBCOMMANDS_WITH_ITEM_REFERENCE.has(context.subcommand)) {
651
+ continue;
652
+ }
653
+ const candidate = firstPositionalToken(context.remaining);
654
+ if (!candidate) {
655
+ continue;
656
+ }
657
+ if (looksLikePrefixedItemId(candidate, idPrefix)) {
658
+ ids.add(candidate);
659
+ }
660
+ }
661
+ return [...ids].sort((left, right) => left.localeCompare(right));
662
+ }
183
663
  function resolveDirectRunnerSubcommand(parsed) {
184
664
  if (!parsed) {
185
665
  return undefined;
@@ -314,8 +794,8 @@ function parseAddEntries(raw) {
314
794
  const kv = parseCsvKv(entry, "--add");
315
795
  const command = kv.command?.trim() || undefined;
316
796
  const filePath = kv.path?.trim() || undefined;
317
- if (!command && !filePath) {
318
- throw new PmCliError("--add requires command=<value> and/or path=<value>", EXIT_CODE.USAGE);
797
+ if (!command) {
798
+ throw new PmCliError("--add requires command=<value> (path=<value> is optional metadata)", EXIT_CODE.USAGE);
319
799
  }
320
800
  if (command) {
321
801
  assertNoRecursiveTestAllCommand(command);
@@ -328,11 +808,26 @@ function parseAddEntries(raw) {
328
808
  }
329
809
  const timeoutRaw = timeoutSecondsRaw ?? timeoutAliasRaw;
330
810
  const timeoutSeconds = timeoutRaw === undefined ? undefined : Math.floor(parseOptionalNumber(timeoutRaw, "timeout_seconds"));
811
+ const envSet = parseLinkedTestEnvSetValue(kv.env_set?.trim(), "--add");
812
+ const envClear = parseLinkedTestEnvClearValue(kv.env_clear?.trim(), "--add");
813
+ const pmContextMode = parseLinkedTestContextModeValue(kv.pm_context_mode?.trim(), "--add");
814
+ const sharedHostSafe = parseLinkedTestBooleanValue(kv.shared_host_safe?.trim(), "--add", "shared_host_safe");
331
815
  return {
332
816
  command,
333
817
  path: filePath,
334
818
  scope: ensureScope(kv.scope),
335
819
  timeout_seconds: timeoutSeconds,
820
+ pm_context_mode: pmContextMode,
821
+ env_set: envSet,
822
+ env_clear: envClear,
823
+ shared_host_safe: sharedHostSafe,
824
+ assert_stdout_contains: parseLinkedTestStringList(kv.assert_stdout_contains?.trim()),
825
+ assert_stdout_regex: parseLinkedTestRegexList(kv.assert_stdout_regex?.trim(), "--add", "assert_stdout_regex"),
826
+ assert_stderr_contains: parseLinkedTestStringList(kv.assert_stderr_contains?.trim()),
827
+ assert_stderr_regex: parseLinkedTestRegexList(kv.assert_stderr_regex?.trim(), "--add", "assert_stderr_regex"),
828
+ assert_stdout_min_lines: parseLinkedTestMinLines(kv.assert_stdout_min_lines?.trim(), "--add"),
829
+ assert_json_field_equals: parseLinkedTestAssertionEqualsMap(kv.assert_json_field_equals?.trim(), "--add"),
830
+ assert_json_field_gte: parseLinkedTestAssertionGteMap(kv.assert_json_field_gte?.trim(), "--add"),
336
831
  note: kv.note?.trim() || undefined,
337
832
  };
338
833
  });
@@ -345,7 +840,9 @@ function parseRemoveEntries(raw) {
345
840
  if (!trimmed) {
346
841
  throw new PmCliError("--remove requires command or path value", EXIT_CODE.USAGE);
347
842
  }
348
- if (trimmed.includes("=")) {
843
+ if (trimmed.includes("=") ||
844
+ /^(?:[-*+]\s+)?(?:path|command)\s*[:=]/i.test(trimmed) ||
845
+ trimmed.startsWith("```")) {
349
846
  const kv = parseCsvKv(trimmed, "--remove");
350
847
  const value = kv.path ?? kv.command;
351
848
  if (!value?.trim()) {
@@ -356,19 +853,673 @@ function parseRemoveEntries(raw) {
356
853
  return trimmed;
357
854
  });
358
855
  }
359
- export async function runLinkedTests(tests, defaultTimeoutSeconds) {
856
+ function closeLinkedTestStdin(child) {
857
+ // Force EOF on child stdin so non-interactive runs do not wait on input.
858
+ try {
859
+ child.stdin?.end();
860
+ }
861
+ catch {
862
+ // Child stdin can already be closed depending on command startup timing.
863
+ }
864
+ }
865
+ function summarizeLinkedTestCommand(command) {
866
+ const normalized = command.trim().replaceAll(/\s+/g, " ");
867
+ if (normalized.length <= MAX_LINKED_TEST_COMMAND_LABEL_LENGTH) {
868
+ return normalized;
869
+ }
870
+ return `${normalized.slice(0, MAX_LINKED_TEST_COMMAND_LABEL_LENGTH - 3)}...`;
871
+ }
872
+ function shouldEmitLinkedTestProgress(mode) {
873
+ /* c8 ignore start -- reserved for future explicit "off" mode wiring. */
874
+ if (mode === "off") {
875
+ return false;
876
+ }
877
+ /* c8 ignore stop */
878
+ if (mode === "always") {
879
+ return true;
880
+ }
881
+ return process.stderr.isTTY === true;
882
+ }
883
+ function emitLinkedTestProgress(message) {
884
+ try {
885
+ process.stderr.write(`${message}\n`);
886
+ }
887
+ catch {
888
+ // Ignore transient stderr write failures.
889
+ }
890
+ }
891
+ function beginLinkedTestProgress(context, mode) {
892
+ if (!shouldEmitLinkedTestProgress(mode)) {
893
+ return null;
894
+ }
895
+ const commandLabel = summarizeLinkedTestCommand(context.command);
896
+ const startAt = Date.now();
897
+ emitLinkedTestProgress(`[pm test] linked-test ${context.index}/${context.total} start timeout_ms=${context.timeoutMs} command="${commandLabel}"`);
898
+ const heartbeat = setInterval(() => {
899
+ const elapsedMs = Date.now() - startAt;
900
+ emitLinkedTestProgress(`[pm test] linked-test ${context.index}/${context.total} running elapsed_ms=${elapsedMs} command="${commandLabel}"`);
901
+ }, linkedTestHeartbeatIntervalMs());
902
+ heartbeat.unref?.();
903
+ return heartbeat;
904
+ }
905
+ function endLinkedTestProgress(context, executionResult, startedAt, mode) {
906
+ if (!shouldEmitLinkedTestProgress(mode)) {
907
+ return;
908
+ }
909
+ const commandLabel = summarizeLinkedTestCommand(context.command);
910
+ const elapsedMs = Date.now() - startedAt;
911
+ const failed = executionResult.timedOut || executionResult.maxBufferExceeded || executionResult.exitCode !== 0;
912
+ const statusLabel = failed ? "failed" : "passed";
913
+ const reasonTokens = [];
914
+ if (executionResult.timedOut) {
915
+ reasonTokens.push("reason=timeout");
916
+ }
917
+ if (executionResult.maxBufferExceeded) {
918
+ reasonTokens.push("reason=max_buffer");
919
+ }
920
+ if (executionResult.signal) {
921
+ reasonTokens.push(`signal=${executionResult.signal}`);
922
+ }
923
+ const exitLabel = executionResult.exitCode === null ? "null" : String(executionResult.exitCode);
924
+ const reasonSuffix = reasonTokens.length > 0 ? ` ${reasonTokens.join(" ")}` : "";
925
+ emitLinkedTestProgress(`[pm test] linked-test ${context.index}/${context.total} end status=${statusLabel} exit_code=${exitLabel} elapsed_ms=${elapsedMs}${reasonSuffix} command="${commandLabel}"`);
926
+ }
927
+ /* c8 ignore start -- process-tree teardown paths are highly platform-dependent. */
928
+ async function killProcessTree(pid) {
929
+ if (!Number.isInteger(pid) || pid <= 0) {
930
+ return;
931
+ }
932
+ if (process.platform === "win32") {
933
+ await new Promise((resolve) => {
934
+ const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
935
+ stdio: "ignore",
936
+ windowsHide: true,
937
+ });
938
+ killer.on("error", () => resolve());
939
+ killer.on("close", () => resolve());
940
+ });
941
+ return;
942
+ }
943
+ try {
944
+ process.kill(-pid, "SIGKILL");
945
+ return;
946
+ }
947
+ catch {
948
+ // Fall back to direct child kill when no process group is available.
949
+ }
950
+ try {
951
+ process.kill(pid, "SIGKILL");
952
+ }
953
+ catch {
954
+ // The process can already be gone.
955
+ }
956
+ }
957
+ /* c8 ignore stop */
958
+ async function runLinkedTestCommand(command, timeoutMs, env, progressContext, progressMode) {
959
+ const startedAt = Date.now();
960
+ const heartbeat = beginLinkedTestProgress(progressContext, progressMode);
961
+ const child = spawn(command, {
962
+ cwd: process.cwd(),
963
+ env,
964
+ shell: true,
965
+ windowsHide: true,
966
+ detached: process.platform !== "win32",
967
+ stdio: ["pipe", "pipe", "pipe"],
968
+ });
969
+ closeLinkedTestStdin(child);
970
+ let stdout = "";
971
+ let stderr = "";
972
+ let stdoutBytes = 0;
973
+ let stderrBytes = 0;
974
+ let timedOut = false;
975
+ let maxBufferExceeded = false;
976
+ let spawnError;
977
+ let forceKillTimer = null;
978
+ let timedOutTimer = null;
979
+ let terminationRequested = false;
980
+ const clearTimers = () => {
981
+ if (heartbeat) {
982
+ clearInterval(heartbeat);
983
+ }
984
+ if (timedOutTimer) {
985
+ clearTimeout(timedOutTimer);
986
+ timedOutTimer = null;
987
+ }
988
+ if (forceKillTimer) {
989
+ clearTimeout(forceKillTimer);
990
+ forceKillTimer = null;
991
+ }
992
+ };
993
+ /* c8 ignore start -- timeout termination branches depend on scheduler/process-group timing. */
994
+ const requestTermination = async () => {
995
+ if (terminationRequested) {
996
+ return;
997
+ }
998
+ terminationRequested = true;
999
+ const pid = child.pid;
1000
+ if (!pid || pid <= 0) {
1001
+ try {
1002
+ child.kill("SIGTERM");
1003
+ }
1004
+ catch {
1005
+ // Child can already be closed.
1006
+ }
1007
+ return;
1008
+ }
1009
+ if (process.platform === "win32") {
1010
+ await killProcessTree(pid);
1011
+ return;
1012
+ }
1013
+ try {
1014
+ process.kill(-pid, "SIGTERM");
1015
+ }
1016
+ catch {
1017
+ /* c8 ignore next 4 -- platform-specific process-group fallback path. */
1018
+ try {
1019
+ child.kill("SIGTERM");
1020
+ }
1021
+ catch {
1022
+ // Child can already be closed.
1023
+ }
1024
+ }
1025
+ /* c8 ignore next 3 -- exercised only when timeout escalation triggers force-kill fallback. */
1026
+ forceKillTimer = setTimeout(() => {
1027
+ void killProcessTree(pid);
1028
+ }, linkedTestTimeoutForceKillDelayMs());
1029
+ forceKillTimer.unref?.();
1030
+ };
1031
+ /* c8 ignore stop */
1032
+ const appendChunk = (chunk, target) => {
1033
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1034
+ const bytes = Buffer.byteLength(text);
1035
+ if (target === "stdout") {
1036
+ stdoutBytes += bytes;
1037
+ if (stdoutBytes <= TEST_OUTPUT_MAX_BUFFER_BYTES) {
1038
+ stdout += text;
1039
+ }
1040
+ }
1041
+ else {
1042
+ stderrBytes += bytes;
1043
+ if (stderrBytes <= TEST_OUTPUT_MAX_BUFFER_BYTES) {
1044
+ stderr += text;
1045
+ }
1046
+ }
1047
+ if (!maxBufferExceeded && (stdoutBytes > TEST_OUTPUT_MAX_BUFFER_BYTES || stderrBytes > TEST_OUTPUT_MAX_BUFFER_BYTES)) {
1048
+ maxBufferExceeded = true;
1049
+ void requestTermination();
1050
+ }
1051
+ };
1052
+ child.stdout?.on("data", (chunk) => appendChunk(chunk, "stdout"));
1053
+ child.stderr?.on("data", (chunk) => appendChunk(chunk, "stderr"));
1054
+ /* c8 ignore next 5 -- shell spawn error callbacks are non-deterministic across platforms. */
1055
+ child.on("error", (error) => {
1056
+ spawnError = error.message;
1057
+ });
1058
+ /* c8 ignore next 4 -- callback scheduling timing is non-deterministic under coverage instrumentation. */
1059
+ timedOutTimer = setTimeout(() => {
1060
+ timedOut = true;
1061
+ void requestTermination();
1062
+ }, timeoutMs);
1063
+ timedOutTimer.unref?.();
1064
+ const { code, signal } = await new Promise((resolve) => {
1065
+ child.on("close", (closeCode, closeSignal) => {
1066
+ resolve({
1067
+ code: closeCode,
1068
+ signal: closeSignal,
1069
+ });
1070
+ });
1071
+ });
1072
+ clearTimers();
1073
+ const executionResult = {
1074
+ stdout,
1075
+ stderr,
1076
+ exitCode: code,
1077
+ signal,
1078
+ timedOut,
1079
+ maxBufferExceeded,
1080
+ spawnError,
1081
+ };
1082
+ endLinkedTestProgress(progressContext, executionResult, startedAt, progressMode);
1083
+ return executionResult;
1084
+ }
1085
+ function formatLinkedTestExecutionError(result, timeoutMs) {
1086
+ const details = [];
1087
+ if (result.maxBufferExceeded) {
1088
+ details.push(`Linked test output exceeded maxBuffer=${TEST_OUTPUT_MAX_BUFFER_BYTES} bytes. Reduce output volume or split the command.`);
1089
+ }
1090
+ if (result.timedOut && timeoutMs > 0) {
1091
+ details.push(`Linked test timed out after ${timeoutMs}ms.`);
1092
+ }
1093
+ const signalMessage = result.signal ? `Linked test command terminated by signal ${result.signal}.` : undefined;
1094
+ const baseMessage = result.spawnError?.trim() || signalMessage || "Linked test command failed.";
1095
+ if (details.length === 0) {
1096
+ return baseMessage;
1097
+ }
1098
+ return `${baseMessage} ${details.join(" ")}`;
1099
+ }
1100
+ function hasInfraCollisionSignal(result) {
1101
+ const combined = [result.spawnError ?? "", result.stderr, result.stdout].join("\n");
1102
+ return LINKED_TEST_INFRA_COLLISION_PATTERNS.some((pattern) => pattern.test(combined));
1103
+ }
1104
+ export function classifyLinkedTestFailure(result) {
1105
+ if (hasInfraCollisionSignal(result)) {
1106
+ return "infra_collision";
1107
+ }
1108
+ if (result.timedOut) {
1109
+ return "timeout";
1110
+ }
1111
+ if (result.maxBufferExceeded) {
1112
+ return "max_buffer";
1113
+ }
1114
+ if (result.spawnError) {
1115
+ return "spawn_error";
1116
+ }
1117
+ if (result.signal) {
1118
+ return "signal";
1119
+ }
1120
+ return "assertion_failure";
1121
+ }
1122
+ function createEmptyFailureCategoryCounts() {
1123
+ return {
1124
+ infra_collision: 0,
1125
+ assertion_failure: 0,
1126
+ empty_run: 0,
1127
+ timeout: 0,
1128
+ max_buffer: 0,
1129
+ spawn_error: 0,
1130
+ signal: 0,
1131
+ };
1132
+ }
1133
+ export function countFailureCategories(runResults) {
1134
+ const counts = createEmptyFailureCategoryCounts();
1135
+ for (const result of runResults) {
1136
+ if (result.status !== "failed" || !result.failure_category) {
1137
+ continue;
1138
+ }
1139
+ counts[result.failure_category] += 1;
1140
+ }
1141
+ return counts;
1142
+ }
1143
+ function applyEnvDirectiveStage(env, directives) {
1144
+ for (const [key, value] of Object.entries(directives.env_set)) {
1145
+ if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
1146
+ continue;
1147
+ }
1148
+ env[key] = value;
1149
+ }
1150
+ for (const key of directives.env_clear) {
1151
+ if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
1152
+ continue;
1153
+ }
1154
+ delete env[key];
1155
+ }
1156
+ }
1157
+ function applySharedHostSafeDefaults(env) {
1158
+ if (env.PORT === undefined) {
1159
+ env.PORT = "0";
1160
+ }
1161
+ if (env.HOST === undefined) {
1162
+ env.HOST = "127.0.0.1";
1163
+ }
1164
+ if (env.PM_SHARED_HOST_SAFE === undefined) {
1165
+ env.PM_SHARED_HOST_SAFE = "1";
1166
+ }
1167
+ if (env.PLAYWRIGHT_HTML_OPEN === undefined) {
1168
+ env.PLAYWRIGHT_HTML_OPEN = "never";
1169
+ }
1170
+ if (env.PW_TEST_HTML_REPORT_OPEN === undefined) {
1171
+ env.PW_TEST_HTML_REPORT_OPEN = "never";
1172
+ }
1173
+ }
1174
+ function resolveEffectiveLinkedTestDirectives(runtimeDirectives, linkedTest) {
1175
+ const envSet = { ...runtimeDirectives.env_set, ...(linkedTest.env_set ?? {}) };
1176
+ const envClear = [...new Set([...runtimeDirectives.env_clear, ...(linkedTest.env_clear ?? [])])];
1177
+ const sharedHostSafe = linkedTest.shared_host_safe ?? runtimeDirectives.shared_host_safe;
1178
+ return {
1179
+ env_set: envSet,
1180
+ env_clear: envClear,
1181
+ shared_host_safe: sharedHostSafe,
1182
+ };
1183
+ }
1184
+ function resolveRuntimeDirectives(envSetEntries, envClearEntries, sharedHostSafe) {
1185
+ return {
1186
+ env_set: mergeEnvSetDirectives(envSetEntries, "--env-set"),
1187
+ env_clear: mergeEnvClearDirectives(envClearEntries, "--env-clear"),
1188
+ shared_host_safe: sharedHostSafe === true,
1189
+ };
1190
+ }
1191
+ async function copyIntoSandboxIfPresent(sourcePath, targetPath, recursive = false) {
1192
+ if (!(await pathExists(sourcePath))) {
1193
+ return;
1194
+ }
1195
+ await mkdir(path.dirname(targetPath), { recursive: true });
1196
+ if (recursive) {
1197
+ await cp(sourcePath, targetPath, { recursive: true, force: true });
1198
+ return;
1199
+ }
1200
+ await cp(sourcePath, targetPath, { force: true });
1201
+ }
1202
+ async function seedLinkedTestSandbox(sandboxPmPath, sandboxGlobalPath, sourceRoots) {
1203
+ await copyIntoSandboxIfPresent(getSettingsPath(sourceRoots.projectPmRoot), getSettingsPath(sandboxPmPath));
1204
+ await copyIntoSandboxIfPresent(path.join(sourceRoots.projectPmRoot, "extensions"), path.join(sandboxPmPath, "extensions"), true);
1205
+ await copyIntoSandboxIfPresent(getSettingsPath(sourceRoots.globalPmRoot), getSettingsPath(sandboxGlobalPath));
1206
+ await copyIntoSandboxIfPresent(path.join(sourceRoots.globalPmRoot, "extensions"), path.join(sandboxGlobalPath, "extensions"), true);
1207
+ }
1208
+ async function seedLinkedTestTrackerData(sourceRoot, sandboxRoot) {
1209
+ if (!(await pathExists(sourceRoot))) {
1210
+ return;
1211
+ }
1212
+ const entries = await readdir(sourceRoot, { withFileTypes: true });
1213
+ for (const entry of entries) {
1214
+ if (LINKED_TEST_TRACKER_DIRS_TO_SKIP.has(entry.name)) {
1215
+ continue;
1216
+ }
1217
+ const sourcePath = path.join(sourceRoot, entry.name);
1218
+ const targetPath = path.join(sandboxRoot, entry.name);
1219
+ if (entry.isDirectory()) {
1220
+ await copyIntoSandboxIfPresent(sourcePath, targetPath, true);
1221
+ continue;
1222
+ }
1223
+ if (entry.isFile()) {
1224
+ await copyIntoSandboxIfPresent(sourcePath, targetPath);
1225
+ }
1226
+ }
1227
+ }
1228
+ async function countLinkedTestItemFiles(pmRoot) {
1229
+ if (!(await pathExists(pmRoot))) {
1230
+ return 0;
1231
+ }
1232
+ let total = 0;
1233
+ const entries = await readdir(pmRoot, { withFileTypes: true });
1234
+ for (const entry of entries) {
1235
+ if (!entry.isDirectory() || LINKED_TEST_ITEM_COUNT_DIRS_TO_SKIP.has(entry.name)) {
1236
+ continue;
1237
+ }
1238
+ const folderPath = path.join(pmRoot, entry.name);
1239
+ let files;
1240
+ try {
1241
+ files = await readdir(folderPath, { withFileTypes: true });
1242
+ }
1243
+ catch {
1244
+ continue;
1245
+ }
1246
+ for (const file of files) {
1247
+ if (!file.isFile()) {
1248
+ continue;
1249
+ }
1250
+ if (ITEM_FILE_EXTENSIONS.some((extension) => file.name.toLowerCase().endsWith(extension))) {
1251
+ total += 1;
1252
+ }
1253
+ }
1254
+ }
1255
+ return total;
1256
+ }
1257
+ export function resolveLinkedTestFailureExitCode(execution) {
1258
+ const rawExitCode = typeof execution.exitCode === "number" ? execution.exitCode : 1;
1259
+ if ((execution.timedOut || execution.maxBufferExceeded) && rawExitCode === 0) {
1260
+ return 1;
1261
+ }
1262
+ return rawExitCode;
1263
+ }
1264
+ function splitJsonPathSegments(fieldPath) {
1265
+ const segments = [];
1266
+ const tokens = fieldPath.match(/[^.[\]]+|\[\d+\]/g) ?? [];
1267
+ for (const token of tokens) {
1268
+ if (token.startsWith("[") && token.endsWith("]")) {
1269
+ const parsedIndex = Number.parseInt(token.slice(1, -1), 10);
1270
+ if (!Number.isInteger(parsedIndex) || parsedIndex < 0) {
1271
+ return [];
1272
+ }
1273
+ segments.push(parsedIndex);
1274
+ continue;
1275
+ }
1276
+ segments.push(token);
1277
+ }
1278
+ return segments;
1279
+ }
1280
+ function readJsonPathValue(root, fieldPath) {
1281
+ const normalizedPath = fieldPath.trim();
1282
+ if (normalizedPath.length === 0) {
1283
+ return { found: false, value: undefined };
1284
+ }
1285
+ const segments = splitJsonPathSegments(normalizedPath);
1286
+ if (segments.length === 0) {
1287
+ return { found: false, value: undefined };
1288
+ }
1289
+ let current = root;
1290
+ for (const segment of segments) {
1291
+ if (typeof segment === "number") {
1292
+ if (!Array.isArray(current) || segment >= current.length) {
1293
+ return { found: false, value: undefined };
1294
+ }
1295
+ current = current[segment];
1296
+ continue;
1297
+ }
1298
+ if (typeof current !== "object" || current === null || !(segment in current)) {
1299
+ return { found: false, value: undefined };
1300
+ }
1301
+ current = current[segment];
1302
+ }
1303
+ return { found: true, value: current };
1304
+ }
1305
+ function parseAssertionLiteral(raw) {
1306
+ const trimmed = raw.trim();
1307
+ if (trimmed === "true") {
1308
+ return true;
1309
+ }
1310
+ if (trimmed === "false") {
1311
+ return false;
1312
+ }
1313
+ if (trimmed === "null") {
1314
+ return null;
1315
+ }
1316
+ const numeric = Number(trimmed);
1317
+ if (trimmed.length > 0 && Number.isFinite(numeric)) {
1318
+ return numeric;
1319
+ }
1320
+ if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
1321
+ try {
1322
+ return JSON.parse(trimmed);
1323
+ }
1324
+ catch {
1325
+ // Fall back to string comparison for malformed JSON literals.
1326
+ }
1327
+ }
1328
+ return trimmed;
1329
+ }
1330
+ function compareAssertionValues(actual, expected) {
1331
+ if (typeof actual === "object" &&
1332
+ actual !== null &&
1333
+ typeof expected === "object" &&
1334
+ expected !== null) {
1335
+ return JSON.stringify(actual) === JSON.stringify(expected);
1336
+ }
1337
+ return Object.is(actual, expected);
1338
+ }
1339
+ function evaluateLinkedTestAssertions(linkedTest, stdout, stderr) {
1340
+ const failures = [];
1341
+ for (const expected of linkedTest.assert_stdout_contains ?? []) {
1342
+ if (!stdout.includes(expected)) {
1343
+ failures.push(`stdout missing required text: "${expected}"`);
1344
+ }
1345
+ }
1346
+ for (const pattern of linkedTest.assert_stdout_regex ?? []) {
1347
+ try {
1348
+ const regex = new RegExp(pattern, "m");
1349
+ if (!regex.test(stdout)) {
1350
+ failures.push(`stdout failed regex assertion: /${pattern}/m`);
1351
+ }
1352
+ }
1353
+ catch (error) {
1354
+ failures.push(`stdout regex assertion is invalid: /${pattern}/ (${error instanceof Error ? error.message : String(error)})`);
1355
+ }
1356
+ }
1357
+ for (const expected of linkedTest.assert_stderr_contains ?? []) {
1358
+ if (!stderr.includes(expected)) {
1359
+ failures.push(`stderr missing required text: "${expected}"`);
1360
+ }
1361
+ }
1362
+ for (const pattern of linkedTest.assert_stderr_regex ?? []) {
1363
+ try {
1364
+ const regex = new RegExp(pattern, "m");
1365
+ if (!regex.test(stderr)) {
1366
+ failures.push(`stderr failed regex assertion: /${pattern}/m`);
1367
+ }
1368
+ }
1369
+ catch (error) {
1370
+ failures.push(`stderr regex assertion is invalid: /${pattern}/ (${error instanceof Error ? error.message : String(error)})`);
1371
+ }
1372
+ }
1373
+ if (typeof linkedTest.assert_stdout_min_lines === "number") {
1374
+ const lineCount = stdout
1375
+ .split(/\r?\n/)
1376
+ .map((line) => line.trim())
1377
+ .filter((line) => line.length > 0).length;
1378
+ if (lineCount < linkedTest.assert_stdout_min_lines) {
1379
+ failures.push(`stdout line count ${lineCount} is below required minimum ${linkedTest.assert_stdout_min_lines}`);
1380
+ }
1381
+ }
1382
+ const jsonEqualsAssertions = linkedTest.assert_json_field_equals ?? {};
1383
+ const jsonGteAssertions = linkedTest.assert_json_field_gte ?? {};
1384
+ const needsJsonAssertions = Object.keys(jsonEqualsAssertions).length > 0 || Object.keys(jsonGteAssertions).length > 0;
1385
+ if (!needsJsonAssertions) {
1386
+ return failures;
1387
+ }
1388
+ let parsedJson;
1389
+ try {
1390
+ parsedJson = JSON.parse(stdout);
1391
+ }
1392
+ catch (error) {
1393
+ failures.push(`stdout is not valid JSON for assert_json_field_* checks: ${error instanceof Error ? error.message : String(error)}`);
1394
+ return failures;
1395
+ }
1396
+ for (const [fieldPath, expectedRaw] of Object.entries(jsonEqualsAssertions)) {
1397
+ const resolved = readJsonPathValue(parsedJson, fieldPath);
1398
+ if (!resolved.found) {
1399
+ failures.push(`assert_json_field_equals missing path "${fieldPath}"`);
1400
+ continue;
1401
+ }
1402
+ const expected = parseAssertionLiteral(expectedRaw);
1403
+ if (!compareAssertionValues(resolved.value, expected)) {
1404
+ failures.push(`assert_json_field_equals mismatch at "${fieldPath}" (expected=${JSON.stringify(expected)} actual=${JSON.stringify(resolved.value)})`);
1405
+ }
1406
+ }
1407
+ for (const [fieldPath, expectedMinimum] of Object.entries(jsonGteAssertions)) {
1408
+ const resolved = readJsonPathValue(parsedJson, fieldPath);
1409
+ if (!resolved.found) {
1410
+ failures.push(`assert_json_field_gte missing path "${fieldPath}"`);
1411
+ continue;
1412
+ }
1413
+ if (typeof resolved.value !== "number" || !Number.isFinite(resolved.value)) {
1414
+ failures.push(`assert_json_field_gte path "${fieldPath}" resolved to non-numeric value`);
1415
+ continue;
1416
+ }
1417
+ if (resolved.value < expectedMinimum) {
1418
+ failures.push(`assert_json_field_gte failed at "${fieldPath}" (expected >= ${expectedMinimum}, actual ${resolved.value})`);
1419
+ }
1420
+ }
1421
+ return failures;
1422
+ }
1423
+ const EMPTY_LINKED_TEST_RUN_PATTERNS = [
1424
+ { code: "no_projects_matched_filters", regex: /\bNo projects matched the filters\b/i },
1425
+ { code: "no_test_files_found", regex: /\bNo test files found\b/i },
1426
+ { code: "no_tests_found", regex: /\bNo tests found\b/i },
1427
+ { code: "no_matching_tests", regex: /\bNo matching tests?\b/i },
1428
+ { code: "collected_zero_items", regex: /\bcollected 0 items?\b/i },
1429
+ ];
1430
+ function detectEmptyLinkedTestRun(stdout, stderr) {
1431
+ const combined = `${stdout}\n${stderr}`;
1432
+ for (const pattern of EMPTY_LINKED_TEST_RUN_PATTERNS) {
1433
+ if (pattern.regex.test(combined)) {
1434
+ return pattern.code;
1435
+ }
1436
+ }
1437
+ return null;
1438
+ }
1439
+ export async function runLinkedTests(tests, defaultTimeoutSeconds, options) {
360
1440
  const results = [];
361
1441
  const sandboxRoot = await mkdtemp(path.join(tmpdir(), "pm-linked-test-"));
362
- const sandboxPmPath = path.join(sandboxRoot, "project", ".agents", "pm");
363
- const sandboxGlobalPath = path.join(sandboxRoot, "global");
1442
+ const schemaSandboxPmPath = path.join(sandboxRoot, "schema", "project", ".agents", "pm");
1443
+ const schemaSandboxGlobalPath = path.join(sandboxRoot, "schema", "global");
1444
+ const trackerSandboxPmPath = path.join(sandboxRoot, "tracker", "project", ".agents", "pm");
1445
+ const trackerSandboxGlobalPath = path.join(sandboxRoot, "tracker", "global");
1446
+ const runLevelPmContextMode = parsePmContextMode(options?.pmContext);
1447
+ const progressMode = options?.progress === true ? "always" : "auto";
1448
+ const runtimeDirectives = resolveRuntimeDirectives(options?.envSet, options?.envClear, options?.sharedHostSafe);
1449
+ let sourceProjectItemCount = 0;
1450
+ let sourceGlobalItemCount = 0;
1451
+ let schemaSandboxProjectItemCount = 0;
1452
+ let schemaSandboxGlobalItemCount = 0;
1453
+ let trackerSandboxProjectItemCount = 0;
1454
+ let trackerSandboxGlobalItemCount = 0;
1455
+ const sourceRoots = options?.sourceRoots;
1456
+ const projectExtensionsSeeded = Boolean(sourceRoots);
1457
+ const globalExtensionsSeeded = Boolean(sourceRoots);
364
1458
  try {
365
- await runInit(undefined, { path: sandboxPmPath });
366
- for (const linkedTest of tests) {
1459
+ await runInit(undefined, { path: schemaSandboxPmPath });
1460
+ await runInit(undefined, { path: schemaSandboxGlobalPath });
1461
+ await runInit(undefined, { path: trackerSandboxPmPath });
1462
+ await runInit(undefined, { path: trackerSandboxGlobalPath });
1463
+ if (sourceRoots) {
1464
+ await seedLinkedTestSandbox(schemaSandboxPmPath, schemaSandboxGlobalPath, sourceRoots);
1465
+ await seedLinkedTestSandbox(trackerSandboxPmPath, trackerSandboxGlobalPath, sourceRoots);
1466
+ await seedLinkedTestTrackerData(sourceRoots.projectPmRoot, trackerSandboxPmPath);
1467
+ await seedLinkedTestTrackerData(sourceRoots.globalPmRoot, trackerSandboxGlobalPath);
1468
+ sourceProjectItemCount = await countLinkedTestItemFiles(sourceRoots.projectPmRoot);
1469
+ sourceGlobalItemCount = await countLinkedTestItemFiles(sourceRoots.globalPmRoot);
1470
+ }
1471
+ schemaSandboxProjectItemCount = await countLinkedTestItemFiles(schemaSandboxPmPath);
1472
+ schemaSandboxGlobalItemCount = await countLinkedTestItemFiles(schemaSandboxGlobalPath);
1473
+ trackerSandboxProjectItemCount = await countLinkedTestItemFiles(trackerSandboxPmPath);
1474
+ trackerSandboxGlobalItemCount = await countLinkedTestItemFiles(trackerSandboxGlobalPath);
1475
+ const buildExecutionContext = (isPmCommand, isPmTrackerReadCommand, requestedPmContextMode, effectivePmContextMode, autoPmContextApplied) => {
1476
+ const selectedSandboxProjectPmPath = effectivePmContextMode === "tracker" ? trackerSandboxPmPath : schemaSandboxPmPath;
1477
+ const selectedSandboxGlobalPmPath = effectivePmContextMode === "tracker" ? trackerSandboxGlobalPath : schemaSandboxGlobalPath;
1478
+ const selectedSandboxProjectItemCount = effectivePmContextMode === "tracker" ? trackerSandboxProjectItemCount : schemaSandboxProjectItemCount;
1479
+ const selectedSandboxGlobalItemCount = effectivePmContextMode === "tracker" ? trackerSandboxGlobalItemCount : schemaSandboxGlobalItemCount;
1480
+ const mismatchDetected = isPmCommand && sourceProjectItemCount !== selectedSandboxProjectItemCount;
1481
+ return {
1482
+ requested_pm_context_mode: requestedPmContextMode,
1483
+ pm_context_mode: effectivePmContextMode,
1484
+ auto_pm_context_applied: autoPmContextApplied,
1485
+ is_pm_command: isPmCommand,
1486
+ is_pm_tracker_read_command: isPmTrackerReadCommand,
1487
+ source_project_pm_path: sourceRoots?.projectPmRoot ?? "",
1488
+ sandbox_project_pm_path: selectedSandboxProjectPmPath,
1489
+ source_global_pm_path: sourceRoots?.globalPmRoot ?? "",
1490
+ sandbox_global_pm_path: selectedSandboxGlobalPmPath,
1491
+ source_project_item_count: sourceProjectItemCount,
1492
+ sandbox_project_item_count: selectedSandboxProjectItemCount,
1493
+ source_global_item_count: sourceGlobalItemCount,
1494
+ sandbox_global_item_count: selectedSandboxGlobalItemCount,
1495
+ mismatch_detected: mismatchDetected,
1496
+ project_extensions_seeded: projectExtensionsSeeded,
1497
+ global_extensions_seeded: globalExtensionsSeeded,
1498
+ };
1499
+ };
1500
+ for (let index = 0; index < tests.length; index += 1) {
1501
+ const linkedTest = tests[index];
1502
+ const linkedOverridePmContextMode = typeof linkedTest.pm_context_mode === "string" && linkedTest.pm_context_mode.trim().length > 0
1503
+ ? parsePmContextMode(linkedTest.pm_context_mode)
1504
+ : undefined;
1505
+ const isPmCommand = typeof linkedTest.command === "string" && linkedTest.command.length > 0
1506
+ ? commandInvokesPmCli(linkedTest.command)
1507
+ : false;
1508
+ const isPmTrackerReadCommand = isPmCommand && typeof linkedTest.command === "string" && linkedTest.command.length > 0
1509
+ ? commandInvokesPmTrackerReadCommand(linkedTest.command)
1510
+ : false;
1511
+ const autoPmContextApplied = options?.autoPmContext === true && isPmTrackerReadCommand;
1512
+ const requestedPmContextMode = autoPmContextApplied
1513
+ ? "auto"
1514
+ : resolveLinkedTestRequestedContextMode(linkedTest, runLevelPmContextMode, options?.overrideLinkedPmContext === true);
1515
+ const effectivePmContextMode = resolveLinkedTestEffectiveContextMode(requestedPmContextMode, isPmTrackerReadCommand);
1516
+ const executionContext = buildExecutionContext(isPmCommand, isPmTrackerReadCommand, requestedPmContextMode, effectivePmContextMode, autoPmContextApplied);
367
1517
  if (!linkedTest.command) {
368
1518
  results.push({
369
1519
  command: linkedTest.command,
370
1520
  path: linkedTest.path,
371
1521
  status: "skipped",
1522
+ execution_context: executionContext,
372
1523
  error: "No command configured for this linked test.",
373
1524
  });
374
1525
  continue;
@@ -379,43 +1530,124 @@ export async function runLinkedTests(tests, defaultTimeoutSeconds) {
379
1530
  command: linkedTest.command,
380
1531
  path: linkedTest.path,
381
1532
  status: "skipped",
1533
+ execution_context: executionContext,
382
1534
  error: runtimeSafetySkipReason,
383
1535
  });
384
1536
  continue;
385
1537
  }
386
- const timeoutMs = ((linkedTest.timeout_seconds ?? defaultTimeoutSeconds ?? 120) * 1000);
387
- try {
388
- const executed = await exec(linkedTest.command, {
389
- timeout: timeoutMs,
390
- cwd: process.cwd(),
391
- maxBuffer: TEST_OUTPUT_MAX_BUFFER_BYTES,
392
- env: {
393
- ...process.env,
394
- PM_PATH: sandboxPmPath,
395
- PM_GLOBAL_PATH: sandboxGlobalPath,
396
- },
1538
+ const failOnMismatchByDefault = executionContext.pm_context_mode === "schema" &&
1539
+ executionContext.is_pm_tracker_read_command &&
1540
+ executionContext.mismatch_detected;
1541
+ const failOnMismatchByFlag = options?.failOnContextMismatch === true && executionContext.is_pm_command && executionContext.mismatch_detected;
1542
+ if (failOnMismatchByDefault || failOnMismatchByFlag) {
1543
+ const mismatchHint = buildPmContextMismatchHint({
1544
+ executionContext,
1545
+ runLevelPmContextMode,
1546
+ linkedOverridePmContextMode,
397
1547
  });
1548
+ const mismatchPrefix = options?.checkContext === true ? "Linked test preflight PM context mismatch detected" : "Linked test PM context mismatch detected";
398
1549
  results.push({
399
1550
  command: linkedTest.command,
400
1551
  path: linkedTest.path,
401
- status: "passed",
402
- exit_code: 0,
403
- stdout: executed.stdout,
404
- stderr: executed.stderr,
1552
+ status: "failed",
1553
+ exit_code: 1,
1554
+ failure_category: "assertion_failure",
1555
+ execution_context: executionContext,
1556
+ error: `${mismatchPrefix} (source_project_items=${executionContext.source_project_item_count}, ` +
1557
+ `sandbox_project_items=${executionContext.sandbox_project_item_count}).${mismatchHint}`,
405
1558
  });
1559
+ continue;
406
1560
  }
407
- catch (error) {
408
- const err = error;
1561
+ if (options?.requireAssertionsForPm === true && executionContext.is_pm_command && !hasLinkedTestAssertions(linkedTest)) {
409
1562
  results.push({
410
1563
  command: linkedTest.command,
411
1564
  path: linkedTest.path,
412
1565
  status: "failed",
413
- exit_code: typeof err.code === "number" ? err.code : 1,
414
- stdout: err.stdout,
415
- stderr: err.stderr,
416
- error: err.message,
1566
+ exit_code: 1,
1567
+ failure_category: "assertion_failure",
1568
+ execution_context: executionContext,
1569
+ error: "Linked PM command requires assertions when --require-assertions-for-pm is enabled.",
1570
+ });
1571
+ continue;
1572
+ }
1573
+ const timeoutMs = ((linkedTest.timeout_seconds ?? defaultTimeoutSeconds ?? 120) * 1000);
1574
+ const effectiveDirectives = resolveEffectiveLinkedTestDirectives(runtimeDirectives, linkedTest);
1575
+ const executionEnv = { ...process.env };
1576
+ applyEnvDirectiveStage(executionEnv, runtimeDirectives);
1577
+ applyEnvDirectiveStage(executionEnv, {
1578
+ env_set: linkedTest.env_set ?? {},
1579
+ env_clear: linkedTest.env_clear ?? [],
1580
+ });
1581
+ if (effectiveDirectives.shared_host_safe) {
1582
+ applySharedHostSafeDefaults(executionEnv);
1583
+ }
1584
+ executionEnv.FORCE_COLOR = "0";
1585
+ executionEnv.PM_PATH = executionContext.sandbox_project_pm_path;
1586
+ executionEnv.PM_GLOBAL_PATH = executionContext.sandbox_global_pm_path;
1587
+ const execution = await runLinkedTestCommand(linkedTest.command, timeoutMs, executionEnv, {
1588
+ index: index + 1,
1589
+ total: tests.length,
1590
+ timeoutMs,
1591
+ command: linkedTest.command,
1592
+ }, progressMode);
1593
+ const passed = execution.exitCode === 0 && !execution.timedOut && !execution.maxBufferExceeded;
1594
+ if (passed) {
1595
+ if (options?.failOnEmptyTestRun === true) {
1596
+ const emptyRunSignal = detectEmptyLinkedTestRun(execution.stdout, execution.stderr);
1597
+ if (emptyRunSignal) {
1598
+ results.push({
1599
+ command: linkedTest.command,
1600
+ path: linkedTest.path,
1601
+ status: "failed",
1602
+ exit_code: 1,
1603
+ failure_category: "empty_run",
1604
+ execution_context: executionContext,
1605
+ stdout: execution.stdout,
1606
+ stderr: execution.stderr,
1607
+ error: `Linked test reported an empty test run (${emptyRunSignal}) while --fail-on-empty-test-run is enabled. ` +
1608
+ "Update test selection or disable --fail-on-empty-test-run for this run.",
1609
+ });
1610
+ continue;
1611
+ }
1612
+ }
1613
+ const assertionFailures = evaluateLinkedTestAssertions(linkedTest, execution.stdout, execution.stderr);
1614
+ if (assertionFailures.length > 0) {
1615
+ results.push({
1616
+ command: linkedTest.command,
1617
+ path: linkedTest.path,
1618
+ status: "failed",
1619
+ exit_code: 1,
1620
+ failure_category: "assertion_failure",
1621
+ execution_context: executionContext,
1622
+ stdout: execution.stdout,
1623
+ stderr: execution.stderr,
1624
+ error: `Linked test assertion(s) failed: ${assertionFailures.join("; ")}`,
1625
+ });
1626
+ continue;
1627
+ }
1628
+ results.push({
1629
+ command: linkedTest.command,
1630
+ path: linkedTest.path,
1631
+ status: "passed",
1632
+ exit_code: 0,
1633
+ execution_context: executionContext,
1634
+ stdout: execution.stdout,
1635
+ stderr: execution.stderr,
417
1636
  });
1637
+ continue;
418
1638
  }
1639
+ const failureCategory = classifyLinkedTestFailure(execution);
1640
+ results.push({
1641
+ command: linkedTest.command,
1642
+ path: linkedTest.path,
1643
+ status: "failed",
1644
+ exit_code: resolveLinkedTestFailureExitCode(execution),
1645
+ failure_category: failureCategory,
1646
+ execution_context: executionContext,
1647
+ stdout: execution.stdout,
1648
+ stderr: execution.stderr,
1649
+ error: formatLinkedTestExecutionError(execution, timeoutMs),
1650
+ });
419
1651
  }
420
1652
  }
421
1653
  finally {
@@ -424,13 +1656,17 @@ export async function runLinkedTests(tests, defaultTimeoutSeconds) {
424
1656
  return results;
425
1657
  }
426
1658
  export async function runTest(id, options, global) {
1659
+ const stdinResolver = createStdinTokenResolver();
427
1660
  const pmRoot = resolvePmRoot(process.cwd(), global.path);
428
1661
  if (!(await pathExists(getSettingsPath(pmRoot)))) {
429
1662
  throw new PmCliError(`Tracker is not initialized at ${pmRoot}. Run pm init first.`, EXIT_CODE.NOT_FOUND);
430
1663
  }
431
1664
  const settings = await readSettings(pmRoot);
432
- const adds = parseAddEntries(options.add);
433
- const removes = parseRemoveEntries(options.remove);
1665
+ const typeRegistry = resolveItemTypeRegistry(settings, getActiveExtensionRegistrations());
1666
+ const resolvedAdds = await stdinResolver.resolveList(options.add, "--add");
1667
+ const resolvedRemoves = await stdinResolver.resolveList(options.remove, "--remove");
1668
+ const adds = parseAddEntries(resolvedAdds);
1669
+ const removes = parseRemoveEntries(resolvedRemoves);
434
1670
  const shouldMutate = adds.length > 0 || removes.length > 0;
435
1671
  let tests = [];
436
1672
  let itemId;
@@ -447,7 +1683,10 @@ export async function runTest(id, options, global) {
447
1683
  mutate(document) {
448
1684
  const next = [...(document.front_matter.tests ?? [])];
449
1685
  for (const add of adds) {
450
- const exists = next.some((entry) => entry.command === add.command && entry.path === add.path && entry.scope === add.scope);
1686
+ const exists = next.some((entry) => entry.command === add.command &&
1687
+ entry.path === add.path &&
1688
+ entry.scope === add.scope &&
1689
+ entry.pm_context_mode === add.pm_context_mode);
451
1690
  if (!exists) {
452
1691
  next.push(add);
453
1692
  }
@@ -468,23 +1707,103 @@ export async function runTest(id, options, global) {
468
1707
  itemId = result.item.id;
469
1708
  }
470
1709
  else {
471
- const located = await locateItem(pmRoot, id, settings.id_prefix);
1710
+ const located = await locateItem(pmRoot, id, settings.id_prefix, settings.item_format, typeRegistry.type_to_folder);
472
1711
  if (!located) {
473
1712
  throw new PmCliError(`Item ${id} not found`, EXIT_CODE.NOT_FOUND);
474
1713
  }
475
1714
  itemId = located.id;
476
- const loaded = await readLocatedItem(located);
1715
+ const loaded = await readLocatedItem(located, { schema: settings.schema });
477
1716
  tests = loaded.document.front_matter.tests ?? [];
478
1717
  }
479
1718
  let defaultTimeoutSeconds;
480
1719
  if (options.timeout !== undefined) {
481
1720
  defaultTimeoutSeconds = parseOptionalNumber(options.timeout, "timeout");
482
1721
  }
483
- const runResults = options.run === true ? await runLinkedTests(tests, defaultTimeoutSeconds) : [];
1722
+ const pmContextMode = parsePmContextMode(options.pmContext);
1723
+ const hasRuntimeDirectiveFlags = (options.envSet?.length ?? 0) > 0 ||
1724
+ (options.envClear?.length ?? 0) > 0 ||
1725
+ options.sharedHostSafe === true ||
1726
+ options.pmContext !== undefined ||
1727
+ options.overrideLinkedPmContext === true ||
1728
+ options.failOnContextMismatch === true ||
1729
+ options.failOnSkipped === true ||
1730
+ options.failOnEmptyTestRun === true ||
1731
+ options.requireAssertionsForPm === true ||
1732
+ options.checkContext === true ||
1733
+ options.autoPmContext === true;
1734
+ if (hasRuntimeDirectiveFlags && options.run !== true) {
1735
+ throw new PmCliError("--env-set, --env-clear, --shared-host-safe, --pm-context, --override-linked-pm-context, --fail-on-context-mismatch, --fail-on-skipped, --fail-on-empty-test-run, --require-assertions-for-pm, --check-context, and --auto-pm-context require --run", EXIT_CODE.USAGE);
1736
+ }
1737
+ const runStartedAt = options.run === true ? nowIso() : undefined;
1738
+ const runResults = options.run === true
1739
+ ? await runLinkedTests(tests, defaultTimeoutSeconds, {
1740
+ progress: options.progress,
1741
+ envSet: options.envSet,
1742
+ envClear: options.envClear,
1743
+ sharedHostSafe: options.sharedHostSafe,
1744
+ pmContext: pmContextMode,
1745
+ overrideLinkedPmContext: options.overrideLinkedPmContext,
1746
+ failOnContextMismatch: options.failOnContextMismatch,
1747
+ failOnEmptyTestRun: options.failOnEmptyTestRun,
1748
+ requireAssertionsForPm: options.requireAssertionsForPm,
1749
+ checkContext: options.checkContext,
1750
+ autoPmContext: options.autoPmContext,
1751
+ sourceRoots: {
1752
+ projectPmRoot: pmRoot,
1753
+ globalPmRoot: resolveGlobalPmRoot(process.cwd()),
1754
+ },
1755
+ })
1756
+ : [];
1757
+ const failureCategories = countFailureCategories(runResults);
1758
+ const failOnSkippedTriggered = options.run === true && options.failOnSkipped === true && runResults.some((entry) => entry.status === "skipped");
1759
+ const warnings = [];
1760
+ if (options.run === true && options.checkContext === true) {
1761
+ const preflight = summarizeContextPreflight(runResults);
1762
+ warnings.push(`context_preflight:checked_pm_commands=${preflight.checked_pm_commands};` +
1763
+ `tracker_read_commands=${preflight.tracker_read_commands};` +
1764
+ `mismatches=${preflight.mismatches};` +
1765
+ `auto_remediated=${preflight.auto_remediated}`);
1766
+ }
1767
+ if (options.run === true && runStartedAt && settings.testing.record_results_to_items === true) {
1768
+ const summary = summarizeRunResultStatuses(runResults);
1769
+ const trackedRunId = resolveTrackedRunId("test");
1770
+ const attemptRaw = process.env.PM_BACKGROUND_TEST_RUN_ATTEMPT?.trim();
1771
+ const parsedAttempt = attemptRaw ? Number.parseInt(attemptRaw, 10) : Number.NaN;
1772
+ const resumedFrom = process.env.PM_BACKGROUND_TEST_RUN_RESUMED_FROM?.trim();
1773
+ try {
1774
+ await appendTrackedTestRunSummary({
1775
+ pmRoot,
1776
+ settings,
1777
+ itemId,
1778
+ author: resolveAuthor(options.author, settings.author_default),
1779
+ message: `Track test run summary (${trackedRunId})`,
1780
+ entry: {
1781
+ run_id: trackedRunId,
1782
+ kind: "test",
1783
+ status: summary.failed > 0 || failOnSkippedTriggered === true ? "failed" : "passed",
1784
+ started_at: runStartedAt,
1785
+ finished_at: nowIso(),
1786
+ recorded_at: nowIso(),
1787
+ attempt: Number.isFinite(parsedAttempt) && parsedAttempt >= 1 ? parsedAttempt : undefined,
1788
+ resumed_from: resumedFrom && resumedFrom.length > 0 ? resumedFrom : undefined,
1789
+ passed: summary.passed,
1790
+ failed: summary.failed,
1791
+ skipped: summary.skipped,
1792
+ fail_on_skipped_triggered: failOnSkippedTriggered ? true : undefined,
1793
+ },
1794
+ });
1795
+ }
1796
+ catch (error) {
1797
+ warnings.push(`test_result_tracking_failed:${itemId}:${error instanceof Error ? error.message : String(error)}`);
1798
+ }
1799
+ }
484
1800
  return {
485
1801
  id: itemId,
486
1802
  tests,
487
1803
  run_results: runResults,
1804
+ failure_categories: failureCategories,
1805
+ fail_on_skipped_triggered: failOnSkippedTriggered ? true : undefined,
1806
+ warnings: warnings.length > 0 ? warnings : undefined,
488
1807
  changed: shouldMutate,
489
1808
  count: tests.length,
490
1809
  };