@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
package/AGENTS.md CHANGED
@@ -139,7 +139,7 @@ full-repo suite on a tiny edit outrank the phase brief.
139
139
  canonical contents, carry-forward, and manifest pass the bundled precommit
140
140
  validator; report shipped only after the exact `.project/`-only ship commit
141
141
  passes the postcommit validator and `validate-integrated` proves the
142
- two-parent integration merge, its tag, and its ancestry on origin/main —
142
+ integration landing, its tag, and its ancestry on origin/main —
143
143
  while integration is pending the router routes back to ship instead of
144
144
  reporting shipped or starting the next milestone.
145
145
 
package/CHANGELOG.md CHANGED
@@ -11,6 +11,50 @@ app and the daemon have their own file,
11
11
  [daemon/app/CHANGELOG.md](daemon/app/CHANGELOG.md). Release bookkeeping and
12
12
  test-only changes are not listed; the Git history has them.
13
13
 
14
+ ## [1.6.2] - 2026-10-17
15
+
16
+ ### Changed
17
+ - record 1.6.2 live host release evidence
18
+ - prepare 1.6.2 patch candidate
19
+ - Document README What's new heading in RELEASE.md
20
+ - update release notes for v1.6.1
21
+
22
+ ### Fixed
23
+ - keep the README What's new section current from release notes
24
+ - read quoted STATE values in the ship checkpoint guard
25
+ - refuse a build checkpoint while STATE is ship/active
26
+ - keep evidence-line citations when final review reuses a wave
27
+ ## [1.6.1] - 2026-10-16
28
+
29
+ ### Added
30
+ - accept GitHub rebase merges in pull-request integration
31
+
32
+ ### Changed
33
+ - record 1.6.1 live host release evidence
34
+ - prepare 1.6.1 patch candidate
35
+ - Note PR rebase-merge landing in CONTEXT, WORKFLOW, AGENTS
36
+ - Note owner-authorized push exception in FULL.md and HOOKS.md
37
+ - correct the minimum Git version in TEST_ENVIRONMENT.md
38
+ - prove the landed member base through the file scope check
39
+ - describe project Update with no project install
40
+ - update release notes for v1.6.0
41
+
42
+ ### Fixed
43
+ - run dispatch_driver subprocess helpers from the selected runtime
44
+ - refuse a relative model_policy --record and check release before locking
45
+ - let promote-next replace a DOCS-AUDIT carry-forward the lookahead keeps
46
+ - let the owner authorize one exact pre-ship push to a task branch
47
+ - post Discord changelogs from automated releases
48
+ - stage a landing or checkpoint change set with one git add
49
+ - pass the landing message and member paths to git through stdin
50
+ - do not check the paths that a landed task brief names
51
+ - accept a landed task as the only owner of an Interface contract bullet
52
+ - say why a project has no workflow status in the desktop app
53
+
54
+ ### Other
55
+ - Accept only same value or T### for pending rulings
56
+ - list not-started projects and re-probe after project install
57
+ - sort idle projects with shipped in legacy dashboard
14
58
  ## [1.6.0] - 2026-10-12
15
59
 
16
60
  ### Added
package/FULL.md CHANGED
@@ -322,9 +322,9 @@ approve **Archive and ship**, the archive transaction begins:
322
322
  - `MANIFEST.md` records contents and ship metadata
323
323
  - One **ship commit** (subject `ship: M00N — <slug>`) touches only `.project/`
324
324
  - Ship then either merges `gsd-path/M00N` onto `main` directly or opens a
325
- GitHub PR and waits for you to merge it with a merge commit. Path-created PRs
326
- include a GSD Path credit footer.
327
- - After the two-parent merge validates, Path tags `milestone/<NNN>-<slug>`
325
+ GitHub PR and waits for you to merge it with a merge commit or a rebase
326
+ merge. Path-created PRs include a GSD Path credit footer.
327
+ - After the landing validates, Path tags `milestone/<NNN>-<slug>`
328
328
  - Archives are **read-only** — guard hooks enforce this if installed
329
329
 
330
330
  Direct integration is the project default. Before build, ask the router to set
@@ -454,7 +454,8 @@ approves.
454
454
  - You cannot share a milestone in progress through the remote. With
455
455
  [guard hooks](HOOKS.md) installed, the pre-push hook refuses `gsd-path/M00N`
456
456
  before its ship commit. It also refuses any other branch that carries the
457
- unfinished state. Hooks are optional and local to each clone. A clone
457
+ unfinished state, unless the owner authorizes that exact commit (see
458
+ [HOOKS.md](HOOKS.md)). Hooks are optional and local to each clone. A clone
458
459
  without hooks has no such check.
459
460
 
460
461
  **Do not**
package/HOOKS.md CHANGED
@@ -351,6 +351,22 @@ such as `rm -rf scratch` passes the archive check; other guard rules still apply
351
351
  without `STATE.md` passes on an ordinary ref. An absent bound ref passes
352
352
  deletion. The authorized publisher is `archive_milestone.py integrate`;
353
353
  the hook checks ref updates, not which client initiated them
354
+ - **allows** an owner-approved push of unshipped work, such as a task
355
+ branch that CI must measure before the task can finish. The owner records
356
+ the exact commit for the exact branch, then pushes it:
357
+
358
+ ```bash
359
+ git update-ref refs/gsd-path/authorizations/<project>/push/refs/heads/<branch> <commit>
360
+ ```
361
+
362
+ `<project>` is the `project` field in that commit's `STATE.md`. The hook
363
+ accepts the push only at that commit; a later commit, such as one that
364
+ removes the measurement inputs, needs its own record. Bound `gsd-path/M###`
365
+ refs, the default branch, and tags never accept an authorization. The
366
+ authorization accepts every push of that commit to that branch until the
367
+ owner removes it with `git update-ref -d <same ref>`. Deleting the remote
368
+ branch afterwards needs no authorization. Only the owner records an
369
+ authorization; an agent asks the owner for one
354
370
  - **allows** adding files to archive during the ship transaction, before the
355
371
  branch closes
356
372
 
@@ -358,8 +374,8 @@ These local hooks cover pushes made by plain Git and clients that invoke
358
374
  Git with hooks enabled. Creating a PR from an already published ref does not
359
375
  run the pre-push hook. A branch cut from `main` carrying `shipped/done` passes;
360
376
  if `main` instead carries an unshipped bound state, pushes of branches carrying
361
- that state remain blocked until the milestone records are repaired through the
362
- pipeline.
377
+ that state remain blocked, except owner-authorized commits, until the
378
+ milestone records are repaired through the pipeline.
363
379
 
364
380
  ## GitHub-side gate
365
381
 
package/QUICK.md CHANGED
@@ -14,7 +14,7 @@ One router skill drives a gated pipeline: inspect → define → research → de
14
14
  plan → build → ship. Every handoff lives in `.project/` so any session can resume from disk.
15
15
 
16
16
  **Hosts:** Codex, Claude Code, Grok, OpenCode, Antigravity, Cursor, and Kimi
17
- Code have a passing live milestone for release 1.6.0. Copilot CLI, Qwen, Zed,
17
+ Code have a passing live milestone for release 1.6.2. Copilot CLI, Qwen, Zed,
18
18
  Kiro, and Muse Code install, but have no live milestone for that release. See
19
19
  [host tiers](README.md).
20
20
 
package/README.md CHANGED
@@ -9,11 +9,11 @@ milestone loop. While a milestone builds, the next one can be planned in
9
9
  parallel under `.project/next/` (lookahead); a building milestone can also be
10
10
  abandoned on an explicit ruling, archiving its partial work for a re-slice.
11
11
 
12
- **Hosts with a passing full live milestone for release 1.6.0:** Codex, Claude
12
+ **Hosts with a passing full live milestone for release 1.6.2:** Codex, Claude
13
13
  Code, Grok, OpenCode, Antigravity CLI, Cursor, and Kimi Code.
14
14
 
15
15
  **Hosts with an installer and a dispatch contract, but no live milestone for
16
- release 1.6.0:** GitHub Copilot CLI (account quota exhausted), Qwen Code, Zed,
16
+ release 1.6.2:** GitHub Copilot CLI (account quota exhausted), Qwen Code, Zed,
17
17
  and Kiro (API credits), and Muse Code (no live release evidence recorded yet).
18
18
  These hosts are excluded from the release evaluation. Exclusion is not a pass.
19
19
 
@@ -24,14 +24,16 @@ The [host trust matrix](https://github.com/open-gsd/gsd-path/blob/main/docs/trus
24
24
  is the source of truth for live milestone proof and each host's guard tier.
25
25
 
26
26
  <!-- release-docs -->
27
- **Latest npm release:** [@opengsd/gsd-path@1.6.0](https://www.npmjs.com/package/@opengsd/gsd-path/v/1.6.0) — [release notes](CHANGELOG.md#160---2026-10-12)
27
+ ## What's new in 1.6.2 — 2026-10-17
28
+
29
+ **Latest npm release:** [@opengsd/gsd-path@1.6.2](https://www.npmjs.com/package/@opengsd/gsd-path/v/1.6.2) — [release notes](CHANGELOG.md#162---2026-10-17)
28
30
 
29
31
  **Recent highlights**
30
- - let a later task supersede an earlier task and retire its Verify
31
- - document the default branch record of a member
32
- - member paths read the default branch that MEMBERS.md records
33
- - record the default branch of a member in MEMBERS.md
34
- - add --uninstall to the skill installers
32
+ - keep the README What's new section current from release notes
33
+ - read quoted STATE values in the ship checkpoint guard
34
+ - refuse a build checkpoint while STATE is ship/active
35
+ - keep evidence-line citations when final review reuses a wave
36
+ - record 1.6.2 live host release evidence
35
37
 
36
38
  <!-- /release-docs -->
37
39
 
@@ -55,25 +57,6 @@ is the source of truth for live milestone proof and each host's guard tier.
55
57
  | [RELEASE.md](RELEASE.md) | Maintainer CI and npm release cycle |
56
58
  | [GUIDE.md](GUIDE.md) | Pointer to the guides above |
57
59
 
58
- ## What's new — October 2, 2026
59
-
60
- These changes are merged into the source checkout; npm installs use the latest
61
- published release.
62
-
63
- - **Desktop app (pre-release):** one app for macOS, Windows, and Linux shows
64
- your projects, manages skills in each coding agent, and updates itself. See
65
- [Desktop app](#desktop-app).
66
- - **Skills from the published release:** the monitor installs skills from the
67
- npm release, checks the download against the registry's sha512, and refuses
68
- one that does not match. A release picker lets you stay on an older version.
69
- - **Safer monitor writes:** a monitor started by the app accepts changes only
70
- with the app's local token, so another web page cannot change your setup.
71
- - **Environment editor:** edit a project's `.env` files with values masked and
72
- a diff before each save.
73
- - **Usage charts:** tokens and cost per day, time per phase, tasks per wave,
74
- and verify history. Missing data shows as missing, never as zero.
75
- - **Muse Code** is a supported host.
76
-
77
60
  ## Install from npm
78
61
 
79
62
  Requires **Node.js 18.17+** for the npm installer and **Python 3.9+** for
@@ -465,7 +448,8 @@ an atomic close across repos.
465
448
  Shipping moves milestone artifacts into `archive/` with a MANIFEST. It then
466
449
  either merges the milestone's `gsd-path/M00N` branch into `main` directly or
467
450
  opens/reuses a GitHub PR and waits for its user-controlled merge. Path reports
468
- shipped only after a two-parent merge and the milestone tag validate. Before
451
+ shipped only after the landing (a two-parent merge, or a proven GitHub rebase
452
+ merge of the PR) and the milestone tag validate. Before
469
453
  any next-milestone files change, the router binds
470
454
  a new `gsd-path/M00N` at the updated `origin/main`. `STATE.md`, `REPOSITORY.md`,
471
455
  `LESSONS.md`, `next/`, and the program artifacts (`CHARTER.md`, `ROADMAP.md`,
package/WORKFLOW.md CHANGED
@@ -513,7 +513,11 @@ untouched.
513
513
  One exception: a DOCS-AUDIT.md with pending `planned: no` rulings is copied
514
514
  atomically back into a recreated `research/` so the alignment queue survives;
515
515
  the archived original keeps full history. Active and archived research may
516
- coexist only for that byte-identical carry-forward.
516
+ coexist only for that byte-identical carry-forward. When a lookahead track
517
+ brings its own `research/`, `promote-next` removes the carry-forward in the
518
+ promotion commit, but only if the lookahead DOCS-AUDIT.md keeps every
519
+ carry-forward ruling (a pending row may gain a `T###`); otherwise promotion
520
+ stops.
517
521
 
518
522
  Archives become read-only when committed. No phase may modify a committed
519
523
  archive; the next milestone's inspection may read it. A retry with a persisted
@@ -612,9 +616,9 @@ and requires the remote default to be the recorded
612
616
  - `pull-request` requires `gh` authentication for GitHub.com and a GitHub.com
613
617
  origin. Path publishes the exact ship commit and creates or reuses the one
614
618
  eligible PR to `main` with a GSD Path credit footer. It returns
615
- `awaiting-merge` until a human GitHub user merges it with a merge commit; Path
616
- never enables auto-merge or merges the PR. After validation, Path creates the
617
- annotated milestone tag. The [ship contract](skills/gsd-path-ship/SKILL.md)
619
+ `awaiting-merge` until a human GitHub user merges it with a merge commit or a
620
+ rebase merge; Path never enables auto-merge or merges the PR. After
621
+ validation, Path creates the annotated milestone tag. The [ship contract](skills/gsd-path-ship/SKILL.md)
618
622
  owns the candidate, provenance, topology, publication, and recovery rules.
619
623
  - `external-landing` is for an origin that is not GitHub.com, such as GitLab,
620
624
  Gitea, or GitHub Enterprise Server, where a merge request is the only route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-path",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Disk-backed AI agent pipeline. Docs: DOCS.md (start here), QUICK.md, FULL.md, UPDATE.md",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
@@ -117,9 +117,21 @@ def _refresh_stop_errors() -> None:
117
117
  model_policy.PolicyError)
118
118
 
119
119
 
120
+ # The selected Project runtime directory once _reload_pinned_modules ran; None keeps bundled helpers.
121
+ _RUNTIME_ROOT: Optional[Path] = None
122
+
123
+
124
+ def helper_script(name: str) -> Path:
125
+ """A subprocess helper from the selected runtime when it ships one, else the bundled sibling."""
126
+ if _RUNTIME_ROOT is not None and (_RUNTIME_ROOT / name).is_file():
127
+ return _RUNTIME_ROOT / name
128
+ return Path(__file__).resolve().parent / name
129
+
130
+
120
131
  def _reload_pinned_modules(runtime_root: Path) -> None:
121
132
  global isolation, build_state, task_context, contracts, pipeline_state
122
- global archive_milestone, review_findings, model_policy
133
+ global archive_milestone, review_findings, model_policy, _RUNTIME_ROOT
134
+ _RUNTIME_ROOT = runtime_root
123
135
  if _bundled_helpers():
124
136
  path = str(runtime_root)
125
137
  if path not in sys.path:
@@ -981,7 +993,7 @@ class Round:
981
993
  diagnose = shlex.join([
982
994
  sys.executable,
983
995
  "-B",
984
- str(Path(__file__).with_name("pipeline_diagnose.py")),
996
+ str(helper_script("pipeline_diagnose.py")),
985
997
  "diagnose",
986
998
  "--repo",
987
999
  str(self.primary),
@@ -1677,8 +1689,8 @@ class Review:
1677
1689
 
1678
1690
 
1679
1691
  def helper_json(receipt: Dict[str, object], script: str, *arguments: str, cwd: Path) -> Dict[str, object]:
1680
- """Run a bundled helper and return its JSON stdout; a non-zero exit is a typed stop."""
1681
- command = [sys.executable, "-B", str(Path(__file__).resolve().parent / script), *arguments]
1692
+ """Run a helper (runtime-selected, else bundled) and return its JSON stdout; a non-zero exit is a typed stop."""
1693
+ command = [sys.executable, "-B", str(helper_script(script)), *arguments]
1682
1694
  completed = subprocess.run(command, cwd=cwd, capture_output=True, encoding="utf-8", errors="replace")
1683
1695
  step = {"script": script, "command": command, "exit_code": completed.returncode,
1684
1696
  "stdout": completed.stdout, "stderr": completed.stderr}
@@ -2290,7 +2302,7 @@ Heavy: {'yes' if heavy else 'no'}
2290
2302
  if plan_changed:
2291
2303
  _common.atomic_write(plan_path, updated)
2292
2304
  receipt["plan_wave"] = fix_wave
2293
- lint = subprocess.run([sys.executable, "-B", str(Path(__file__).resolve().parent / "check_task_briefs.py"),
2305
+ lint = subprocess.run([sys.executable, "-B", str(helper_script("check_task_briefs.py")),
2294
2306
  "--repo", str(primary), "--base", isolation.current_sha(primary),
2295
2307
  "--tasks-dir", f"{options.project_dir}/tasks",
2296
2308
  "--project-dir", options.project_dir], capture_output=True, encoding="utf-8", errors="replace")
@@ -69,6 +69,11 @@ PUBLICATION_HINT = (
69
69
  "(STATE shipped/done) through archive_milestone.py integrate; findings during "
70
70
  "ship reopen through the patch plan (see SHIP.md)"
71
71
  )
72
+ AUTHORIZED_PUSH_HINT = (
73
+ "an owner-approved push of one exact commit to a branch other than the bound "
74
+ "or default branch needs refs/gsd-path/authorizations/<project>/push/<ref> "
75
+ "at that commit (see HOOKS.md)"
76
+ )
72
77
  LEGACY_STATE_FIELDS = {
73
78
  "pipeline",
74
79
  "project",
@@ -621,14 +626,25 @@ def pre_push_violations(lines):
621
626
  continue
622
627
  state = state_at(local_sha)
623
628
  owed = unshipped_bound_branch(state) if state is not None else None
624
- if owed is not None:
629
+ if owed is not None and not authorized_publication(state, remote_ref, local_sha):
625
630
  found.append(
626
631
  f"{remote_ref} <- {local_sha[:12]} carries unshipped {owed} work "
627
- f"({state.get('phase')}/{state.get('status')}); {PUBLICATION_HINT}"
632
+ f"({state.get('phase')}/{state.get('status')}); {PUBLICATION_HINT}; "
633
+ f"{AUTHORIZED_PUSH_HINT}"
628
634
  )
629
635
  return found
630
636
 
631
637
 
638
+ def authorized_publication(state, remote_ref, sha):
639
+ """The owner authorized this exact commit on this one non-default branch."""
640
+ project = state.get("project")
641
+ if not isinstance(project, str) or not members.NAME_RE.fullmatch(project):
642
+ return False
643
+ if not remote_ref.startswith("refs/heads/") or remote_ref == f"refs/heads/{default_branch()}":
644
+ return False
645
+ return members.authorized(repo_root(), project, "push", remote_ref, sha)
646
+
647
+
632
648
  def is_ship_commit(subject):
633
649
  return bool(subject and subject.strip().casefold().startswith("ship:"))
634
650
 
@@ -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"
@@ -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)