bigpowers 2.90.2 → 2.91.1

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 (140) hide show
  1. package/.agents/skills/build-epic/SKILL.md +1 -1
  2. package/.agents/skills/commit-message/SKILL.md +1 -0
  3. package/.agents/skills/guard-git/SKILL.md +7 -2
  4. package/.agents/skills/release-branch/SKILL.md +4 -5
  5. package/.agents/skills/run-planning/SKILL.md +6 -1
  6. package/.agents/skills/verify-work/SKILL.md +2 -1
  7. package/.cline/skills/build-epic/SKILL.md +1 -1
  8. package/.cline/skills/commit-message/SKILL.md +1 -0
  9. package/.cline/skills/guard-git/SKILL.md +7 -2
  10. package/.cline/skills/release-branch/SKILL.md +4 -5
  11. package/.cline/skills/run-planning/SKILL.md +6 -1
  12. package/.cline/skills/verify-work/SKILL.md +2 -1
  13. package/.codebuddy/skills/build-epic/SKILL.md +1 -1
  14. package/.codebuddy/skills/commit-message/SKILL.md +1 -0
  15. package/.codebuddy/skills/guard-git/SKILL.md +7 -2
  16. package/.codebuddy/skills/release-branch/SKILL.md +4 -5
  17. package/.codebuddy/skills/run-planning/SKILL.md +6 -1
  18. package/.codebuddy/skills/verify-work/SKILL.md +2 -1
  19. package/.codex/skills/build-epic/SKILL.md +1 -1
  20. package/.codex/skills/commit-message/SKILL.md +1 -0
  21. package/.codex/skills/guard-git/SKILL.md +7 -2
  22. package/.codex/skills/release-branch/SKILL.md +4 -5
  23. package/.codex/skills/run-planning/SKILL.md +6 -1
  24. package/.codex/skills/verify-work/SKILL.md +2 -1
  25. package/.copilot/skills/build-epic/SKILL.md +1 -1
  26. package/.copilot/skills/commit-message/SKILL.md +1 -0
  27. package/.copilot/skills/guard-git/SKILL.md +7 -2
  28. package/.copilot/skills/release-branch/SKILL.md +4 -5
  29. package/.copilot/skills/run-planning/SKILL.md +6 -1
  30. package/.copilot/skills/verify-work/SKILL.md +2 -1
  31. package/.cursor/rules/build-epic.mdc +1 -1
  32. package/.cursor/rules/commit-message.mdc +1 -0
  33. package/.cursor/rules/guard-git.mdc +7 -2
  34. package/.cursor/rules/release-branch.mdc +4 -5
  35. package/.cursor/rules/run-planning.mdc +6 -1
  36. package/.cursor/rules/verify-work.mdc +2 -1
  37. package/.gemini/extensions/bigpowers/commands/prompts/build-epic.md +1 -1
  38. package/.gemini/extensions/bigpowers/commands/prompts/commit-message.md +1 -0
  39. package/.gemini/extensions/bigpowers/commands/prompts/guard-git.md +7 -2
  40. package/.gemini/extensions/bigpowers/commands/prompts/release-branch.md +4 -5
  41. package/.gemini/extensions/bigpowers/commands/prompts/run-planning.md +6 -1
  42. package/.gemini/extensions/bigpowers/commands/prompts/verify-work.md +2 -1
  43. package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
  44. package/.gemini/extensions/bigpowers/skills/build-epic/SKILL.md +1 -1
  45. package/.gemini/extensions/bigpowers/skills/commit-message/SKILL.md +1 -0
  46. package/.gemini/extensions/bigpowers/skills/guard-git/SKILL.md +7 -2
  47. package/.gemini/extensions/bigpowers/skills/release-branch/SKILL.md +4 -5
  48. package/.gemini/extensions/bigpowers/skills/run-planning/SKILL.md +6 -1
  49. package/.gemini/extensions/bigpowers/skills/verify-work/SKILL.md +2 -1
  50. package/.hermes/skills/build-epic/SKILL.md +1 -1
  51. package/.hermes/skills/commit-message/SKILL.md +1 -0
  52. package/.hermes/skills/guard-git/SKILL.md +7 -2
  53. package/.hermes/skills/release-branch/SKILL.md +4 -5
  54. package/.hermes/skills/run-planning/SKILL.md +6 -1
  55. package/.hermes/skills/verify-work/SKILL.md +2 -1
  56. package/.kilocode/rules/build-epic.md +1 -1
  57. package/.kilocode/rules/commit-message.md +1 -0
  58. package/.kilocode/rules/guard-git.md +7 -2
  59. package/.kilocode/rules/release-branch.md +4 -5
  60. package/.kilocode/rules/run-planning.md +6 -1
  61. package/.kilocode/rules/verify-work.md +2 -1
  62. package/.mimocode/skills/build-epic/SKILL.md +1 -1
  63. package/.mimocode/skills/commit-message/SKILL.md +1 -0
  64. package/.mimocode/skills/guard-git/SKILL.md +7 -2
  65. package/.mimocode/skills/release-branch/SKILL.md +4 -5
  66. package/.mimocode/skills/run-planning/SKILL.md +6 -1
  67. package/.mimocode/skills/verify-work/SKILL.md +2 -1
  68. package/.opencode/skills/build-epic/SKILL.md +1 -1
  69. package/.opencode/skills/commit-message/SKILL.md +1 -0
  70. package/.opencode/skills/guard-git/SKILL.md +7 -2
  71. package/.opencode/skills/release-branch/SKILL.md +4 -5
  72. package/.opencode/skills/run-planning/SKILL.md +6 -1
  73. package/.opencode/skills/verify-work/SKILL.md +2 -1
  74. package/.pi/package.json +1 -1
  75. package/.pi/prompts/build-epic.md +1 -1
  76. package/.pi/prompts/commit-message.md +1 -0
  77. package/.pi/prompts/guard-git.md +7 -2
  78. package/.pi/prompts/release-branch.md +4 -5
  79. package/.pi/prompts/run-planning.md +6 -1
  80. package/.pi/prompts/verify-work.md +2 -1
  81. package/.pi/skills/build-epic/SKILL.md +1 -1
  82. package/.pi/skills/commit-message/SKILL.md +1 -0
  83. package/.pi/skills/guard-git/SKILL.md +7 -2
  84. package/.pi/skills/release-branch/SKILL.md +4 -5
  85. package/.pi/skills/run-planning/SKILL.md +6 -1
  86. package/.pi/skills/verify-work/SKILL.md +2 -1
  87. package/.qwen/skills/build-epic/SKILL.md +1 -1
  88. package/.qwen/skills/commit-message/SKILL.md +1 -0
  89. package/.qwen/skills/guard-git/SKILL.md +7 -2
  90. package/.qwen/skills/release-branch/SKILL.md +4 -5
  91. package/.qwen/skills/run-planning/SKILL.md +6 -1
  92. package/.qwen/skills/verify-work/SKILL.md +2 -1
  93. package/.trae/skills/build-epic/SKILL.md +1 -1
  94. package/.trae/skills/commit-message/SKILL.md +1 -0
  95. package/.trae/skills/guard-git/SKILL.md +7 -2
  96. package/.trae/skills/release-branch/SKILL.md +4 -5
  97. package/.trae/skills/run-planning/SKILL.md +6 -1
  98. package/.trae/skills/verify-work/SKILL.md +2 -1
  99. package/.windsurf/rules/build-epic.md +1 -1
  100. package/.windsurf/rules/commit-message.md +1 -0
  101. package/.windsurf/rules/guard-git.md +7 -2
  102. package/.windsurf/rules/release-branch.md +4 -5
  103. package/.windsurf/rules/run-planning.md +6 -1
  104. package/.windsurf/rules/verify-work.md +2 -1
  105. package/.zcode/skills/build-epic/SKILL.md +1 -1
  106. package/.zcode/skills/commit-message/SKILL.md +1 -0
  107. package/.zcode/skills/guard-git/SKILL.md +7 -2
  108. package/.zcode/skills/release-branch/SKILL.md +4 -5
  109. package/.zcode/skills/run-planning/SKILL.md +6 -1
  110. package/.zcode/skills/verify-work/SKILL.md +2 -1
  111. package/CHANGELOG.md +15 -0
  112. package/extensions/omp-hooks.ts +197 -58
  113. package/hooks/pre/bash.sh +54 -7
  114. package/hooks/pre-tool-use.sh +72 -12
  115. package/package.json +2 -1
  116. package/scripts/convert-legado.sh +1 -0
  117. package/scripts/lib/__pycache__/atomic_write.cpython-310.pyc +0 -0
  118. package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
  119. package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
  120. package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
  121. package/scripts/lib/assert-epic-gate.sh +32 -0
  122. package/scripts/lib/assert-planning-artifacts.sh +65 -0
  123. package/scripts/lib/git-argv.py +168 -0
  124. package/scripts/lib/golden-suite-gates.sh +2 -0
  125. package/scripts/omp-guard-probe.ts +46 -0
  126. package/scripts/test-generated-path-hook.sh +57 -0
  127. package/scripts/test-guard-git-normalize.sh +189 -0
  128. package/scripts/test-test-lock-hook.sh +100 -0
  129. package/scripts/validate-doctrine.sh +4 -4
  130. package/scripts/verify-adversarial.sh +146 -0
  131. package/scripts/verify-tdd-red-commit.sh +39 -1
  132. package/skills/build-epic/SKILL.md +1 -1
  133. package/skills/commit-message/SKILL.md +1 -0
  134. package/skills/guard-git/SKILL.md +7 -2
  135. package/skills/release-branch/SKILL.md +4 -5
  136. package/skills/run-planning/SKILL.md +6 -1
  137. package/skills/verify-work/SKILL.md +2 -1
  138. package/skills-lock.json +6 -6
  139. package/tests/test-artifact-assertions.sh +86 -0
  140. package/tests/test-verify-adversarial.sh +27 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [2.91.1](https://github.com/danielvm-git/bigpowers/compare/v2.91.0...v2.91.1) (2026-11-03)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **hooks:** give each guard fixture row its own branch context ([#196](https://github.com/danielvm-git/bigpowers/issues/196)) ([5d379df](https://github.com/danielvm-git/bigpowers/commit/5d379df5dc1d9f139636a3f361ee35a1a1ceedd0))
7
+ * **hooks:** judge git argv instead of command substrings in guard-git ([#194](https://github.com/danielvm-git/bigpowers/issues/194)) ([9532372](https://github.com/danielvm-git/bigpowers/commit/95323721c0257964b572d9e0a37834b34955944f)), closes [#184](https://github.com/danielvm-git/bigpowers/issues/184)
8
+
9
+ # [2.91.0](https://github.com/danielvm-git/bigpowers/compare/v2.90.2...v2.91.0) (2026-11-03)
10
+
11
+
12
+ ### Features
13
+
14
+ * **hooks:** enforce mechanical gates for red-commit, artifacts, and adversarial mutation (e86) ([#191](https://github.com/danielvm-git/bigpowers/issues/191)) ([7a1b090](https://github.com/danielvm-git/bigpowers/commit/7a1b0907aa20962ee71e825c4996aa80c213be3f))
15
+
1
16
  ## [2.90.2](https://github.com/danielvm-git/bigpowers/compare/v2.90.1...v2.90.2) (2026-11-03)
2
17
 
3
18
 
@@ -18,15 +18,152 @@ import { fileURLToPath } from "node:url";
18
18
  // only truly foreign entries (e.g. a stray node_modules symlink) need exclusion.
19
19
  const EXCLUDED_DIRS = new Set([".git", "node_modules"]);
20
20
 
21
- const DANGEROUS_PATTERNS = [
22
- /git\s+reset\s+--hard/,
23
- /git\s+clean\s+-f/,
24
- /git\s+branch\s+-D/,
25
- /git\s+checkout\s+\./,
26
- /git\s+restore\s+\./,
27
- /push\s+--force/,
28
- /push\s+-f(?:\s|$)/,
29
- ];
21
+ // ---------------------------------------------------------------------------
22
+ // Git argv normalization (BUG-2026-11-03-guard-git-flag-normalize, #184).
23
+ //
24
+ // Literal substring patterns cannot see a global flag in front of the
25
+ // subcommand (`git -C repo reset --hard`), a flag between the subcommand and the
26
+ // destructive option (`git reset -q --hard`), or an alternative spelling of the
27
+ // same act (`git checkout -- .`, `git branch --delete --force`). Tokenize the
28
+ // command, drop the global options, then judge (subcommand, arguments).
29
+ //
30
+ // scripts/lib/git-argv.py is the same rule table in Python for the shell hooks;
31
+ // scripts/test-guard-git-normalize.sh fails if the two disagree on any fixture.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** Quote-aware split; a quoting error degrades to whitespace splitting, never to eval. */
35
+ export function tokenizeArgs(command: string): string[] {
36
+ const tokens: string[] = [];
37
+ let current = "";
38
+ let quote: '"' | "'" | null = null;
39
+ let hasToken = false;
40
+ for (let i = 0; i < command.length; i++) {
41
+ const ch = command[i];
42
+ if (quote) {
43
+ if (ch === quote) quote = null;
44
+ else current += ch;
45
+ continue;
46
+ }
47
+ if (ch === '"' || ch === "'") {
48
+ quote = ch as '"' | "'";
49
+ hasToken = true;
50
+ continue;
51
+ }
52
+ if (/\s/.test(ch)) {
53
+ if (hasToken) {
54
+ tokens.push(current);
55
+ current = "";
56
+ hasToken = false;
57
+ }
58
+ continue;
59
+ }
60
+ current += ch;
61
+ hasToken = true;
62
+ }
63
+ if (hasToken) tokens.push(current);
64
+ return tokens;
65
+ }
66
+
67
+ // Global options that consume the next token as their value.
68
+ const GIT_VALUE_FLAGS = new Set([
69
+ "-C", "-c", "--exec-path", "--git-dir", "--work-tree", "--namespace",
70
+ "--super-prefix", "--config-env",
71
+ ]);
72
+
73
+ // Global options that stand alone.
74
+ const GIT_BARE_FLAGS = new Set([
75
+ "-p", "--paginate", "--no-pager", "--bare", "--no-replace-objects",
76
+ "--literal-pathspecs", "--glob-pathspecs", "--icase-pathspecs",
77
+ "--no-optional-locks", "--version", "--help",
78
+ ]);
79
+
80
+ const SEPARATORS = new Set([";", "&&", "||", "|", "&", "(", ")", "\n"]);
81
+
82
+ function shortFlagHas(args: string[], letter: string): boolean {
83
+ return args.some((a) => a.startsWith("-") && !a.startsWith("--") && a.slice(1).includes(letter));
84
+ }
85
+
86
+ /** Block reason for one normalized git invocation, or null. Mirror git-argv.py. */
87
+ export function classifyGitInvocation(sub: string, args: string[]): string | null {
88
+ if (sub === "reset" && args.includes("--hard")) return "git reset --hard discards uncommitted work";
89
+ if (sub === "clean" && (shortFlagHas(args, "f") || args.includes("--force"))) {
90
+ return "git clean -f deletes untracked files";
91
+ }
92
+ if (sub === "branch") {
93
+ const forced =
94
+ args.includes("-D") ||
95
+ (args.includes("--delete") && (args.includes("--force") || shortFlagHas(args, "f")));
96
+ if (forced) return "git branch -D deletes an unmerged branch";
97
+ }
98
+ if (sub === "checkout") {
99
+ if (args.includes("-f") || args.includes("--force")) return "git checkout -f discards local changes";
100
+ if (args.includes(".")) return "git checkout . discards local changes";
101
+ }
102
+ if (sub === "restore" && (args.includes("--worktree") || args.includes("."))) {
103
+ return "git restore discards local changes";
104
+ }
105
+ if (sub === "push") {
106
+ const leased = args.some((a) => a === "--force-with-lease" || a.startsWith("--force-with-lease="));
107
+ const forced =
108
+ args.includes("-f") ||
109
+ args.includes("--force") ||
110
+ args.some((a) => a.startsWith("+") && !a.startsWith("++"));
111
+ if (forced && !leased) return "force push rewrites remote history";
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** Every (subcommand, args) pair in a command, with global options stripped. */
117
+ export function gitInvocations(tokens: string[]): Array<{ sub: string; args: string[] }> {
118
+ const found: Array<{ sub: string; args: string[] }> = [];
119
+ let i = 0;
120
+ while (i < tokens.length) {
121
+ const token = tokens[i];
122
+ if (token.split("/").pop() !== "git") {
123
+ i += 1;
124
+ continue;
125
+ }
126
+ let j = i + 1;
127
+ while (j < tokens.length) {
128
+ const opt = tokens[j];
129
+ if (SEPARATORS.has(opt)) break;
130
+ const name = opt.split("=")[0];
131
+ if (GIT_VALUE_FLAGS.has(name)) {
132
+ j += opt.includes("=") ? 1 : 2;
133
+ continue;
134
+ }
135
+ if (GIT_BARE_FLAGS.has(name) || (opt.startsWith("-") && !opt.startsWith("--") && opt.length > 1)) {
136
+ j += 1;
137
+ continue;
138
+ }
139
+ break;
140
+ }
141
+ if (j >= tokens.length || SEPARATORS.has(tokens[j])) {
142
+ i = j + 1;
143
+ continue;
144
+ }
145
+ const sub = tokens[j];
146
+ const args: string[] = [];
147
+ let k = j + 1;
148
+ while (k < tokens.length && !SEPARATORS.has(tokens[k])) {
149
+ args.push(tokens[k]);
150
+ k += 1;
151
+ }
152
+ found.push({ sub, args });
153
+ i = k;
154
+ }
155
+ return found;
156
+ }
157
+
158
+ /** The normalized verdict for a whole shell command: a reason to block, or null. */
159
+ export function dangerousGitCommand(command: string): string | null {
160
+ for (const { sub, args } of gitInvocations(tokenizeArgs(command))) {
161
+ const reason = classifyGitInvocation(sub, args);
162
+ if (reason) return reason;
163
+ }
164
+ return null;
165
+ }
166
+
30
167
 
31
168
  // Conventional Commits specifies `type(scope)!:`, but many repositories write
32
169
  // `type!(scope):`. Accept both; the leading type is what matters.
@@ -224,6 +361,46 @@ function currentBranch(cwd: string): string {
224
361
  }
225
362
  }
226
363
 
364
+ /** Everything the tool_call handler decides, as a pure function (testable). */
365
+ export function evaluateBashCall(
366
+ command: string,
367
+ opts: {
368
+ landMode: boolean;
369
+ hasRepo: boolean;
370
+ branchFor: (upTo: number) => string | undefined;
371
+ },
372
+ ): string | null {
373
+ const gitReason = dangerousGitCommand(command);
374
+ if (gitReason) return `BLOCKED: ${gitReason}. Use a safer approach or ask the user.`;
375
+
376
+ if (!opts.hasRepo) return null;
377
+
378
+ const pushBranch = opts.branchFor(command.length) ?? "unknown";
379
+ if (/git\s+push/.test(command) && !opts.landMode && protectedPushTarget(command, pushBranch)) {
380
+ return "BLOCKED: Direct push to protected branch. Use kickoff-branch + release-branch.";
381
+ }
382
+
383
+ if (/git\s+commit/.test(command)) {
384
+ const commitBranches = [...command.matchAll(/\bgit\s+commit\b/g)].map(
385
+ (m) => opts.branchFor(m.index ?? 0) ?? "unknown",
386
+ );
387
+ const hit = commitBranches.find((b) => (PROTECTED_BRANCHES as readonly string[]).includes(b));
388
+ if (hit) {
389
+ return `BLOCKED: Direct commits to '${hit}' are forbidden. Use a feature branch.`;
390
+ }
391
+ const msg = extractCommitMessage(command);
392
+ if (msg) {
393
+ if (!CONVENTIONAL_RE.test(msg)) {
394
+ return `BLOCKED: Commit subject '${msg.split("\n")[0]}' must follow Conventional Commits: <type>(<scope>): <subject>.`;
395
+ }
396
+ if (msg.split("\n")[0].length > 72) {
397
+ return "BLOCKED: Commit subject line must be \u226472 characters.";
398
+ }
399
+ }
400
+ }
401
+ return null;
402
+ }
403
+
227
404
  function protectedPushTarget(command: string, branch: string): boolean {
228
405
  if (PROTECTED_BRANCH_RE.test(command)) return true;
229
406
  const trimmed = command.trim();
@@ -414,55 +591,17 @@ export default function bigpowers(pi: ExtensionAPI) {
414
591
  const { command } = event.input as { command?: string };
415
592
  if (!command) return;
416
593
 
417
- for (const pat of DANGEROUS_PATTERNS) {
418
- if (pat.test(command)) {
419
- return {
420
- block: true,
421
- reason: `BLOCKED: '${command}' matches dangerous pattern '${pat}'.`,
422
- };
423
- }
424
- }
425
-
426
594
  const sessionRoot = process.cwd();
427
- if (!inGitRepo(sessionRoot)) return;
428
- const pushBranch = currentBranch(commandGitCwd(command, command.length, sessionRoot));
429
-
430
- if (/git\s+push/.test(command) && protectedPushTarget(command, pushBranch)) {
431
- return {
432
- block: true,
433
- reason:
434
- "BLOCKED: Direct push to protected branch. Use kickoff-branch + release-branch.",
435
- };
436
- }
437
-
438
- if (/git\s+commit/.test(command)) {
439
- const commitBranches = [...command.matchAll(/\bgit\s+commit\b/g)].map((m) =>
440
- currentBranch(commandGitCwd(command, m.index ?? 0, sessionRoot)),
441
- );
442
- const hit = commitBranches.find((b) =>
443
- (PROTECTED_BRANCHES as readonly string[]).includes(b),
444
- );
445
- if (hit) {
446
- return {
447
- block: true,
448
- reason: `BLOCKED: Direct commits to '${hit}' are forbidden. Use a feature branch.`,
449
- };
450
- }
451
- const msg = extractCommitMessage(command);
452
- if (msg) {
453
- if (!CONVENTIONAL_RE.test(msg)) {
454
- return {
455
- block: true,
456
- reason: `BLOCKED: Commit subject '${msg.split("\n")[0]}' must follow Conventional Commits: <type>(<scope>): <subject>.`,
457
- };
458
- }
459
- if (msg.split("\n")[0].length > 72) {
460
- return {
461
- block: true,
462
- reason: "BLOCKED: Commit subject line must be ≤72 characters.",
463
- };
464
- }
465
- }
466
- }
595
+ const verdict = evaluateBashCall(command, {
596
+ landMode: process.env.GIT_BIGPOWERS_LAND === "1",
597
+ // `undefined` means "there is no repo here"; the guard then only judges
598
+ // the argv rules, which need no branch.
599
+ branchFor: (upTo: number) =>
600
+ inGitRepo(sessionRoot)
601
+ ? currentBranch(commandGitCwd(command, upTo, sessionRoot))
602
+ : undefined,
603
+ hasRepo: inGitRepo(sessionRoot),
604
+ });
605
+ if (verdict) return { block: true, reason: verdict };
467
606
  });
468
607
  }
package/hooks/pre/bash.sh CHANGED
@@ -11,7 +11,8 @@ INPUT=$(cat)
11
11
  COMMAND=$(echo "$INPUT" | jq -r '.command // .tool_input.command // empty' 2>/dev/null || true)
12
12
  [ -z "$COMMAND" ] && exit 0
13
13
 
14
- DANGEROUS_PATTERNS=(
14
+ # Last-resort literals, used only when the normalizer is unreachable.
15
+ LEGACY_DANGEROUS=(
15
16
  "git reset --hard"
16
17
  "git clean -f"
17
18
  "git branch -D"
@@ -19,18 +20,64 @@ DANGEROUS_PATTERNS=(
19
20
  "push -f"
20
21
  )
21
22
  PROTECTED_BRANCHES=("main" "master")
23
+ COMMIT_MSG_RE='-m[[:space:]]+"([^"]+)"'
22
24
  CONVENTIONAL_RE='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?!?:[[:space:]].+'
23
25
 
24
- for p in "${DANGEROUS_PATTERNS[@]}"; do
25
- if echo "$COMMAND" | grep -qE "$p"; then
26
- printf '{"decision":"block","reason":"BLOCKED: dangerous pattern %q"}\n' "$p"
27
- exit 0
26
+ # --- Normalized git argv (BUG-2026-11-03-guard-git-flag-normalize, #184) ------
27
+ # Literal substring matching cannot see a global flag in front of the subcommand
28
+ # or a flag between the subcommand and the destructive option: the destructive
29
+ # tokens are all still there, only their position changed. The rules live in
30
+ # scripts/lib/git-argv.py and are mirrored in extensions/omp-hooks.ts;
31
+ # scripts/test-guard-git-normalize.sh fails if the two disagree on any fixture.
32
+ SELF_PATH="${BASH_SOURCE[0]}"
33
+ # Only pay for realpath when the hook actually is a symlink (installed copies).
34
+ # This hook runs on every tool call, so a spawn per invocation is real latency.
35
+ if [ -L "$SELF_PATH" ] && command -v python3 >/dev/null 2>&1; then
36
+ SELF_PATH="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$SELF_PATH" 2>/dev/null || echo "$SELF_PATH")"
37
+ fi
38
+ GUARD_ROOT="$(cd "$(dirname "$SELF_PATH")/.." 2>/dev/null && pwd -P || true)"
39
+ GIT_ARGV="$GUARD_ROOT/scripts/lib/git-argv.py"
40
+
41
+ # classify_git_argv <command>: echoes "block: <reason>" or nothing.
42
+ # Fast path: a command with no "git" token cannot contain a git invocation, so
43
+ # the helper (a python start) is skipped. Falls back to the legacy literal
44
+ # patterns when the helper is unreachable (hook symlinked outside the package).
45
+ classify_git_argv() {
46
+ local cmd="$1" out=""
47
+ case "$cmd" in
48
+ *git*) ;;
49
+ *) return 0 ;;
50
+ esac
51
+ if command -v python3 >/dev/null 2>&1 && [ -f "$GIT_ARGV" ]; then
52
+ out="$(python3 "$GIT_ARGV" --command "$cmd" 2>/dev/null || true)"
53
+ case "$out" in
54
+ block:*) printf '%s' "$out"; return 0 ;;
55
+ *) return 0 ;;
56
+ esac
28
57
  fi
29
- done
58
+ return 0
59
+ }
60
+
61
+ GIT_VERDICT="$(classify_git_argv "$COMMAND")"
62
+ if [ -n "$GIT_VERDICT" ]; then
63
+ printf '{"decision":"block","reason":"BLOCKED: %s"}\n' "${GIT_VERDICT#block: }"
64
+ exit 0
65
+ fi
66
+ if [ ! -f "$GIT_ARGV" ]; then
67
+ for p in "${LEGACY_DANGEROUS[@]}"; do
68
+ if echo "$COMMAND" | grep -qE "$p"; then
69
+ printf '{"decision":"block","reason":"BLOCKED: dangerous pattern %q"}\n' "$p"
70
+ exit 0
71
+ fi
72
+ done
73
+ fi
30
74
 
31
75
  if [[ "$COMMAND" =~ git[[:space:]]+commit ]]; then
32
76
  MSG=""
33
- if [[ "$COMMAND" =~ -m[[:space:]]+"([^"]+)" ]]; then MSG="${BASH_REMATCH[1]}"; fi
77
+ # Parentheses inside [[ =~ ]] must come from a variable, not the literal
78
+ # pattern: bash parses `([^"]+)` as a syntax error (pre-existing since #118,
79
+ # so this fallback hook never ran; BUG-2026-11-03-guard-git-flag-normalize).
80
+ if [[ "$COMMAND" =~ $COMMIT_MSG_RE ]]; then MSG="${BASH_REMATCH[1]}"; fi
34
81
  if [ -n "$MSG" ] && [[ ! "$MSG" =~ $CONVENTIONAL_RE ]]; then
35
82
  echo '{"decision":"block","reason":"BLOCKED: Conventional Commits required"}'
36
83
  exit 0
@@ -11,16 +11,13 @@ set -euo pipefail
11
11
 
12
12
  # Configuration
13
13
  PROTECTED_BRANCHES=("main" "master")
14
- DANGEROUS_PATTERNS=(
14
+ # Last-resort literals, used only when the normalizer is unreachable.
15
+ LEGACY_DANGEROUS=(
15
16
  "git reset --hard"
16
- "git clean -fd"
17
17
  "git clean -f"
18
18
  "git branch -D"
19
- "git checkout \\."
20
- "git restore \\."
21
19
  "push --force"
22
20
  "push -f"
23
- "reset --hard"
24
21
  )
25
22
  CONVENTIONAL_COMMITS_REGEX='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:[[:space:]].+'
26
23
  LAND_MODE="${GIT_BIGPOWERS_LAND:-}"
@@ -29,6 +26,41 @@ LAND_MODE="${GIT_BIGPOWERS_LAND:-}"
29
26
  # GIT_GUARDRAILS_MODE: claude (default) | cursor | gemini
30
27
  MODE="${GIT_GUARDRAILS_MODE:-claude}"
31
28
 
29
+ # --- Normalized git argv (BUG-2026-11-03-guard-git-flag-normalize, #184) ------
30
+ # Literal substring matching cannot see a global flag in front of the subcommand
31
+ # or a flag between the subcommand and the destructive option: the destructive
32
+ # tokens are all still there, only their position changed. The rules live in
33
+ # scripts/lib/git-argv.py and are mirrored in extensions/omp-hooks.ts;
34
+ # scripts/test-guard-git-normalize.sh fails if the two disagree on any fixture.
35
+ SELF_PATH="${BASH_SOURCE[0]}"
36
+ # Only pay for realpath when the hook actually is a symlink (installed copies).
37
+ # This hook runs on every tool call, so a spawn per invocation is real latency.
38
+ if [ -L "$SELF_PATH" ] && command -v python3 >/dev/null 2>&1; then
39
+ SELF_PATH="$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$SELF_PATH" 2>/dev/null || echo "$SELF_PATH")"
40
+ fi
41
+ GUARD_ROOT="$(cd "$(dirname "$SELF_PATH")/.." 2>/dev/null && pwd -P || true)"
42
+ GIT_ARGV="$GUARD_ROOT/scripts/lib/git-argv.py"
43
+
44
+ # classify_git_argv <command>: echoes "block: <reason>" or nothing.
45
+ # Fast path: a command with no "git" token cannot contain a git invocation, so
46
+ # the helper (a python start) is skipped. Falls back to the legacy literal
47
+ # patterns when the helper is unreachable (hook symlinked outside the package).
48
+ classify_git_argv() {
49
+ local cmd="$1" out=""
50
+ case "$cmd" in
51
+ *git*) ;;
52
+ *) return 0 ;;
53
+ esac
54
+ if command -v python3 >/dev/null 2>&1 && [ -f "$GIT_ARGV" ]; then
55
+ out="$(python3 "$GIT_ARGV" --command "$cmd" 2>/dev/null || true)"
56
+ case "$out" in
57
+ block:*) printf '%s' "$out"; return 0 ;;
58
+ *) return 0 ;;
59
+ esac
60
+ fi
61
+ return 0
62
+ }
63
+
32
64
  deny() {
33
65
  local reason="$1"
34
66
  if [ "$MODE" = "gemini" ]; then
@@ -42,20 +74,48 @@ deny() {
42
74
  # Read input from stdin
43
75
  INPUT=$(cat)
44
76
 
45
- # Extract command using jq (standard in bigpowers)
77
+ # Extract command and file path using jq (standard in bigpowers)
46
78
  COMMAND=$(echo "$INPUT" | jq -r '.command // .tool_input.command // empty')
79
+ FILE_PATH=$(echo "$INPUT" | jq -r '.file_path // .tool_input.file_path // .path // .tool_input.path // .target_file // .tool_input.target_file // empty')
80
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool // .name // .tool_name // empty')
81
+
82
+ # 0. Check file editing rules
83
+ if [ -n "$FILE_PATH" ]; then
84
+ # e86s01: Generated paths are blocked at edit time
85
+ if [[ "$FILE_PATH" =~ ^\.cursor/rules/ ]] || [[ "$FILE_PATH" =~ ^\.gemini/extensions/ ]] || [[ "$FILE_PATH" =~ ^\.windsurf/ ]]; then
86
+ deny "BLOCKED: Generated file '$FILE_PATH' cannot be edited directly. Edit the source SKILL.md instead and run bash scripts/sync-skills.sh."
87
+ fi
88
+
89
+ # e86s02: Tests are locked during fix_bug flow after RED commit
90
+ if [[ "$FILE_PATH" =~ (^|/)test/ ]] || [[ "$FILE_PATH" =~ (^|/)tests/ ]] || [[ "$FILE_PATH" =~ \.test\.[^/]+$ ]] || [[ "$FILE_PATH" =~ _test\.[^/]+$ ]] || [[ "$FILE_PATH" =~ (^|/)test_[^/]+$ ]]; then
91
+ if [ -f specs/state.yaml ] && grep -qE "active_flow:[[:space:]]*fix_bug" specs/state.yaml 2>/dev/null; then
92
+ if git rev-parse --git-dir >/dev/null 2>&1; then
93
+ recent_log=$(git log -n 5 --format=%s 2>/dev/null || true)
94
+ if echo "$recent_log" | grep -qE '^test(\(.+\))?:'; then
95
+ deny "BLOCKED: Test files ($FILE_PATH) are locked during fix_bug after the RED commit. Write the fix in production code (src/), not in tests."
96
+ fi
97
+ fi
98
+ fi
99
+ fi
100
+ fi
47
101
 
48
102
  if [ -z "$COMMAND" ]; then
49
103
  [ "$MODE" = "gemini" ] && echo '{"decision":"allow"}'
50
104
  exit 0
51
105
  fi
52
106
 
53
- # 1. Check for dangerous patterns (git push is NOT blanket-blocked)
54
- for p in "${DANGEROUS_PATTERNS[@]}"; do
55
- if echo "$COMMAND" | grep -qE "$p"; then
56
- deny "BLOCKED: '$COMMAND' matches dangerous pattern '$p'. Use a safer approach or ask the user."
57
- fi
58
- done
107
+ # 1. Normalized git argv (git push is NOT blanket-blocked)
108
+ GIT_VERDICT="$(classify_git_argv "$COMMAND")"
109
+ if [ -n "$GIT_VERDICT" ]; then
110
+ deny "BLOCKED: ${GIT_VERDICT#block: }. Use a safer approach or ask the user."
111
+ fi
112
+ if [ ! -f "$GIT_ARGV" ]; then
113
+ for p in "${LEGACY_DANGEROUS[@]}"; do
114
+ if echo "$COMMAND" | grep -qE "$p"; then
115
+ deny "BLOCKED: '$COMMAND' matches dangerous pattern '$p'. Use a safer approach or ask the user."
116
+ fi
117
+ done
118
+ fi
59
119
 
60
120
  # 2. Check for git commit / git push
61
121
  if [[ "$COMMAND" =~ git[[:space:]]+commit ]] || [[ "$COMMAND" =~ git[[:space:]]+push ]]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bigpowers",
3
- "version": "2.90.2",
3
+ "version": "2.91.1",
4
4
  "description": "73 agent skills synthesizing 17 years of software engineering discipline into a prescriptive methodology for solo developers",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -68,6 +68,7 @@
68
68
  "zod": "^3.24.2"
69
69
  },
70
70
  "devDependencies": {
71
+ "@ctxo/lang-typescript": "^0.11.0",
71
72
  "@semantic-release/changelog": "^6.0.3",
72
73
  "@semantic-release/exec": "^7.1.0",
73
74
  "@semantic-release/git": "^10.0.1",
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # convert-legado.sh — RELEASE-PLAN.md + SCOPE.md → YAML layout (one-time migration helper)
3
+ # bp-manual-utility: legacy MD spec converter (pre-e44 migration)
3
4
  set -euo pipefail
4
5
  source "$(dirname "${BASH_SOURCE[0]}")/lib/python-env.sh"
5
6
  SPECS="$REPO_ROOT/specs"
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env bash
2
+ # story: e86 / GH #182
3
+ # Mechanical audit-code pass gate before commit / release.
4
+ set -euo pipefail
5
+
6
+ STAGE="${1:-commit}"
7
+
8
+ AUDIT_PASSED=false
9
+
10
+ # Check specs/AUDIT_LATEST.md
11
+ if [[ -f specs/AUDIT_LATEST.md ]]; then
12
+ if grep -qiE "VERDICT:[[:space:]]*PASS|RESULT:[[:space:]]*PASS" specs/AUDIT_LATEST.md; then
13
+ AUDIT_PASSED=true
14
+ fi
15
+ fi
16
+
17
+ # Check specs/execution-status.yaml
18
+ if [[ "$AUDIT_PASSED" == "false" ]] && [[ -f specs/execution-status.yaml ]]; then
19
+ if grep -qE "audit_passed:[[:space:]]*true" specs/execution-status.yaml 2>/dev/null; then
20
+ AUDIT_PASSED=true
21
+ fi
22
+ fi
23
+
24
+ if [[ "$AUDIT_PASSED" == "false" ]]; then
25
+ echo "ERROR [$STAGE]: audit-code check is missing or did not pass (Issue #182)." >&2
26
+ echo "Remediation: Run audit-code and ensure all checks pass before advancing to $STAGE." >&2
27
+ echo "If audit fails, loop back to develop-tdd to address findings." >&2
28
+ exit 1
29
+ fi
30
+
31
+ echo "PASS [$STAGE]: audit-code verified."
32
+ exit 0
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # story: e86 / GH #183
3
+ # Mechanical artifact existence assertion for run-planning workflow steps.
4
+ set -euo pipefail
5
+
6
+ STEP="${1:-}"
7
+
8
+ if [[ -z "$STEP" ]]; then
9
+ echo "Usage: bash scripts/lib/assert-planning-artifacts.sh <step_name>" >&2
10
+ exit 1
11
+ fi
12
+
13
+ case "$STEP" in
14
+ survey-context)
15
+ if [[ ! -f specs/tech-architecture/tech-stack.md ]] && [[ ! -f specs/state.yaml ]]; then
16
+ echo "ERROR [survey-context]: Neither specs/tech-architecture/tech-stack.md nor specs/state.yaml exists." >&2
17
+ exit 1
18
+ fi
19
+ ;;
20
+
21
+ scope-work)
22
+ if [[ ! -f specs/product/SCOPE_LATEST.yaml ]] || [[ ! -s specs/product/SCOPE_LATEST.yaml ]]; then
23
+ echo "ERROR [scope-work]: specs/product/SCOPE_LATEST.yaml is missing or empty." >&2
24
+ exit 1
25
+ fi
26
+ ;;
27
+
28
+ research-first)
29
+ # Research can record in specs/product/PRIOR_ART.md or directly in specs/
30
+ if [[ ! -f specs/product/PRIOR_ART.md ]] && [[ ! -f specs/product/SCOPE_LATEST.yaml ]]; then
31
+ echo "ERROR [research-first]: Neither specs/product/PRIOR_ART.md nor specs/product/SCOPE_LATEST.yaml found." >&2
32
+ exit 1
33
+ fi
34
+ ;;
35
+
36
+ elaborate-spec)
37
+ if [[ ! -f specs/planning-context.yaml ]] && [[ ! -f specs/product/SCOPE_LATEST.yaml ]]; then
38
+ echo "ERROR [elaborate-spec]: specs/planning-context.yaml is missing." >&2
39
+ exit 1
40
+ fi
41
+ ;;
42
+
43
+ plan-release)
44
+ if [[ ! -f specs/release-plan.yaml ]] || [[ ! -s specs/release-plan.yaml ]]; then
45
+ echo "ERROR [plan-release]: specs/release-plan.yaml is missing or empty." >&2
46
+ exit 1
47
+ fi
48
+ ;;
49
+
50
+ slice-tasks)
51
+ # Assert at least one task file exists in specs/epics/
52
+ if ! find specs/epics -name "*-tasks.yaml" 2>/dev/null | grep -q .; then
53
+ echo "ERROR [slice-tasks]: No tasks files (*-tasks.yaml) found in specs/epics/." >&2
54
+ exit 1
55
+ fi
56
+ ;;
57
+
58
+ *)
59
+ echo "WARNING: Unknown step '$STEP' — skipping artifact assertion" >&2
60
+ exit 0
61
+ ;;
62
+ esac
63
+
64
+ echo "PASS: Planning artifacts asserted for step '$STEP'."
65
+ exit 0