@opengsd/gsd-path 1.6.0 → 1.6.1

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 (169) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/FULL.md +5 -4
  4. package/HOOKS.md +18 -2
  5. package/QUICK.md +1 -1
  6. package/README.md +10 -9
  7. package/WORKFLOW.md +8 -4
  8. package/package.json +1 -1
  9. package/scripts/_common.py +43 -0
  10. package/scripts/check_task_briefs.py +10 -7
  11. package/scripts/dispatch_driver.py +17 -5
  12. package/scripts/git_guard.py +18 -2
  13. package/scripts/integration.py +17 -4
  14. package/scripts/isolation.py +46 -14
  15. package/scripts/model_policy.py +8 -1
  16. package/scripts/pipeline_git.py +19 -5
  17. package/scripts/pipeline_state.py +3 -1
  18. package/scripts/state_promote.py +77 -1
  19. package/skills/gsd-path/PLAN.md +3 -2
  20. package/skills/gsd-path/SHIP.md +12 -7
  21. package/skills/gsd-path/references/model-policy.md +3 -1
  22. package/skills/gsd-path/references/operating-rules.md +3 -2
  23. package/skills/gsd-path/references/runtime-selection.md +2 -1
  24. package/skills/gsd-path/scripts/_common.py +43 -0
  25. package/skills/gsd-path/scripts/check_task_briefs.py +10 -7
  26. package/skills/gsd-path/scripts/dispatch_driver.py +17 -5
  27. package/skills/gsd-path/scripts/git_guard.py +18 -2
  28. package/skills/gsd-path/scripts/integration.py +17 -4
  29. package/skills/gsd-path/scripts/isolation.py +46 -14
  30. package/skills/gsd-path/scripts/model_policy.py +8 -1
  31. package/skills/gsd-path/scripts/pipeline_git.py +19 -5
  32. package/skills/gsd-path/scripts/pipeline_state.py +3 -1
  33. package/skills/gsd-path/scripts/state_promote.py +77 -1
  34. package/skills/gsd-path-build/references/model-policy.md +3 -1
  35. package/skills/gsd-path-build/references/operating-rules.md +3 -2
  36. package/skills/gsd-path-build/references/runtime-selection.md +2 -1
  37. package/skills/gsd-path-build/scripts/_common.py +43 -0
  38. package/skills/gsd-path-build/scripts/check_task_briefs.py +10 -7
  39. package/skills/gsd-path-build/scripts/dispatch_driver.py +17 -5
  40. package/skills/gsd-path-build/scripts/integration.py +17 -4
  41. package/skills/gsd-path-build/scripts/isolation.py +46 -14
  42. package/skills/gsd-path-build/scripts/model_policy.py +8 -1
  43. package/skills/gsd-path-build/scripts/pipeline_git.py +19 -5
  44. package/skills/gsd-path-build/scripts/pipeline_state.py +3 -1
  45. package/skills/gsd-path-build/scripts/state_promote.py +77 -1
  46. package/skills/gsd-path-decide/references/model-policy.md +3 -1
  47. package/skills/gsd-path-decide/references/operating-rules.md +3 -2
  48. package/skills/gsd-path-decide/references/runtime-selection.md +2 -1
  49. package/skills/gsd-path-decide/scripts/_common.py +43 -0
  50. package/skills/gsd-path-decide/scripts/check_task_briefs.py +10 -7
  51. package/skills/gsd-path-decide/scripts/integration.py +17 -4
  52. package/skills/gsd-path-decide/scripts/isolation.py +46 -14
  53. package/skills/gsd-path-decide/scripts/model_policy.py +8 -1
  54. package/skills/gsd-path-decide/scripts/pipeline_git.py +19 -5
  55. package/skills/gsd-path-decide/scripts/pipeline_state.py +3 -1
  56. package/skills/gsd-path-decide/scripts/state_promote.py +77 -1
  57. package/skills/gsd-path-define/references/operating-rules.md +3 -2
  58. package/skills/gsd-path-define/references/runtime-selection.md +2 -1
  59. package/skills/gsd-path-define/scripts/_common.py +43 -0
  60. package/skills/gsd-path-define/scripts/check_task_briefs.py +10 -7
  61. package/skills/gsd-path-define/scripts/integration.py +17 -4
  62. package/skills/gsd-path-define/scripts/isolation.py +46 -14
  63. package/skills/gsd-path-define/scripts/model_policy.py +8 -1
  64. package/skills/gsd-path-define/scripts/pipeline_git.py +19 -5
  65. package/skills/gsd-path-define/scripts/pipeline_state.py +3 -1
  66. package/skills/gsd-path-define/scripts/state_promote.py +77 -1
  67. package/skills/gsd-path-discuss/references/operating-rules.md +3 -2
  68. package/skills/gsd-path-discuss/references/runtime-selection.md +2 -1
  69. package/skills/gsd-path-discuss/scripts/_common.py +43 -0
  70. package/skills/gsd-path-discuss/scripts/model_policy.py +8 -1
  71. package/skills/gsd-path-docs-audit/references/model-policy.md +3 -1
  72. package/skills/gsd-path-docs-audit/references/operating-rules.md +3 -2
  73. package/skills/gsd-path-docs-audit/references/runtime-selection.md +2 -1
  74. package/skills/gsd-path-docs-audit/scripts/_common.py +43 -0
  75. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +10 -7
  76. package/skills/gsd-path-docs-audit/scripts/integration.py +17 -4
  77. package/skills/gsd-path-docs-audit/scripts/isolation.py +46 -14
  78. package/skills/gsd-path-docs-audit/scripts/model_policy.py +8 -1
  79. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +19 -5
  80. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +3 -1
  81. package/skills/gsd-path-docs-audit/scripts/state_promote.py +77 -1
  82. package/skills/gsd-path-forensics/references/operating-rules.md +3 -2
  83. package/skills/gsd-path-forensics/references/runtime-selection.md +2 -1
  84. package/skills/gsd-path-forensics/scripts/_common.py +43 -0
  85. package/skills/gsd-path-forensics/scripts/model_policy.py +8 -1
  86. package/skills/gsd-path-inspect/references/model-policy.md +3 -1
  87. package/skills/gsd-path-inspect/references/operating-rules.md +3 -2
  88. package/skills/gsd-path-inspect/references/runtime-selection.md +2 -1
  89. package/skills/gsd-path-inspect/scripts/_common.py +43 -0
  90. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +10 -7
  91. package/skills/gsd-path-inspect/scripts/integration.py +17 -4
  92. package/skills/gsd-path-inspect/scripts/isolation.py +46 -14
  93. package/skills/gsd-path-inspect/scripts/model_policy.py +8 -1
  94. package/skills/gsd-path-inspect/scripts/pipeline_git.py +19 -5
  95. package/skills/gsd-path-inspect/scripts/pipeline_state.py +3 -1
  96. package/skills/gsd-path-inspect/scripts/state_promote.py +77 -1
  97. package/skills/gsd-path-loop/references/operating-rules.md +3 -2
  98. package/skills/gsd-path-loop/references/runtime-selection.md +2 -1
  99. package/skills/gsd-path-loop/scripts/_common.py +43 -0
  100. package/skills/gsd-path-loop/scripts/model_policy.py +8 -1
  101. package/skills/gsd-path-migrate/references/operating-rules.md +3 -2
  102. package/skills/gsd-path-migrate/references/runtime-selection.md +2 -1
  103. package/skills/gsd-path-migrate/scripts/_common.py +43 -0
  104. package/skills/gsd-path-migrate/scripts/model_policy.py +8 -1
  105. package/skills/gsd-path-plan/SKILL.md +3 -2
  106. package/skills/gsd-path-plan/references/model-policy.md +3 -1
  107. package/skills/gsd-path-plan/references/operating-rules.md +3 -2
  108. package/skills/gsd-path-plan/references/runtime-selection.md +2 -1
  109. package/skills/gsd-path-plan/scripts/_common.py +43 -0
  110. package/skills/gsd-path-plan/scripts/check_task_briefs.py +10 -7
  111. package/skills/gsd-path-plan/scripts/integration.py +17 -4
  112. package/skills/gsd-path-plan/scripts/isolation.py +46 -14
  113. package/skills/gsd-path-plan/scripts/model_policy.py +8 -1
  114. package/skills/gsd-path-plan/scripts/pipeline_git.py +19 -5
  115. package/skills/gsd-path-plan/scripts/pipeline_state.py +3 -1
  116. package/skills/gsd-path-plan/scripts/state_promote.py +77 -1
  117. package/skills/gsd-path-research/references/model-policy.md +3 -1
  118. package/skills/gsd-path-research/references/operating-rules.md +3 -2
  119. package/skills/gsd-path-research/references/runtime-selection.md +2 -1
  120. package/skills/gsd-path-research/scripts/_common.py +43 -0
  121. package/skills/gsd-path-research/scripts/check_task_briefs.py +10 -7
  122. package/skills/gsd-path-research/scripts/integration.py +17 -4
  123. package/skills/gsd-path-research/scripts/isolation.py +46 -14
  124. package/skills/gsd-path-research/scripts/model_policy.py +8 -1
  125. package/skills/gsd-path-research/scripts/pipeline_git.py +19 -5
  126. package/skills/gsd-path-research/scripts/pipeline_state.py +3 -1
  127. package/skills/gsd-path-research/scripts/state_promote.py +77 -1
  128. package/skills/gsd-path-roadmap/references/model-policy.md +3 -1
  129. package/skills/gsd-path-roadmap/references/operating-rules.md +3 -2
  130. package/skills/gsd-path-roadmap/references/runtime-selection.md +2 -1
  131. package/skills/gsd-path-roadmap/scripts/_common.py +43 -0
  132. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +10 -7
  133. package/skills/gsd-path-roadmap/scripts/integration.py +17 -4
  134. package/skills/gsd-path-roadmap/scripts/isolation.py +46 -14
  135. package/skills/gsd-path-roadmap/scripts/model_policy.py +8 -1
  136. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +19 -5
  137. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +3 -1
  138. package/skills/gsd-path-roadmap/scripts/state_promote.py +77 -1
  139. package/skills/gsd-path-ship/SKILL.md +12 -7
  140. package/skills/gsd-path-ship/references/model-policy.md +3 -1
  141. package/skills/gsd-path-ship/references/operating-rules.md +3 -2
  142. package/skills/gsd-path-ship/references/runtime-selection.md +2 -1
  143. package/skills/gsd-path-ship/scripts/_common.py +43 -0
  144. package/skills/gsd-path-ship/scripts/check_task_briefs.py +10 -7
  145. package/skills/gsd-path-ship/scripts/integration.py +17 -4
  146. package/skills/gsd-path-ship/scripts/isolation.py +46 -14
  147. package/skills/gsd-path-ship/scripts/model_policy.py +8 -1
  148. package/skills/gsd-path-ship/scripts/pipeline_git.py +19 -5
  149. package/skills/gsd-path-ship/scripts/pipeline_state.py +3 -1
  150. package/skills/gsd-path-ship/scripts/state_promote.py +77 -1
  151. package/skills/gsd-path-undo/references/operating-rules.md +3 -2
  152. package/skills/gsd-path-undo/references/runtime-selection.md +2 -1
  153. package/skills/gsd-path-undo/scripts/_common.py +43 -0
  154. package/skills/gsd-path-undo/scripts/model_policy.py +8 -1
  155. package/skills/path/PLAN.md +3 -2
  156. package/skills/path/SHIP.md +12 -7
  157. package/skills/path/references/model-policy.md +3 -1
  158. package/skills/path/references/operating-rules.md +3 -2
  159. package/skills/path/references/runtime-selection.md +2 -1
  160. package/skills/path/scripts/_common.py +43 -0
  161. package/skills/path/scripts/check_task_briefs.py +10 -7
  162. package/skills/path/scripts/dispatch_driver.py +17 -5
  163. package/skills/path/scripts/git_guard.py +18 -2
  164. package/skills/path/scripts/integration.py +17 -4
  165. package/skills/path/scripts/isolation.py +46 -14
  166. package/skills/path/scripts/model_policy.py +8 -1
  167. package/skills/path/scripts/pipeline_git.py +19 -5
  168. package/skills/path/scripts/pipeline_state.py +3 -1
  169. package/skills/path/scripts/state_promote.py +77 -1
@@ -845,6 +845,8 @@ def require_pull_request_merge(
845
845
  merge_commit: str,
846
846
  ship_commit: str,
847
847
  remote_default: str,
848
+ *,
849
+ allow_rebase: bool = False,
848
850
  ) -> None:
849
851
  resolved = optional_ref(project, merge_commit)
850
852
  if resolved != merge_commit:
@@ -853,9 +855,16 @@ def require_pull_request_merge(
853
855
  run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
854
856
  "inspect pull-request merge parents",
855
857
  ).split()
856
- if len(parents) != 3:
858
+ if allow_rebase and len(parents) == 2:
859
+ problem = _common.rebase_landing_problem(project, ship_commit, merge_commit)
860
+ if problem is not None:
861
+ raise ArchiveError(
862
+ "pull-request integration must use a two-parent merge commit "
863
+ f"or a rebase merge of the ship commits: {problem}"
864
+ )
865
+ elif len(parents) != 3:
857
866
  raise ArchiveError("pull-request integration must use a two-parent merge commit")
858
- if parents[2] != ship_commit:
867
+ elif parents[2] != ship_commit:
859
868
  raise ArchiveError("pull-request merge second parent is not the ship commit")
860
869
  first_parent = archive_milestone.require_git_success(
861
870
  run_git(project, "rev-list", "--first-parent", remote_default),
@@ -1026,7 +1035,9 @@ def integrate_pull_request(
1026
1035
  merge_commit,
1027
1036
  )
1028
1037
  remote_default = refresh_origin(project)["remote_default"]
1029
- require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
1038
+ require_pull_request_merge(
1039
+ project, merge_commit, ship_commit, remote_default, allow_rebase=True
1040
+ )
1030
1041
  pull = update_pull_request_body(
1031
1042
  repository,
1032
1043
  pull,
@@ -1473,7 +1484,9 @@ def validate_integrated(
1473
1484
  pull["number"],
1474
1485
  merge_commit,
1475
1486
  )
1476
- require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
1487
+ require_pull_request_merge(
1488
+ project, merge_commit, ship_commit, remote_default, allow_rebase=True
1489
+ )
1477
1490
  require_annotated_tag(
1478
1491
  project,
1479
1492
  tag_ref,
@@ -1219,9 +1219,17 @@ def _commit_member_source(sidecar: Path, journal: Dict[str, object], allowed: Se
1219
1219
  undeclared = undeclared_paths(sidecar, member_base, pending, allowed)
1220
1220
  if undeclared:
1221
1221
  raise IsolationError("undeclared paths: " + ", ".join(undeclared))
1222
- git_output(sidecar, "add", "-A", "--", *sorted(pending))
1223
1222
  body = member_commit_body(str(journal["task_file"]), sorted(pending), member_base, str(journal["base"]))
1224
- git_output(sidecar, "commit", "-q", "-m", str(journal["subject"]), "-m", body.strip())
1223
+ # The paths and the body grow with the change set, so both go through
1224
+ # stdin: a Windows command line holds about 32 KiB.
1225
+ for arguments, text in (
1226
+ (("add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"),
1227
+ "".join(f"{path}\0" for path in sorted(pending))),
1228
+ (("commit", "-q", "-F", "-"), _commit_message(str(journal["subject"]), body)),
1229
+ ):
1230
+ result = run_git(sidecar, *arguments, input=text)
1231
+ if result.returncode != 0:
1232
+ raise IsolationError((result.stderr or result.stdout).strip() or "git command failed")
1225
1233
  source = current_sha(sidecar)
1226
1234
  if source == member_base:
1227
1235
  raise IsolationError("no changes to land")
@@ -1669,6 +1677,25 @@ def committed_paths_since(repo: Path, base: str) -> Set[str]:
1669
1677
  )
1670
1678
 
1671
1679
 
1680
+ def _stage_paths(repo: Path, mode: str, paths: Set[str]) -> None:
1681
+ """Stage `paths` with one `git add`.
1682
+
1683
+ One call for each path fails when a file replaces a tracked directory: the
1684
+ first call drops the files below the directory from the index, and then
1685
+ their pathspecs match no file. The `./` prefix stops pathspec magic, so a
1686
+ name such as `:!b` cannot exclude another path of the set. Git names the
1687
+ path in its error text.
1688
+ """
1689
+ if not paths:
1690
+ return
1691
+ added = run_git(
1692
+ repo, "add", mode, "--pathspec-from-file=-", "--pathspec-file-nul",
1693
+ input="".join(f"./{path}\0" for path in sorted(paths)),
1694
+ )
1695
+ if added.returncode != 0:
1696
+ raise IsolationError(f"could not stage the change set: {added.stderr.strip()}")
1697
+
1698
+
1672
1699
  def ignored_legacy_ledger(repo: Path) -> bool:
1673
1700
  """True when a verify ledger absent from HEAD is hidden by an ignore rule.
1674
1701
 
@@ -1684,17 +1711,25 @@ def ignored_legacy_ledger(repo: Path) -> bool:
1684
1711
  return run_git(repo, "check-ignore", "-q", "--no-index", "--", VERIFY_LEDGER_PATH).returncode == 0
1685
1712
 
1686
1713
 
1714
+ def _commit_message(subject: str, body: str) -> str:
1715
+ """The message for `git commit -F -`.
1716
+
1717
+ A landing body lists every changed path. As a `-m` argument it can exceed
1718
+ the Windows command-line limit, so the message goes through stdin. Like
1719
+ `-m`, the message ends its last line.
1720
+ """
1721
+ message = f"{subject}\n\n{body}"
1722
+ return message if message.endswith("\n") else message + "\n"
1723
+
1724
+
1687
1725
  def _commit_pending(
1688
1726
  repo: Path, pending: Set[str], subject: str, body: str, forced: Set[str] = frozenset()
1689
1727
  ) -> str:
1690
1728
  reset = run_git(repo, "reset", "-q", "HEAD")
1691
1729
  if reset.returncode != 0:
1692
1730
  raise IsolationError("could not clear the index before commit")
1693
- for path in sorted(pending):
1694
- mode = "-f" if path in forced else "-A"
1695
- added = run_git(repo, "add", mode, "--", path)
1696
- if added.returncode != 0:
1697
- raise IsolationError(f"could not stage {path}: {added.stderr.strip()}")
1731
+ _stage_paths(repo, "-A", pending - forced)
1732
+ _stage_paths(repo, "-f", pending & forced)
1698
1733
  staged = _split_paths(
1699
1734
  git_output(
1700
1735
  repo,
@@ -1707,7 +1742,7 @@ def _commit_pending(
1707
1742
  )
1708
1743
  if staged != pending:
1709
1744
  raise IsolationError("staged paths do not match the uncommitted change set")
1710
- committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
1745
+ committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
1711
1746
  if committed.returncode != 0:
1712
1747
  raise IsolationError((committed.stderr or committed.stdout).strip() or "commit failed")
1713
1748
  return current_sha(repo)
@@ -1786,18 +1821,15 @@ def commit_allowed_changes(
1786
1821
  reset = run_git(repo, "reset", "-q", "HEAD")
1787
1822
  if reset.returncode != 0:
1788
1823
  raise IsolationError("could not clear the index before landing")
1789
- for path in sorted(staging):
1790
- added = run_git(repo, "add", "-A", "--", path)
1791
- if added.returncode != 0:
1792
- raise IsolationError(f"could not stage {path}: {added.stderr.strip()}")
1824
+ _stage_paths(repo, "-A", staging)
1793
1825
  staged = _split_paths(
1794
- git_output(repo, "diff", "--cached", "--name-only", "--relative")
1826
+ git_output(repo, "diff", "--no-renames", "--cached", "--name-only", "--relative")
1795
1827
  )
1796
1828
  if staged != staging:
1797
1829
  raise IsolationError("staged paths do not match the uncommitted change set")
1798
1830
  verified_tree = git_output(repo, "write-tree")
1799
1831
  verified_delta = _tree_delta(repo, base, verified_tree)
1800
- committed = run_git(repo, "commit", "-q", "-m", subject, "-m", body)
1832
+ committed = run_git(repo, "commit", "-q", "-F", "-", input=_commit_message(subject, body))
1801
1833
  if committed.returncode != 0:
1802
1834
  raise IsolationError(
1803
1835
  (committed.stderr or committed.stdout).strip() or "commit failed"
@@ -196,7 +196,14 @@ def task_settings(path):
196
196
 
197
197
  def native_record(args):
198
198
  """The parent owns lifecycle; release requires its recorded terminal-result receipt."""
199
+ # A relative path would resolve against cwd, which may be the immutable runtime store.
200
+ if not args.record.is_absolute():
201
+ raise PolicyError(f'--record must be an absolute path: {args.record}')
199
202
  record = args.record.resolve()
203
+ missing = f'release requires an existing record and terminal-result evidence: {record}'
204
+ # Check before log_lock creates the record directory and its lock file.
205
+ if args.action == 'release' and not record.exists():
206
+ raise PolicyError(missing)
200
207
  with log_lock({'log': str(record)}):
201
208
  data = read_json(record) if record.exists() else None
202
209
  identity = {'repo': str(args.repo.resolve()), 'project_dir': args.project_dir,
@@ -209,7 +216,7 @@ def native_record(args):
209
216
  raise PolicyError(f'record belongs to another assignment or milestone/track/cycle ({differing})')
210
217
  if args.action == 'release':
211
218
  if data is None or not args.terminal_result:
212
- raise PolicyError('release requires an existing record and terminal-result evidence')
219
+ raise PolicyError(missing)
213
220
  data.update(active=False, terminal_result=args.terminal_result)
214
221
  else:
215
222
  if args.capabilities is None:
@@ -484,7 +484,12 @@ def _require_tag_integration_proof(
484
484
  "1",
485
485
  landing,
486
486
  ).stdout.split()
487
- if len(parents) != 3 or parents[2] != ship:
487
+ rebased = (
488
+ mode == "pull-request"
489
+ and len(parents) == 2
490
+ and _common.rebase_landing_problem(repo, ship, landing) is None
491
+ )
492
+ if not rebased and (len(parents) != 3 or parents[2] != ship):
488
493
  raise PipelineGitError(f"{mode} integration proof has invalid merge topology")
489
494
  first_parent = _run_git(repo, "rev-list", "--first-parent", base).stdout.splitlines()
490
495
  if landing not in first_parent:
@@ -694,6 +699,7 @@ def retire_previous_branch(
694
699
  expected_sha: str,
695
700
  *,
696
701
  allow_remote_absent: bool = False,
702
+ rebased: bool = False,
697
703
  ) -> None:
698
704
  """Delete an integrated previous bound branch, locally and on origin.
699
705
 
@@ -741,7 +747,8 @@ def retire_previous_branch(
741
747
  if _ref_exists(repo, tracking_ref):
742
748
  _run_git(repo, "update-ref", "-d", tracking_ref)
743
749
  if _ref_exists(repo, local_ref):
744
- _run_git(repo, "branch", "-d", previous_branch)
750
+ # A rebased ship is not an ancestor of HEAD, so -d would refuse it.
751
+ _run_git(repo, "branch", "-D" if rebased else "-d", previous_branch)
745
752
 
746
753
 
747
754
  def _retire_members(repo: Path, ship_sha: str) -> None:
@@ -840,9 +847,15 @@ def bind_next_milestone_branch(
840
847
  landing_sha,
841
848
  check=False,
842
849
  )
843
- if integrated.returncode == 1:
850
+ # A forge rebase merge lands copies of the ship commits (#418); the
851
+ # pull-request milestone tag must then prove the landing.
852
+ rebased = (
853
+ integrated.returncode == 1
854
+ and _common.rebase_landing_problem(repo, ship_sha, landing_sha) is None
855
+ )
856
+ if integrated.returncode == 1 and not rebased:
844
857
  raise PipelineGitError("milestone landing does not contain the ship commit")
845
- if integrated.returncode != 0:
858
+ if integrated.returncode not in (0, 1):
846
859
  detail = integrated.stderr.strip() or integrated.stdout.strip()
847
860
  raise PipelineGitError(f"could not verify milestone landing: {detail}")
848
861
  landing_integrated = _run_git(
@@ -858,7 +871,7 @@ def bind_next_milestone_branch(
858
871
  if landing_integrated.returncode != 0:
859
872
  detail = landing_integrated.stderr.strip() or landing_integrated.stdout.strip()
860
873
  raise PipelineGitError(f"could not verify integrated branch: {detail}")
861
- if allow_remote_absent:
874
+ if allow_remote_absent or rebased:
862
875
  proven_landing = _require_tag_integration_proof(
863
876
  repo,
864
877
  ship_sha,
@@ -994,6 +1007,7 @@ def bind_next_milestone_branch(
994
1007
  allow_remote_absent=(
995
1008
  allow_remote_absent or journal["stage"] in {"switched", "retired"}
996
1009
  ),
1010
+ rebased=rebased,
997
1011
  )
998
1012
  _retire_members(repo, ship_sha)
999
1013
  journal["stage"] = "retired"
@@ -1024,7 +1024,9 @@ def _bind_next_journal_recovery(
1024
1024
  )
1025
1025
  if resolved_landing.returncode != 0 or resolved_landing.stdout.strip() != landing:
1026
1026
  raise PipelineStateError("bind-next journal landing is not an existing full SHA")
1027
- if not _is_ancestor(repo, ship, landing):
1027
+ if not _is_ancestor(repo, ship, landing) and _common.rebase_landing_problem(
1028
+ repo, ship, landing
1029
+ ) is not None:
1028
1030
  raise PipelineStateError("bind-next journal landing does not contain ship")
1029
1031
  if not _is_ancestor(repo, landing, base):
1030
1032
  raise PipelineStateError("bind-next journal landing is not an ancestor of base")
@@ -343,6 +343,67 @@ def _completed_promotion(
343
343
  }
344
344
 
345
345
 
346
+ def _ruling_rows(text: str) -> dict[str, list[str]]:
347
+ """Map Queue # to the cells of each `## User rulings` table row."""
348
+ rows: dict[str, list[str]] = {}
349
+ in_rulings = False
350
+ for line in text.splitlines():
351
+ if line == "## User rulings":
352
+ in_rulings = True
353
+ continue
354
+ if in_rulings and line.startswith("## "):
355
+ break
356
+ if in_rulings and line.lstrip().startswith("|"):
357
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
358
+ if len(cells) == 4 and cells[0].isdigit():
359
+ rows[cells[0]] = cells
360
+ return rows
361
+
362
+
363
+ def _superseded_carry_forward(
364
+ repo: Path,
365
+ active_state: PipelineState,
366
+ source: Path,
367
+ destination: Path,
368
+ ) -> bool:
369
+ """True when active research is only the shipped DOCS-AUDIT carry-forward
370
+ and the lookahead audit keeps every one of its rulings."""
371
+ if not active_state.archive or destination.is_symlink() or not destination.is_dir():
372
+ return False
373
+ archived = repo / active_state.archive / "research" / "DOCS-AUDIT.md"
374
+ active = destination / "DOCS-AUDIT.md"
375
+ lookahead = source / "DOCS-AUDIT.md"
376
+ entries = [
377
+ path for path in destination.iterdir() if not state_checkpoint._is_junk_file(path)
378
+ ]
379
+ if entries != [active] or any(
380
+ path.is_symlink() or not path.is_file() for path in (archived, active, lookahead)
381
+ ):
382
+ return False
383
+ carried = active.read_bytes()
384
+ if carried != archived.read_bytes():
385
+ return False
386
+ rulings = _ruling_rows(carried.decode("utf-8"))
387
+ if not any(cells[3] == "no" for cells in rulings.values()):
388
+ return False
389
+ kept = _ruling_rows(lookahead.read_text(encoding="utf-8"))
390
+ for number, cells in rulings.items():
391
+ other = kept.get(number)
392
+ # Plan may record a T### for a pending ruling; nothing else may change.
393
+ if (
394
+ other is None
395
+ or other[:3] != cells[:3]
396
+ or (
397
+ other[3] != cells[3]
398
+ and not (cells[3] == "no" and re.fullmatch(r"T\d{3}", other[3]))
399
+ )
400
+ ):
401
+ raise PipelineStateError(
402
+ f"lookahead DOCS-AUDIT.md does not keep carry-forward ruling #{number}"
403
+ )
404
+ return True
405
+
406
+
346
407
  def _prepare_promotion(
347
408
  repo: Path,
348
409
  project: Path,
@@ -475,12 +536,17 @@ def _prepare_promotion(
475
536
  raise PipelineStateError("lookahead STATE branch and archive must be null")
476
537
 
477
538
  tracks: dict[str, str] = {}
539
+ carry_forward: Optional[str] = None
478
540
  for name in PROMOTION_TRACKS:
479
541
  source = next_root / name
480
542
  destination = project / name
481
543
  if source.exists() or source.is_symlink():
482
544
  if destination.exists() or destination.is_symlink():
483
- raise PipelineStateError(f"promotion destination already exists: {destination}")
545
+ if name != "research" or not _superseded_carry_forward(
546
+ repo, active_state, source, destination
547
+ ):
548
+ raise PipelineStateError(f"promotion destination already exists: {destination}")
549
+ carry_forward = state_checkpoint._tree_digest(destination)
484
550
  tracks[name] = state_checkpoint._tree_digest(source)
485
551
  if not tracks:
486
552
  raise PipelineStateError("lookahead track has no promotable artifacts")
@@ -507,6 +573,7 @@ def _prepare_promotion(
507
573
  "landing": landing,
508
574
  "base": base,
509
575
  "tracks": tracks,
576
+ "carry_forward_sha256": carry_forward,
510
577
  "residual_sha256": state_checkpoint._tree_digest(next_root, tuple(tracks)),
511
578
  "next_state_sha256": state_checkpoint._sha256(next_text),
512
579
  "active_state_sha256": state_checkpoint._sha256(active_text),
@@ -559,6 +626,15 @@ def _resume_track_moves(project: Path, journal: dict[str, object], path: Path) -
559
626
  destination = project / name
560
627
  source_exists = source.exists() or source.is_symlink()
561
628
  destination_exists = destination.exists() or destination.is_symlink()
629
+ carry_forward = journal.get("carry_forward_sha256")
630
+ if name == "research" and source_exists and destination_exists and carry_forward:
631
+ if state_checkpoint._tree_digest(destination) != carry_forward:
632
+ raise PipelineStateError("DOCS-AUDIT carry-forward drifted during promotion")
633
+ # The archive and the ship commit keep the carry-forward copy.
634
+ shutil.rmtree(destination)
635
+ journal["stage"] = "removed:carry-forward"
636
+ pipeline_state._write_json(path, journal)
637
+ destination_exists = False
562
638
  if source_exists and destination_exists:
563
639
  raise PipelineStateError(f"promotion has both source and destination: {name}")
564
640
  if not source_exists and not destination_exists:
@@ -341,13 +341,18 @@ The persisted `STATE.archive` field is the transaction identity.
341
341
  PR is open, the helper returns `status: awaiting-merge`. Present
342
342
  **Outcome**, link
343
343
  the returned PR as **Review**, and state in **Next** that the user must
344
- merge it with GitHub's merge-commit method. Stop this ship invocation;
345
- Path never enables auto-merge or merges the PR. On rerun after a human
346
- GitHub user merges it, the helper requires the PR head to remain the ship
347
- commit and its landing to be a two-parent merge with that ship commit as
348
- second parent on `origin/main` first-parent history. It then writes and
349
- pushes the annotated milestone tag containing the PR URL, ship SHA, and
350
- landing SHA. A closed unmerged PR, squash, rebase, merge queue, moved
344
+ merge it with GitHub's merge-commit or rebase method. Stop this ship
345
+ invocation; Path never enables auto-merge or merges the PR. On rerun after
346
+ a human GitHub user merges it, the helper requires the PR head to remain
347
+ the ship commit and its landing to be on `origin/main` first-parent
348
+ history as either a two-parent merge with that ship commit as second
349
+ parent, or a rebase merge: the last of a first-parent run of single-parent
350
+ commits whose `git patch-id --stable` values match, in order, the
351
+ non-merge commits from the landing to the ship commit (with equal trees
352
+ when `main` did not move). It then writes and pushes the annotated
353
+ milestone tag containing the PR URL, ship SHA, and landing SHA. A
354
+ milestone that was rebase-merged before this support existed recovers by
355
+ rerunning ship. A closed unmerged PR, squash, merge queue, moved
351
356
  head, duplicate PR, or non-GitHub.com origin blocks. The remote bound
352
357
  branch may be absent after a valid merge because GitHub may auto-delete it.
353
358
  - `external-landing` is for an origin where Path cannot open the review
@@ -70,7 +70,9 @@ settings block; their default inheritance remains usable.
70
70
  Resolve the bundled helper to an absolute path. Use one record under the Git
71
71
  common directory's `gsd-path/model-selection/` directory per assignment. Scope
72
72
  the path and `--scope` to the milestone, active/lookahead track, and review cycle
73
- where applicable; later work must not reuse an earlier record.
73
+ where applicable; later work must not reuse an earlier record. The helper
74
+ refuses a relative `--record`, and `release` of a missing record names the
75
+ path and creates nothing.
74
76
 
75
77
  ```text
76
78
  python3 -B <helper> resolve --repo <primary> --scope <milestone/track/cycle> --assignment <logical-name> --role <role> --record <absolute-record> --capabilities <advertised-json>
@@ -136,8 +136,9 @@ bundles this file; spawned agents follow their role brief instead.
136
136
  for the current STATE.branch milestone must be an ancestor of origin/main;
137
137
  if it is not, that milestone's integration is incomplete and control routes
138
138
  to ship, not build. Direct mode requires the matching canonical `integrate:`
139
- commit; PR mode requires tag metadata and a two-parent merge whose second
140
- parent is the ship commit. A null branch returns
139
+ commit; PR mode requires tag metadata and either a two-parent merge whose
140
+ second parent is the ship commit or a proven rebase merge of the ship
141
+ commits. A null branch returns
141
142
  to the router; build never creates, selects, switches, or rebinds it.
142
143
  - The ship phase makes exactly one commit on the bound branch — the
143
144
  `.project/`-only ship commit recording
@@ -27,7 +27,8 @@ helper: run the bundled copy until the project runtime is upgraded.
27
27
  `dispatch_driver.py` and `workflow_run.py` always run from the installed skill
28
28
  bundle, because they read role briefs and templates from it; a skill update
29
29
  replaces them, a runtime upgrade does not. The driver reloads the helper modules
30
- that it imports from the selected runtime.
30
+ that it imports from the selected runtime, and runs the subprocess helpers that
31
+ the runtime lists (such as the fix-task brief lint) from it.
31
32
 
32
33
  With no declaration, retain the legacy helper lookup. An existing
33
34
  `.gsd-path/runtime/` requires the explicit `--runtime-migrate --project PATH`
@@ -361,6 +361,49 @@ def run_git(
361
361
  return _run_exact(git_command(repo, *arguments), input=input)
362
362
 
363
363
 
364
+ def _patch_ids(repo: Path, commits: Sequence[str]) -> list[Optional[str]]:
365
+ """`git patch-id --stable` per commit, in order; None for an empty commit."""
366
+ log = run_git(repo, "log", "--no-walk=unsorted", "-p", "--binary", "--no-color",
367
+ "--no-ext-diff", *commits)
368
+ if log.returncode != 0:
369
+ raise ValueError("could not read commit patches")
370
+ ids = run_git(repo, "patch-id", "--stable", input=log.stdout)
371
+ if ids.returncode != 0:
372
+ raise ValueError("could not compute patch ids")
373
+ by_commit = {commit: patch for patch, commit in (line.split() for line in ids.stdout.splitlines())}
374
+ return [by_commit.get(commit) for commit in commits]
375
+
376
+
377
+ def rebase_landing_problem(repo: Path, ship: str, landing: str) -> Optional[str]:
378
+ """Why landing is not a forge rebase merge of ship, or None when it is (#418).
379
+
380
+ A rebase merge replays ship's own commits onto the default branch. Proof:
381
+ the first-parent run that ends at landing has one non-merge commit per
382
+ non-merge commit of landing..ship, with equal patch ids in the same order;
383
+ when that run starts at ship's fork point, landing's tree equals ship's.
384
+ """
385
+ listed = run_git(repo, "rev-list", "--reverse", "--topo-order", "--no-merges", f"{landing}..{ship}")
386
+ if listed.returncode != 0:
387
+ return "could not list the ship commits"
388
+ originals = listed.stdout.split()
389
+ if not originals:
390
+ return "landing already contains the ship commit"
391
+ walked = run_git(repo, "rev-list", "--first-parent", "--parents", "-n", str(len(originals)), landing)
392
+ rows = [line.split() for line in walked.stdout.splitlines()][::-1]
393
+ if walked.returncode != 0 or len(rows) != len(originals) or any(len(row) != 2 for row in rows):
394
+ return "landing is not a linear run of rebased commits"
395
+ try:
396
+ if _patch_ids(repo, originals) != _patch_ids(repo, [row[0] for row in rows]):
397
+ return "rebased commits do not replay the ship commits in order"
398
+ except ValueError as error:
399
+ return str(error)
400
+ fork = run_git(repo, "merge-base", ship, landing).stdout.strip()
401
+ trees = run_git(repo, "rev-parse", f"{ship}^{{tree}}", f"{landing}^{{tree}}").stdout.split()
402
+ if rows[0][1] == fork and (len(trees) != 2 or trees[0] != trees[1]):
403
+ return "rebased tree differs from the ship tree"
404
+ return None
405
+
406
+
364
407
  def git_command(repo: Path, *arguments: str) -> tuple[str, ...]:
365
408
  """Git argv for repo, including WSL drvfs workarounds when needed."""
366
409
  return ("git", "-C", str(repo), *git_drvfs_config_flags(repo), *arguments)
@@ -387,7 +387,9 @@ def _lint_task(
387
387
  )
388
388
  break
389
389
 
390
- for name in PROSE_SECTIONS:
390
+ # A landed brief is history that nobody can edit: only an unlanded brief must name present paths.
391
+ landed = task_id in (landed_bases or {})
392
+ for name in () if landed else PROSE_SECTIONS:
391
393
  body = sections.get(name)
392
394
  if body is None or not _clean(body):
393
395
  continue
@@ -400,8 +402,6 @@ def _lint_task(
400
402
  and token not in supplied
401
403
  and not _supplied_contract(repo, path, token)
402
404
  and not _pipeline_workspace_file(repo, path, token)
403
- # A landed brief is history: the pipeline moves its own files (build recovery, archive) later.
404
- and not (task_id in (landed_bases or {}) and _pipeline_workspace_path(repo, path, token))
405
405
  and not _base_exists(git_root, base, token)
406
406
  ):
407
407
  problems.append(f"## {name} names a path missing at the layer base: {token}")
@@ -411,7 +411,7 @@ def _lint_task(
411
411
  block = VERIFY_BLOCK_PATTERN.search(verify_body)
412
412
  if block is None or not block.group("block").strip():
413
413
  problems.append("## Verify must contain a non-empty fenced bash block")
414
- else:
414
+ elif not landed:
415
415
  for token in _verify_tokens(
416
416
  block.group("block"),
417
417
  repo=repo,
@@ -448,9 +448,10 @@ def validate_task_briefs(
448
448
 
449
449
  Plain lint uses the layer base without overrides. Plan approval and dispatch
450
450
  supply transitive dependency files for unlanded tasks and resolved historical
451
- bases for landed tasks, whose immutable briefs must survive later file changes.
451
+ bases for landed tasks. A landed brief is immutable: its `files` scope is
452
+ checked at that base, and the paths its prose and Verify name are not checked.
452
453
  The caller validates the task graph and landed metadata before supplying
453
- these mappings; landed tasks receive no dependency-file allowance.
454
+ these mappings.
454
455
  """
455
456
 
456
457
  resolved_base = _resolve_base(root, base)
@@ -481,6 +482,9 @@ def validate_task_briefs(
481
482
  for shape, task_ids in contracts.items():
482
483
  if len(task_ids) == 1:
483
484
  task_id = next(iter(task_ids))
485
+ # A landed brief is history: a later repair may reword the bullet in its partner.
486
+ if task_id in (landed_bases or {}):
487
+ continue
484
488
  problems.append(
485
489
  f"{task_id}: interface contract is not shared by any other task: {shape}"
486
490
  )
@@ -592,7 +596,6 @@ def plan_validation_bases(
592
596
  )
593
597
  except IsolationError as error:
594
598
  raise BriefError(f"{task_id} landed task has invalid historical base: {error}") from error
595
- dependency_files[task_id] = set()
596
599
  except check_handoffs.HandoffError as error:
597
600
  raise BriefError(str(error)) from error
598
601
  return tasks, dependency_files, landed_bases
@@ -845,6 +845,8 @@ def require_pull_request_merge(
845
845
  merge_commit: str,
846
846
  ship_commit: str,
847
847
  remote_default: str,
848
+ *,
849
+ allow_rebase: bool = False,
848
850
  ) -> None:
849
851
  resolved = optional_ref(project, merge_commit)
850
852
  if resolved != merge_commit:
@@ -853,9 +855,16 @@ def require_pull_request_merge(
853
855
  run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
854
856
  "inspect pull-request merge parents",
855
857
  ).split()
856
- if len(parents) != 3:
858
+ if allow_rebase and len(parents) == 2:
859
+ problem = _common.rebase_landing_problem(project, ship_commit, merge_commit)
860
+ if problem is not None:
861
+ raise ArchiveError(
862
+ "pull-request integration must use a two-parent merge commit "
863
+ f"or a rebase merge of the ship commits: {problem}"
864
+ )
865
+ elif len(parents) != 3:
857
866
  raise ArchiveError("pull-request integration must use a two-parent merge commit")
858
- if parents[2] != ship_commit:
867
+ elif parents[2] != ship_commit:
859
868
  raise ArchiveError("pull-request merge second parent is not the ship commit")
860
869
  first_parent = archive_milestone.require_git_success(
861
870
  run_git(project, "rev-list", "--first-parent", remote_default),
@@ -1026,7 +1035,9 @@ def integrate_pull_request(
1026
1035
  merge_commit,
1027
1036
  )
1028
1037
  remote_default = refresh_origin(project)["remote_default"]
1029
- require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
1038
+ require_pull_request_merge(
1039
+ project, merge_commit, ship_commit, remote_default, allow_rebase=True
1040
+ )
1030
1041
  pull = update_pull_request_body(
1031
1042
  repository,
1032
1043
  pull,
@@ -1473,7 +1484,9 @@ def validate_integrated(
1473
1484
  pull["number"],
1474
1485
  merge_commit,
1475
1486
  )
1476
- require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
1487
+ require_pull_request_merge(
1488
+ project, merge_commit, ship_commit, remote_default, allow_rebase=True
1489
+ )
1477
1490
  require_annotated_tag(
1478
1491
  project,
1479
1492
  tag_ref,