specpro-cli 0.1.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 (76) hide show
  1. specpro_cli/__init__.py +16 -0
  2. specpro_cli/assets/commands/specpro.analyze.md +1102 -0
  3. specpro_cli/assets/commands/specpro.checklist.md +335 -0
  4. specpro_cli/assets/commands/specpro.clarify.md +581 -0
  5. specpro_cli/assets/commands/specpro.constitution.md +488 -0
  6. specpro_cli/assets/commands/specpro.feature.md +115 -0
  7. specpro_cli/assets/commands/specpro.implement.md +1881 -0
  8. specpro_cli/assets/commands/specpro.manual-test.md +206 -0
  9. specpro_cli/assets/commands/specpro.plan.md +3284 -0
  10. specpro_cli/assets/commands/specpro.qc.md +1489 -0
  11. specpro_cli/assets/commands/specpro.scenarios.md +154 -0
  12. specpro_cli/assets/commands/specpro.specify.md +1449 -0
  13. specpro_cli/assets/commands/specpro.status.md +863 -0
  14. specpro_cli/assets/commands/specpro.tasks.md +1207 -0
  15. specpro_cli/assets/commands/specpro.test-implement.md +462 -0
  16. specpro_cli/assets/commands/specpro.test-plan.md +383 -0
  17. specpro_cli/assets/commands/specpro.user-manual.md +178 -0
  18. specpro_cli/assets/scripts/bash/check-anti-coupling.sh +293 -0
  19. specpro_cli/assets/scripts/bash/check-prerequisites.sh +176 -0
  20. specpro_cli/assets/scripts/bash/common.sh +88 -0
  21. specpro_cli/assets/scripts/bash/create-new-feature.sh +336 -0
  22. specpro_cli/assets/scripts/bash/qc-auto-fix.sh +121 -0
  23. specpro_cli/assets/scripts/bash/setup-plan.sh +60 -0
  24. specpro_cli/assets/scripts/bash/verify-cumulative-records.sh +203 -0
  25. specpro_cli/assets/scripts/bash/verify-deliverables-tracked.sh +147 -0
  26. specpro_cli/assets/scripts/bash/verify-deployment.sh +239 -0
  27. specpro_cli/assets/scripts/bash/verify-frontmatter-yaml.sh +63 -0
  28. specpro_cli/assets/scripts/bash/verify-ledger.sh +376 -0
  29. specpro_cli/assets/scripts/bash/verify-shapes.sh +1082 -0
  30. specpro_cli/assets/scripts/git-hooks/pre-commit +243 -0
  31. specpro_cli/assets/scripts/install-git-hooks.sh +67 -0
  32. specpro_cli/assets/scripts/powershell/check-anti-coupling.ps1 +249 -0
  33. specpro_cli/assets/scripts/powershell/check-prerequisites.ps1 +148 -0
  34. specpro_cli/assets/scripts/powershell/common.ps1 +95 -0
  35. specpro_cli/assets/scripts/powershell/create-new-feature.ps1 +229 -0
  36. specpro_cli/assets/scripts/powershell/qc-auto-fix.ps1 +110 -0
  37. specpro_cli/assets/scripts/powershell/setup-plan.ps1 +61 -0
  38. specpro_cli/assets/scripts/powershell/verify-cumulative-records.ps1 +133 -0
  39. specpro_cli/assets/scripts/powershell/verify-deliverables-tracked.ps1 +112 -0
  40. specpro_cli/assets/scripts/powershell/verify-deployment.ps1 +278 -0
  41. specpro_cli/assets/scripts/powershell/verify-frontmatter-yaml.ps1 +56 -0
  42. specpro_cli/assets/scripts/powershell/verify-ledger.ps1 +383 -0
  43. specpro_cli/assets/scripts/powershell/verify-shapes.ps1 +978 -0
  44. specpro_cli/assets/templates/agent-context-template.md +49 -0
  45. specpro_cli/assets/templates/assumptions-template.md +248 -0
  46. specpro_cli/assets/templates/checklist-template.md +40 -0
  47. specpro_cli/assets/templates/clarifications-template.md +155 -0
  48. specpro_cli/assets/templates/constitution-template.md +50 -0
  49. specpro_cli/assets/templates/feature-spec-template.md +66 -0
  50. specpro_cli/assets/templates/plan-overview-template.md +150 -0
  51. specpro_cli/assets/templates/plan-template.md +387 -0
  52. specpro_cli/assets/templates/protocol-golden-bytes-guide.md +195 -0
  53. specpro_cli/assets/templates/requirements-template.md +356 -0
  54. specpro_cli/assets/templates/spec-template.md +267 -0
  55. specpro_cli/assets/templates/tasks-template.md +252 -0
  56. specpro_cli/assets/templates/test-tasks-template.md +174 -0
  57. specpro_cli/cli/__init__.py +5 -0
  58. specpro_cli/cli/cmd_init.py +416 -0
  59. specpro_cli/cli/cmd_remove.py +122 -0
  60. specpro_cli/cli/entry.py +181 -0
  61. specpro_cli/integrations/__init__.py +36 -0
  62. specpro_cli/integrations/base.py +601 -0
  63. specpro_cli/integrations/claude/__init__.py +101 -0
  64. specpro_cli/integrations/copilot/__init__.py +153 -0
  65. specpro_cli/integrations/cursor_agent/__init__.py +51 -0
  66. specpro_cli/integrations/gemini/__init__.py +44 -0
  67. specpro_cli/integrations/opencode/__init__.py +48 -0
  68. specpro_cli/integrations/qodercli/__init__.py +54 -0
  69. specpro_cli/integrations/registry.py +88 -0
  70. specpro_cli/packaged/__init__.py +5 -0
  71. specpro_cli/packaged/sync.py +106 -0
  72. specpro_cli-0.1.0.dist-info/METADATA +117 -0
  73. specpro_cli-0.1.0.dist-info/RECORD +76 -0
  74. specpro_cli-0.1.0.dist-info/WHEEL +4 -0
  75. specpro_cli-0.1.0.dist-info/entry_points.txt +2 -0
  76. specpro_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,203 @@
1
+ #!/bin/bash
2
+ #
3
+ # Cumulative-record verifier — a cross-round record must not live in an artifact that
4
+ # some command REBUILDS WHOLESALE.
5
+ #
6
+ # Why this exists (plan.md → Constraint 11, FR-025): a record whose members accumulate
7
+ # round over round derives its value from surviving. Put it in a container that a command
8
+ # replaces wholesale and the accumulation is **silently destroyed** — the rebuild is a
9
+ # successful `cp`, so every exit code in the chain is 0.
10
+ #
11
+ # ⚠️ **Measured, not hypothetical** (2026-09-19): the tool-defect baseline lived at the end
12
+ # of `specs/checklists/requirements.md`, which `/specpro-qc` copies from its template on
13
+ # every run. One run took that section from **10 rows → 0 rows** and the file from
14
+ # **47086 B → 13216 B**, with no error anywhere. The list survived that long because the
15
+ # deletion happened to go unnoticed.
16
+ #
17
+ # ⚠️ **The two facts that must both hold look contradictory and are not**: the quality
18
+ # checklist MUST be rebuilt every run (its value is *this* run's verdict — FR-060), and the
19
+ # regression baseline MUST accumulate (its value is the defects found across runs — FR-025).
20
+ # ⇒ They must be **two artifacts**. Merging them uses one's property to destroy the other's,
21
+ # which is exactly the measured shape above.
22
+ #
23
+ # ⚠️ **Both sides are DERIVED — there is no registry, and that is a design decision.**
24
+ # An earlier version kept two hand-written registries (records; rebuild sites) *inside this
25
+ # script*. That is not just a list that fails by omission: it makes the verifier
26
+ # **unfixtureable**. A fixture would have to edit the script to plant a violation, and the
27
+ # PowerShell twin under test is the one in the repository while the tree holds its own copy —
28
+ # so an edit to one side's registry breaks parity, and the fixture can never be written.
29
+ # ⇒ **The subject declares itself where it lives**:
30
+ # · a cross-round record says so in its own header: `**Cumulative**: …`
31
+ # · a wholesale rebuild is a `cp … "$VAR"` in `commands/`, which is what that act
32
+ # looks like here (an ordinary `cp .env.example .env` inside a quickstart example
33
+ # is not a rebuild — the destination is not a variable)
34
+ # Fixtures are then plain data edits to the tree, and both twins read the same data.
35
+ #
36
+ # Usage: scripts/bash/verify-cumulative-records.sh
37
+ # RECORDS_HOOK_BASE=<path> scripts/bash/verify-cumulative-records.sh (fixtures)
38
+ # Exit: 0 = no record sits on a rebuild path; 1 = at least one violation
39
+
40
+ # ⚠️ NOT `set -e`: this script reads greps that are *expected* to miss.
41
+ set -uo pipefail
42
+
43
+ is_repo_root() { [ -d "$1/commands" ] && [ -d "$1/templates" ] && [ -d "$1/scripts" ]; }
44
+
45
+ # Base resolution mirrors `verify-deployment.sh` (T180 / ISS-124): position-independent, and
46
+ # the RESULT is tested rather than the method — the same source resolves correctly from the
47
+ # repository root and from the deployed mirror, which sit at different depths.
48
+ resolve_base() {
49
+ local cand here
50
+ if cand=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$cand" ] && is_repo_root "$cand"; then
51
+ printf '%s' "$cand"; return 0
52
+ fi
53
+ here=$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
54
+ for cand in "$here/../.." "$here/../../.."; do
55
+ cand=$(CDPATH="" cd "$cand" 2>/dev/null && pwd) || continue
56
+ is_repo_root "$cand" && { printf '%s' "$cand"; return 0; }
57
+ done
58
+ return 1
59
+ }
60
+
61
+ BASE="${RECORDS_HOOK_BASE:-}"
62
+ if [ -n "$BASE" ]; then
63
+ BASE=$(CDPATH="" cd "$BASE" 2>/dev/null && pwd) || { echo "✗ RECORDS_HOOK_BASE does not resolve: $RECORDS_HOOK_BASE"; exit 1; }
64
+ is_repo_root "$BASE" || { echo "✗ RECORDS_HOOK_BASE is not a repository root: $BASE"; exit 1; }
65
+ else
66
+ BASE=$(resolve_base) || { echo "✗ cannot locate the repository root"; exit 1; }
67
+ fi
68
+
69
+ HITS=0
70
+ ok() { echo " ✓ $1"; }
71
+ hit() { echo " ✗ $1"; HITS=$((HITS + 1)); }
72
+ skip() { echo " ⊘ $1"; }
73
+
74
+ echo "Cumulative-record check: $BASE"
75
+
76
+ # -----------------------------------------------------------------------------------------
77
+ # 1. Records — every file under specs/ that declares itself cumulative
78
+ # -----------------------------------------------------------------------------------------
79
+ echo " [1] cross-round records (self-declared: \`**Cumulative**: …\`)"
80
+ RECORDS=""
81
+ if [ -d "$BASE/specs" ]; then
82
+ RECORDS=$(grep -rlE '^\*\*Cumulative\*\*: ' "$BASE/specs" --include='*.md' 2>/dev/null \
83
+ | sed "s|^$BASE/||" | LC_ALL=C sort || true)
84
+ fi
85
+ if [ -z "$RECORDS" ]; then
86
+ # ⚠️ A SKIP, not a hit: this script ships with the tool, and a user project may legitimately
87
+ # have no cross-round record at all. ⚠️ The cost is real and stated: **un-declaring a record
88
+ # turns this check off for it** — which is why the declaration lives in the record's own
89
+ # header, where removing it is a visible edit to the record itself.
90
+ skip "no file under specs/ declares itself cumulative — nothing is proven"
91
+ else
92
+ while IFS= read -r r; do
93
+ [ -n "$r" ] || continue
94
+ ok "record: $r"
95
+ done <<EOF
96
+ $RECORDS
97
+ EOF
98
+ fi
99
+
100
+ # -----------------------------------------------------------------------------------------
101
+ # 2. Rebuild sites — a `cp` in commands/ whose destination is a shell variable
102
+ # -----------------------------------------------------------------------------------------
103
+ # ⚠️ The optional closing quote is load-bearing. The destination is written `"$VAR"`, so a
104
+ # pattern anchored on the variable running bare to end-of-line finds **nothing** and this
105
+ # check reports a clean skip while seeing no subject at all — a detection that cannot see
106
+ # its own subject, which is the shape it exists to catch. Measured: the first version had
107
+ # exactly that bug.
108
+ REBUILD_RE='^[[:space:]]*cp[[:space:]]+.*\$[A-Za-z_][A-Za-z0-9_]*["'"'"']?[[:space:]]*$'
109
+ echo " [2] wholesale-rebuild sites (\`cp\` to a shell variable in commands/)"
110
+ SITES=""
111
+ if [ -d "$BASE/commands" ]; then
112
+ SITES=$(grep -nH -E "$REBUILD_RE" "$BASE"/commands/*.md 2>/dev/null || true)
113
+ fi
114
+
115
+ # The destination is a variable; resolve it to a path SUFFIX by reading that variable's
116
+ # binding **in the same document** and dropping everything up to and including the last
117
+ # `$VAR/` (then the leading variable name, by cutting at the first `/`). Only the suffix is
118
+ # compared, so the binding's own base variable (`$SPECS_DIR`, `$REPO`) never has to be
119
+ # resolved — the question is only "does the record live under there?".
120
+ dest_suffix() {
121
+ local doc=$1 var=$2 binding
122
+ binding=$(grep -oE "^[[:space:]]*${var}=\"[^\"]*\"" "$BASE/$doc" 2>/dev/null | head -1)
123
+ [ -n "$binding" ] || { printf ''; return; }
124
+ binding=${binding#*=\"}
125
+ binding=${binding%\"}
126
+ binding=${binding##*\$}
127
+ printf '%s' "${binding#*/}"
128
+ }
129
+
130
+ SITE_N=0
131
+ while IFS= read -r entry; do
132
+ [ -n "$entry" ] || continue
133
+ SITE_N=$((SITE_N + 1))
134
+ # `grep -nH` emits `path:line:content` — split three ways; comparing the whole remainder
135
+ # would carry the line number and never match anything.
136
+ file=${entry%%:*}
137
+ rest=${entry#*:}
138
+ num=${rest%%:*}
139
+ text=${rest#*:}
140
+ text=${text#"${text%%[![:space:]]*}"}
141
+ rel=${file#"$BASE"/}
142
+ var=$(printf '%s' "$text" | grep -oE '\$[A-Za-z_][A-Za-z0-9_]*' | tail -1 | tr -d '$')
143
+ suffix=$(dest_suffix "$rel" "$var")
144
+ if [ -z "$suffix" ]; then
145
+ hit "rebuild site: cannot resolve \$$var in $rel:$num — nothing is proven about it"
146
+ continue
147
+ fi
148
+ ok "rebuild site: $rel:$num → $suffix"
149
+ done <<EOF
150
+ $SITES
151
+ EOF
152
+ if [ "$SITE_N" -eq 0 ]; then
153
+ skip "no \`cp … \$VAR\` line found in commands/ — nothing is proven about rebuild sites"
154
+ fi
155
+
156
+ # -----------------------------------------------------------------------------------------
157
+ # 3. The proposition: no record sits where a rebuild lands
158
+ # -----------------------------------------------------------------------------------------
159
+ echo " [3] no cross-round record sits on a rebuild path"
160
+ CHECKED=0
161
+ while IFS= read -r entry; do
162
+ [ -n "$entry" ] || continue
163
+ file=${entry%%:*}
164
+ rest=${entry#*:}
165
+ text=${rest#*:}
166
+ text=${text#"${text%%[![:space:]]*}"}
167
+ rel=${file#"$BASE"/}
168
+ var=$(printf '%s' "$text" | grep -oE '\$[A-Za-z_][A-Za-z0-9_]*' | tail -1 | tr -d '$')
169
+ suffix=$(dest_suffix "$rel" "$var")
170
+ [ -n "$suffix" ] || continue
171
+ while IFS= read -r r; do
172
+ [ -n "$r" ] || continue
173
+ CHECKED=$((CHECKED + 1))
174
+ case "$r" in
175
+ */"$suffix"|"$suffix")
176
+ hit "a cross-round record lives in an artifact rebuilt wholesale — $r (rebuilt by $rel)" ;;
177
+ *)
178
+ ok "not on a rebuild path: $r" ;;
179
+ esac
180
+ done <<EOF
181
+ $RECORDS
182
+ EOF
183
+ done <<EOF
184
+ $SITES
185
+ EOF
186
+ if [ "$CHECKED" -eq 0 ]; then
187
+ skip "no (record × rebuild site) pair to compare — nothing is proven"
188
+ fi
189
+
190
+ # -----------------------------------------------------------------------------------------
191
+ # ⚠️ Coverage, stated rather than implied: the check is only as wide as its two derivations.
192
+ # · a record that does not declare itself is invisible here (see check 1)
193
+ # · a rebuild that is not `cp`-based, or whose destination is not a variable, is invisible
194
+ # here — e.g. a command that rewrites a file through `awk`/`sed` in place.
195
+ # Both are the "enumeration narrower than the distribution" shape. Widening either means
196
+ # widening the derivation, never adding a hand-written row.
197
+ # -----------------------------------------------------------------------------------------
198
+ echo
199
+ if [ "$HITS" -gt 0 ]; then
200
+ echo "✗ cumulative records: $HITS violation(s)"
201
+ exit 1
202
+ fi
203
+ echo "✓ cumulative records: no record sits on a rebuild path"
@@ -0,0 +1,147 @@
1
+ #!/bin/bash
2
+ #
3
+ # Deliverables-tracked verifier — every deliverable path must be tracked by git.
4
+ #
5
+ # Carrier for: specs/tasks.md -> T150 (carrying ISS-56 ②). The criterion is stated
6
+ # there as: `git ls-files --error-unmatch <path>` holds for EVERY deliverable path.
7
+ #
8
+ # The three deliverable classes it checks (same task):
9
+ # ① paths named by the Location of each CHECKED task in specs/tasks.md
10
+ # ② source files under commands/ · templates/ · scripts/ (+ tools/ in this repo)
11
+ # ③ artifacts declared by the commands' `writes:` blocks — they land in the
12
+ # artifact repos (specs/ · docs/), so those repos are swept whole
13
+ #
14
+ # ⚠️ SCOPE IS THE SUBJECT, NOT THE WHOLE REPO (ISS-75): the old wording counted
15
+ # ALL untracked-unignored files, which swept in root-level session transcripts
16
+ # (handoff*.md) that are NOT deliverables. A criterion failing on non-subjects
17
+ # invites "fix the criterion to match reality" — shape 1. So this verifier only
18
+ # asserts INSIDE the deliverable dirs and the artifact repos; a root-level
19
+ # transcript is out of scope by construction, not by exemption list.
20
+ #
21
+ # Class ① parsing is deliberately conservative: a backtick segment is checked
22
+ # only when it is unambiguously a path (no spaces, no globs). Under-reporting is
23
+ # accepted (the same discipline as the documented-snippet verifier); a false
24
+ # accusation is not — the contract's stated failure mode is losing credibility.
25
+ #
26
+ # Bash 3.2 compatible by construction — no 4.0+ expansions, no associative arrays.
27
+ # Read-only: nothing is written.
28
+ #
29
+ set -u
30
+
31
+ REPO=$(git rev-parse --show-toplevel 2>/dev/null)
32
+ if [ -z "$REPO" ]; then
33
+ echo "deliverables-tracked: not inside a git repository"
34
+ exit 1
35
+ fi
36
+ echo "deliverables-tracked: $REPO"
37
+ FAIL=0
38
+
39
+ # --- class ② — deliverable source dirs in THIS repo ------------------------
40
+ SRC_DIRS="commands templates scripts tools"
41
+ untracked=$(cd "$REPO" && git ls-files --others --exclude-standard -- $SRC_DIRS)
42
+ if [ -n "$untracked" ]; then
43
+ FAIL=1
44
+ echo "✗ class ② — untracked files under deliverable source dirs ($SRC_DIRS):"
45
+ printf '%s\n' "$untracked" | sed 's/^/ /'
46
+ else
47
+ echo "✓ class ② — source dirs ($SRC_DIRS): every file tracked"
48
+ fi
49
+
50
+ # --- class ③ — artifact repos (specs/ · docs/) ------------------------------
51
+ for sub in specs docs; do
52
+ if [ -e "$REPO/$sub/.git" ]; then
53
+ u=$(git -C "$REPO/$sub" ls-files --others --exclude-standard)
54
+ if [ -n "$u" ]; then
55
+ FAIL=1
56
+ echo "✗ class ③ — untracked files in the $sub/ artifact repo:"
57
+ printf '%s\n' "$u" | sed 's/^/ /'
58
+ else
59
+ echo "✓ class ③ — artifact repo $sub/: every file tracked"
60
+ fi
61
+ fi
62
+ done
63
+
64
+ # --- class ① — Location paths named by CHECKED tasks ------------------------
65
+ #
66
+ # ⚠️ Judgment is THREE-CONDITIONED, and the reason is measured: backtick segments in
67
+ # a task line include descriptions' code words (function names, flags, FR ids),
68
+ # so "not tracked" alone accuses hundreds of non-paths — a false-accusation rate
69
+ # that would destroy the criterion's credibility on its first run. The verdict
70
+ # therefore needs the file to EXIST first, and (T254 / ISS-235) git to NOT be
71
+ # deliberately ignoring it:
72
+ # tracked -> verified
73
+ # untracked BUT on disk -> real violation (written, never committed)
74
+ # ignored BY DESIGN -> skipped (a derived deploy mirror — `.claude/`,
75
+ # `.specpro/` — is gitignored on purpose; the
76
+ # deliverable is its source side, covered by the
77
+ # same Location's other path)
78
+ # not on disk at all -> skipped (future deliverable / shorthand / note)
79
+ TASKS="$REPO/specs/tasks.md"
80
+ checked_n=0
81
+ skipped_n=0
82
+ if [ -f "$TASKS" ]; then
83
+ segments=$(sed -n 's/^- \[x\] T[0-9].*$/&/p' "$TASKS" | grep -oE '`[^`]+`' | tr -d '`')
84
+ while IFS= read -r seg; do
85
+ [ -z "$seg" ] && continue
86
+ case "$seg" in
87
+ *\ *) skipped_n=$((skipped_n + 1)); continue ;; # space -> prose/note
88
+ specs/*) repo_path="$REPO/specs"; rel=${seg#specs/} ;;
89
+ docs/*) repo_path="$REPO/docs"; rel=${seg#docs/} ;;
90
+ /*) skipped_n=$((skipped_n + 1)); continue ;; # absolute
91
+ *) repo_path="$REPO"; rel=$seg ;;
92
+ esac
93
+ # A segment that reduces to an empty path (`specs/` bare) is not a path —
94
+ # feeding it to git only produces a pathspec error on stderr (T254).
95
+ if [ -z "$rel" ]; then
96
+ skipped_n=$((skipped_n + 1))
97
+ continue
98
+ fi
99
+ # Brace expansion, one level: `x.{a,b}.md` -> prefix + each + suffix.
100
+ case "$rel" in
101
+ *\{*)
102
+ pre=${rel%%\{*}
103
+ tmp=${rel#*\{}
104
+ mid=${tmp%%\}*}
105
+ suf=${tmp#*\}}
106
+ for br in $(printf '%s' "$mid" | tr ',' ' '); do
107
+ one="$pre$br$suf"
108
+ if [ -z "$one" ]; then
109
+ skipped_n=$((skipped_n + 1))
110
+ continue
111
+ fi
112
+ if git -C "$repo_path" check-ignore -q -- "$one"; then
113
+ skipped_n=$((skipped_n + 1)) # ignored by design: derived mirror, not a deliverable
114
+ elif git -C "$repo_path" ls-files --error-unmatch -- "$one" >/dev/null 2>&1; then
115
+ checked_n=$((checked_n + 1))
116
+ elif [ -f "$repo_path/$one" ]; then
117
+ FAIL=1
118
+ echo "✗ class ① — task Location file exists but is NOT tracked: $seg (expanded: $one)"
119
+ else
120
+ skipped_n=$((skipped_n + 1))
121
+ fi
122
+ done
123
+ continue
124
+ ;;
125
+ esac
126
+ if git -C "$repo_path" check-ignore -q -- "$rel"; then
127
+ skipped_n=$((skipped_n + 1)) # ignored by design: derived mirror, not a deliverable
128
+ elif git -C "$repo_path" ls-files --error-unmatch -- "$rel" >/dev/null 2>&1; then
129
+ checked_n=$((checked_n + 1))
130
+ elif [ -f "$repo_path/$rel" ]; then
131
+ FAIL=1
132
+ echo "✗ class ① — task Location file exists but is NOT tracked: $seg"
133
+ else
134
+ skipped_n=$((skipped_n + 1))
135
+ fi
136
+ done <<EOF
137
+ $segments
138
+ EOF
139
+ echo "✓ class ① — task Locations: $checked_n path(s) verified tracked · $skipped_n skipped (not on disk / not a path / ignored-by-design)"
140
+ fi
141
+
142
+ if [ "$FAIL" -eq 0 ]; then
143
+ echo "✓ deliverables-tracked: every deliverable path is tracked"
144
+ exit 0
145
+ fi
146
+ echo "✗ deliverables-tracked: untracked deliverables found — commit them or fix the criterion"
147
+ exit 1
@@ -0,0 +1,239 @@
1
+ #!/bin/bash
2
+ #
3
+ # Deployment-drift verifier — every mirror group must match its source.
4
+ #
5
+ # Why this exists: 宪法 Q1 requires that "一份内容在多个位置存在时,它们不得在无人
6
+ # 察觉的情况下分叉", and 宪法 1.1 requires that "命令与模板的改动与部署动作成对提交,
7
+ # 不留'已改源未部署'的中间态". Before this script, the only thing enforcing either was
8
+ # an instruction to run three `diff` commands by hand — and "记得跑" is not an enforcement
9
+ # point. The enforcement point is `scripts/git-hooks/pre-commit` §3, which calls this.
10
+ #
11
+ # 核验强度按目标形态确定 (plan.md → Constraint 1), and the form is judged FIRST:
12
+ # same — the mirror is a byte-for-byte copy ⇒ diff -rq, zero output expected
13
+ # derived — the mirror is produced by a rule (e.g. the skills layout) ⇒ "同一源 +
14
+ # 同一基类 ⇒ 同一产出" is the criterion, and **it is not implemented here**
15
+ #
16
+ # ⚠️ A group whose form is `derived` FAILS, loudly. That is deliberate: the alternative
17
+ # is to run a byte-comparison against a target that cannot be byte-equal, which reports
18
+ # failure for a correct deployment — or to skip it, which reports success for an
19
+ # unchecked one. A verifier that cannot check a group must say so, not pass it.
20
+ #
21
+ # ⚠️ A missing mirror FAILS too — never "skip". An absent group and a clean group are
22
+ # indistinguishable in the output otherwise, which is the 空跑报绿 shape (ISS-83).
23
+ #
24
+ # Usage: scripts/bash/verify-deployment.sh
25
+ # DEPLOY_HOOK_BASE=<path> scripts/bash/verify-deployment.sh (verify a copy)
26
+ # Exit: 0 = every group is in sync; 1 = at least one violation (details printed)
27
+
28
+ # ⚠️ NOT `set -e`: this script's logic is driven by reading the exit codes of `diff`, and
29
+ # `set -e` would end it before the reading happens (宪法 2.2.2, second carve-out — "其逻辑
30
+ # 以退出码为输入的核验脚本").
31
+ set -uo pipefail
32
+
33
+ # The repository root is resolved from THIS script's location, not from the caller's cwd:
34
+ # the pre-commit hook runs from the repo root, but a fixture case runs the script from a
35
+ # temporary tree, and a cwd-relative base would then verify the wrong place (or nothing).
36
+ # `DEPLOY_HOOK_BASE` overrides it for exactly that fixture case.
37
+ #
38
+ # ⚠️ **Position-independent, and the result is TESTED** (T180 / `ISS-124`): the previous
39
+ # `dirname "${BASH_SOURCE[0]}"/../..` is correct only from the SOURCE layout — from the
40
+ # deployed mirror (`.specpro/scripts/bash/…`) it lands one level short, on `.specpro`.
41
+ # ⚠️ That failure is silent in the worst direction: a mirror table read against the
42
+ # wrong tree still prints "✓ in sync", indistinguishable from a real pass. And this
43
+ # script is the one the pre-commit hook calls, so the wrong tree is the likely one.
44
+ #
45
+ # Order: git (correct from BOTH positions) → the two candidate depths, each tested.
46
+ # The sentinel tests the RESULT, not the method — including for an override, since an
47
+ # override that names a non-root is exactly what it exists to catch.
48
+ is_repo_root() { [ -d "$1/commands" ] && [ -d "$1/templates" ] && [ -d "$1/scripts" ]; }
49
+
50
+ resolve_base() {
51
+ local cand here
52
+ if cand=$(git rev-parse --show-toplevel 2>/dev/null) && [ -n "$cand" ] && is_repo_root "$cand"; then
53
+ printf '%s' "$cand"; return 0
54
+ fi
55
+ here=$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
56
+ for cand in "$here/../.." "$here/../../.."; do
57
+ cand=$(CDPATH="" cd "$cand" 2>/dev/null && pwd) || continue
58
+ is_repo_root "$cand" && { printf '%s' "$cand"; return 0; }
59
+ done
60
+ return 1
61
+ }
62
+
63
+ if [ -n "${DEPLOY_HOOK_BASE:-}" ]; then
64
+ BASE="$DEPLOY_HOOK_BASE"
65
+ else
66
+ BASE=$(resolve_base) || BASE=''
67
+ fi
68
+
69
+ if ! is_repo_root "$BASE"; then
70
+ echo "✗ BASE is not a repository root: '${BASE:-<unresolved>}'" >&2
71
+ echo " Expected it to contain commands/ , templates/ and scripts/ ." >&2
72
+ if [ -n "${DEPLOY_HOOK_BASE:-}" ]; then
73
+ echo " Source: the DEPLOY_HOOK_BASE override." >&2
74
+ else
75
+ echo " Source: auto-resolution (git toplevel, then the script's two possible depths)." >&2
76
+ fi
77
+ echo " ⚠️ Every group below would be compared against a tree this script never read —" >&2
78
+ echo " an absent mirror and an in-sync mirror are the same output otherwise." >&2
79
+ exit 1
80
+ fi
81
+
82
+ # --- the mirror table ---------------------------------------------------------------
83
+ # 源 | 镜像 | 形态. One row per group. The form column is the authority for the check
84
+ # below — it is NOT guessed from the target's shape, because guessing turns a design
85
+ # decision into a filesystem accident.
86
+ PAIRS=(
87
+ "commands|.claude/skills|derived"
88
+ "templates|.specpro/templates|same"
89
+ "scripts/bash|.specpro/scripts/bash|same"
90
+ )
91
+
92
+ # --- the exclusion list, and why it is not a hidden default --------------------------
93
+ # `scripts/` entries with no mirror are listed HERE, by name, with a reason. A directory
94
+ # that is neither mirrored nor listed is a violation (§reverse below) — otherwise the
95
+ # next un-mirrored directory joins silently, which is the same silent-uncoverage the
96
+ # derived section list was removed to fix (ISS-88).
97
+ #
98
+ # ⚠️ `powershell` is here because the bootstrap instance does not need it: the mirror
99
+ # exists so THIS repository can run its own toolchain, and this repository is not Windows.
100
+ # The PowerShell implementations ship to user projects through the CLI's asset snapshot,
101
+ # which has its own verifier (Constraint 4). ⇒ It is excluded by DECISION, not by neglect.
102
+ EXCLUDED_IN_SCRIPTS="powershell git-hooks install-git-hooks.sh"
103
+
104
+ FAIL=0
105
+ echo "Deployment drift: $BASE"
106
+ echo
107
+
108
+ for pair in "${PAIRS[@]}"; do
109
+ src="${BASE}/${pair%%|*}"
110
+ rest="${pair#*|}"
111
+ dst="${BASE}/${rest%%|*}"
112
+ form="${rest##*|}"
113
+
114
+ printf " [%s] %s → %s\n" "$form" "${pair%%|*}" "${rest%%|*}"
115
+
116
+ if [ ! -e "$src" ]; then
117
+ echo " ✗ source does not exist: $src"
118
+ echo " → the table names a group that is not there; fix the table or restore the source."
119
+ FAIL=1
120
+ continue
121
+ fi
122
+
123
+ # ⚠️ NOT `continue` / NOT `: ok`. A missing mirror is a violation, and saying so is
124
+ # the whole difference between a verifier and a decoration.
125
+ if [ ! -d "$dst" ]; then
126
+ echo " ✗ mirror missing: $dst"
127
+ echo " → deploy the source, or add the group to EXCLUDED_* by name with a reason."
128
+ echo " A missing target MUST NOT be skipped: an absent group and a clean one look identical."
129
+ FAIL=1
130
+ continue
131
+ fi
132
+
133
+ if [ "$form" = "derived" ]; then
134
+ # Reproducible check (FR-007 / 宪法 1.1 T3): same source + same base class ⇒
135
+ # same output. The mirror is produced by THIS repo's install CLI, so the check
136
+ # runs that CLI twice from the CURRENT source into two temp dirs — the two
137
+ # outputs must agree byte-for-byte (determinism), and the mirror must equal the
138
+ # first (sync). A stale mirror therefore fails exactly like a drifted copy,
139
+ # which is the drift this gate exists to catch. Two installs, not one: a single
140
+ # install-vs-mirror comparison would silently accept a CLI that embeds
141
+ # timestamps or paths into its output.
142
+ #
143
+ # ⚠️ No installer ⇒ UNVERIFIED ⇒ FAIL. Never skip: an unchecked group that
144
+ # reports success is indistinguishable from a clean one.
145
+ py=""
146
+ for pycand in "$BASE/tools/install/.venv/bin/python" "$BASE/tools/install/.venv/Scripts/python.exe"; do
147
+ if [ -x "$pycand" ]; then py="$pycand"; break; fi
148
+ done
149
+ if [ -z "$py" ]; then
150
+ echo " ✗ derived check cannot run: no install CLI venv under tools/install/."
151
+ echo " → the derived mirror is produced by this repo's own CLI; without it this"
152
+ echo " group is UNVERIFIED, and reporting success would be a false pass."
153
+ FAIL=1
154
+ continue
155
+ fi
156
+ tmp1=$(mktemp -d "${TMPDIR:-/tmp}/specpro-derived.XXXXXX") || tmp1=""
157
+ tmp2=$(mktemp -d "${TMPDIR:-/tmp}/specpro-derived.XXXXXX") || tmp2=""
158
+ if [ -z "$tmp1" ] || [ -z "$tmp2" ]; then
159
+ echo " ✗ derived check cannot run: mktemp failed."
160
+ FAIL=1
161
+ continue
162
+ fi
163
+ inst_ok=1
164
+ ( cd "$BASE/tools/install" && "$py" -c "from specpro_cli.cli.entry import app; app()" init "$tmp1" --agent claude --source "$BASE" >/dev/null 2>&1 ) || inst_ok=0
165
+ ( cd "$BASE/tools/install" && "$py" -c "from specpro_cli.cli.entry import app; app()" init "$tmp2" --agent claude --source "$BASE" >/dev/null 2>&1 ) || inst_ok=0
166
+ if [ "$inst_ok" = 0 ] || [ ! -d "$tmp1/.claude/skills" ] || [ ! -d "$tmp2/.claude/skills" ]; then
167
+ echo " ✗ derived check cannot run: the install CLI failed to produce .claude/skills."
168
+ echo " → fix the CLI or its venv; until then this group is UNVERIFIED (never a silent pass)."
169
+ FAIL=1
170
+ rm -rf "$tmp1" "$tmp2"
171
+ continue
172
+ fi
173
+ if [ -n "$(diff -rq -x '.DS_Store' "$tmp1/.claude/skills" "$tmp2/.claude/skills" 2>&1)" ]; then
174
+ echo " ✗ derived: two installs of the same source DISAGREE — output is not reproducible."
175
+ rm -rf "$tmp1" "$tmp2"
176
+ FAIL=1
177
+ continue
178
+ fi
179
+ drift=$(diff -rq -x '.DS_Store' "$tmp1/.claude/skills" "$dst" 2>&1)
180
+ # Normalize the temp prefix away: the drift lines must not depend on where the
181
+ # fresh install landed (bash and the PowerShell twin resolve different temp
182
+ # roots, and the parity contract is "same stdout").
183
+ drift=${drift//"$tmp1"/'<fresh-install>'}
184
+ rm -rf "$tmp1" "$tmp2"
185
+ if [ -n "$drift" ]; then
186
+ echo " ✗ derived drift (mirror ≠ fresh install of the current source):"
187
+ echo "$drift" | sed 's/^/ /'
188
+ FAIL=1
189
+ else
190
+ echo " ✓ derived: reproducible (2 installs agree) and in sync with the current source"
191
+ fi
192
+ continue
193
+ fi
194
+
195
+ drift=$(diff -rq -x '.DS_Store' "$src" "$dst" 2>&1)
196
+ if [ -n "$drift" ]; then
197
+ echo " ✗ drift:"
198
+ echo "$drift" | sed 's/^/ /'
199
+ FAIL=1
200
+ else
201
+ echo " ✓ byte-identical"
202
+ fi
203
+ done
204
+
205
+ # --- reverse assertion: nothing under scripts/ is silently unmirrored -----------------
206
+ echo
207
+ if [ -d "$BASE/scripts" ]; then
208
+ uncovered=""
209
+ for entry in "$BASE/scripts"/*; do
210
+ [ -e "$entry" ] || continue
211
+ name=$(basename "$entry")
212
+ case " $EXCLUDED_IN_SCRIPTS " in *" $name "*) continue ;; esac
213
+ covered=0
214
+ for pair in "${PAIRS[@]}"; do
215
+ case "${pair%%|*}" in "scripts/$name"*) covered=1 ;; esac
216
+ done
217
+ [ "$covered" = 0 ] && uncovered="$uncovered $name"
218
+ done
219
+ if [ -n "$uncovered" ]; then
220
+ echo " ✗ scripts/ entries that are neither mirrored nor listed as excluded:"
221
+ printf ' %s\n' $uncovered
222
+ echo " → add a mirror row, or add the name to EXCLUDED_IN_SCRIPTS with a reason."
223
+ echo " Otherwise the next un-mirrored directory joins without anyone noticing."
224
+ FAIL=1
225
+ else
226
+ echo " ✓ every scripts/ entry is either mirrored or listed as excluded"
227
+ fi
228
+ fi
229
+
230
+ # --- verdict --------------------------------------------------------------------------
231
+ echo
232
+ if [ "$FAIL" = 0 ]; then
233
+ echo "✓ deployment: every group is in sync"
234
+ exit 0
235
+ else
236
+ echo "✗ deployment: VIOLATIONS FOUND (see above)"
237
+ echo " → 宪法 1.1: 部署是该次落地的收尾动作 —— 源与镜像的差异不得跨提交存在。"
238
+ exit 1
239
+ fi
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ #
3
+ # Frontmatter-YAML verifier — every frontmatter block in commands/*.md MUST parse
4
+ # as YAML. Carrier for: specs/tasks.md -> T152 (carrying ISS-59 ① · ISS-81).
5
+ #
6
+ # Why this exists: CLAUDE.md states the command topology is NOT prose but the
7
+ # machine-readable `handoffs:` declarations — and the `writes:` ownership map
8
+ # lives in the same frontmatter block. A frontmatter block that fails to parse
9
+ # and a block that does not exist are INDISTINGUISHABLE to every executor
10
+ # (shape 3: two states no mechanism tells apart, and nothing reports it).
11
+ # The triggering instance was one unquoted colon in a `description:` — fixed
12
+ # by hand, leaving "nothing checks the next one". This script is the check
13
+ # for the next one.
14
+ #
15
+ # Scope (ISS-81 ruling, narrowing an over-broad original): "凡有 frontmatter
16
+ # 的产物都必须可解析" — templates/ carry no frontmatter and are OUT of the scan
17
+ # face. The deployed mirror .claude/skills/ (derived form since T090) is generated
18
+ # from commands/ by the install CLI and held by the deployment verifier, so scanning
19
+ # the source side covers both.
20
+ #
21
+ # Bash 3.2 compatible. Read-only. Requires python3 with PyYAML (the install CLI
22
+ # is a Python package — a maintained checkout has both).
23
+ #
24
+ set -u
25
+
26
+ REPO=$(git rev-parse --show-toplevel 2>/dev/null)
27
+ if [ -z "$REPO" ]; then
28
+ echo "frontmatter-yaml: not inside a git repository"
29
+ exit 1
30
+ fi
31
+
32
+ COMMANDS_DIR="$REPO/commands"
33
+ if [ ! -d "$COMMANDS_DIR" ]; then
34
+ echo "frontmatter-yaml: no commands/ directory at $REPO — nothing to scan"
35
+ exit 1
36
+ fi
37
+
38
+ python3 - "$COMMANDS_DIR" <<'PY'
39
+ import glob
40
+ import os
41
+ import sys
42
+
43
+ import yaml
44
+
45
+ d = sys.argv[1]
46
+ parsed = 0
47
+ bad = []
48
+ for f in sorted(glob.glob(os.path.join(d, '*.md'))):
49
+ with open(f, encoding='utf-8') as fh:
50
+ t = fh.read()
51
+ if not t.startswith('---'):
52
+ continue # "凡有 frontmatter 的产物" — absent frontmatter is out of scope
53
+ parsed += 1
54
+ end = t.find('\n---', 3)
55
+ try:
56
+ yaml.safe_load(t[3:end])
57
+ except Exception as ex:
58
+ print(f"✗ {os.path.relpath(f, d)}: {str(ex).splitlines()[0]}")
59
+ bad.append(f)
60
+ print(f"frontmatter-yaml: {parsed} frontmatter block(s) parsed · {len(bad)} failed")
61
+ sys.exit(1 if bad else 0)
62
+ PY
63
+ exit $?