@opengsd/gsd-path 1.6.0 → 1.6.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 (176) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +44 -0
  3. package/FULL.md +5 -4
  4. package/HOOKS.md +18 -2
  5. package/QUICK.md +1 -1
  6. package/README.md +12 -28
  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 +66 -14
  15. package/scripts/lean_verification.py +5 -1
  16. package/scripts/model_policy.py +8 -1
  17. package/scripts/pipeline_git.py +19 -5
  18. package/scripts/pipeline_state.py +3 -1
  19. package/scripts/state_promote.py +77 -1
  20. package/skills/gsd-path/BUILD.md +3 -1
  21. package/skills/gsd-path/PLAN.md +3 -2
  22. package/skills/gsd-path/SHIP.md +14 -8
  23. package/skills/gsd-path/references/model-policy.md +3 -1
  24. package/skills/gsd-path/references/operating-rules.md +3 -2
  25. package/skills/gsd-path/references/runtime-selection.md +2 -1
  26. package/skills/gsd-path/scripts/_common.py +43 -0
  27. package/skills/gsd-path/scripts/check_task_briefs.py +10 -7
  28. package/skills/gsd-path/scripts/dispatch_driver.py +17 -5
  29. package/skills/gsd-path/scripts/git_guard.py +18 -2
  30. package/skills/gsd-path/scripts/integration.py +17 -4
  31. package/skills/gsd-path/scripts/isolation.py +66 -14
  32. package/skills/gsd-path/scripts/lean_verification.py +5 -1
  33. package/skills/gsd-path/scripts/model_policy.py +8 -1
  34. package/skills/gsd-path/scripts/pipeline_git.py +19 -5
  35. package/skills/gsd-path/scripts/pipeline_state.py +3 -1
  36. package/skills/gsd-path/scripts/state_promote.py +77 -1
  37. package/skills/gsd-path-build/SKILL.md +3 -1
  38. package/skills/gsd-path-build/references/model-policy.md +3 -1
  39. package/skills/gsd-path-build/references/operating-rules.md +3 -2
  40. package/skills/gsd-path-build/references/runtime-selection.md +2 -1
  41. package/skills/gsd-path-build/scripts/_common.py +43 -0
  42. package/skills/gsd-path-build/scripts/check_task_briefs.py +10 -7
  43. package/skills/gsd-path-build/scripts/dispatch_driver.py +17 -5
  44. package/skills/gsd-path-build/scripts/integration.py +17 -4
  45. package/skills/gsd-path-build/scripts/isolation.py +66 -14
  46. package/skills/gsd-path-build/scripts/model_policy.py +8 -1
  47. package/skills/gsd-path-build/scripts/pipeline_git.py +19 -5
  48. package/skills/gsd-path-build/scripts/pipeline_state.py +3 -1
  49. package/skills/gsd-path-build/scripts/state_promote.py +77 -1
  50. package/skills/gsd-path-decide/references/model-policy.md +3 -1
  51. package/skills/gsd-path-decide/references/operating-rules.md +3 -2
  52. package/skills/gsd-path-decide/references/runtime-selection.md +2 -1
  53. package/skills/gsd-path-decide/scripts/_common.py +43 -0
  54. package/skills/gsd-path-decide/scripts/check_task_briefs.py +10 -7
  55. package/skills/gsd-path-decide/scripts/integration.py +17 -4
  56. package/skills/gsd-path-decide/scripts/isolation.py +66 -14
  57. package/skills/gsd-path-decide/scripts/model_policy.py +8 -1
  58. package/skills/gsd-path-decide/scripts/pipeline_git.py +19 -5
  59. package/skills/gsd-path-decide/scripts/pipeline_state.py +3 -1
  60. package/skills/gsd-path-decide/scripts/state_promote.py +77 -1
  61. package/skills/gsd-path-define/references/operating-rules.md +3 -2
  62. package/skills/gsd-path-define/references/runtime-selection.md +2 -1
  63. package/skills/gsd-path-define/scripts/_common.py +43 -0
  64. package/skills/gsd-path-define/scripts/check_task_briefs.py +10 -7
  65. package/skills/gsd-path-define/scripts/integration.py +17 -4
  66. package/skills/gsd-path-define/scripts/isolation.py +66 -14
  67. package/skills/gsd-path-define/scripts/model_policy.py +8 -1
  68. package/skills/gsd-path-define/scripts/pipeline_git.py +19 -5
  69. package/skills/gsd-path-define/scripts/pipeline_state.py +3 -1
  70. package/skills/gsd-path-define/scripts/state_promote.py +77 -1
  71. package/skills/gsd-path-discuss/references/operating-rules.md +3 -2
  72. package/skills/gsd-path-discuss/references/runtime-selection.md +2 -1
  73. package/skills/gsd-path-discuss/scripts/_common.py +43 -0
  74. package/skills/gsd-path-discuss/scripts/model_policy.py +8 -1
  75. package/skills/gsd-path-docs-audit/references/model-policy.md +3 -1
  76. package/skills/gsd-path-docs-audit/references/operating-rules.md +3 -2
  77. package/skills/gsd-path-docs-audit/references/runtime-selection.md +2 -1
  78. package/skills/gsd-path-docs-audit/scripts/_common.py +43 -0
  79. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +10 -7
  80. package/skills/gsd-path-docs-audit/scripts/integration.py +17 -4
  81. package/skills/gsd-path-docs-audit/scripts/isolation.py +66 -14
  82. package/skills/gsd-path-docs-audit/scripts/model_policy.py +8 -1
  83. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +19 -5
  84. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +3 -1
  85. package/skills/gsd-path-docs-audit/scripts/state_promote.py +77 -1
  86. package/skills/gsd-path-forensics/references/operating-rules.md +3 -2
  87. package/skills/gsd-path-forensics/references/runtime-selection.md +2 -1
  88. package/skills/gsd-path-forensics/scripts/_common.py +43 -0
  89. package/skills/gsd-path-forensics/scripts/model_policy.py +8 -1
  90. package/skills/gsd-path-inspect/references/model-policy.md +3 -1
  91. package/skills/gsd-path-inspect/references/operating-rules.md +3 -2
  92. package/skills/gsd-path-inspect/references/runtime-selection.md +2 -1
  93. package/skills/gsd-path-inspect/scripts/_common.py +43 -0
  94. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +10 -7
  95. package/skills/gsd-path-inspect/scripts/integration.py +17 -4
  96. package/skills/gsd-path-inspect/scripts/isolation.py +66 -14
  97. package/skills/gsd-path-inspect/scripts/model_policy.py +8 -1
  98. package/skills/gsd-path-inspect/scripts/pipeline_git.py +19 -5
  99. package/skills/gsd-path-inspect/scripts/pipeline_state.py +3 -1
  100. package/skills/gsd-path-inspect/scripts/state_promote.py +77 -1
  101. package/skills/gsd-path-loop/references/operating-rules.md +3 -2
  102. package/skills/gsd-path-loop/references/runtime-selection.md +2 -1
  103. package/skills/gsd-path-loop/scripts/_common.py +43 -0
  104. package/skills/gsd-path-loop/scripts/model_policy.py +8 -1
  105. package/skills/gsd-path-migrate/references/operating-rules.md +3 -2
  106. package/skills/gsd-path-migrate/references/runtime-selection.md +2 -1
  107. package/skills/gsd-path-migrate/scripts/_common.py +43 -0
  108. package/skills/gsd-path-migrate/scripts/model_policy.py +8 -1
  109. package/skills/gsd-path-plan/SKILL.md +3 -2
  110. package/skills/gsd-path-plan/references/model-policy.md +3 -1
  111. package/skills/gsd-path-plan/references/operating-rules.md +3 -2
  112. package/skills/gsd-path-plan/references/runtime-selection.md +2 -1
  113. package/skills/gsd-path-plan/scripts/_common.py +43 -0
  114. package/skills/gsd-path-plan/scripts/check_task_briefs.py +10 -7
  115. package/skills/gsd-path-plan/scripts/integration.py +17 -4
  116. package/skills/gsd-path-plan/scripts/isolation.py +66 -14
  117. package/skills/gsd-path-plan/scripts/model_policy.py +8 -1
  118. package/skills/gsd-path-plan/scripts/pipeline_git.py +19 -5
  119. package/skills/gsd-path-plan/scripts/pipeline_state.py +3 -1
  120. package/skills/gsd-path-plan/scripts/state_promote.py +77 -1
  121. package/skills/gsd-path-research/references/model-policy.md +3 -1
  122. package/skills/gsd-path-research/references/operating-rules.md +3 -2
  123. package/skills/gsd-path-research/references/runtime-selection.md +2 -1
  124. package/skills/gsd-path-research/scripts/_common.py +43 -0
  125. package/skills/gsd-path-research/scripts/check_task_briefs.py +10 -7
  126. package/skills/gsd-path-research/scripts/integration.py +17 -4
  127. package/skills/gsd-path-research/scripts/isolation.py +66 -14
  128. package/skills/gsd-path-research/scripts/model_policy.py +8 -1
  129. package/skills/gsd-path-research/scripts/pipeline_git.py +19 -5
  130. package/skills/gsd-path-research/scripts/pipeline_state.py +3 -1
  131. package/skills/gsd-path-research/scripts/state_promote.py +77 -1
  132. package/skills/gsd-path-roadmap/references/model-policy.md +3 -1
  133. package/skills/gsd-path-roadmap/references/operating-rules.md +3 -2
  134. package/skills/gsd-path-roadmap/references/runtime-selection.md +2 -1
  135. package/skills/gsd-path-roadmap/scripts/_common.py +43 -0
  136. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +10 -7
  137. package/skills/gsd-path-roadmap/scripts/integration.py +17 -4
  138. package/skills/gsd-path-roadmap/scripts/isolation.py +66 -14
  139. package/skills/gsd-path-roadmap/scripts/model_policy.py +8 -1
  140. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +19 -5
  141. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +3 -1
  142. package/skills/gsd-path-roadmap/scripts/state_promote.py +77 -1
  143. package/skills/gsd-path-ship/SKILL.md +14 -8
  144. package/skills/gsd-path-ship/references/model-policy.md +3 -1
  145. package/skills/gsd-path-ship/references/operating-rules.md +3 -2
  146. package/skills/gsd-path-ship/references/runtime-selection.md +2 -1
  147. package/skills/gsd-path-ship/scripts/_common.py +43 -0
  148. package/skills/gsd-path-ship/scripts/check_task_briefs.py +10 -7
  149. package/skills/gsd-path-ship/scripts/integration.py +17 -4
  150. package/skills/gsd-path-ship/scripts/isolation.py +66 -14
  151. package/skills/gsd-path-ship/scripts/lean_verification.py +5 -1
  152. package/skills/gsd-path-ship/scripts/model_policy.py +8 -1
  153. package/skills/gsd-path-ship/scripts/pipeline_git.py +19 -5
  154. package/skills/gsd-path-ship/scripts/pipeline_state.py +3 -1
  155. package/skills/gsd-path-ship/scripts/state_promote.py +77 -1
  156. package/skills/gsd-path-undo/references/operating-rules.md +3 -2
  157. package/skills/gsd-path-undo/references/runtime-selection.md +2 -1
  158. package/skills/gsd-path-undo/scripts/_common.py +43 -0
  159. package/skills/gsd-path-undo/scripts/model_policy.py +8 -1
  160. package/skills/path/BUILD.md +3 -1
  161. package/skills/path/PLAN.md +3 -2
  162. package/skills/path/SHIP.md +14 -8
  163. package/skills/path/references/model-policy.md +3 -1
  164. package/skills/path/references/operating-rules.md +3 -2
  165. package/skills/path/references/runtime-selection.md +2 -1
  166. package/skills/path/scripts/_common.py +43 -0
  167. package/skills/path/scripts/check_task_briefs.py +10 -7
  168. package/skills/path/scripts/dispatch_driver.py +17 -5
  169. package/skills/path/scripts/git_guard.py +18 -2
  170. package/skills/path/scripts/integration.py +17 -4
  171. package/skills/path/scripts/isolation.py +66 -14
  172. package/skills/path/scripts/lean_verification.py +5 -1
  173. package/skills/path/scripts/model_policy.py +8 -1
  174. package/skills/path/scripts/pipeline_git.py +19 -5
  175. package/skills/path/scripts/pipeline_state.py +3 -1
  176. package/skills/path/scripts/state_promote.py +77 -1
@@ -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"
@@ -4470,6 +4502,20 @@ def attest(
4470
4502
  }
4471
4503
 
4472
4504
 
4505
+ def _committed_ship_active(repo: Path) -> bool:
4506
+ """True when the STATE.md committed at HEAD is ship/active."""
4507
+
4508
+ result = run_git(repo, "show", "HEAD:.project/STATE.md")
4509
+ if result.returncode != 0:
4510
+ return False
4511
+ frontmatter = result.stdout.split("\n---", 1)[0]
4512
+ fields = {
4513
+ key: _unquote(value)
4514
+ for key, value in re.findall(r"(?m)^(phase|status):(.*)$", frontmatter)
4515
+ }
4516
+ return fields.get("phase") == "ship" and fields.get("status") == "active"
4517
+
4518
+
4473
4519
  def checkpoint(
4474
4520
  repo: Path,
4475
4521
  expected_head: str,
@@ -4519,6 +4565,12 @@ def checkpoint(
4519
4565
  "subject": subject.strip(),
4520
4566
  }
4521
4567
  raise IsolationError("primary HEAD differs from --expected-head")
4568
+ if subject.strip().startswith("build:") and _committed_ship_active(repo):
4569
+ raise IsolationError(
4570
+ "STATE is ship/active: final review creates no pre-ship commit; leave its "
4571
+ "outputs uncommitted for archive_milestone.py, and send findings through "
4572
+ "the patch plan (see SHIP.md)"
4573
+ )
4522
4574
  pending = uncommitted_paths(repo)
4523
4575
  forced: Set[str] = set()
4524
4576
  # Build only: an approval undo resets to a parent without the ledger and would drop it.
@@ -108,10 +108,14 @@ def _final_view(relative, text, criteria, reviewed, head, surfaces=None):
108
108
  fields[field] = contracts._raw_source_field(block, field, relative)
109
109
  if "Surface" in fields and not {"Check", "Observed"}.issubset(fields):
110
110
  raise contracts.HandoffError(f"{criterion} lacks its recorded surface walkthrough")
111
+ reference = f"{relative} — {criterion}"
112
+ if "Observed" in fields and evidence:
113
+ # The reviewer may cite held-out and judgment ids on the evidence line.
114
+ reference += f"; evidence: {evidence}"
111
115
  lines += [f"### {criterion} — {title}", "", "- **Verdict**: met",
112
116
  f"- **Check**: {fields.get('Check', 'none')}",
113
117
  f"- **Observed**: {fields.get('Observed', evidence)}",
114
- f"- **Reference**: {relative} — {criterion}",
118
+ f"- **Reference**: {reference}",
115
119
  "- **Finding**: none", "- **Fix direction**: none"]
116
120
  if "Surface" in fields:
117
121
  expected = (surfaces or {}).get(criterion)
@@ -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:
@@ -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)
@@ -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:
@@ -130,7 +130,9 @@ STATE.md-only "finalize" checkpoint for work already checkpointed. The helper
130
130
  refuses a `build:` checkpoint that deletes or renames a `.project/tasks/`
131
131
  brief unless the canonical `build: abandon milestone <slug>` checkpoint moves
132
132
  that same brief into `.project/archive/`; build adds fix tasks and repairs
133
- briefs, it never replans.
133
+ briefs, it never replans. It also refuses a `build:` checkpoint while the
134
+ committed STATE is `ship/active`: final-review outputs stay uncommitted until
135
+ the ship commit.
134
136
 
135
137
  ## Execute from runtime receipts
136
138
 
@@ -263,8 +263,9 @@ as stated in Lookahead mode.
263
263
 
264
264
  Before approval, the helper validates the task graph and brief paths.
265
265
  Pending briefs may reference files supplied by transitive dependencies.
266
- Landed briefs use their recorded historical base, with no dependency-file
267
- allowance, so later file changes do not invalidate immutable briefs.
266
+ A landed brief is immutable history: the helper checks its `files` scope
267
+ at the recorded historical base and does not check the paths that its
268
+ prose and Verify name.
268
269
  This applies to plan re-approval too; the dispatch CLI remains HEAD-only
269
270
  with no overrides.
270
271
 
@@ -69,7 +69,8 @@ so the position is unchanged. Then:
69
69
  task logs prove it is the immediately preceding `ship/blocked` finding
70
70
  set, the approved patch tasks copied every finding verbatim, and those tasks
71
71
  are now done; otherwise it blocks. Git history preserves the superseded
72
- finding, while final review itself still creates no pre-ship commit.
72
+ finding, while final review itself still creates no pre-ship commit
73
+ (`isolation.py checkpoint` refuses a `build:` checkpoint at `ship/active`).
73
74
  2. Derive a stable numbered list of cross-wave integration risks from
74
75
  interfaces and flows that span waves. List only genuine risks that could
75
76
  plausibly fail; never pad the list. Include PLAN.md's project Verify as
@@ -341,13 +342,18 @@ The persisted `STATE.archive` field is the transaction identity.
341
342
  PR is open, the helper returns `status: awaiting-merge`. Present
342
343
  **Outcome**, link
343
344
  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
345
+ merge it with GitHub's merge-commit or rebase method. Stop this ship
346
+ invocation; Path never enables auto-merge or merges the PR. On rerun after
347
+ a human GitHub user merges it, the helper requires the PR head to remain
348
+ the ship commit and its landing to be on `origin/main` first-parent
349
+ history as either a two-parent merge with that ship commit as second
350
+ parent, or a rebase merge: the last of a first-parent run of single-parent
351
+ commits whose `git patch-id --stable` values match, in order, the
352
+ non-merge commits from the landing to the ship commit (with equal trees
353
+ when `main` did not move). It then writes and pushes the annotated
354
+ milestone tag containing the PR URL, ship SHA, and landing SHA. A
355
+ milestone that was rebase-merged before this support existed recovers by
356
+ rerunning ship. A closed unmerged PR, squash, merge queue, moved
351
357
  head, duplicate PR, or non-GitHub.com origin blocks. The remote bound
352
358
  branch may be absent after a valid merge because GitHub may auto-delete it.
353
359
  - `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)