@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.
- package/README.md +9 -1
- package/docs/developer-usage-guide.md +1 -1
- package/docs/workflow-phases.md +5 -4
- package/package.json +1 -1
- package/skills/pwk-brainstorming/SKILL.md +24 -18
- package/skills/pwk-brainstorming/references/design-format.md +14 -14
- package/skills/pwk-brainstorming/references/umbrella.md +7 -8
- package/skills/pwk-brainstorming/scripts/validate-design.mjs +239 -0
- package/skills/pwk-code-review/SKILL.md +4 -4
- package/skills/pwk-executing-tasks/SKILL.md +55 -63
- package/skills/pwk-executing-tasks/references/progress-format.md +20 -21
- package/skills/pwk-executing-tasks/references/resume.md +3 -3
- package/skills/pwk-executing-tasks/references/review.md +12 -19
- package/skills/pwk-executing-tasks/scripts/build-review-packet.mjs +399 -0
- package/skills/pwk-executing-tasks/scripts/progress.mjs +180 -0
- package/skills/pwk-executing-tasks/scripts/ref.mjs +71 -0
- package/skills/pwk-executing-tasks/scripts/stage-check.mjs +48 -0
- package/skills/pwk-finalizing/SKILL.md +21 -31
- package/skills/pwk-status/SKILL.md +4 -4
- package/skills/pwk-brainstorming/scripts/validate-design.py +0 -194
- package/skills/pwk-executing-tasks/scripts/build-review-packet.py +0 -320
|
@@ -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** —
|
|
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
|
|
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
|
|
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
|
|
31
|
-
4. **Cross-check the roster — fail closed on unstarted work.**
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
44
|
-
- The sweep is file-based by necessity: executing and finalizing usually run in fresh sessions with no memory of the brainstorm
|
|
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
|
|
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
|
|
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
|
|
79
|
-
4. **Curate lessons (Agile Scrum Master hat)** — if `docs/lessons.md` exists: add missed lessons, generalize domain-specific rules
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
-
|
|
96
|
-
-
|
|
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
|
|
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]'`)
|
|
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)
|
|
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
|
|
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())
|