@opengsd/gsd-core 1.20.0 → 1.21.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 (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -41,6 +41,8 @@ exports.isSafeRefArgument = isSafeRefArgument;
41
41
  exports.isSafeDateArgument = isSafeDateArgument;
42
42
  exports.commitIsAncestor = commitIsAncestor;
43
43
  exports.resolveEvaluationScope = resolveEvaluationScope;
44
+ exports.parseGitVersion = parseGitVersion;
45
+ exports.remergeDiffSupported = remergeDiffSupported;
44
46
  exports.evaluateEvaluationScope = evaluateEvaluationScope;
45
47
  const node_fs_1 = __importDefault(require("node:fs"));
46
48
  const node_path_1 = __importDefault(require("node:path"));
@@ -48,6 +50,7 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
48
50
  const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
49
51
  const gate_phase_context_cjs_1 = require("./gate-phase-context.cjs");
50
52
  const pattern_cjs_1 = require("./pattern.cjs");
53
+ const security_cjs_1 = require("./security.cjs");
51
54
  // eslint-disable-next-line @typescript-eslint/no-require-imports
52
55
  const planningWorkspaceMod = require("./planning-workspace.cjs");
53
56
  const { readScopedConfigValue } = planningWorkspaceMod;
@@ -63,6 +66,8 @@ exports.SCOPE_EXCLUSION_PATHSPECS = Object.freeze([
63
66
  const SHA_CHUNK = 400;
64
67
  const DEFAULT_MAX_COMMITS = 200;
65
68
  const MAX_PATHSPECS = 20;
69
+ const MAX_SUB_REPOS = 64;
70
+ const MAX_ID_LOOKUPS = 1024;
66
71
  // ─── SUMMARY `## Task Commits` extraction (port of the #4127 model) ───────────
67
72
  const TASK_COMMITS_HEADING = /^## Task Commits[ \t\r]*$/;
68
73
  const NEXT_HEADING = /^## /;
@@ -242,15 +247,33 @@ class ScopeUnreadable extends Error {
242
247
  this.exitCode = exitCode;
243
248
  }
244
249
  }
245
- function makeGit(projectDir, runner) {
250
+ /** The git subcommand of `args`: the first argument after any leading `-c <key=value>` pairs. */
251
+ function gitSubcommand(args) {
252
+ let index = 0;
253
+ while (args[index] === '-c')
254
+ index += 2;
255
+ return args[index] ?? '';
256
+ }
257
+ /**
258
+ * git settings a `planning.sub_repos` workspace's own `.git/config` must not override: a configured
259
+ * repo is checkout content, so no setting that makes a read-only command run a program may apply
260
+ * (an fsmonitor command; `log.showSignature` with a `gpg.program` on a signed commit). Applied to
261
+ * every git call of the sub_repos path, root included; the single-repo path keeps its exact argv.
262
+ * Bounded by construction: at most MAX_SUB_REPOS + 1 repos, each a fixed number of calls, each
263
+ * bounded by the runner's timeout.
264
+ */
265
+ const SUB_REPO_GIT_HARDENING = [
266
+ '-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', '-c', 'gpg.program=false',
267
+ ];
268
+ function makeGit(projectDir, runner, leading = []) {
246
269
  return (args, cwd = projectDir) => {
247
- const result = runner(args, { cwd });
270
+ const result = runner([...leading, ...args], { cwd });
248
271
  if (result.timedOut)
249
272
  throw new ScopeUnreadable('git-timeout');
250
273
  if (result.exitCode === 127)
251
274
  throw new ScopeUnreadable('git-unavailable');
252
275
  if (result.exitCode !== 0)
253
- throw new ScopeUnreadable(`git-failed:${args[0] ?? ''}`, result.exitCode);
276
+ throw new ScopeUnreadable(`git-failed:${gitSubcommand(args)}`, result.exitCode);
254
277
  return result.stdout;
255
278
  };
256
279
  }
@@ -301,8 +324,41 @@ function isAncestor(git, sha, of) {
301
324
  function commitIsAncestor(projectDir, sha, of, execGit) {
302
325
  if (!isSafeRefArgument(sha) || !isSafeRefArgument(of))
303
326
  return null;
327
+ const runner = execGit ?? shell_command_projection_cjs_1.execGit;
304
328
  try {
305
- return isAncestor(makeGit(projectDir, execGit ?? shell_command_projection_cjs_1.execGit), sha, of);
329
+ const names = readSubRepoNames(projectDir);
330
+ if (names.length === 0)
331
+ return isAncestor(makeGit(projectDir, runner), sha, of);
332
+ // #5867: the commits of a `planning.sub_repos` workspace live in the sub-repos (and the
333
+ // workspace root, when it is a repo itself). A repo that does not hold the commit cannot answer
334
+ // (exit 128 -> unanswered). The first repo that holds both commits is authoritative (a clone's
335
+ // answer is the same).
336
+ let rootGit = null;
337
+ try {
338
+ const rootTop = makeGit(projectDir, runner, SUB_REPO_GIT_HARDENING)(['rev-parse', '--show-toplevel']);
339
+ if (realDir(rootTop) === realDir(projectDir))
340
+ rootGit = makeGit(projectDir, runner, SUB_REPO_GIT_HARDENING);
341
+ }
342
+ catch (error) {
343
+ if (!(error instanceof ScopeUnreadable && error.exitCode === 128))
344
+ throw error;
345
+ }
346
+ const gits = [
347
+ ...(rootGit === null ? [] : [rootGit]),
348
+ ...names.map((name) => makeGit(containedSubRepoDir(projectDir, name), runner, SUB_REPO_GIT_HARDENING)),
349
+ ];
350
+ for (const git of gits) {
351
+ try {
352
+ return isAncestor(git, sha, of);
353
+ }
354
+ catch (error) {
355
+ // Only "this repo does not hold the commit" (exit 128) is unanswered; a timeout or a missing
356
+ // git is not an answer and must not let another repo's "no" stand for it.
357
+ if (!(error instanceof ScopeUnreadable && error.exitCode === 128))
358
+ throw error;
359
+ }
360
+ }
361
+ return null;
306
362
  }
307
363
  catch (error) {
308
364
  if (error instanceof ScopeUnreadable)
@@ -317,15 +373,327 @@ function emptyScope(unit) {
317
373
  changedFiles: [], files: [], missingOnDisk: [], outsideUnion: [], unreachable: [], rangeBase: null,
318
374
  };
319
375
  }
376
+ // ─── planning.sub_repos (#5867) ───────────────────────────────────────────────
377
+ /**
378
+ * The configured `planning.sub_repos` names (a legacy top-level `sub_repos` is honored when the
379
+ * nested key is absent, the order `detectSubRepos` and `loadConfig` apply). Empty when none are
380
+ * configured. Read with the scope-aware config reader (no `loadConfig`: it rewrites config.json and
381
+ * spawns git). A blank string is dropped like `detectSubRepos` drops it; any other non-string is
382
+ * `unresolvable`, never silently skipped, because a skipped repo narrows the scope.
383
+ */
384
+ function readSubRepoNames(projectDir) {
385
+ const nested = readScopedConfigValue(projectDir, ['planning', 'sub_repos']);
386
+ const raw = nested.present ? nested.value : readScopedConfigValue(projectDir, ['sub_repos']).value;
387
+ if (!Array.isArray(raw))
388
+ return [];
389
+ // Each repo costs several bounded git spawns; refuse rather than amplify.
390
+ if (raw.length > MAX_SUB_REPOS)
391
+ throw new ScopeUnreadable('sub-repo-too-many');
392
+ const names = [];
393
+ raw.forEach((entry, index) => {
394
+ if (typeof entry !== 'string')
395
+ throw new ScopeUnreadable(`sub-repo-invalid:${index}`);
396
+ const trimmed = entry.trim().replace(/\\/g, '/');
397
+ if (trimmed === '')
398
+ return;
399
+ // Canonical spelling (`./x`, `a/../b`, `x/`): it prefixes the paths the scope reports.
400
+ const name = node_path_1.default.posix.normalize(trimmed).replace(/\/+$/, '');
401
+ if (name !== '.' && !names.includes(name))
402
+ names.push(name);
403
+ });
404
+ return names;
405
+ }
406
+ /** A sub-repo's directory, which must stay inside the project (realpath containment, symlinks included). */
407
+ function containedSubRepoDir(projectDir, name) {
408
+ const contained = (0, security_cjs_1.tryWithinRoot)(name, projectDir, security_cjs_1.PathAcceptance.RelativeOnly);
409
+ if (contained === null)
410
+ throw new ScopeUnreadable(`sub-repo-escapes-root:${name}`);
411
+ return contained;
412
+ }
413
+ /** The real path of a directory git or the config named; one that cannot be resolved is unreadable. */
414
+ function realDir(dir) {
415
+ try {
416
+ return node_fs_1.default.realpathSync.native(dir);
417
+ }
418
+ catch {
419
+ throw new ScopeUnreadable('sub-repo-realpath-failed');
420
+ }
421
+ }
422
+ /**
423
+ * Every repo a `planning.sub_repos` workspace's history lives in: the workspace root when it is
424
+ * itself a repo (its own toplevel), then each configured sub-repo. A configured sub-repo that
425
+ * cannot be read, or is not the root of its own repo (git would otherwise answer from a parent),
426
+ * makes the whole scope `unresolvable` and names the repo.
427
+ */
428
+ function openSources(projectDir, names, runner, ref) {
429
+ const sources = [];
430
+ const seen = new Set();
431
+ const rootGit = makeGit(projectDir, runner, SUB_REPO_GIT_HARDENING);
432
+ try {
433
+ const top = rootGit(['rev-parse', '--show-toplevel']);
434
+ if (realDir(top) === realDir(projectDir)) {
435
+ seen.add(realDir(projectDir));
436
+ rootGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
437
+ sources.push({ prefix: '', git: rootGit, repoRoot: top });
438
+ }
439
+ }
440
+ catch (error) {
441
+ // Not a repo (128) or no such commit yet (1): the workspace root contributes nothing. A timeout
442
+ // or a missing git still rethrows.
443
+ if (!(error instanceof ScopeUnreadable && (error.exitCode === 128 || error.exitCode === 1)))
444
+ throw error;
445
+ }
446
+ const rootCovers = seen.has(realDir(projectDir));
447
+ for (const name of names) {
448
+ const dir = containedSubRepoDir(projectDir, name);
449
+ const stat = (0, gate_evidence_cjs_1.statEvidence)(dir);
450
+ if (stat.kind === 'unreadable')
451
+ throw new ScopeUnreadable(`sub-repo-unreadable:${name}:${stat.reason}`);
452
+ if (stat.kind === 'none' || !stat.value.isDirectory())
453
+ throw new ScopeUnreadable(`sub-repo-unreadable:${name}:not-a-directory`);
454
+ const git = makeGit(dir, runner, SUB_REPO_GIT_HARDENING);
455
+ let top;
456
+ try {
457
+ top = git(['rev-parse', '--show-toplevel']);
458
+ }
459
+ catch (error) {
460
+ if (error instanceof ScopeUnreadable)
461
+ throw new ScopeUnreadable(`sub-repo-unreadable:${name}:${error.reason}`, error.exitCode);
462
+ throw error;
463
+ }
464
+ if (realDir(top) !== realDir(dir)) {
465
+ // A plain folder of the workspace root's own repo is already covered by the root source; any
466
+ // other directory would make git answer from a parent repo it does not own.
467
+ if (rootCovers && realDir(top) === realDir(projectDir))
468
+ continue;
469
+ throw new ScopeUnreadable(`sub-repo-unreadable:${name}:not-a-repo-root`);
470
+ }
471
+ if (seen.has(realDir(dir)))
472
+ continue;
473
+ seen.add(realDir(dir));
474
+ try {
475
+ git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
476
+ }
477
+ catch (error) {
478
+ // No such commit in this repo (an empty repo, or a ref only another repo has): it holds nothing
479
+ // to scope. A timeout or a missing git still rethrows.
480
+ if (error instanceof ScopeUnreadable && error.exitCode === 1)
481
+ continue;
482
+ if (error instanceof ScopeUnreadable)
483
+ throw new ScopeUnreadable(`sub-repo-unreadable:${name}:${error.reason}`, error.exitCode);
484
+ throw error;
485
+ }
486
+ sources.push({ prefix: name, git, repoRoot: top });
487
+ }
488
+ // No repo could answer for the ref: the same "could not look" the single-repo path gives.
489
+ if (sources.length === 0)
490
+ throw new ScopeUnreadable('git-failed:rev-parse', 1);
491
+ return sources;
492
+ }
493
+ const withPrefix = (prefix, file) => (prefix === '' ? file : `${prefix}/${file}`);
494
+ /** Committer time (unix seconds) per sha, for ordering commits that come from different repos. */
495
+ function commitTimes(git, shas) {
496
+ const times = new Map();
497
+ for (const group of chunk(shas, SHA_CHUNK)) {
498
+ for (const line of git(['log', '--no-walk=unsorted', '--format=%H%x1f%ct', ...group]).split('\n')) {
499
+ const [sha, time] = line.split('\x1f');
500
+ if (sha && time !== undefined && /^[0-9]+$/.test(time))
501
+ times.set(sha, Number(time));
502
+ }
503
+ }
504
+ return times;
505
+ }
506
+ function splitSince(options, sources) {
507
+ const split = { holders: new Set(), time: null };
508
+ const since = options.since;
509
+ if (since === undefined)
510
+ return split;
511
+ for (const source of sources) {
512
+ const sha = gitOrNo(source.git, ['rev-parse', '--verify', '--quiet', `${since}^{commit}`]);
513
+ if (sha === null)
514
+ continue;
515
+ split.holders.add(source);
516
+ split.time ??= commitTimes(source.git, [sha]).get(sha) ?? null;
517
+ }
518
+ if (split.holders.size === 0)
519
+ throw new ScopeUnreadable('since-not-found');
520
+ if (split.time === null)
521
+ throw new ScopeUnreadable('since-time-unreadable');
522
+ return split;
523
+ }
524
+ /** `options` for one source: a repo that does not hold the `since` commit is filtered by its time instead. */
525
+ function optionsForSource(options, source, split) {
526
+ if (options.since === undefined || split.holders.has(source))
527
+ return options;
528
+ const { since: _since, ...rest } = options;
529
+ void _since;
530
+ return rest;
531
+ }
532
+ function withSourcePrefix(commit, prefix) {
533
+ return commit.files === undefined ? commit : { ...commit, files: commit.files.map((f) => withPrefix(prefix, f)) };
534
+ }
535
+ function resolveAcrossSources(projectDir, unit, options, sources, ref) {
536
+ if (unit.kind === 'phase')
537
+ return resolvePhaseAcrossSources(projectDir, unit, options, sources, ref);
538
+ const scope = emptyScope(unit);
539
+ const max = clampMaxCommits(options.maxCommits);
540
+ const split = splitSince(options, sources);
541
+ const found = sources.map((source) => {
542
+ const opts = optionsForSource(options, source, split);
543
+ const lookup = lookupSubjectCommits(projectDir, unit, emptyScope(unit), source.git, ref, opts);
544
+ const times = commitTimes(source.git, lookup.commits.map((c) => c.sha));
545
+ if (split.time !== null && !split.holders.has(source)) {
546
+ const boundary = split.time;
547
+ lookup.commits = lookup.commits.filter((c) => (times.get(c.sha) ?? 0) > boundary);
548
+ }
549
+ return { source, opts, lookup, times };
550
+ });
551
+ scope.source = unit.kind === 'plan' ? 'plan-subjects' : 'quick-subjects';
552
+ // Newest first across repos: a k-way merge that keeps each repo's own log order (a stable pick:
553
+ // ties go to the earlier source); one entry per sha.
554
+ const seenShas = new Set();
555
+ const queues = found.map((entry) => entry.lookup.commits.filter((commit) => {
556
+ if (seenShas.has(commit.sha))
557
+ return false;
558
+ seenShas.add(commit.sha);
559
+ return true;
560
+ }));
561
+ const kept = [];
562
+ const heads = queues.map(() => 0);
563
+ while (kept.length < max) {
564
+ let best = -1;
565
+ let bestTime = -1;
566
+ queues.forEach((queue, index) => {
567
+ const head = queue[heads[index] ?? 0];
568
+ if (head === undefined)
569
+ return;
570
+ const time = found[index]?.times.get(head.sha) ?? 0;
571
+ if (best === -1 || time > bestTime) {
572
+ best = index;
573
+ bestTime = time;
574
+ }
575
+ });
576
+ if (best === -1)
577
+ break;
578
+ const commit = queues[best]?.[heads[best] ?? 0];
579
+ if (commit === undefined)
580
+ break;
581
+ kept.push({ index: best, commit });
582
+ heads[best] = (heads[best] ?? 0) + 1;
583
+ }
584
+ const finished = found.map((entry, index) => {
585
+ const part = emptyScope(unit);
586
+ finishSubjectScope(part, entry.source.git, kept.filter((k) => k.index === index).map((k) => k.commit), entry.lookup.parentsOf, entry.opts, entry.source.repoRoot);
587
+ return part;
588
+ });
589
+ const byRepo = new Map();
590
+ finished.forEach((part, index) => {
591
+ const prefix = found[index]?.source.prefix ?? '';
592
+ for (const commit of part.commits)
593
+ byRepo.set(commit.sha, withSourcePrefix(commit, prefix));
594
+ });
595
+ scope.commits = kept.map((k) => byRepo.get(k.commit.sha) ?? k.commit);
596
+ const gather = (pick) => uniqueSorted(finished.flatMap((part, index) => pick(part).map((f) => withPrefix(found[index]?.source.prefix ?? '', f))));
597
+ scope.changedFiles = gather((part) => part.changedFiles);
598
+ scope.files = gather((part) => part.files);
599
+ scope.missingOnDisk = gather((part) => part.missingOnDisk);
600
+ if (scope.commits.length === 0)
601
+ scope.reason = 'no-matching-commits';
602
+ else if (options.commitsOnly !== true && scope.changedFiles.length === 0) {
603
+ scope.reason = finished.every((part) => part.commits.length === 0 || part.reason === 'merge-only') ? 'merge-only' : 'empty-after-exclusions';
604
+ }
605
+ return scope;
606
+ }
607
+ function resolvePhaseAcrossSources(projectDir, unit, options, sources, ref) {
608
+ const gathered = gatherPhase(projectDir, unit);
609
+ const primary = sources[0];
610
+ if (primary === undefined)
611
+ throw new ScopeUnreadable('no-sources');
612
+ // A task commit id belongs to the repo that holds it: an id only another repo has is not
613
+ // "unreachable" here. An id no repo holds goes to the primary source, which names it unreachable.
614
+ const ids = [...new Set(gathered.refs)];
615
+ // One lookup per id per repo: bound the aggregate, not just the repo count.
616
+ if (ids.length * sources.length > MAX_ID_LOOKUPS)
617
+ throw new ScopeUnreadable('sub-repo-too-many-lookups');
618
+ const holders = ids.map((id) => sources.filter((source) => {
619
+ try {
620
+ return gitOrNo(source.git, ['rev-parse', '--verify', '--quiet', `${id}^{commit}`]) !== null;
621
+ }
622
+ catch (error) {
623
+ // exit 128 = an ambiguous short id: that repo owns it, and names it unreachable below.
624
+ if (error instanceof ScopeUnreadable && error.exitCode === 128)
625
+ return true;
626
+ throw error;
627
+ }
628
+ }));
629
+ const assigned = new Map();
630
+ ids.forEach((id, i) => {
631
+ const owners = holders[i] ?? [];
632
+ for (const owner of owners.length > 0 ? owners : [primary])
633
+ assigned.set(owner, [...(assigned.get(owner) ?? []), id]);
634
+ });
635
+ const active = sources.filter((source) => assigned.has(source));
636
+ // The workspace root (when it is a repo) always takes part: it may hold the phase-start anchor a
637
+ // sub-repo cannot, so a phase whose task commits add nothing still degrades to its range.
638
+ const run = sources.filter((source) => assigned.has(source) || source.prefix === '');
639
+ if (run.length === 0)
640
+ run.push(primary);
641
+ const split = splitSince(options, sources);
642
+ const parts = run.map((source) => {
643
+ const part = emptyScope(unit);
644
+ const subset = { ...gathered, refs: assigned.get(source) ?? (active.length === 0 ? gathered.refs : []) };
645
+ const notAfter = split.time !== null && !split.holders.has(source) ? split.time : undefined;
646
+ resolvePhase(projectDir, unit, part, source.git, ref, optionsForSource(options, source, split), source.repoRoot, subset, source.prefix === '', true, notAfter);
647
+ return { source, part };
648
+ });
649
+ const merged = emptyScope(unit);
650
+ merged.unreachable = uniqueSorted(parts.flatMap(({ part }) => part.unreachable));
651
+ merged.rangeBase = parts.map(({ part }) => part.rangeBase).find((base) => base !== null) ?? null;
652
+ const taken = parts.filter(({ part }) => part.source === 'task-commits');
653
+ const degraded = parts.find(({ part }) => part.status === 'degraded');
654
+ const pick = taken.length > 0 ? taken : degraded ? [degraded] : [];
655
+ if (pick.length === 0) {
656
+ // Every repo was read and none has task commits for the phase, and a sub-repo has no phase-start
657
+ // anchor to widen to: a loud, empty `degraded` scope, never a pass-shaped empty one and never
658
+ // `unresolvable` (nothing was unreadable).
659
+ merged.status = 'degraded';
660
+ merged.source = 'phase-range';
661
+ merged.reason = `${parts[0]?.part.reason ?? 'no-task-commits'}:no-phase-start-anchor`;
662
+ return merged;
663
+ }
664
+ merged.status = taken.length > 0 ? 'resolved' : 'degraded';
665
+ merged.source = taken.length > 0 ? 'task-commits' : 'phase-range';
666
+ merged.reason = taken.length > 0 ? null : (parts.find(({ source, part }) => source.prefix !== '' && part.reason !== null)?.part.reason ?? degraded?.part.reason ?? null);
667
+ const seenShas = new Set();
668
+ for (const { source, part } of pick) {
669
+ for (const commit of part.commits) {
670
+ if (seenShas.has(commit.sha))
671
+ continue;
672
+ seenShas.add(commit.sha);
673
+ merged.commits.push(withSourcePrefix(commit, source.prefix));
674
+ }
675
+ }
676
+ const gather = (pickFiles) => uniqueSorted(pick.flatMap(({ source, part }) => pickFiles(part).map((f) => withPrefix(source.prefix, f))));
677
+ merged.changedFiles = gather((part) => part.changedFiles);
678
+ merged.files = gather((part) => part.files);
679
+ merged.missingOnDisk = gather((part) => part.missingOnDisk);
680
+ merged.outsideUnion = gather((part) => part.outsideUnion);
681
+ return merged;
682
+ }
320
683
  function resolveEvaluationScope(projectDir, unit, options = {}) {
321
684
  const scope = emptyScope(unit);
322
- const git = makeGit(projectDir, options.execGit ?? shell_command_projection_cjs_1.execGit);
685
+ const runner = options.execGit ?? shell_command_projection_cjs_1.execGit;
686
+ const git = makeGit(projectDir, runner);
323
687
  const ref = options.ref ?? 'HEAD';
324
688
  try {
325
689
  if (!isSafeRefArgument(ref))
326
690
  throw new ScopeUnreadable('unsafe-ref');
327
691
  if (options.since !== undefined && !isSafeRefArgument(options.since))
328
692
  throw new ScopeUnreadable('unsafe-since');
693
+ // #5867: a `planning.sub_repos` workspace keeps its history in the sub-repos, not at the root.
694
+ const subRepoNames = readSubRepoNames(projectDir);
695
+ if (subRepoNames.length > 0)
696
+ return resolveAcrossSources(projectDir, unit, options, openSources(projectDir, subRepoNames, runner, ref), ref);
329
697
  git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
330
698
  const repoRoot = git(['rev-parse', '--show-toplevel']);
331
699
  if (unit.kind === 'phase')
@@ -339,8 +707,62 @@ function resolveEvaluationScope(projectDir, unit, options = {}) {
339
707
  return { ...emptyScope(unit), status: 'unresolvable', reason };
340
708
  }
341
709
  }
342
- /** A commit's own file set. `--first-parent`: a merge commit contributes its diff against parent 1. */
343
- function commitFiles(git, sha) {
710
+ /** First git release with `show --remerge-diff` (#5751). */
711
+ const REMERGE_DIFF_MIN_GIT = [2, 36];
712
+ /**
713
+ * The first `<major>.<minor>` in `git --version` output ("git version 2.45.1.windows.1",
714
+ * "2.36.0-rc0", "2.54.0 (Apple Git-157)"), or null when there is none.
715
+ */
716
+ function parseGitVersion(output) {
717
+ const match = /(\d+)\.(\d+)/.exec(output);
718
+ return match ? { major: Number(match[1]), minor: Number(match[2]) } : null;
719
+ }
720
+ /** Whether `git --version` output names a git that has `show --remerge-diff`; unparseable reads as no. */
721
+ function remergeDiffSupported(output) {
722
+ const version = parseGitVersion(output);
723
+ if (version === null)
724
+ return false;
725
+ return version.major > REMERGE_DIFF_MIN_GIT[0]
726
+ || (version.major === REMERGE_DIFF_MIN_GIT[0] && version.minor >= REMERGE_DIFF_MIN_GIT[1]);
727
+ }
728
+ /** Per-resolve cache of the `git --version` probe, keyed by the bound git function. */
729
+ const remergeSupportCache = new WeakMap();
730
+ /**
731
+ * Whether this git can report a merge's own changes (`--remerge-diff`, git >= 2.36). Detected from
732
+ * `git --version`, never from error output: an unknown option exits 128 or 129 depending on where
733
+ * it is parsed. An unparseable version reads as unsupported, the safe side. A timeout still
734
+ * rethrows (unresolvable); any other probe failure reads as unsupported.
735
+ */
736
+ function supportsRemergeDiff(git) {
737
+ const cached = remergeSupportCache.get(git);
738
+ if (cached !== undefined)
739
+ return cached;
740
+ let supported = false;
741
+ try {
742
+ supported = remergeDiffSupported(git(['--version']));
743
+ }
744
+ catch (error) {
745
+ if (error instanceof ScopeUnreadable && error.reason === 'git-timeout')
746
+ throw error;
747
+ }
748
+ remergeSupportCache.set(git, supported);
749
+ return supported;
750
+ }
751
+ /**
752
+ * A commit's own file set. A non-merge commit contributes what it changed. A merge commit
753
+ * contributes only what the merge itself changed (conflict resolutions and edits, from
754
+ * `--remerge-diff`), so a clean merge contributes nothing and both merge orientations agree (#5751).
755
+ * `--first-parent` would report a catch-up merge's whole base branch. On a git without
756
+ * `--remerge-diff`, or for an octopus merge (remerge covers two-parent merges), a merge contributes
757
+ * nothing: it may under-report, and never reports the base branch's work. `parents` is the commit's
758
+ * parent count, read from the log that listed it (no per-commit lookup).
759
+ */
760
+ function commitFiles(git, sha, parents) {
761
+ if (parents > 1) {
762
+ if (parents !== 2 || !supportsRemergeDiff(git))
763
+ return [];
764
+ return splitNul(git(['-c', 'core.quotepath=off', 'show', '--remerge-diff', '--pretty=format:', '--name-only', '-z', sha, '--', '.', ...exports.SCOPE_EXCLUSION_PATHSPECS]));
765
+ }
344
766
  const out = git(['-c', 'core.quotepath=off', 'show', '--first-parent', '--pretty=format:', '--name-only', '-z', sha, '--', '.', ...exports.SCOPE_EXCLUSION_PATHSPECS]);
345
767
  return splitNul(out);
346
768
  }
@@ -358,32 +780,125 @@ function finalizeFiles(scope, repoRoot, changed) {
358
780
  scope.files = existing;
359
781
  scope.missingOnDisk = missing;
360
782
  }
783
+ /** `%P` rides every log so a commit's parents come from the listing itself, never a per-commit lookup. */
361
784
  function logFormat(withBody) {
362
- return withBody ? '--format=%H%x1f%s%x1f%b%x1e' : '--format=%H%x1f%s%x1e';
785
+ return withBody ? '--format=%H%x1f%P%x1f%s%x1f%b%x1e' : '--format=%H%x1f%P%x1f%s%x1e';
363
786
  }
364
- function parseLog(out, withBody) {
787
+ function parseLog(out, withBody, parentsOut) {
365
788
  const commits = [];
366
789
  for (const record of out.split('\x1e')) {
367
790
  const trimmed = record.replace(/^\n+/, '');
368
791
  if (!trimmed)
369
792
  continue;
370
- const [sha, subject, body] = trimmed.split('\x1f');
793
+ const [sha, parents, subject, body] = trimmed.split('\x1f');
371
794
  if (!sha || !/^[0-9a-f]{40}$/.test(sha))
372
795
  continue;
373
796
  const commit = { sha, subject: subject ?? '' };
374
797
  if (withBody)
375
798
  commit.body = (body ?? '').trim();
376
799
  commits.push(commit);
800
+ parentsOut?.set(sha, (parents ?? '').split(' ').filter(Boolean));
377
801
  }
378
802
  return commits;
379
803
  }
380
- function subjectsFor(git, shas, withBody, chunkSize) {
804
+ function subjectsFor(git, shas, withBody, chunkSize, parentsOut) {
381
805
  const out = [];
382
806
  for (const group of chunk(shas, chunkSize)) {
383
- out.push(...parseLog(git(['log', '--no-walk=unsorted', logFormat(withBody), ...group]), withBody));
807
+ out.push(...parseLog(git(['log', '--no-walk=unsorted', logFormat(withBody), ...group]), withBody, parentsOut));
384
808
  }
385
809
  return out;
386
810
  }
811
+ /**
812
+ * Whether every merge-base of `p1` and `p2` is `rangeBase` or one of its ancestors: the second side
813
+ * was forked from history that predates the phase, so it is base work. False when `rangeBase` is the
814
+ * empty tree, when there is no merge-base, and when git cannot answer (a timeout or a failure): the
815
+ * caller then subtracts nothing and the files stay in `outsideUnion`.
816
+ */
817
+ function forkedAtOrBeforePhaseStart(git, p1, p2, rangeBase) {
818
+ if (rangeBase === EMPTY_TREE)
819
+ return false;
820
+ try {
821
+ const bases = (gitOrNo(git, ['merge-base', '--all', p1, p2]) ?? '').split('\n').filter(Boolean);
822
+ return bases.length > 0 && bases.every((base) => isAncestor(git, base, rangeBase));
823
+ }
824
+ catch (error) {
825
+ if (error instanceof ScopeUnreadable)
826
+ return false;
827
+ throw error;
828
+ }
829
+ }
830
+ /**
831
+ * The files a listed two-parent merge brought in from its SECOND parent's side: base work, not the
832
+ * unit's. A merge contributes nothing when ANY of these holds, so unlisted phase work is never hidden:
833
+ * a listed commit sits on that side (`<p2> ^<p1>`: a phase branch merged into its base); a listed
834
+ * non-merge commit is an ancestor of `<p2>` after the phase start (`<p2> ^<rangeBase>`, one walk: a
835
+ * branch forked from the phase, whose unlisted commits are phase work); the fork point
836
+ * (`git merge-base <p1> <p2>`) is not at or before `rangeBase` (a side forked from phase work, or a
837
+ * later catch-up whose fork point is an earlier catch-up's base commit: reported as an advisory
838
+ * false positive, never hidden); `rangeBase` is the empty tree or there is no merge-base; any git
839
+ * call for this merge fails or times out (this merge subtracts nothing, the others are unaffected);
840
+ * or the file was touched by a commit of the phase window (`<rangeBase>..<tip>` minus what `<p2>`
841
+ * reaches, listed or not, before or after the merge, other merge commits included through their own
842
+ * changes against each parent, `git log -m`; the merge itself is not counted). Only files touched
843
+ * solely by the second parent's side are subtracted.
844
+ */
845
+ function phaseSideFiles(git, tip, p2, rangeBase, mergeSha) {
846
+ const files = new Set();
847
+ const out = git([
848
+ '-c', 'core.quotepath=off', 'log', '-m', '--format=%x01%H', '--name-only', '-z', tip, `^${p2}`,
849
+ ...(rangeBase === EMPTY_TREE ? [] : [`^${rangeBase}`]), '--', '.', ...exports.SCOPE_EXCLUSION_PATHSPECS,
850
+ ]);
851
+ let own = false;
852
+ for (const raw of out.split('\0')) {
853
+ const token = raw.replace(/^\n+/, '');
854
+ if (token.length === 0)
855
+ continue;
856
+ if (token.startsWith('\x01')) {
857
+ own = token.slice(1) === mergeSha;
858
+ continue;
859
+ }
860
+ if (!own)
861
+ files.add(token);
862
+ }
863
+ return files;
864
+ }
865
+ function baseSideMergeFiles(git, merges, parentsOf, listed, listedNonMerge, rangeBase, tip) {
866
+ const files = new Set();
867
+ for (const sha of merges) {
868
+ const parents = parentsOf.get(sha) ?? [];
869
+ if (parents.length !== 2)
870
+ continue;
871
+ const [p1 = '', p2 = ''] = parents;
872
+ try {
873
+ const secondSide = git(['rev-list', p2, `^${p1}`]).split('\n').filter(Boolean);
874
+ if (secondSide.some((entry) => listed.has(entry)))
875
+ continue;
876
+ // One walk of the second parent's history after the phase start, intersected with the listed
877
+ // non-merge commits (never one ancestry spawn per listed commit).
878
+ const reachable = new Set(git([
879
+ 'rev-list', p2, ...(rangeBase === EMPTY_TREE ? [] : [`^${rangeBase}`]),
880
+ ]).split('\n').filter(Boolean));
881
+ if (listedNonMerge.some((entry) => reachable.has(entry)))
882
+ continue;
883
+ if (!forkedAtOrBeforePhaseStart(git, p1, p2, rangeBase))
884
+ continue;
885
+ // Every commit in the window `<rangeBase>..<tip>` the second parent does not reach, before AND
886
+ // after this merge, minus this merge's own records (its first-parent diff is what is being
887
+ // subtracted from). `-m` lists a merge commit's changes against each parent: another merge's
888
+ // conflict resolution or edit is phase work. Over-inclusion only keeps a file in outsideUnion.
889
+ const phaseSide = phaseSideFiles(git, tip, p2, rangeBase, sha);
890
+ const brought = rangeFiles(git, `${sha}^1`, sha).filter((file) => !phaseSide.has(file));
891
+ for (const file of brought)
892
+ files.add(file);
893
+ }
894
+ catch (error) {
895
+ // A merge whose check could not be answered subtracts nothing (its files stay named).
896
+ if (!(error instanceof ScopeUnreadable))
897
+ throw error;
898
+ }
899
+ }
900
+ return files;
901
+ }
387
902
  /**
388
903
  * The tip of `shas`: topological order lists a descendant before its ancestors, so the first
389
904
  * commit of a walk started at all of them is one no other listed commit descends from. (Commit
@@ -418,7 +933,7 @@ function phaseRangeBase(git, phaseDir, ref) {
418
933
  return null;
419
934
  return gitOrNo(git, ['rev-parse', '--verify', '--quiet', `${first}^`]) ?? EMPTY_TREE;
420
935
  }
421
- function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
936
+ function gatherPhase(projectDir, unit) {
422
937
  let phaseDir = unit.phaseDir ?? '';
423
938
  if (unit.phaseDir === undefined) {
424
939
  const located = (0, gate_phase_context_cjs_1.resolvePhaseDir)(projectDir, unit.phase);
@@ -447,6 +962,17 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
447
962
  throw new ScopeUnreadable(`summary-unreadable:${name}`);
448
963
  }
449
964
  }
965
+ return { phaseDir, refs, summaryCount };
966
+ }
967
+ /**
968
+ * `gathered` / `anchored` / `partial` serve a `planning.sub_repos` workspace (#5867): the ids are
969
+ * read once and each repo resolves only the ids it holds (`gathered`); only the workspace root can
970
+ * hold the phase directory's history, so a sub-repo has no phase-start anchor (`anchored` false); and
971
+ * a repo whose own ids add nothing returns an empty `none` scope instead of degrading (`partial`),
972
+ * because another repo may have the phase's files.
973
+ */
974
+ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot, gathered, anchored = true, partial = false, notAfter) {
975
+ const { phaseDir, refs, summaryCount } = gathered ?? gatherPhase(projectDir, unit);
450
976
  // Resolve each listed id to a full sha reachable from the ref (and, for wave scoping, newer than `since`).
451
977
  const shas = [];
452
978
  const unreachable = [];
@@ -470,28 +996,47 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
470
996
  sinceDropped += 1;
471
997
  continue;
472
998
  }
999
+ if (notAfter !== undefined && (commitTimes(git, [full]).get(full) ?? 0) <= notAfter) {
1000
+ sinceDropped += 1;
1001
+ continue;
1002
+ }
473
1003
  shas.push(full);
474
1004
  }
475
1005
  scope.unreachable = unreachable.sort();
476
- scope.rangeBase = options.since ?? phaseRangeBase(git, phaseDir, ref);
1006
+ scope.rangeBase = options.since ?? (anchored ? phaseRangeBase(git, phaseDir, ref) : null);
477
1007
  const union = new Set();
1008
+ const merges = [];
1009
+ const parentsOf = new Map();
478
1010
  const commits = [];
479
1011
  const withFiles = options.includeFiles ?? true;
480
1012
  // At least 2: a chunk of one never shrinks the list `newestOf` reduces.
481
1013
  const chunkSize = Math.max(2, options.shaChunk ?? SHA_CHUNK);
482
- for (const meta of subjectsFor(git, [...new Set(shas)], options.includeBody === true, chunkSize)) {
483
- const files = commitFiles(git, meta.sha);
1014
+ for (const meta of subjectsFor(git, [...new Set(shas)], options.includeBody === true, chunkSize, parentsOf)) {
1015
+ const parentCount = parentsOf.get(meta.sha)?.length ?? 0;
1016
+ const files = commitFiles(git, meta.sha, parentCount);
1017
+ if (parentCount > 1)
1018
+ merges.push(meta.sha);
484
1019
  for (const file of files)
485
1020
  union.add(file);
486
1021
  commits.push(withFiles ? { ...meta, files: [...files].sort() } : meta);
487
1022
  }
488
- if (union.size > 0) {
1023
+ // Every listed commit is a merge: a merge carries only its resolutions, so the union is not the
1024
+ // phase's work (a SUMMARY that lists just the phase-into-base merge sha). A listed task commit that
1025
+ // touched only excluded paths is still a task commit, so it keeps the pre-existing reasons.
1026
+ const mergeOnly = merges.length > 0 && merges.length === commits.length;
1027
+ if (union.size > 0 && !mergeOnly) {
489
1028
  scope.source = 'task-commits';
490
1029
  scope.commits = commits;
491
1030
  finalizeFiles(scope, repoRoot, union);
492
1031
  if (scope.rangeBase) {
493
- const window = rangeFiles(git, scope.rangeBase, newestOf(git, commits.map((c) => c.sha), chunkSize));
494
- scope.outsideUnion = uniqueSorted(window.filter((f) => !union.has(f)));
1032
+ const listed = new Set(commits.map((c) => c.sha));
1033
+ const tip = newestOf(git, [...listed], chunkSize);
1034
+ const window = rangeFiles(git, scope.rangeBase, tip);
1035
+ // The window still holds what a listed catch-up merge brought in from the base; that is base
1036
+ // work the union deliberately omits, not phase work another commit did.
1037
+ const listedNonMerge = commits.filter((c) => (parentsOf.get(c.sha)?.length ?? 0) <= 1).map((c) => c.sha);
1038
+ const baseSide = baseSideMergeFiles(git, merges, parentsOf, listed, listedNonMerge, scope.rangeBase, tip);
1039
+ scope.outsideUnion = uniqueSorted(window.filter((f) => !union.has(f) && !baseSide.has(f)));
495
1040
  }
496
1041
  return;
497
1042
  }
@@ -499,9 +1044,15 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
499
1044
  const reason = summaryCount === 0 ? 'no-summary'
500
1045
  : refs.length === 0 ? 'no-task-commit-rows'
501
1046
  : shas.length === 0 ? (sinceDropped > 0 ? 'no-task-commits-since' : 'no-reachable-task-commits')
502
- : 'empty-after-exclusions';
503
- if (!scope.rangeBase)
1047
+ : mergeOnly ? 'merge-only-task-commits'
1048
+ : 'empty-after-exclusions';
1049
+ if (!scope.rangeBase) {
1050
+ if (partial) {
1051
+ scope.reason = reason;
1052
+ return;
1053
+ }
504
1054
  throw new ScopeUnreadable(`${reason}:no-phase-start-anchor`);
1055
+ }
505
1056
  scope.status = 'degraded';
506
1057
  scope.reason = reason;
507
1058
  scope.source = 'phase-range';
@@ -509,7 +1060,8 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
509
1060
  scope.commits = parseLog(git(['log', `${scope.rangeBase}..${ref}`, logFormat(options.includeBody === true), '-n', String(max)]), options.includeBody === true);
510
1061
  finalizeFiles(scope, repoRoot, rangeFiles(git, scope.rangeBase, ref));
511
1062
  }
512
- function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot) {
1063
+ /** The plan/quick unit's matching commits (newest first, subject-anchored, `since` applied). */
1064
+ function lookupSubjectCommits(projectDir, unit, scope, git, ref, options) {
513
1065
  const withBody = options.includeBody === true;
514
1066
  // Refuse rather than truncate: a silently dropped positive pathspec narrows the match.
515
1067
  const pathspecs = options.pathspecs ?? [];
@@ -554,12 +1106,17 @@ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot)
554
1106
  }
555
1107
  const out = git(['log', range, logFormat(withBody), '-n', String(max * 2), ...sinceArgs, ...grepArgs, ...(pathspecs.length > 0 ? ['--', ...pathspecs] : [])]);
556
1108
  // `--grep` matches ANY message line; a plan lookup is subject-anchored, so filter on the subject.
557
- let commits = parseLog(out, withBody).filter((c) => anchored === null || anchored.test(c.subject)).slice(0, max);
1109
+ const parentsOf = new Map();
1110
+ let commits = parseLog(out, withBody, parentsOf).filter((c) => anchored === null || anchored.test(c.subject)).slice(0, max);
558
1111
  const since = options.since;
559
1112
  if (since !== undefined)
560
1113
  commits = commits.filter((c) => !isAncestor(git, c.sha, since));
1114
+ return { commits, parentsOf };
1115
+ }
1116
+ /** The scope (commits, file sets, reason) of the commits `lookupSubjectCommits` found. */
1117
+ function finishSubjectScope(scope, git, commits, parentsOf, options, repoRoot) {
561
1118
  if (options.commitsOnly === true) {
562
- scope.commits = commits;
1119
+ scope.commits = [...commits];
563
1120
  if (commits.length === 0)
564
1121
  scope.reason = 'no-matching-commits';
565
1122
  return;
@@ -567,8 +1124,12 @@ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot)
567
1124
  const withFiles = options.includeFiles ?? false;
568
1125
  const union = new Set();
569
1126
  const result = [];
1127
+ let mergeCount = 0;
570
1128
  for (const meta of commits) {
571
- const files = commitFiles(git, meta.sha);
1129
+ const parentCount = parentsOf.get(meta.sha)?.length ?? 0;
1130
+ if (parentCount > 1)
1131
+ mergeCount += 1;
1132
+ const files = commitFiles(git, meta.sha, parentCount);
572
1133
  for (const file of files)
573
1134
  union.add(file);
574
1135
  result.push(withFiles ? { ...meta, files: [...files].sort() } : meta);
@@ -577,11 +1138,16 @@ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot)
577
1138
  finalizeFiles(scope, repoRoot, union);
578
1139
  // Found nothing: a truthful "none" (status stays `resolved` — git was read), said out loud so a
579
1140
  // caller can tell "no such commits" from "an empty scope that was never computed". Commits whose
580
- // every path is excluded (planning-only) are an empty scope too, and say so.
1141
+ // every path is excluded (planning-only) are an empty scope too, and say so; so are matches that
1142
+ // are all merges (a merge carries only its own resolutions, #5751).
581
1143
  if (result.length === 0)
582
1144
  scope.reason = 'no-matching-commits';
583
1145
  else if (scope.changedFiles.length === 0)
584
- scope.reason = 'empty-after-exclusions';
1146
+ scope.reason = mergeCount === result.length ? 'merge-only' : 'empty-after-exclusions';
1147
+ }
1148
+ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot) {
1149
+ const { commits, parentsOf } = lookupSubjectCommits(projectDir, unit, scope, git, ref, options);
1150
+ finishSubjectScope(scope, git, commits, parentsOf, options, repoRoot);
585
1151
  }
586
1152
  // ─── The `check evaluation-scope` verb ────────────────────────────────────────
587
1153
  const VALUE_FLAGS = new Set(['--phase', '--phase-dir', '--plan', '--quick', '--since', '--ref', '--pathspec', '--committed-since', '--max-commits']);