workhorse-workflows 0.2.0__py3-none-any.whl

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 (197) hide show
  1. workhorse_workflows/__init__.py +16 -0
  2. workhorse_workflows/author/__init__.py +15 -0
  3. workhorse_workflows/author/nodes/__init__.py +71 -0
  4. workhorse_workflows/author/nodes/_blueprint.py +13 -0
  5. workhorse_workflows/author/nodes/_stubs.py +33 -0
  6. workhorse_workflows/author/nodes/artifacts.py +477 -0
  7. workhorse_workflows/author/nodes/config.py +174 -0
  8. workhorse_workflows/author/nodes/coverage.py +182 -0
  9. workhorse_workflows/author/nodes/epics.py +83 -0
  10. workhorse_workflows/author/nodes/stories.py +636 -0
  11. workhorse_workflows/author/parity_surveyor/__init__.py +17 -0
  12. workhorse_workflows/author/parity_surveyor/flow.py +275 -0
  13. workhorse_workflows/author/parity_surveyor/nodes/__init__.py +21 -0
  14. workhorse_workflows/author/parity_surveyor/nodes/parity.py +292 -0
  15. workhorse_workflows/author/prompts/audit-story.md +93 -0
  16. workhorse_workflows/author/prompts/decompose-epics.md +98 -0
  17. workhorse_workflows/author/prompts/design-mockup.md +80 -0
  18. workhorse_workflows/author/prompts/resolve-integrity.md +87 -0
  19. workhorse_workflows/author/prompts/resolve-operator.md +78 -0
  20. workhorse_workflows/author/prompts/review-coverage.md +76 -0
  21. workhorse_workflows/author/prompts/review-epics.md +54 -0
  22. workhorse_workflows/author/prompts/rework-epics.md +37 -0
  23. workhorse_workflows/author/prompts/rework-story.md +61 -0
  24. workhorse_workflows/author/prompts/split-stories.md +115 -0
  25. workhorse_workflows/author/prompts/surveyor/assess-parity-unit.md +58 -0
  26. workhorse_workflows/author/prompts/surveyor/assess-unit.md +89 -0
  27. workhorse_workflows/author/prompts/surveyor/fix-record.md +61 -0
  28. workhorse_workflows/author/prompts/surveyor/partition-findings.md +84 -0
  29. workhorse_workflows/author/prompts/surveyor/plan-units.md +85 -0
  30. workhorse_workflows/author/prompts/surveyor/resolve-operator.md +78 -0
  31. workhorse_workflows/author/prompts/write-epic.md +97 -0
  32. workhorse_workflows/author/prompts/write-story.md +157 -0
  33. workhorse_workflows/author/shared/__init__.py +20 -0
  34. workhorse_workflows/author/shared/paths.py +202 -0
  35. workhorse_workflows/author/shared/schemas/__init__.py +98 -0
  36. workhorse_workflows/author/shared/schemas/_base.py +36 -0
  37. workhorse_workflows/author/shared/schemas/main.py +258 -0
  38. workhorse_workflows/author/shared/schemas/parity.py +42 -0
  39. workhorse_workflows/author/shared/schemas/survey.py +197 -0
  40. workhorse_workflows/author/shared/survey/__init__.py +53 -0
  41. workhorse_workflows/author/shared/survey/blueprint.py +15 -0
  42. workhorse_workflows/author/shared/survey/inventory.py +377 -0
  43. workhorse_workflows/author/shared/survey/records.py +373 -0
  44. workhorse_workflows/author/shared/survey/stubs.py +52 -0
  45. workhorse_workflows/author/shared/survey/units.py +203 -0
  46. workhorse_workflows/author/surveyor/__init__.py +33 -0
  47. workhorse_workflows/author/surveyor/flow.py +595 -0
  48. workhorse_workflows/author/surveyor/nodes/__init__.py +21 -0
  49. workhorse_workflows/author/surveyor/nodes/config.py +100 -0
  50. workhorse_workflows/author/surveyor/nodes/partition.py +313 -0
  51. workhorse_workflows/author/workflow.py +1088 -0
  52. workhorse_workflows/coder/__init__.py +53 -0
  53. workhorse_workflows/coder/dev/__init__.py +18 -0
  54. workhorse_workflows/coder/dev/flow.py +574 -0
  55. workhorse_workflows/coder/docs/__init__.py +20 -0
  56. workhorse_workflows/coder/docs/flow.py +368 -0
  57. workhorse_workflows/coder/dream/__init__.py +15 -0
  58. workhorse_workflows/coder/dream/flow.py +119 -0
  59. workhorse_workflows/coder/dream/nodes.py +329 -0
  60. workhorse_workflows/coder/fix/__init__.py +16 -0
  61. workhorse_workflows/coder/fix/flow.py +361 -0
  62. workhorse_workflows/coder/fix_ci/__init__.py +16 -0
  63. workhorse_workflows/coder/fix_ci/flow.py +229 -0
  64. workhorse_workflows/coder/genesis/__init__.py +17 -0
  65. workhorse_workflows/coder/genesis/flow.py +385 -0
  66. workhorse_workflows/coder/genesis/nodes.py +674 -0
  67. workhorse_workflows/coder/nodes/__init__.py +50 -0
  68. workhorse_workflows/coder/nodes/pr.py +719 -0
  69. workhorse_workflows/coder/prompts/apply-genesis-conventions.md +78 -0
  70. workhorse_workflows/coder/prompts/apply-qa-fixes.md +137 -0
  71. workhorse_workflows/coder/prompts/apply-review.md +182 -0
  72. workhorse_workflows/coder/prompts/audit-qa.md +72 -0
  73. workhorse_workflows/coder/prompts/check-code-reuse.md +90 -0
  74. workhorse_workflows/coder/prompts/code-reuse.md +94 -0
  75. workhorse_workflows/coder/prompts/code-review.md +60 -0
  76. workhorse_workflows/coder/prompts/document-story.md +99 -0
  77. workhorse_workflows/coder/prompts/dream-reflect.md +101 -0
  78. workhorse_workflows/coder/prompts/fix-ci.md +32 -0
  79. workhorse_workflows/coder/prompts/fix-genesis.md +53 -0
  80. workhorse_workflows/coder/prompts/fix-lint.md +44 -0
  81. workhorse_workflows/coder/prompts/fix-merge.md +48 -0
  82. workhorse_workflows/coder/prompts/fix-regression.md +99 -0
  83. workhorse_workflows/coder/prompts/implement-plan.md +222 -0
  84. workhorse_workflows/coder/prompts/plan-qa.md +279 -0
  85. workhorse_workflows/coder/prompts/plan-story.md +395 -0
  86. workhorse_workflows/coder/prompts/qa-fix-item.md +77 -0
  87. workhorse_workflows/coder/prompts/qa-story.md +114 -0
  88. workhorse_workflows/coder/prompts/refine-plan.md +301 -0
  89. workhorse_workflows/coder/prompts/repair-qa-context.md +43 -0
  90. workhorse_workflows/coder/prompts/replan-epic.md +28 -0
  91. workhorse_workflows/coder/prompts/report-qa-dev-pass.md +90 -0
  92. workhorse_workflows/coder/prompts/report-qa-dev.md +86 -0
  93. workhorse_workflows/coder/prompts/resolve-operator.md +77 -0
  94. workhorse_workflows/coder/prompts/review-implementation.md +213 -0
  95. workhorse_workflows/coder/prompts/review-qa-plan.md +49 -0
  96. workhorse_workflows/coder/prompts/review-story-documentation.md +46 -0
  97. workhorse_workflows/coder/prompts/setup-fix.md +149 -0
  98. workhorse_workflows/coder/prompts/triage-qa.md +168 -0
  99. workhorse_workflows/coder/qa/__init__.py +24 -0
  100. workhorse_workflows/coder/qa/flow.py +1106 -0
  101. workhorse_workflows/coder/qa/nodes/__init__.py +43 -0
  102. workhorse_workflows/coder/qa/nodes/evidence.py +624 -0
  103. workhorse_workflows/coder/qa/nodes/hygiene.py +293 -0
  104. workhorse_workflows/coder/qa/nodes/qa.py +264 -0
  105. workhorse_workflows/coder/qa/nodes/regression.py +417 -0
  106. workhorse_workflows/coder/review/__init__.py +18 -0
  107. workhorse_workflows/coder/review/flow.py +510 -0
  108. workhorse_workflows/coder/shared/__init__.py +40 -0
  109. workhorse_workflows/coder/shared/backlog.py +652 -0
  110. workhorse_workflows/coder/shared/blueprint.py +13 -0
  111. workhorse_workflows/coder/shared/ci.py +372 -0
  112. workhorse_workflows/coder/shared/commits.py +125 -0
  113. workhorse_workflows/coder/shared/contract.py +50 -0
  114. workhorse_workflows/coder/shared/dev.py +575 -0
  115. workhorse_workflows/coder/shared/docs.py +393 -0
  116. workhorse_workflows/coder/shared/okf.py +119 -0
  117. workhorse_workflows/coder/shared/ostler_qa.py +151 -0
  118. workhorse_workflows/coder/shared/paths.py +235 -0
  119. workhorse_workflows/coder/shared/queue.py +1189 -0
  120. workhorse_workflows/coder/shared/review.py +212 -0
  121. workhorse_workflows/coder/shared/schemas/__init__.py +211 -0
  122. workhorse_workflows/coder/shared/schemas/_base.py +37 -0
  123. workhorse_workflows/coder/shared/schemas/backlog.py +74 -0
  124. workhorse_workflows/coder/shared/schemas/ci.py +85 -0
  125. workhorse_workflows/coder/shared/schemas/dev.py +253 -0
  126. workhorse_workflows/coder/shared/schemas/docs.py +129 -0
  127. workhorse_workflows/coder/shared/schemas/dream.py +66 -0
  128. workhorse_workflows/coder/shared/schemas/genesis.py +133 -0
  129. workhorse_workflows/coder/shared/schemas/okf.py +32 -0
  130. workhorse_workflows/coder/shared/schemas/pr.py +101 -0
  131. workhorse_workflows/coder/shared/schemas/qa.py +475 -0
  132. workhorse_workflows/coder/shared/schemas/queue.py +192 -0
  133. workhorse_workflows/coder/shared/schemas/review.py +107 -0
  134. workhorse_workflows/coder/shared/schemas/story.py +56 -0
  135. workhorse_workflows/coder/shared/story.py +234 -0
  136. workhorse_workflows/coder/shared/story_status.py +164 -0
  137. workhorse_workflows/coder/shared/stubs.py +77 -0
  138. workhorse_workflows/coder/shared/worktree.py +92 -0
  139. workhorse_workflows/coder/workflow.py +1030 -0
  140. workhorse_workflows/hello_world/__init__.py +12 -0
  141. workhorse_workflows/hello_world/prompts/greet.md +12 -0
  142. workhorse_workflows/hello_world/workflow.py +87 -0
  143. workhorse_workflows/kit/__init__.py +140 -0
  144. workhorse_workflows/kit/credentials.py +81 -0
  145. workhorse_workflows/kit/git.py +353 -0
  146. workhorse_workflows/kit/github.py +173 -0
  147. workhorse_workflows/kit/workspace.py +389 -0
  148. workhorse_workflows/okf_builder/__init__.py +19 -0
  149. workhorse_workflows/okf_builder/nodes/__init__.py +37 -0
  150. workhorse_workflows/okf_builder/nodes/coverage.py +391 -0
  151. workhorse_workflows/okf_builder/nodes/prepare.py +149 -0
  152. workhorse_workflows/okf_builder/nodes/waivers.py +112 -0
  153. workhorse_workflows/okf_builder/prompts/enumerate-surfaces.md +84 -0
  154. workhorse_workflows/okf_builder/prompts/investigate.md +225 -0
  155. workhorse_workflows/okf_builder/prompts/recheck-coverage.md +130 -0
  156. workhorse_workflows/okf_builder/prompts/walkthrough-web.md +193 -0
  157. workhorse_workflows/okf_builder/shared/__init__.py +23 -0
  158. workhorse_workflows/okf_builder/shared/blueprint.py +13 -0
  159. workhorse_workflows/okf_builder/shared/checkpoint.py +251 -0
  160. workhorse_workflows/okf_builder/shared/paths.py +117 -0
  161. workhorse_workflows/okf_builder/shared/schemas.py +285 -0
  162. workhorse_workflows/okf_builder/shared/stubs.py +60 -0
  163. workhorse_workflows/okf_builder/shared/worklist.py +184 -0
  164. workhorse_workflows/okf_builder/walkthrough_web/__init__.py +25 -0
  165. workhorse_workflows/okf_builder/walkthrough_web/flow.py +356 -0
  166. workhorse_workflows/okf_builder/walkthrough_web/nodes/__init__.py +35 -0
  167. workhorse_workflows/okf_builder/walkthrough_web/nodes/stack.py +186 -0
  168. workhorse_workflows/okf_builder/walkthrough_web/nodes/walkthrough.py +423 -0
  169. workhorse_workflows/okf_builder/workflow.py +513 -0
  170. workhorse_workflows/research/__init__.py +7 -0
  171. workhorse_workflows/research/nodes/__init__.py +34 -0
  172. workhorse_workflows/research/nodes/_blueprint.py +13 -0
  173. workhorse_workflows/research/nodes/program.py +204 -0
  174. workhorse_workflows/research/nodes/publish.py +55 -0
  175. workhorse_workflows/research/nodes/setup.py +87 -0
  176. workhorse_workflows/research/prompts/define-new-direction.md +70 -0
  177. workhorse_workflows/research/prompts/extend-program.md +57 -0
  178. workhorse_workflows/research/prompts/gate-check.md +45 -0
  179. workhorse_workflows/research/prompts/implement-experiment.md +56 -0
  180. workhorse_workflows/research/prompts/lead-goal-review.md +67 -0
  181. workhorse_workflows/research/prompts/record-result.md +33 -0
  182. workhorse_workflows/research/prompts/research-lead-review.md +77 -0
  183. workhorse_workflows/research/prompts/revive-gate.md +43 -0
  184. workhorse_workflows/research/prompts/rework-experiment.md +45 -0
  185. workhorse_workflows/research/prompts/select-next-gate.md +37 -0
  186. workhorse_workflows/research/scaffold/__init__.py +9 -0
  187. workhorse_workflows/research/scaffold/new_program.py +121 -0
  188. workhorse_workflows/research/scaffold/templates/PROGRESS.md +15 -0
  189. workhorse_workflows/research/scaffold/templates/README.md +93 -0
  190. workhorse_workflows/research/scaffold/templates/gate.md +41 -0
  191. workhorse_workflows/research/schemas.py +284 -0
  192. workhorse_workflows/research/workflow.py +534 -0
  193. workhorse_workflows-0.2.0.dist-info/METADATA +253 -0
  194. workhorse_workflows-0.2.0.dist-info/RECORD +197 -0
  195. workhorse_workflows-0.2.0.dist-info/WHEEL +4 -0
  196. workhorse_workflows-0.2.0.dist-info/entry_points.txt +6 -0
  197. workhorse_workflows-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,16 @@
1
+ """The stablemate workflows, as code.
2
+
3
+ Each workflow is a subpackage — its ``workflow.py``, its ``nodes/``, its ``prompts/``
4
+ — and declares its own console script in ``[project.scripts]``, so it is reached as
5
+ ``workhorse-<name> run``. Nothing resolves a workflow by name: the script hands the
6
+ engine the ``Registry`` object directly. Shared workflow-side helpers live in ``kit/``.
7
+
8
+ Imports point one way: a workflow's ``workflow.py`` imports its ``nodes/`` and
9
+ ``flows/``; nothing under ``nodes/`` imports ``workflow.py``. The dependency on
10
+ workhorse runs the same direction — this package imports the engine, the engine knows
11
+ nothing about what is in here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __all__: list[str] = []
@@ -0,0 +1,15 @@
1
+ """The `author` workflow: turn a backlog into epics and stories the coder can build.
2
+
3
+ Ported from `base-library/workflows/author/`. The package has the layout
4
+ `workflows/README.md` prescribes for every workflow — one directory per *machine*:
5
+
6
+ * `workflow.py` — the main machine, and nothing else
7
+ * `nodes/` — the non-agent work only that machine calls, grouped by subject
8
+ * `surveyor/`, `parity_surveyor/` — the sub-graphs `self.handoff(...)` reaches: each
9
+ `flow.py` beside the `nodes/` only it calls
10
+ * `shared/` — what a second machine also reaches: `paths`, `schemas`, and the `survey`
11
+ library both survey flows walk
12
+ * `prompts/` — every agent turn's Markdown, at the package root because a sub-flow's
13
+ prompt path resolves against the *parent* package directory
14
+ """
15
+ from __future__ import annotations
@@ -0,0 +1,71 @@
1
+ """The non-agent work only the **main** author machine calls, grouped by subject.
2
+
3
+ Importing this package registers every node on the shared `blueprint`, which is the one
4
+ name `workflow.py` needs from here. The submodules are the subjects:
5
+
6
+ * `config` — what the run works on, and the branch it works on it in
7
+ * `epics` — which epic is next
8
+ * `stories` — one story at a time: seed it, pick it, validate it, ground it
9
+ * `coverage` — whether an epic's stories cover it, and the backlog it consumed
10
+ * `artifacts` — the whole-run gates, and the git tail that ships what they passed
11
+
12
+ The sub-graphs' nodes are not here, and they keep a blueprint of their own so a reader can
13
+ see which nodes belong to which machine: what both survey flows call is in
14
+ [`shared/survey/`](../shared/survey), and what one of them calls sits beside that flow, in
15
+ [`surveyor/nodes/`](../surveyor/nodes) and
16
+ [`parity_surveyor/nodes/`](../parity_surveyor/nodes).
17
+
18
+ Ported from `base-library/workflows/author/scripts/`. The same three things change as in
19
+ `research`, and nothing else does: the JSON envelope on stdout becomes a **returned
20
+ model**, the positional `sys.argv` entries become **typed parameters**, and a `sys.exit(1)`
21
+ becomes `raise WorkflowFailed(...)`. Two shapes specific to these scripts go with them:
22
+
23
+ * every `emit(...)` / `done(...)` helper ended in `sys.exit(0)` — an "outputs and stop"
24
+ that only made sense for a subprocess. A node returns its model instead, and the
25
+ *caller* decides whether an unsuccessful result ends the flow;
26
+ * the `[script-name]` log prefixes are gone. The run record already names the state that
27
+ logged the line, so the prefix was the engine's job all along.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ from workhorse_workflows.author.nodes._blueprint import blueprint
32
+ from workhorse_workflows.author.nodes.artifacts import (
33
+ commit_author,
34
+ open_author_pr,
35
+ validate_artifacts,
36
+ verify_integrity,
37
+ verify_reconcile,
38
+ )
39
+ from workhorse_workflows.author.nodes.config import branch_author, load_config
40
+ from workhorse_workflows.author.nodes.coverage import prune_backlog, validate_coverage
41
+ from workhorse_workflows.author.nodes.epics import select_epic
42
+ from workhorse_workflows.author.nodes.stories import (
43
+ check_story_feedback,
44
+ check_story_grounding,
45
+ prune_bullet,
46
+ record_attempt,
47
+ seed_story,
48
+ select_story,
49
+ validate_story,
50
+ )
51
+
52
+ __all__ = [
53
+ "blueprint",
54
+ "branch_author",
55
+ "check_story_feedback",
56
+ "check_story_grounding",
57
+ "commit_author",
58
+ "load_config",
59
+ "open_author_pr",
60
+ "prune_backlog",
61
+ "prune_bullet",
62
+ "record_attempt",
63
+ "seed_story",
64
+ "select_epic",
65
+ "select_story",
66
+ "validate_artifacts",
67
+ "validate_coverage",
68
+ "validate_story",
69
+ "verify_integrity",
70
+ "verify_reconcile",
71
+ ]
@@ -0,0 +1,13 @@
1
+ """The one `Blueprint` every node module in this package decorates against.
2
+
3
+ It is its own module rather than `nodes/__init__.py` so the submodules can import it
4
+ without importing the package that imports them. `nodes/__init__.py` re-exports it, and
5
+ that re-export is the only name `workflow.py` needs.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from workhorse.pyflow import Blueprint
10
+
11
+ blueprint = Blueprint("author")
12
+
13
+ __all__ = ["blueprint"]
@@ -0,0 +1,33 @@
1
+ """What the author's deterministic gates return under `--dry-run`.
2
+
3
+ A dry run replaces every node body with a stand-in, and an undeclared stand-in is a
4
+ **blank** instance of the return model — which for all four validators reads `ok=False`
5
+ and for both verifiers reads `holds=False`. Those are the failing branches, and each one
6
+ routes into the operator-resolution ladder: two autonomous resolutions, then an `Await`.
7
+ An `Await` blocks until a human edits the file it wrote, so a dry run that reaches one
8
+ never returns.
9
+
10
+ So these are declared, and the blueprint's `stub=` is exactly the seam for it (see
11
+ `Blueprint.node`: "Declaring one is how a workflow turns `--dry-run` from 'every branch
12
+ takes an arbitrary path' into a real smoke test of its happy path"). Two things they do
13
+ NOT fix, both recorded as findings in the progress ledger: `--dry-run --params
14
+ operator_mode=human` sends every gate to an `Await` regardless of what a stub says, and
15
+ the driver has no "a dry run never waits" escape to fall back on.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from workhorse_workflows.author.shared.schemas.main import Defects, VerifyReport
20
+
21
+
22
+ def clean(*_args: object, **_kwargs: object) -> Defects:
23
+ """`validate_story`, `check_story_grounding`, `validate_coverage`,
24
+ `validate_artifacts` — nothing wrong with what the run wrote."""
25
+ return Defects(ok=True)
26
+
27
+
28
+ def holds(*_args: object, **_kwargs: object) -> VerifyReport:
29
+ """`verify_reconcile`, `verify_integrity` — the graph reconciles and links up."""
30
+ return VerifyReport(holds=True)
31
+
32
+
33
+ __all__ = ["clean", "holds"]
@@ -0,0 +1,477 @@
1
+ """The whole-run gates, and the git tail that ships what they passed.
2
+
3
+ Ported from `base-library/workflows/author/scripts/{reconcile-artifacts,ostler-doctor,
4
+ validate-artifacts,commit-author,open-author-pr}.py`.
5
+
6
+ Two divergences from the scripts, both recorded rather than absorbed:
7
+
8
+ * `open_author_pr` resolves the GitHub token by calling `kit.github.resolve_github_token`
9
+ directly, where the script spawned `gh-token.py` as a subprocess and read its stdout.
10
+ `gh-token.py` *is* a one-line wrapper over that function, so the resolution order is
11
+ identical — the subprocess was the only way a YAML script could share code with another
12
+ script's process. It joins the deletion list.
13
+ * `open_author_pr` uses `kit.github.find_open_pr` instead of the script's private copy.
14
+ The kit version swallows a `GithubException` while listing, so an API error there now
15
+ reads as "no PR is open" and the failure surfaces one step later, from `create_pull`,
16
+ with its own message. Both paths still fail the node.
17
+
18
+ The two YAML nodes `commit_author` and `commit_incomplete` ran the *same* script with a
19
+ different first argument, so they are one node here, called with `mode="incomplete"` on
20
+ the failure edge. They sit on mutually exclusive paths, so `self.output(commit_author)`
21
+ still names exactly one commit.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from pathlib import Path
27
+
28
+ from ostler import Ostler, markdown
29
+ from workhorse.pyflow import WorkflowFailed
30
+ from workhorse.scriptutil import find_repo_root
31
+ from workhorse_workflows.author.nodes._blueprint import blueprint
32
+ from workhorse_workflows.author.nodes import _stubs
33
+ from workhorse_workflows.author.shared import paths
34
+ from workhorse_workflows.author.shared.paths import launch_repo_root, survey_repo_root
35
+ from workhorse_workflows.author.shared.schemas.main import Committed, Defects, PullRequest, VerifyReport
36
+ from workhorse_workflows.kit import branch_exists, commit_paths, remote_urls, show_file
37
+ from workhorse_workflows.kit import github as github_kit
38
+
39
+ #: Story statuses that mean the coder has nothing left to do with it.
40
+ _DONE_TOKENS = ("qa passed", "passed", "done", "merged", "complete")
41
+
42
+
43
+ # ── reconciliation: what this run silently dropped ──────────────────────────
44
+
45
+
46
+ def _subsection_ids(text: str, heading: str) -> set[str]:
47
+ """The `### <id>` titles directly under the `## <heading>` section of an epic.md.
48
+
49
+ Read off the parsed heading tree, so a `##`-looking line inside a fenced example is not a
50
+ section and the nesting is the parser's rather than a "still inside it" flag.
51
+ """
52
+ ids: set[str] = set()
53
+ for root in markdown.split(text or "").sections:
54
+ for section in root.walk():
55
+ if section.level == 2 and section.title.strip().lower() == heading.lower():
56
+ ids.update(c.title.strip() for c in section.children if c.level == 3)
57
+ return ids
58
+
59
+
60
+ @blueprint.node(stub=_stubs.holds)
61
+ def verify_reconcile(
62
+ logger: logging.Logger,
63
+ epics_dir: str = "",
64
+ ref: str = "HEAD",
65
+ repo_dir: str = "",
66
+ ) -> VerifyReport:
67
+ """Scope this run silently dropped, measured against the last committed epics.
68
+
69
+ `ostler doctor` catches *dangling* references; it does not catch a **clean removal** —
70
+ an IDed entity deleted along with every reference to it. That is the "dropped scope"
71
+ failure: a re-run re-derives an epic and quietly omits a seed item a prior run
72
+ committed, leaving nothing dangling to find. So this compares the parsed `epic.md`
73
+ subsection ids on both sides.
74
+
75
+ **Removals block, additions don't**, and it is fail-open on infrastructure: no git, no
76
+ epics dir, or no epic with a committed baseline is a clean `skipped`, never a block.
77
+ """
78
+ ref = ref.strip() or "HEAD"
79
+ root = launch_repo_root(repo_dir)
80
+ epics_rel = paths.epics_dir(root, epics_dir)
81
+ epics_path = root / epics_rel
82
+
83
+ if show_file(root, ref, epics_rel) is None and not (root / ".git").exists():
84
+ logger.info("not a git repo at %s — reconciliation gate skipped", root)
85
+ return VerifyReport(skipped=True, report="not a git repo — reconciliation gate skipped")
86
+ if not epics_path.is_dir():
87
+ logger.info("no epics dir at %s — skipped", epics_path)
88
+ return VerifyReport(skipped=True, report=f"no epics dir at {epics_rel} — skipped")
89
+
90
+ drops: list[str] = []
91
+ checked = 0
92
+ for epic_md in sorted(epics_path.glob("*/epic.md")):
93
+ epic = epic_md.parent.name
94
+ base = show_file(root, ref, str(epic_md.relative_to(root)))
95
+ if base is None:
96
+ continue # brand-new epic (no committed baseline) — nothing to reconcile
97
+ checked += 1
98
+ now = epic_md.read_text(encoding="utf-8")
99
+
100
+ for sid in sorted(_subsection_ids(base, "Seeds") - _subsection_ids(now, "Seeds")):
101
+ drops.append(
102
+ f" - [dropped-seed] ({epic}) seed item '{sid}' was committed but is "
103
+ f"absent now — confirm it was intentionally dropped (record the reason), "
104
+ f"or restore it"
105
+ )
106
+ for slug in sorted(_subsection_ids(base, "Stories") - _subsection_ids(now, "Stories")):
107
+ drops.append(
108
+ f" - [dropped-story] ({epic}) story '{slug}' was committed but is "
109
+ f"absent now — confirm intentional, or restore it"
110
+ )
111
+
112
+ if checked == 0:
113
+ logger.info("no epics with a committed baseline at %s — skipped", ref)
114
+ return VerifyReport(
115
+ skipped=True, report=f"no epics with a committed baseline at {ref} — skipped"
116
+ )
117
+
118
+ summary = f"reconcile vs {ref}: {checked} epic(s) checked, {len(drops)} silent drop(s)"
119
+ logger.info(summary)
120
+ if not drops:
121
+ return VerifyReport(holds=True, report=summary)
122
+
123
+ lines = [
124
+ "This run silently removed planning entities that a prior run committed.",
125
+ "Each is a scope drop with no dangling reference left for `ostler doctor` to catch.",
126
+ "Confirm each was intentional (record the disposition/reason) or restore it — a silent",
127
+ "removal of prior scope is a regression, not a clean re-derivation.",
128
+ "",
129
+ *drops,
130
+ ]
131
+ return VerifyReport(errors="\n".join(lines), report=summary)
132
+
133
+
134
+ # ── referential integrity of the whole graph ────────────────────────────────
135
+
136
+
137
+ @blueprint.node(stub=_stubs.holds)
138
+ def verify_integrity(logger: logging.Logger, epic: str = "", repo_dir: str = "") -> VerifyReport:
139
+ """`ostler doctor` over the whole graph, as a blocking gate.
140
+
141
+ The per-epic coverage validator proves seeds map to stories *within* an epic; story
142
+ grounding proves one story rests on real seeds. Neither catches the cross-run drift
143
+ here: a story referencing another epic's seed, a reference resolving to nothing.
144
+ ostler *computes* those facts; this turns its error-level findings into a gate.
145
+
146
+ **Errors block, warnings don't**, and an unloadable graph is a `skipped` — the same
147
+ opt-in-by-presence stance the other author gates take. `epic` blank means the whole
148
+ graph, which is what the final gate wants.
149
+ """
150
+ okf = Ostler(launch_repo_root(repo_dir))
151
+
152
+ try:
153
+ report = okf.doctor(epic=epic.strip() or None)
154
+ except (OSError, ValueError, RuntimeError) as exc:
155
+ logger.warning("ostler doctor could not run (%s) — skipped", exc)
156
+ return VerifyReport(skipped=True, report=f"ostler doctor could not run ({exc}) — skipped")
157
+
158
+ findings = report.get("findings", [])
159
+ errors = [f for f in findings if f.get("severity") == "error"]
160
+ warns = [f for f in findings if f.get("severity") == "warn"]
161
+ summary = (
162
+ f"ostler doctor [{report.get('org', '?')}/{report.get('profile', '?')}]: "
163
+ f"{len(errors)} error(s), {len(warns)} warning(s)"
164
+ )
165
+ logger.info(summary)
166
+
167
+ if not errors:
168
+ return VerifyReport(holds=True, report=summary)
169
+
170
+ lines = [
171
+ "ostler doctor found referential-integrity errors in the planning-doc graph.",
172
+ "Each is a graph break (a reference that resolves to nothing, or to the wrong epic).",
173
+ "Reconcile each with `ostler edit` (set-owner / relink / rename) or escalate — never",
174
+ "delete a reference or fabricate an entity to silence the check.",
175
+ "",
176
+ ]
177
+ for f in errors:
178
+ scope = f.get("epic") or f.get("ref") or ""
179
+ scope = f" ({scope})" if scope else ""
180
+ lines.append(f" - [{f.get('code', '?')}]{scope} {f.get('message', '')}")
181
+ return VerifyReport(errors="\n".join(lines), report=summary)
182
+
183
+
184
+ # ── the last gate before the run may report success ─────────────────────────
185
+
186
+
187
+ def _is_done(status: str) -> bool:
188
+ s = (status or "").strip().lower()
189
+ return any(tok in s for tok in _DONE_TOKENS)
190
+
191
+
192
+ @blueprint.node(stub=_stubs.clean)
193
+ def validate_artifacts(logger: logging.Logger, repo_dir: str = "") -> Defects:
194
+ """Can the coder engine actually walk what this run produced?
195
+
196
+ A valid epics queue, every queued epic loadable with at least one story, every story
197
+ **authored** by ostler's verdict, and at least one story still selectable — otherwise
198
+ the coder has nothing to run.
199
+
200
+ This gate used to accept a `story.md` that merely existed and carried a
201
+ `- **Status**:` line, both of which `ostler create story` writes into the scaffold. A
202
+ queue of 44 empty stubs passed here, the run reported success and opened a PR. An
203
+ unauthored story is now an error that names the epic, the slug and the empty sections,
204
+ and it does not count toward `selectable`.
205
+
206
+ The YAML passed `epics_dir` here for the node's contract and the script never read it:
207
+ ostler discovers the graph's doc roots itself. This takes no arguments.
208
+ """
209
+ okf = Ostler(survey_repo_root(repo_dir))
210
+
211
+ try:
212
+ queue = okf.todo()
213
+ except (OSError, ValueError, RuntimeError):
214
+ reason = "could not read the epics index via ostler's in-process API"
215
+ logger.warning(reason)
216
+ return Defects(errors=reason)
217
+ if not queue:
218
+ logger.info("the epics index lists no epics")
219
+ return Defects(errors="the epics index lists no epics")
220
+
221
+ by_epic: dict[str, list[dict]] = {}
222
+ for s in okf.list("story"):
223
+ by_epic.setdefault(str(s.get("epic", "")), []).append(s)
224
+
225
+ # An epic ostler could not load has no epic.md — the graph's own answer, so this node
226
+ # never stats a file and never gets a second opinion about what a document must contain.
227
+ loadable = {e.name for e in okf.graph.epics}
228
+
229
+ errors: list[str] = []
230
+ selectable = 0
231
+ for raw_epic in queue:
232
+ epic = str(raw_epic)
233
+ if epic not in loadable:
234
+ errors.append(f"epic '{epic}': epic.md missing (ostler cannot load the epic)")
235
+ stories = by_epic.get(epic, [])
236
+ if not stories:
237
+ errors.append(f"epic '{epic}': lists no stories in `## Stories`")
238
+ continue
239
+ for s in stories:
240
+ slug = s.get("slug", "?")
241
+ path = s.get("path", "")
242
+ if not s.get("hasStoryMd"):
243
+ errors.append(
244
+ f"epic '{epic}' story '{slug}': story.md missing at {path or '<no path>'}"
245
+ )
246
+ elif not s.get("authored"):
247
+ empty = ", ".join(s.get("unwrittenSections") or []) or "its required sections"
248
+ errors.append(
249
+ f"epic '{epic}' story '{slug}': story.md is still a bare scaffold "
250
+ f"({empty} empty) at {path}"
251
+ )
252
+ elif not _is_done(str(s.get("status", ""))):
253
+ selectable += 1
254
+
255
+ if selectable == 0 and not errors:
256
+ errors.append("no selectable story (coder would have nothing to run)")
257
+
258
+ logger.info(
259
+ "artifacts validation: %d error(s), %d selectable stor(y/ies)", len(errors), selectable
260
+ )
261
+ return Defects(ok=not errors, errors="\n".join(errors))
262
+
263
+
264
+ # ── git: keep the work, and ship it ─────────────────────────────────────────
265
+
266
+
267
+ def _commit_message(mode: str, epic: str, bullet: str) -> str:
268
+ if mode == "incomplete":
269
+ # The failure edge of the final gate. The partial prose is worth keeping — it is what
270
+ # a rerun resumes from — but it must never look like a finished run, and the workflow
271
+ # ends red right after this so no PR is opened on it. The message is the marker a
272
+ # human (or a `git log` skim) needs to spot the branch as unfinished.
273
+ if epic:
274
+ return f"author: INCOMPLETE — unwritten stories, do not merge ({epic})"
275
+ return "author: INCOMPLETE — unwritten stories, do not merge"
276
+ if mode == "survey":
277
+ return "author: survey intake and epic backlog authoring"
278
+ if mode == "story" and epic:
279
+ trimmed = bullet.strip().splitlines()[0][:72] if bullet.strip() else ""
280
+ return f"author: {epic} — {trimmed}" if trimmed else f"author: {epic}"
281
+ return "author: epic backlog authoring"
282
+
283
+
284
+ @blueprint.node
285
+ def commit_author(
286
+ logger: logging.Logger,
287
+ mode: str = "epic",
288
+ epic: str = "",
289
+ bullet: str = "",
290
+ repo_dir: str = "",
291
+ docs_dir: str = "docs",
292
+ id_registry: str = ".agents/ids.json",
293
+ ) -> Committed:
294
+ """Commit the epic/story docs this run wrote, in the one repo it wrote them in.
295
+
296
+ Author only ever writes into the docs repo running the workflow — unlike coder there
297
+ is no affected-repos resolution, so this always commits at the repo root.
298
+ `mode="incomplete"` is the failure edge and only changes the message.
299
+
300
+ **Scoped to what this workflow writes, not to the whole tree.** The repo author runs
301
+ in is routinely a checkout somebody is *also* working in — `repo_dir` defaults to the
302
+ directory the run was launched from, so it points straight at their working tree. A
303
+ `git add -A` here commits their in-flight edits under an `author:` subject, which is
304
+ how a run ends up owning changes it never made.
305
+
306
+ Two scopes, because the run writes in two places: `docs_dir` for the prose, and
307
+ `id_registry` for ostler's id ledger, which lives outside the docs tree and *must*
308
+ travel with the documents it numbers — an id minted but left uncommitted is reminted
309
+ for a different entity by the next run. A scope that does not exist is dropped rather
310
+ than passed to git, which would fail the whole commit on an unmatched pathspec.
311
+ """
312
+ repo_root = find_repo_root(repo_dir)
313
+ if not (repo_root / ".git").exists():
314
+ logger.info("no .git at %s — nothing to commit", repo_root)
315
+ return Committed()
316
+
317
+ scope = [
318
+ rel for rel in (docs_dir.strip(), id_registry.strip()) if rel and (repo_root / rel).exists()
319
+ ]
320
+ if not scope:
321
+ logger.info("nothing author writes exists under %s — nothing to commit", repo_root)
322
+ return Committed()
323
+
324
+ committed = commit_paths(repo_root, _commit_message(mode, epic, bullet), *scope)
325
+ if committed:
326
+ logger.info("committed %s in %s", " ".join(scope), repo_root)
327
+ return Committed(committed=committed)
328
+
329
+
330
+ def _pr_title(mode: str, epic: str, bullet: str) -> str:
331
+ if mode == "survey":
332
+ return "Author: survey intake and epic/story backlog authoring"
333
+ if mode == "story" and epic:
334
+ trimmed = bullet.strip().splitlines()[0][:72] if bullet.strip() else ""
335
+ return f"Author: {epic} — {trimmed}" if trimmed else f"Author: {epic}"
336
+ return "Author: epic/story backlog authoring"
337
+
338
+
339
+ def _pr_body(mode: str) -> str:
340
+ label = "survey mode" if mode == "survey" else "story mode" if mode == "story" else "epic mode"
341
+ return "\n".join(
342
+ [
343
+ "## Summary",
344
+ "",
345
+ f"Automated epic/story docs authored by the `author` workflow ({label}).",
346
+ "",
347
+ "---",
348
+ "*Automated author PR. Left open for review — not auto-merged.*",
349
+ ]
350
+ )
351
+
352
+
353
+ def _github_slug(url: str) -> str:
354
+ """`owner/repo` for a supported GitHub remote URL, or `""`."""
355
+ for prefix in ("git@github.com:", "ssh://git@github.com/", "https://github.com/"):
356
+ if url.startswith(prefix):
357
+ return url.removeprefix(prefix).removesuffix(".git")
358
+ return ""
359
+
360
+
361
+ def _resolve_github_slug(repo_path: Path) -> str:
362
+ """Resolve GitHub even when this checkout was cloned from a local bind mount.
363
+
364
+ Container runs clone the host working tree from paths such as `/mnt/repo-src`. In that
365
+ case the clone's origin is local, but the mounted source repository still carries the
366
+ real GitHub origin.
367
+ """
368
+ origins = remote_urls(repo_path)
369
+ for url in origins:
370
+ slug = _github_slug(url)
371
+ if slug:
372
+ return slug
373
+
374
+ for url in origins:
375
+ source = Path(url.removeprefix("file://") if url.startswith("file://") else url)
376
+ if not source.is_absolute():
377
+ source = (repo_path / source).resolve()
378
+ if not source.exists():
379
+ continue
380
+ for source_origin in remote_urls(source):
381
+ slug = _github_slug(source_origin)
382
+ if slug:
383
+ return slug
384
+ return ""
385
+
386
+
387
+ def _base_branch(repo_path: Path, declared: str, fallback: str = "main") -> str:
388
+ if declared:
389
+ return declared
390
+ for candidate in ("develop", "main", "master"):
391
+ if branch_exists(repo_path, candidate):
392
+ return candidate
393
+ return fallback
394
+
395
+
396
+ def _skipped(logger: logging.Logger, reason: str) -> PullRequest:
397
+ """PR delivery is not configured — a supported state, not a failure."""
398
+ logger.info("PR delivery not configured (%s) — skipping", reason)
399
+ return PullRequest(author_pr="skipped", pr_skip_reason=reason)
400
+
401
+
402
+ @blueprint.node
403
+ def open_author_pr(
404
+ logger: logging.Logger,
405
+ base_branch: str = "main",
406
+ author_branch: str = "",
407
+ mode: str = "epic",
408
+ epic: str = "",
409
+ bullet: str = "",
410
+ repo_dir: str = "",
411
+ ) -> PullRequest:
412
+ """Push the run's branch and open one PR in the docs repo.
413
+
414
+ Two outcomes are deliberately not conflated. **PR delivery is not configured** — no
415
+ `.git`, no token, or an `origin` that is not a github.com repository — is a legitimate
416
+ configuration (a `git init` with no remote, the greenfield case), so it is a `skipped`
417
+ and the run still passes. **PR delivery was attempted and failed** — a push error, an
418
+ unreachable repository, a create error — still fails the run: wherever a remote *is*
419
+ configured, "a PR is required" remains true.
420
+ """
421
+ if not author_branch:
422
+ raise WorkflowFailed("no author branch was provided")
423
+
424
+ repo_root = find_repo_root(repo_dir)
425
+ if not (repo_root / ".git").exists():
426
+ return _skipped(logger, f"no .git at {repo_root}")
427
+
428
+ token = github_kit.resolve_github_token(repo_root)
429
+ if not token:
430
+ return _skipped(logger, "no GitHub token is configured")
431
+
432
+ if not branch_exists(repo_root, author_branch):
433
+ raise WorkflowFailed(f"no branch {author_branch} in {repo_root}")
434
+
435
+ slug = _resolve_github_slug(repo_root)
436
+ if not slug:
437
+ # Not configured, not broken: a local-only repo has no forge to deliver to.
438
+ origins = ", ".join(remote_urls(repo_root)) or "<no remote>"
439
+ return _skipped(logger, f"origin does not resolve to a github.com repository: {origins}")
440
+
441
+ # push_branch targets the resolved slug explicitly (the origin may be a local
442
+ # bind-mount path in container runs, so we can't let it re-derive from origin).
443
+ if not github_kit.push_branch(repo_root, token, author_branch, slug=slug, verify=False):
444
+ raise WorkflowFailed(f"push failed for {author_branch}")
445
+
446
+ try:
447
+ gh_repo = github_kit.github_client(token).get_repo(slug)
448
+ except Exception as exc:
449
+ raise WorkflowFailed(f"cannot access github.com repository {slug}: {exc}") from exc
450
+
451
+ existing = github_kit.find_open_pr(gh_repo, author_branch)
452
+ if existing is not None:
453
+ logger.info("PR already open for %s", author_branch)
454
+ return PullRequest(author_pr="exists", pr_url=existing.html_url)
455
+
456
+ base = _base_branch(repo_root, base_branch)
457
+ try:
458
+ pr = gh_repo.create_pull(
459
+ base=base,
460
+ head=author_branch,
461
+ title=_pr_title(mode, epic, bullet),
462
+ body=_pr_body(mode),
463
+ )
464
+ except Exception as exc:
465
+ raise WorkflowFailed(f"PR create failed for {author_branch}: {exc}") from exc
466
+
467
+ logger.info("opened PR for %s -> %s", author_branch, base)
468
+ return PullRequest(author_pr="opened", pr_url=pr.html_url)
469
+
470
+
471
+ __all__ = [
472
+ "commit_author",
473
+ "open_author_pr",
474
+ "validate_artifacts",
475
+ "verify_integrity",
476
+ "verify_reconcile",
477
+ ]