@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
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Build review evidence from explicit, immutable Git bounds. Python 3.9+, stdlib only."""
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import os
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
import re
|
|
8
|
-
import subprocess
|
|
9
|
-
import sys
|
|
10
|
-
import tempfile
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class PacketError(Exception):
|
|
14
|
-
pass
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class Document:
|
|
18
|
-
"""Keep original lines for evidence; inspect headings only outside fenced examples."""
|
|
19
|
-
|
|
20
|
-
def __init__(self, path):
|
|
21
|
-
with path.open(encoding='utf-8', newline='') as source:
|
|
22
|
-
self.lines = source.readlines()
|
|
23
|
-
self.headings = []
|
|
24
|
-
self.visible = set()
|
|
25
|
-
fence = None
|
|
26
|
-
for index, line in enumerate(self.lines):
|
|
27
|
-
marker = re.match(r'^ {0,3}(`{3,}|~{3,})(.*)$', line.rstrip('\r\n'))
|
|
28
|
-
if fence:
|
|
29
|
-
if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip():
|
|
30
|
-
fence = None
|
|
31
|
-
continue
|
|
32
|
-
if marker:
|
|
33
|
-
fence = marker[1]
|
|
34
|
-
continue
|
|
35
|
-
self.visible.add(index)
|
|
36
|
-
heading = re.match(r'^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$', line.rstrip('\r\n'))
|
|
37
|
-
if heading:
|
|
38
|
-
self.headings.append((index, len(heading[1]), heading[2]))
|
|
39
|
-
if fence:
|
|
40
|
-
raise PacketError(f'{path.name}: unclosed code fence')
|
|
41
|
-
|
|
42
|
-
def section(self, title, level=2, required=False):
|
|
43
|
-
matches = [h for h in self.headings if h[1:] == (level, title)]
|
|
44
|
-
if len(matches) > 1 or (required and not matches):
|
|
45
|
-
raise PacketError(f'expected one {title} section; found {len(matches)}')
|
|
46
|
-
if not matches:
|
|
47
|
-
return None
|
|
48
|
-
start = matches[0][0]
|
|
49
|
-
end = next((i for i, depth, _ in self.headings if i > start and depth <= level), len(self.lines))
|
|
50
|
-
return start, end
|
|
51
|
-
|
|
52
|
-
def text(self, start, end):
|
|
53
|
-
return ''.join(self.lines[start:end])
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def git(project, *args):
|
|
57
|
-
result = subprocess.run(
|
|
58
|
-
['git', '-C', str(project), *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
59
|
-
env={**os.environ, 'GIT_OPTIONAL_LOCKS': '0', 'GIT_TERMINAL_PROMPT': '0'},
|
|
60
|
-
)
|
|
61
|
-
if result.returncode:
|
|
62
|
-
invocation = ' '.join(['git', *args])
|
|
63
|
-
raise PacketError(f'{invocation} failed: {result.stderr.decode("utf-8", errors="replace").strip()}')
|
|
64
|
-
return result.stdout.decode('utf-8', errors='replace')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def resolve_input(project, value):
|
|
68
|
-
path = Path(value)
|
|
69
|
-
return (path if path.is_absolute() else project / path).resolve(strict=True)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def paths(args):
|
|
73
|
-
project = Path(args.project).resolve(strict=True)
|
|
74
|
-
if Path(git(project, 'rev-parse', '--show-toplevel').strip()).resolve() != project:
|
|
75
|
-
raise PacketError('--project must be the Git worktree root')
|
|
76
|
-
for value in (args.design, args.progress):
|
|
77
|
-
supplied = Path(value)
|
|
78
|
-
supplied = Path(os.path.abspath(supplied if supplied.is_absolute() else project / supplied))
|
|
79
|
-
lexical_project = Path(os.path.abspath(args.project))
|
|
80
|
-
try:
|
|
81
|
-
relative_input = supplied.relative_to(lexical_project)
|
|
82
|
-
except ValueError:
|
|
83
|
-
relative_input = supplied.relative_to(project)
|
|
84
|
-
current = project
|
|
85
|
-
for part in relative_input.parts:
|
|
86
|
-
current = current / part
|
|
87
|
-
if current.is_symlink():
|
|
88
|
-
raise PacketError('design/progress paths must not traverse symlinks')
|
|
89
|
-
design = resolve_input(project, args.design)
|
|
90
|
-
progress = resolve_input(project, args.progress)
|
|
91
|
-
relative = design.relative_to(project / 'docs' / 'plans')
|
|
92
|
-
if len(relative.parts) != 2 or relative.parts[0] == 'completed' or not design.name.endswith('-design.md'):
|
|
93
|
-
raise PacketError('design must be docs/plans/<topic>/<leaf>-design.md in an active topic')
|
|
94
|
-
leaf = design.name[:-len('-design.md')]
|
|
95
|
-
if not leaf or progress != design.with_name(f'{leaf}-progress.md'):
|
|
96
|
-
raise PacketError('progress must be the matching <leaf>-progress.md beside the design')
|
|
97
|
-
# Resolving inputs must not route a packet write through symlinked topic directories.
|
|
98
|
-
lexical_topic = project / 'docs' / 'plans' / relative.parts[0]
|
|
99
|
-
for directory in (project / 'docs', project / 'docs' / 'plans', lexical_topic):
|
|
100
|
-
if directory.is_symlink():
|
|
101
|
-
raise PacketError('symlink topic directories are not writable packet destinations')
|
|
102
|
-
suffix = '' if args.scope == 'feature' else f'-r{args.requirement}'
|
|
103
|
-
destination = design.with_name(f'{leaf}-review-packet{suffix}.md')
|
|
104
|
-
validate_destination(destination)
|
|
105
|
-
return project, design, progress, destination
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def validate_destination(path):
|
|
109
|
-
if path.is_symlink() or (path.exists() and not path.is_file()):
|
|
110
|
-
raise PacketError('packet destination must be a regular file, not a symlink or directory')
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def requirements(document):
|
|
114
|
-
region = document.section('Requirements', required=True)
|
|
115
|
-
headings = []
|
|
116
|
-
for index, depth, title in document.headings:
|
|
117
|
-
match = re.match(r'^R([1-9][0-9]*):\s*\S', title)
|
|
118
|
-
if depth == 3 and re.match(r'^R[0-9]', title) and not match:
|
|
119
|
-
raise PacketError('malformed requirement heading')
|
|
120
|
-
if depth == 3 and match:
|
|
121
|
-
if not region[0] < index < region[1]:
|
|
122
|
-
raise PacketError('requirement heading outside Requirements section')
|
|
123
|
-
headings.append((int(match[1]), index))
|
|
124
|
-
numbers = [number for number, _ in headings]
|
|
125
|
-
if not numbers or len(set(numbers)) != len(numbers):
|
|
126
|
-
raise PacketError('missing or duplicate requirement headings')
|
|
127
|
-
return headings, region
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def progress_rows(document, project, design):
|
|
131
|
-
first_heading = next((i for i, depth, _ in document.headings if depth == 2), len(document.lines))
|
|
132
|
-
pointers = [document.lines[i].strip()[8:] for i in document.visible
|
|
133
|
-
if i < first_heading and document.lines[i].startswith('Design: ')]
|
|
134
|
-
if len(pointers) != 1 or resolve_input(project, pointers[0]) != design:
|
|
135
|
-
raise PacketError('progress Design header must identify the selected design exactly once')
|
|
136
|
-
start, end = document.section('Requirements', required=True)
|
|
137
|
-
lines = [document.lines[i].strip() for i in range(start + 1, end)
|
|
138
|
-
if i in document.visible and document.lines[i].strip()]
|
|
139
|
-
if len(lines) < 3:
|
|
140
|
-
raise PacketError('missing Requirements table')
|
|
141
|
-
|
|
142
|
-
def cells(line):
|
|
143
|
-
if not line.startswith('|') or not line.endswith('|'):
|
|
144
|
-
raise PacketError('malformed Requirements table row')
|
|
145
|
-
return [cell.strip() for cell in re.split(r'(?<!\\)\|', line)[1:-1]]
|
|
146
|
-
|
|
147
|
-
header = cells(lines[0])
|
|
148
|
-
if header != ['#', 'Done', 'Requirement', 'Per-req ceremony', 'Commit']:
|
|
149
|
-
raise PacketError('Requirements table must have #, Done, Requirement, Per-req ceremony, Commit columns')
|
|
150
|
-
separator = cells(lines[1])
|
|
151
|
-
if len(separator) != 5 or not all(re.fullmatch(r':?-+:?', cell) for cell in separator):
|
|
152
|
-
raise PacketError('malformed Requirements table separator')
|
|
153
|
-
rows = {}
|
|
154
|
-
for line in lines[2:]:
|
|
155
|
-
row = cells(line)
|
|
156
|
-
if len(row) != 5 or not re.fullmatch(r'[1-9][0-9]*', row[0]):
|
|
157
|
-
raise PacketError('malformed Requirements table row')
|
|
158
|
-
number = int(row[0])
|
|
159
|
-
if number in rows:
|
|
160
|
-
raise PacketError(f'duplicate R{number} progress row')
|
|
161
|
-
if row[1] not in ('', '⬜', '🔄', '✅', '❌', '⏭'):
|
|
162
|
-
raise PacketError(f'R{number}: invalid Done cell')
|
|
163
|
-
rows[number] = row
|
|
164
|
-
return rows
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def bounds(project, rows, headings, args):
|
|
168
|
-
numbers = [number for number, _ in headings]
|
|
169
|
-
position = numbers.index(args.requirement) if args.scope == 'requirement' else None
|
|
170
|
-
relevant = numbers if position is None else numbers[max(0, position - 1):position + 1]
|
|
171
|
-
if set(rows) - set(numbers):
|
|
172
|
-
raise PacketError('progress contains requirements absent from the design')
|
|
173
|
-
recorded = {}
|
|
174
|
-
previous = None
|
|
175
|
-
current_head = git(project, 'rev-parse', '--verify', 'HEAD^{commit}').strip()
|
|
176
|
-
for number in relevant:
|
|
177
|
-
if number not in rows:
|
|
178
|
-
raise PacketError(f'R{number}: missing progress row')
|
|
179
|
-
row = rows[number]
|
|
180
|
-
value = row[4]
|
|
181
|
-
if value in ('', '—', '-'):
|
|
182
|
-
if row[1] not in ('❌', '⏭'):
|
|
183
|
-
raise PacketError(f'R{number}: missing Commit entry')
|
|
184
|
-
recorded[number] = []
|
|
185
|
-
continue
|
|
186
|
-
# An ordered whitespace-separated list supports review-fix commits without losing
|
|
187
|
-
# the first feature commit. Other formats are ambiguous, never mined for hashes.
|
|
188
|
-
entries = value.split()
|
|
189
|
-
if not all(re.fullmatch(r'[0-9a-fA-F]{7,64}', entry) for entry in entries):
|
|
190
|
-
raise PacketError(f'R{number}: Commit must contain only ordered hexadecimal commit IDs')
|
|
191
|
-
commits = []
|
|
192
|
-
for entry in entries:
|
|
193
|
-
commit = git(project, 'rev-parse', '--verify', f'{entry}^{{commit}}').strip()
|
|
194
|
-
if previous:
|
|
195
|
-
if previous == commit:
|
|
196
|
-
raise PacketError('duplicate or overlapping recorded commits')
|
|
197
|
-
git(project, 'merge-base', '--is-ancestor', previous, commit)
|
|
198
|
-
git(project, 'merge-base', '--is-ancestor', commit, current_head)
|
|
199
|
-
commits.append(commit)
|
|
200
|
-
previous = commit
|
|
201
|
-
recorded[number] = commits
|
|
202
|
-
if args.scope == 'feature':
|
|
203
|
-
first = next((commits[0] for commits in recorded.values() if commits), None)
|
|
204
|
-
if not first:
|
|
205
|
-
raise PacketError('no recorded commits; cannot infer a feature base')
|
|
206
|
-
return git(project, 'rev-parse', '--verify', f'{first}^').strip(), current_head
|
|
207
|
-
selected = recorded[args.requirement]
|
|
208
|
-
if not selected:
|
|
209
|
-
raise PacketError('selected requirement has no recorded bound')
|
|
210
|
-
if position == 0:
|
|
211
|
-
base = git(project, 'rev-parse', '--verify', f'{selected[0]}^').strip()
|
|
212
|
-
else:
|
|
213
|
-
prior = recorded[numbers[position - 1]]
|
|
214
|
-
if not prior:
|
|
215
|
-
raise PacketError('previous requirement has no recorded bound; cannot guess a range')
|
|
216
|
-
base = prior[-1]
|
|
217
|
-
return base, selected[-1]
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def evidence(document, headings, region, args):
|
|
221
|
-
feature = document.section('Feature acceptance')
|
|
222
|
-
if feature and feature[0] < region[1]:
|
|
223
|
-
raise PacketError('Feature acceptance must follow Requirements')
|
|
224
|
-
if args.scope == 'feature':
|
|
225
|
-
start = headings[0][1]
|
|
226
|
-
end = feature[0] if feature else len(document.lines)
|
|
227
|
-
else:
|
|
228
|
-
position = next(i for i, (number, _) in enumerate(headings) if number == args.requirement)
|
|
229
|
-
start = headings[position][1]
|
|
230
|
-
end = headings[position + 1][1] if position + 1 < len(headings) else region[1]
|
|
231
|
-
criteria = document.text(start, end)
|
|
232
|
-
acceptance = ''
|
|
233
|
-
if feature:
|
|
234
|
-
stop = next((i for i, depth, title in document.headings
|
|
235
|
-
if feature[0] < i < feature[1] and depth == 3 and title.startswith('Feature review:')), feature[1])
|
|
236
|
-
acceptance = document.text(feature[0], stop)
|
|
237
|
-
notes = []
|
|
238
|
-
for index, depth, title in document.headings:
|
|
239
|
-
if depth == 3 and title == 'Production-risk notes' and start <= index < end:
|
|
240
|
-
stop = next((i for i, d, t in document.headings if i > index and
|
|
241
|
-
(d <= 2 or (d == 3 and re.match(r'^R[0-9]+:', t)))), len(document.lines))
|
|
242
|
-
notes.append(document.text(index, min(stop, end)))
|
|
243
|
-
risk = document.section('Production-risk areas')
|
|
244
|
-
if risk:
|
|
245
|
-
notes.append(document.text(*risk))
|
|
246
|
-
return criteria, acceptance, ''.join(notes)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
def build(args):
|
|
250
|
-
project, design_path, progress_path, destination = paths(args)
|
|
251
|
-
design = Document(design_path)
|
|
252
|
-
headings, region = requirements(design)
|
|
253
|
-
if args.scope == 'requirement' and args.requirement not in [n for n, _ in headings]:
|
|
254
|
-
raise PacketError('selected requirement is absent from the design')
|
|
255
|
-
rows = progress_rows(Document(progress_path), project, design_path)
|
|
256
|
-
base, head = bounds(project, rows, headings, args)
|
|
257
|
-
criteria, acceptance, notes = evidence(design, headings, region, args)
|
|
258
|
-
span = f'{base}..{head}'
|
|
259
|
-
pathspec = ['--', '.', ':(glob,exclude)**/*-review-packet*.md']
|
|
260
|
-
sections = [
|
|
261
|
-
('Commits', git(project, 'log', '--oneline', '--no-decorate', span)),
|
|
262
|
-
('Changed files', git(project, 'diff', '--no-ext-diff', '--no-textconv', '--stat', span, *pathspec)),
|
|
263
|
-
('Acceptance criteria (verbatim from the design doc)', criteria),
|
|
264
|
-
('Feature acceptance (verbatim)', acceptance),
|
|
265
|
-
('Production-risk notes (verbatim, if any)', notes),
|
|
266
|
-
('Diff', git(project, 'diff', '--no-ext-diff', '--no-textconv', '--no-color', span, *pathspec)),
|
|
267
|
-
]
|
|
268
|
-
label = 'feature review' if args.scope == 'feature' else f'requirement R{args.requirement} review'
|
|
269
|
-
# The blank line between Base and Head is load-bearing: CommonMark merges
|
|
270
|
-
# consecutive plain lines into one paragraph in every preview.
|
|
271
|
-
packet = f'# Review packet: {design_path.parent.name} — {label}\n\nBase: {base}\n\nHead: {head}\n\n'
|
|
272
|
-
for title, content in sections:
|
|
273
|
-
packet += f'## {title}\n\n{content}\n'
|
|
274
|
-
# All parsing and Git reads finish before a temporary file exists. A failure in
|
|
275
|
-
# assembly leaves an earlier complete packet intact. Replacement is same-directory.
|
|
276
|
-
temporary = None
|
|
277
|
-
try:
|
|
278
|
-
validate_destination(destination)
|
|
279
|
-
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='', dir=destination.parent,
|
|
280
|
-
prefix=f'.{destination.name}.', delete=False) as output:
|
|
281
|
-
temporary = Path(output.name)
|
|
282
|
-
output.write(packet)
|
|
283
|
-
output.flush()
|
|
284
|
-
os.fsync(output.fileno())
|
|
285
|
-
validate_destination(destination)
|
|
286
|
-
os.replace(temporary, destination)
|
|
287
|
-
temporary = None
|
|
288
|
-
finally:
|
|
289
|
-
if temporary is not None:
|
|
290
|
-
temporary.unlink()
|
|
291
|
-
print(f'Wrote {destination}\nRange: {base}..{head}')
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def main():
|
|
295
|
-
parser = argparse.ArgumentParser(description=__doc__, epilog=(
|
|
296
|
-
'--project resolves from the caller cwd and must be a Git worktree root. '
|
|
297
|
-
'--design and --progress are absolute or relative to --project, not the skill or cwd. '
|
|
298
|
-
'Output is <leaf>-review-packet[-rN].md beside the matched docs in docs/plans/<topic>/. '
|
|
299
|
-
'Commit cells contain ordered whitespace-separated hexadecimal commit IDs, not revision expressions.'
|
|
300
|
-
))
|
|
301
|
-
parser.add_argument('--project', required=True)
|
|
302
|
-
parser.add_argument('--design', required=True)
|
|
303
|
-
parser.add_argument('--progress', required=True)
|
|
304
|
-
parser.add_argument('--scope', choices=['feature', 'requirement'], required=True)
|
|
305
|
-
parser.add_argument('--requirement', type=int)
|
|
306
|
-
args = parser.parse_args()
|
|
307
|
-
if (args.scope == 'requirement') != (args.requirement is not None):
|
|
308
|
-
parser.error('--requirement N is required only for --scope requirement')
|
|
309
|
-
try:
|
|
310
|
-
if sys.version_info < (3, 9):
|
|
311
|
-
raise PacketError('Python 3.9 or newer is required; install it explicitly before retrying')
|
|
312
|
-
build(args)
|
|
313
|
-
except (PacketError, OSError, ValueError) as error:
|
|
314
|
-
print(f'error: {error}', file=sys.stderr)
|
|
315
|
-
return 1
|
|
316
|
-
return 0
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if __name__ == '__main__':
|
|
320
|
-
sys.exit(main())
|