@tianhai/pi-workflow-kit 2.8.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fail when the working tree holds changes outside the plan folder.
4
+ *
5
+ * The reviewed diff is a commit range, so anything uncommitted outside docs/plans/ is
6
+ * invisible to the review and can ship unreviewed. The plan folder is exempt: the design,
7
+ * the progress file, and the generated packet are legitimately dirty while a step is in
8
+ * flight. A root that cannot be verified is a failure, never a silent pass.
9
+ *
10
+ * Usage: node stage-check.mjs [root]
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { resolve } from "node:path";
14
+
15
+ const ALLOWED = "docs/plans/";
16
+ const root = resolve(process.argv[2] ?? process.cwd());
17
+
18
+ const probe = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, encoding: "utf8" });
19
+ if (probe.status !== 0 || probe.stdout.trim() !== "true") {
20
+ console.error(`stage-check.mjs: ${root} is not a git worktree — nothing can be verified`);
21
+ process.exit(1);
22
+ }
23
+
24
+ // -uall: git otherwise collapses an untracked directory to "dir/", and the point of the
25
+ // failure is to name the file that would miss the reviewed diff.
26
+ const status = spawnSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root, encoding: "utf8" });
27
+ if (status.status !== 0) {
28
+ console.error(`stage-check.mjs: git status failed in ${root}: ${status.stderr.trim()}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const unquote = (path) => path.replace(/^"(.*)"$/, "$1");
33
+ const offenders = [];
34
+ for (const line of status.stdout.split("\n")) {
35
+ if (!line.trim()) continue;
36
+ const entry = unquote(line.slice(3).trim());
37
+ const path = entry.includes(" -> ") ? entry.split(" -> ").at(-1).trim() : entry;
38
+ if (!path.startsWith(ALLOWED)) offenders.push(path);
39
+ }
40
+
41
+ if (offenders.length) {
42
+ for (const path of offenders) console.error(`stage-check.mjs: uncommitted outside ${ALLOWED} — ${path}`);
43
+ console.error(
44
+ `stage-check.mjs: ${offenders.length} path(s) would be missing from the reviewed diff; commit or remove them first`,
45
+ );
46
+ process.exit(1);
47
+ }
48
+ console.log(`stage-check.mjs: nothing uncommitted outside ${ALLOWED}`);
@@ -12,7 +12,7 @@ Any command or tool recipe in this skill is an example, not the requirement —
12
12
  ## Pre-finalization checks
13
13
 
14
14
  1. **Verify the repo root** — run `pwd` (or your shell's equivalent) and `git rev-parse --show-toplevel`; mismatch → report both paths and stop; never `cd` (a worktree root counts).
15
- 2. **Run the FULL test suite** — every test must pass, and only a green suite ships. Resume spans sessions; re-run the suite yourself rather than trust the previous session's ending state. Anything failing → send the user back to `/skill:pwk-executing-tasks`.
15
+ 2. **Run the FULL test suite** — only a green suite ships; re-run it yourself rather than trusting the previous session's ending state. Anything failing → send the user back to `/skill:pwk-executing-tasks`.
16
16
  3. Read **every** relevant progress file — for a multi-leaf topic that's each leaf's progress file — discovered by listing `docs/plans` recursively, excluding docs/plans/completed/, for `*-progress.md` (topic docs live in `docs/plans/<date>-<topic>/` folders — archived work is not in flight). Use whatever recurses in your harness; one example: `find docs/plans -name '<suffix>' -not -path '*/completed/*'`. With a sandboxed batch executor (e.g. `codemode` on pi ≥ 1.0.0) the listing and every topic’s `Feature phase:` extraction are one call, returning only the summary lines (adapt the paths to the verified root when your cwd differs):
17
17
 
18
18
  ```javascript
@@ -24,29 +24,27 @@ Any command or tool recipe in this skill is an example, not the requirement —
24
24
  return text(files.map((d, i) => d + ' — ' + heads[i]).join('\n') || 'no in-flight topics');
25
25
  ```
26
26
 
27
- More than one topic in flight? Ask which one this finalize ships — finalizing touches only its own topic, and another topic still being designed or executed never blocks this run. Then match, in the chosen topic's file(s), the `Feature phase:` line (e.g. `grep -m1 '^Feature phase:' <file>`) and read only the matching ❌/⏭ verdict rows in the Requirements table (e.g. `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep '^| [0-9].*❌'` / `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep '^| [0-9].*⏭'` — scoped to Requirements-table rows because the Execution summary rows share the same row shape, with reasons); the sections after the tables carry nothing the gate needs. Apply the gates below to the chosen topic's file(s) and no others:
28
- - **Any `❌ failed`** (in any leaf of the topic) → **block**. Present counts and reasons; continue only when the user sends the task back to executing-tasks, or explicitly types `--force-failed` to acknowledge shipping with incomplete requirements.
27
+ More than one topic in flight? Ask which one this finalize ships — finalizing touches only its own topic, and another topic being designed or executed never blocks this run. Then match the chosen topic's `Feature phase:` line (e.g. `grep -m1 '^Feature phase:' <file>`) and read only its ❌/⏭ verdict rows in the Requirements table (e.g. `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep '^| [0-9].*❌'` / `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep '^| [0-9].*⏭'` — scoped to Requirements-table rows because the Execution summary rows share the same row shape); the sections after the tables carry nothing the gate needs. Apply the gates below to the chosen topic's file(s) and no others:
28
+ - **Any `❌ failed`** (in any leaf of the topic) → **block**. Present counts and reasons; continue only when the user sends the task back to executing-tasks, or explicitly types `--force-failed`.
29
29
  - **Only `⏭ skipped`** → warn and confirm ("Requirement N was skipped. Continue, or go back?").
30
- - **`Feature phase` must be `done`** in every progress file of the topic — any other value (`e2e-written`, `implementing (k/N)`, `reviewing`, `ship-paused`) means that topic is still in flight: its ship checkpoint has not been approved. Send the user back to `/skill:pwk-executing-tasks` instead of finalizing.
31
- 4. **Cross-check the roster — fail closed on unstarted work.** Find the topic folder locally: the progress file's `Design:` ref names the design doc, and that directory is the topic folder. An `overview.md` **beside the design doc** (same folder — never a repo-wide `docs/plans/**/overview.md`, or a sibling topic's overview would route this finalize) means a multi-leaf topic. Read that overview's `## Parts (build order)` roster and require one `<leaf>-progress.md` per leaf. A roster leaf with no progress file is **unstarted** — **block**, naming the leaf, and send the user back to `/skill:pwk-executing-tasks` to build it; never dispose an unstarted design unread. (A leaf with a progress file that is not `done` is already stopped by the gate above.)
30
+ - **`Feature phase` must be `done`** in every progress file of the topic — any other value (`e2e-written`, `implementing (k/N)`, `reviewing`, `ship-paused`) means its ship checkpoint has not been approved: send the user back to `/skill:pwk-executing-tasks` instead of finalizing.
31
+ 4. **Cross-check the roster — fail closed on unstarted work.** The progress file's `Design:` ref names the design doc, and that directory is the topic folder. An `overview.md` **beside the design doc** (same folder — never a repo-wide `docs/plans/**/overview.md`) means a multi-leaf topic: read that overview's `## Parts (build order)` roster and require one `<leaf>-progress.md` per leaf. A roster leaf with no progress file is **unstarted** — **block**, naming the leaf, and send the user back to `/skill:pwk-executing-tasks` to build it; never dispose an unstarted design unread. (A leaf whose progress file is not `done` is already stopped by the gate above.)
32
32
 
33
33
  ## Process
34
34
 
35
35
  1. **Derive the topic set** —
36
- - **Multi-leaf topic** (an `overview.md` sits beside the design doc, inside the same `docs/plans/<date>-<topic>/` folder — never a repo-wide `docs/plans/**/overview.md`, and always excluding docs/plans/completed/, so an archived or sibling topic never routes): read its roster; the set is the topic folder itself, disposed as one unit.
36
+ - **Multi-leaf topic** (an `overview.md` beside the design doc, inside the same `docs/plans/<date>-<topic>/` folder — never a repo-wide `docs/plans/**/overview.md`, and always excluding docs/plans/completed/, so an archived or sibling topic never routes): the set is the topic folder itself, disposed as one unit.
37
37
  - **Single-leaf topic**: progress file → `Design:` ref → that design doc's own folder → `<topic>`, which **is the folder slug** — the topic directory's name, and the same value `pwk-executing-tasks` uses for the branch and worktree (never a leaf filename). One folder.
38
38
 
39
39
  Ambiguous with several designs in flight? Ask.
40
- 2. **Run the learning sweep — before any disposal command.** The planning docs are about to be destroyed; extract the durable knowledge first, while its container still exists. Read each design doc's At-a-glance key-decision bullets **and its `Approaches considered` section** (the full forks with their reasoning — every leaf's, for an umbrella), each progress file's `Deviated?` entries (including any deviation decision-records), and each Code digest's `[ALERT]` entries. (A trivial doc has no `## At a glance` and no `Approaches considered` — a trivial slice's sweep is expected to come up honest-empty.) Then:
41
- - **Offer an ADR** (in `docs/adr/`) for each item that passes the three gates — **hard to reverse**, **surprising without context**, **a real trade-off** — informed by how the decision actually played out during execution, not just how it looked at design time.
40
+ 2. **Run the learning sweep — before any disposal command.** The planning docs are about to be destroyed, so extract the durable knowledge first, while its container still exists: each design doc's At-a-glance key-decision bullets **and its `Approaches considered` section** (every leaf's, for an umbrella), each progress file's `Deviated?` entries, and each Code digest's `[ALERT]` entries. (A trivial doc has no `## At a glance` and no `Approaches considered` — expect an honest-empty sweep.) Then:
41
+ - **Offer an ADR** (in `docs/adr/`) for each item that passes the three gates — **hard to reverse**, **surprising without context**, **a real trade-off**.
42
42
  - **Append generic rules** to `docs/lessons.md` (strip domain specifics).
43
- - Both outputs are **honest-empty**: most features qualify for nothing — say so and move on; never manufacture. When an item passes the gates but the recorded material is too thin to draft a credible ADR, **ask the human rather than fabricating** context — the human was there.
44
- - The sweep is file-based by necessity: executing and finalizing usually run in fresh sessions with no memory of the brainstorm conversation — everything the sweep needs must already be on disk.
45
- 3. **Dispose of consumed plan docs — ask archive or delete** — for **every topic** in the set, dispose its topic folder `docs/plans/<date>-<topic>/` **as one unit**: every leaf's `-design.md`, `-progress.md`, and `-review-packet*.md`, plus `overview.md` for an umbrella. The digest sections (`## At a glance`, `## Execution summary`) live inside their host docs and ride the folder — no separate disposal. The folder path is taken verbatim from discovery, and the `????-??-??-` dated shape is the topic's identity — a bare `*<topic>*` pattern would over-match unrelated topics. Present both options and let the human choose:
43
+ - Both outputs are **honest-empty**: most features qualify for nothing — say so and move on; never manufacture. When an item passes the gates but the recorded material is too thin for a credible ADR, **ask the human rather than fabricating**.
44
+ - The sweep is file-based by necessity: executing and finalizing usually run in fresh sessions with no memory of the brainstorm, so everything the sweep needs must already be on disk.
45
+ 3. **Dispose of consumed plan docs — ask archive or delete** — for **every topic** in the set, dispose its topic folder `docs/plans/<date>-<topic>/` **as one unit**: every leaf's `-design.md`, `-progress.md`, and `-review-packet*.md`, plus `overview.md` for an umbrella. The digest sections and the Evidence cells live inside their host docs and ride the folder. The folder path is taken verbatim from discovery, and the `????-??-??-` dated shape is the topic's identity — a bare `*<topic>*` pattern would over-match unrelated topics. Present both options and let the human choose:
46
46
 
47
- - **Delete (default)** — code + tests are the source of truth; removing the scaffold prevents stale plan docs from misleading future sessions:
48
-
49
- The invariant: the topic folder `docs/plans/<date>-<topic>/` is the disposal unit — delete it entirely, nothing more and nothing less; any single operation achieving exactly this is correct, and the bash below is one canonical example.
47
+ - **Delete (default)** — code + tests are the source of truth; removing the scaffold prevents stale plan docs from misleading future sessions. The invariant: the topic folder is the disposal unit — delete it entirely, nothing more and nothing less; any single operation achieving exactly this is correct, and the bash below is one canonical example.
50
48
 
51
49
  ```bash
52
50
  # the topic folder goes as one unit (every leaf, plus overview.md if it has one).
@@ -58,9 +56,7 @@ Any command or tool recipe in this skill is an example, not the requirement —
58
56
  # uncommitted drafts never ride the disposal commit.
59
57
  ```
60
58
 
61
- - **Archive** — keep the planning history for future readers (e.g. a complex design worth preserving) by moving the artifacts into `docs/plans/completed/`:
62
-
63
- The invariant: the topic folder `docs/plans/<date>-<topic>/` moves whole into `docs/plans/completed/` and the destination is verified before committing; any single operation achieving exactly this is correct, and the bash below is one canonical example.
59
+ - **Archive** — keep the planning history for future readers by moving the artifacts into `docs/plans/completed/`. The invariant: the topic folder moves whole into `docs/plans/completed/` and the destination is verified before committing; any single operation achieving exactly this is correct, and the bash below is one canonical example.
64
60
 
65
61
  ```bash
66
62
  mkdir -p docs/plans/completed
@@ -75,23 +71,17 @@ Any command or tool recipe in this skill is an example, not the requirement —
75
71
  git add -A docs/plans/<date>-<topic>/ docs/plans/completed/ && git commit -m "chore: archive planning docs for <topic>"
76
72
  ```
77
73
 
78
- Verify with `ls docs/plans/` before and after. Both paths commit the disposal so the shipped branch is clean. Neither path touches `docs/adr/`, `docs/lessons.md`, `CHANGELOG.md`, or `README.md` — those are permanent.
79
- 4. **Curate lessons (Agile Scrum Master hat)** — if `docs/lessons.md` exists: add missed lessons, generalize domain-specific rules into generic patterns, de-duplicate, categorize, retire stale rules. None exists but lessons were learned? Create it. (The learning sweep above feeds this; curation then shapes the whole file.)
74
+ Verify with `ls docs/plans/` before and after. Both paths commit the disposal so the shipped branch is clean. Neither touches `docs/adr/`, `docs/lessons.md`, `CHANGELOG.md`, or `README.md` — those are permanent.
75
+ 4. **Curate lessons (Agile Scrum Master hat)** — if `docs/lessons.md` exists: add missed lessons, generalize domain-specific rules, de-duplicate, categorize, retire stale rules. None exists but lessons were learned? Create it. (The learning sweep feeds this; curation then shapes the whole file.)
80
76
  5. **Update documentation** — if the API or surface changed: `README.md`, `CHANGELOG.md`, any inline docs. Bump the package version (major for breaking changes).
81
- 6. **Choose a merge strategy** — ask the human:
77
+ 6. **Choose a merge strategy** — ask the human: 1. **Create PR** (`git push origin <branch>` then `gh pr create`). 2. **Rebase & merge** *(recommended)* — rebase onto parent, `--ff-only` merge, push parent, delete branch. 3. **Squash & merge**. 4. **Merge commit** (`--no-ff`).
82
78
 
83
- 1. **Create PR** — `git push origin <branch>` then `gh pr create`.
84
- 2. **Rebase & merge** *(recommended)* — rebase onto parent, `--ff-only` merge, push parent, delete branch.
85
- 3. **Squash & merge** — squash onto parent, push, delete branch.
86
- 4. **Merge commit** — `--no-ff` merge, push parent, delete branch.
87
-
88
- For 2–4, confirm the detected parent branch before proceeding. When a worktree was used, the parent-side commands (merge onto parent, push parent, branch deletion) run from the main working tree — the topic worktree holds the feature-branch checkout, so the parent cannot be checked out there (`git -C ../<repo> …` drives the main side without moving the session; the root check's never-cd rule still holds) — and step 7's worktree removal comes **first**: a branch checked out in a worktree cannot be deleted.
89
- 7. **Clean up** — if a worktree was used, verify presence first (`git worktree list`): when the topic's worktree exists, remove it (`git worktree remove ../<repo>-<topic>`, where `<topic>` is the folder slug — the same one `pwk-executing-tasks` created the branch under, never a leaf slug). The `../` path is written from the main working tree, and the removal runs from there — `git -C ../<repo> worktree remove ../<repo>-<topic>` when the session stands inside the worktree, which git refuses to remove under itself. When absent, cleanup is a silent no-op — never a failure.
79
+ For 2–4, confirm the detected parent branch first. When a worktree was used, the parent-side commands (merge onto parent, push parent, branch deletion) run from the main working tree — the worktree holds the feature branch, so the parent cannot be checked out there (`git -C ../<repo> …`; the never-cd rule still holds) — and step 7's worktree removal comes **first**: a branch checked out in a worktree cannot be deleted.
80
+ 7. **Clean up** — if a worktree was used, check `git worktree list` first: when the topic's worktree exists, remove it (`git worktree remove ../<repo>-<topic>`, `<topic>` being the folder slug, never a leaf slug) from the main working tree — `git -C ../<repo> worktree remove ../<repo>-<topic>` when the session stands inside the worktree, which git refuses to remove under itself. Absent → a silent no-op, never a failure.
90
81
 
91
82
  ## Principles
92
83
 
93
84
  - The learning sweep runs before disposal and touches only `docs/adr/` and `docs/lessons.md` — the two permanent stores.
94
-
95
- - Dispose of the active work's artifacts only (archive or delete, the human's choice) — the unit is the topic folder `docs/plans/<date>-<topic>/`, one folder whether the topic has a single leaf or an umbrella's many. Unrelated topics stay in `docs/plans/`.
96
- - ADRs are permanent institutional memory — they stay out of archive/rotation forever.
97
- - Bump the package version if this is a published change (major for breaking changes).
85
+ - Dispose of the active work's artifacts only, the human's choice — the unit is the topic folder `docs/plans/<date>-<topic>/`, one folder whether a single leaf or an umbrella's many. Unrelated topics stay in `docs/plans/`.
86
+ - ADRs are permanent institutional memory — never archived or rotated.
87
+ - Bump the package version if this is a published change (major for breaking changes).
@@ -5,7 +5,7 @@ description: "Show all active pipeline topics and their phase/progress. Use when
5
5
 
6
6
  # Status
7
7
 
8
- Report on in-flight pipelines in this working tree (a worktree has its own `docs/plans/`, so run status in each). Read-only. This skill **does not unlock the guard** — it needs no writes, so it runs fine inside the design read-only phase. If you want source edits after a status check, invoke the skill for the next phase yourself (the guard follows the skill).
8
+ Report on in-flight pipelines in this working tree (a worktree has its own `docs/plans/`, so run status in each). Read-only: this skill **does not unlock the guard**, so it runs fine inside the design read-only phase — invoke the next phase's skill yourself if you want source edits (the guard follows the skill).
9
9
 
10
10
  Any command or tool recipe in this skill is an example, not the requirement — when your host grants a better tool for an operation (a sandboxed batch executor — e.g. `codemode` on pi ≥ 1.0.0 — a structured file API), use the better tool and treat the recipe as a fallback; the only exact exceptions are this kit's `scripts/` helpers and plain `git` invocations, whose deterministic output is the point.
11
11
 
@@ -24,15 +24,15 @@ Any command or tool recipe in this skill is an example, not the requirement —
24
24
  ```
25
25
 
26
26
  2. **State per topic/part — map the header.** The body (execution summary, review reports, code digest) carries nothing status needs, and neither do design docs. A header reading `Setup: pending` renders the topic as **`awaiting setup`** — the setup checkpoint was never approved (the slot is only ever written when the design doc has a `## Setup` section, so the header alone decides — no design-doc read) — ahead of any phase-derived state below. Map the line to the displayed state:
27
- - `done` → **`done`** — terminal, never shown as in-flight; every row is resolved (`✅` passed, `❌` failed, `⏭` skipped — resolved, not necessarily passed). Append the tally as `N/N` when wanted — the Requirements-table row count, scoped to that table because the Execution summary rows share the same `| <n> |` row shape and a bare grep would double it (e.g. `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep -c '^| [0-9]'`) — and when that same scoped range finds any — `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep -c '^| [0-9].*❌'` / `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep -c '^| [0-9].*⏭'` (the same scoping, so Execution-summary rows — which share the row shape — and prose mentions of the glyphs do not inflate the count) — render the counts, e.g. `done (1 ❌ · 1 ⏭)`, noting finalizing will require `--force-failed` while `❌` rows stand
27
+ - `done` → **`done`** — terminal, never shown as in-flight; every row is resolved (`✅` passed, `❌` failed, `⏭` skipped — resolved, not necessarily passed). Append the tally as `N/N` when wanted — the Requirements-table row count, scoped to that table because the Execution summary rows share the same `| <n> |` row shape and a bare grep would double it (e.g. `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep -c '^| [0-9]'`). Counting `❌`/`⏭` rows over that same scoped range (`sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep -c '^| [0-9].*❌'` / `sed -n '/^## Requirements/,/^## Execution summary/p' <file> | grep -c '^| [0-9].*⏭'` — the same scoping, so Execution-summary rows and prose mentions of the glyphs do not inflate the count) renders the counts, e.g. `done (1 ❌ · 1 ⏭)`, noting finalizing will require `--force-failed` while `❌` rows stand
28
28
  - `implementing (k/N)` → `execute k/N` (the tally rides on the line itself — no extra read)
29
29
  - `e2e-written` → `execute 0/N` (the E2E is written and the run continues into implementation — never shown as a paused state; N comes from the same Requirements-table-scoped row count as `done` — so no tally renders without a defined source)
30
30
  - `reviewing` → `review`
31
- - `ship-paused` → `ship-paused` — next: `/skill:pwk-executing-tasks` (approve the ship checkpoint). Never hint finalizing: only `done` routes there.
31
+ - `ship-paused` → `ship-paused` — next: `/skill:pwk-executing-tasks` (approve the ship checkpoint); only `done` routes to finalizing.
32
32
  - no progress file, only `*-design.md` → `design` — next: `/skill:pwk-executing-tasks`
33
33
  - roster-only (named in the overview, no artifacts) → `not started`
34
34
  - no parseable `Feature phase` line → `execute` (with tally if parseable).
35
- 3. **Group by umbrella** — for each `overview.md`, read its **`## Parts (build order)`** roster (the heading and the `N. <part> — <scope>` item shape are lint-pinned) and roll the parts up by state (the overview carries no status): done parts, in-flight parts (design, execute, review, ship-paused, awaiting setup), not-started parts. A part sitting at the feature-E2E notice (`e2e-written`) rolls up as execute — the notice is a routing moment, not a bucket of its own. Print one roll-up line — `<topic> (umbrella): n done · n in-flight · n not-started` — and when every part is `done` append `— all parts done, ready for /skill:pwk-finalizing`. A `done` single-leaf topic gets the same hint. Once the topic finalizes, its folder — overview included — is disposed, so it no longer appears here. Topics without an overview print flat.
35
+ 3. **Group by umbrella** — for each `overview.md`, read its **`## Parts (build order)`** roster (the heading and the `N. <part> — <scope>` item shape are lint-pinned) and roll the parts up by state (the overview carries no status): done, in-flight (design, execute, review, ship-paused, awaiting setup), not-started. A part at the feature-E2E notice (`e2e-written`) rolls up as execute — a routing moment, not a bucket of its own. Print one roll-up line — `<topic> (umbrella): n done · n in-flight · n not-started` — and when every part is `done` append `— all parts done, ready for /skill:pwk-finalizing`; a `done` single-leaf topic gets the same hint. Once the topic finalizes its folder — overview included — is disposed, so it no longer appears here. Topics without an overview print flat.
36
36
  4. Print a compact table, grouped under any umbrellas, e.g.:
37
37
 
38
38
  ```text
@@ -1,194 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Read-only validation of the workflow's Markdown design structure (Python 3.9+)."""
3
-
4
- import argparse
5
- from dataclasses import dataclass
6
- from pathlib import Path
7
- import re
8
- import sys
9
- from typing import List, Optional, Tuple
10
-
11
-
12
- TAG_VALUES = {
13
- "Checkpoints": ("none", "full"),
14
- "Review": ("skip", "parallel", "inline"),
15
- "Feature review": ("auto", "parallel", "inline"),
16
- }
17
- HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)\s*#*\s*$")
18
- REQUIREMENT = re.compile(r"^R([1-9][0-9]*):\s*(.*)$")
19
- REQUIREMENT_LIKE = re.compile(r"^R(?:[0-9]|\s*:)")
20
- CRITERIA = re.compile(r"^\*\*Acceptance criteria\*\*(?:\s*(?:—|–|-|:).*)?$")
21
-
22
-
23
- @dataclass
24
- class Heading:
25
- level: int
26
- title: str
27
- index: int
28
-
29
-
30
- def visible_lines(text: str) -> Tuple[List[str], List[str]]:
31
- """Blank fenced examples, retaining indexes for diagnostics. Not a Markdown renderer."""
32
- lines = []
33
- errors = []
34
- fence = ""
35
- fence_line = 0
36
- for number, line in enumerate(text.splitlines(), 1):
37
- if fence:
38
- if re.fullmatch(r" {0,3}" + re.escape(fence[0]) + "{" + str(len(fence)) + r",}[ \t]*", line):
39
- fence = ""
40
- lines.append("")
41
- continue
42
- opening = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line)
43
- if opening and not (opening[1][0] == "`" and "`" in opening[2]):
44
- fence = opening[1]
45
- fence_line = number
46
- lines.append("")
47
- else:
48
- lines.append(line)
49
- if fence:
50
- errors.append(f"line {fence_line}: unclosed code fence; close it before validating")
51
- return lines, errors
52
-
53
-
54
- def has_content(lines: List[str]) -> bool:
55
- # Check presence, not whether the assertion is true, testable, or sufficiently specific.
56
- for line in lines:
57
- text = re.sub(r"^\s*(?:[-+*]|[0-9]+[.)])\s*", "", line).strip()
58
- if text and re.search(r"\w", text):
59
- return True
60
- return False
61
-
62
-
63
- def criteria_body(lines: List[str], start: int, end: int) -> List[str]:
64
- """A heading or another bold section label terminates criteria, not just the next R ID."""
65
- result = []
66
- for line in lines[start:end]:
67
- if HEADING.match(line) or re.match(r"^\s*\*\*[^*]+\*\*", line):
68
- break
69
- result.append(line)
70
- return result
71
-
72
-
73
- def validate(text: str) -> Tuple[List[str], List[str]]:
74
- lines, errors = visible_lines(text)
75
- headings = []
76
- for index, line in enumerate(lines):
77
- match = HEADING.match(line)
78
- if match:
79
- headings.append(Heading(len(match[1]), match[2], index))
80
- for heading in headings:
81
- if REQUIREMENT_LIKE.match(heading.title) and heading.level != 3:
82
- errors.append(f"line {heading.index + 1}: {heading.title} must use a ### requirement heading")
83
- requirements = [h for h in headings if h.level == 3 and REQUIREMENT_LIKE.match(h.title)]
84
- features = [h for h in headings if h.level == 2 and h.title == "Feature acceptance"]
85
- summaries = []
86
-
87
- if not requirements:
88
- errors.append("required requirement block missing; add ### R1: <name> with Acceptance criteria")
89
- seen = set()
90
- for expected, heading in enumerate(requirements, 1):
91
- match = REQUIREMENT.fullmatch(heading.title)
92
- if not match:
93
- errors.append(f"line {heading.index + 1}: malformed requirement '{heading.title}'; use ### R{expected}: <name>")
94
- continue
95
- number = int(match[1])
96
- if number in seen:
97
- errors.append(f"line {heading.index + 1}: duplicate requirement R{number}")
98
- elif number != expected:
99
- errors.append(f"line {heading.index + 1}: expected R{expected}, found R{number}; IDs must be sequential in document order")
100
- seen.add(number)
101
- if not match[2].strip():
102
- errors.append(f"line {heading.index + 1}: R{number} requires a nonempty name")
103
-
104
- intro_end = requirements[0].index if requirements else len(lines)
105
- short = any(re.match(r"^In short:\s*\S.*$", line) for line in lines[:intro_end])
106
- full_format = any(h.level == 2 and h.title in ("At a glance", "Requirements") for h in headings)
107
- trivial = short and not full_format and len(requirements) == 1 and requirements[0].title.startswith("R1:") and not features
108
- if not trivial and not features:
109
- errors.append("## Feature acceptance is required for non-trivial designs; the trivial exception needs an In short: one-liner and a single R1 block without full-format sections")
110
- if len(features) > 1:
111
- errors.append("duplicate ## Feature acceptance section; keep exactly one")
112
-
113
- # Record ownership so a valid-looking tag cannot silently land outside its section.
114
- owned_tags = set()
115
-
116
- def tags(start: int, end: int, names: Tuple[str, ...], owner: str) -> dict:
117
- values = {name: TAG_VALUES[name][0] for name in names}
118
- found = set()
119
- for heading in headings:
120
- if not start <= heading.index < end:
121
- continue
122
- for name in names:
123
- if heading.level != 3 or not re.match(re.escape(name) + r"\b", heading.title):
124
- continue
125
- owned_tags.add(heading.index)
126
- if name in found:
127
- errors.append(f"line {heading.index + 1}: duplicate {name} tag in {owner}")
128
- found.add(name)
129
- value = heading.title[len(name):].strip()
130
- value = value[1:].strip() if value.startswith(":") else ""
131
- if value not in TAG_VALUES[name]:
132
- allowed = " | ".join(TAG_VALUES[name])
133
- errors.append(f"line {heading.index + 1}: invalid {name} tag in {owner}; expected {allowed}")
134
- else:
135
- values[name] = value
136
- return values
137
-
138
- for heading in requirements:
139
- end = next((h.index for h in headings if h.index > heading.index and (h.level <= 2 or h in requirements)), len(lines))
140
- criteria_end = next((h.index for h in headings if heading.index < h.index < end), end)
141
- labels = [i for i in range(heading.index + 1, criteria_end) if CRITERIA.fullmatch(lines[i].strip())]
142
- owner = heading.title.split(":", 1)[0]
143
- if not labels:
144
- errors.append(f"line {heading.index + 1}: {owner} needs a standalone **Acceptance criteria** section")
145
- elif len(labels) > 1:
146
- errors.append(f"line {labels[1] + 1}: {owner} has duplicate Acceptance criteria sections")
147
- elif not has_content(criteria_body(lines, labels[0] + 1, end)):
148
- errors.append(f"line {labels[0] + 1}: {owner} Acceptance criteria is empty; add a criterion before the next section")
149
- values = tags(heading.index + 1, end, ("Checkpoints", "Review"), owner)
150
- summaries.append(f"{owner}: Checkpoints={values['Checkpoints']}, Review={values['Review']}")
151
-
152
- for heading in features:
153
- end = next((h.index for h in headings if h.index > heading.index and h.level <= 2), len(lines))
154
- if not has_content(criteria_body(lines, heading.index + 1, end)):
155
- errors.append(f"line {heading.index + 1}: Feature acceptance is empty; add an end-to-end criterion before its review tag")
156
- values = tags(heading.index + 1, end, ("Feature review",), "Feature acceptance")
157
- summaries.append(f"Feature review={values['Feature review']}")
158
-
159
- for heading in headings:
160
- for name in TAG_VALUES:
161
- if re.match(re.escape(name) + r"\b", heading.title) and heading.index not in owned_tags:
162
- owner = "## Feature acceptance" if name == "Feature review" else "a ### R<n>: requirement block"
163
- errors.append(f"line {heading.index + 1}: {name} must be a ### tag inside {owner}")
164
- return errors, summaries
165
-
166
-
167
- def main(argv: Optional[List[str]] = None) -> int:
168
- parser = argparse.ArgumentParser(
169
- description="Read-only structural design validation. Requires Python 3.9+; standard library only. Semantic correctness is not verified.",
170
- epilog="Pass an explicit design path. Absolute paths are used directly; relative paths resolve from the caller's working directory (cwd), not the installed skill directory.",
171
- )
172
- parser.add_argument("design", type=Path, help="UTF-8 Markdown design file (quote paths containing spaces)")
173
- args = parser.parse_args(argv)
174
- if sys.version_info < (3, 9):
175
- print("Python 3.9+ is required; supply a compatible runtime before validation.", file=sys.stderr)
176
- return 2
177
- try:
178
- text = args.design.read_text(encoding="utf-8")
179
- except (OSError, UnicodeError, ValueError) as error:
180
- print(f"{args.design}: cannot read UTF-8 design: {error}", file=sys.stderr)
181
- return 2
182
- errors, summaries = validate(text)
183
- if errors:
184
- for error in errors:
185
- print(f"{args.design}: {error}", file=sys.stderr)
186
- return 1
187
- print(f"{args.design}: structural checks passed. Semantic correctness is not verified.")
188
- for summary in summaries:
189
- print(f" {summary}")
190
- return 0
191
-
192
-
193
- if __name__ == "__main__":
194
- sys.exit(main())