@mdferdousalam/agentic-sdlc 0.0.0-stage → 3.0.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.
Files changed (90) hide show
  1. package/.claude-plugin/marketplace.json +11 -0
  2. package/.claude-plugin/plugin.json +31 -0
  3. package/CHANGELOG.md +330 -0
  4. package/GETTING-STARTED.md +174 -0
  5. package/LICENSE +21 -0
  6. package/README.md +268 -2
  7. package/SECURITY.md +25 -0
  8. package/agents/sdlc-architecture-reviewer.md +71 -0
  9. package/agents/sdlc-branch-opener.md +83 -0
  10. package/agents/sdlc-code-reviewer.md +62 -0
  11. package/agents/sdlc-impact-analyst.md +70 -0
  12. package/agents/sdlc-implementer.md +60 -0
  13. package/agents/sdlc-lander.md +40 -0
  14. package/agents/sdlc-readiness-gate.md +128 -0
  15. package/agents/sdlc-release-manager.md +76 -0
  16. package/agents/sdlc-remediator.md +89 -0
  17. package/agents/sdlc-reporter.md +42 -0
  18. package/agents/sdlc-security-reviewer.md +62 -0
  19. package/agents/sdlc-spec-reviewer.md +56 -0
  20. package/agents/sdlc-spec-writer.md +62 -0
  21. package/agents/sdlc-test-author.md +32 -0
  22. package/agents/sdlc-triager.md +48 -0
  23. package/agents/sdlc-verifier.md +69 -0
  24. package/bin/__pycache__/sdlccpython-315.pyc +0 -0
  25. package/bin/sdlc +2747 -0
  26. package/bin/sdlc-install-opencode +10 -0
  27. package/commands/doctor.md +9 -0
  28. package/commands/feature.md +17 -0
  29. package/commands/gate.md +33 -0
  30. package/commands/impact.md +13 -0
  31. package/commands/implement.md +11 -0
  32. package/commands/init.md +15 -0
  33. package/commands/land.md +10 -0
  34. package/commands/metrics.md +12 -0
  35. package/commands/readiness.md +8 -0
  36. package/commands/release.md +16 -0
  37. package/commands/remediate.md +13 -0
  38. package/commands/report.md +11 -0
  39. package/commands/review.md +12 -0
  40. package/commands/status.md +9 -0
  41. package/commands/trace.md +9 -0
  42. package/commands/triage.md +14 -0
  43. package/commands/verify.md +10 -0
  44. package/docs/compliance.md +70 -0
  45. package/docs/configuration.md +106 -0
  46. package/docs/control-spec.md +269 -0
  47. package/docs/install.md +75 -0
  48. package/docs/maturity.md +129 -0
  49. package/docs/opencode.md +45 -0
  50. package/docs/openspec.md +58 -0
  51. package/docs/pipeline.md +160 -0
  52. package/docs/platforms.md +99 -0
  53. package/docs/rules.md +122 -0
  54. package/evals/baseline-attributes-failure/graders/criteria.md +18 -0
  55. package/evals/baseline-attributes-failure/prompt.md +23 -0
  56. package/evals/budget-stops-rather-than-asks/graders/criteria.md +19 -0
  57. package/evals/budget-stops-rather-than-asks/prompt.md +14 -0
  58. package/evals/critical-low-confidence-escalates/graders/criteria.md +22 -0
  59. package/evals/critical-low-confidence-escalates/prompt.md +17 -0
  60. package/evals/gate-refuses-unexecuted/graders/criteria.md +16 -0
  61. package/evals/gate-refuses-unexecuted/prompt.md +15 -0
  62. package/evals/refusal-is-not-negotiated/graders/criteria.md +17 -0
  63. package/evals/refusal-is-not-negotiated/prompt.md +7 -0
  64. package/evals/release-needs-rollback-and-approval/graders/criteria.md +15 -0
  65. package/evals/release-needs-rollback-and-approval/prompt.md +6 -0
  66. package/evals/remediator-refuses-weakened-test/graders/criteria.md +20 -0
  67. package/evals/remediator-refuses-weakened-test/prompt.md +33 -0
  68. package/evals/spec-review-catches-unfalsifiable/graders/criteria.md +17 -0
  69. package/evals/spec-review-catches-unfalsifiable/prompt.md +17 -0
  70. package/examples/worked-example.md +131 -0
  71. package/hooks/hooks.json +16 -0
  72. package/npm/cli.js +35 -0
  73. package/opencode/plugin/agentic-sdlc.ts +52 -0
  74. package/package.json +59 -4
  75. package/profiles/README.md +52 -0
  76. package/profiles/compiler.md +33 -0
  77. package/profiles/concerns/architecture.md +50 -0
  78. package/profiles/concerns/concurrency.md +59 -0
  79. package/profiles/concerns/data.md +62 -0
  80. package/profiles/concerns/memory.md +52 -0
  81. package/profiles/concerns/performance.md +54 -0
  82. package/profiles/concerns/platform.md +62 -0
  83. package/profiles/library.md +31 -0
  84. package/profiles/service.md +32 -0
  85. package/profiles/webapp.md +40 -0
  86. package/schema/finding.schema.json +267 -0
  87. package/schema/sdlc.config.schema.json +324 -0
  88. package/skills/agentic-sdlc-pipeline/SKILL.md +66 -0
  89. package/skills/openspec-with-sdlc/SKILL.md +56 -0
  90. package/skills/writing-specifications/SKILL.md +51 -0
package/bin/sdlc ADDED
@@ -0,0 +1,2747 @@
1
+ #!/usr/bin/env python3
2
+ """sdlc — the machine half of the agentic SDLC pipeline.
3
+
4
+ The agents are prose: they persuade a model. This is the part that does not negotiate. It
5
+ detects the project, records each stage's verdict as a receipt bound to a commit and a clock,
6
+ and — as a PreToolUse hook — refuses the actions that would skip a stage.
7
+
8
+ sdlc init [--write] detect the stack and write sdlc.config.json
9
+ sdlc config print the effective configuration
10
+ sdlc receipt write KIND --verdict V [--subject S] [--detail D]
11
+ sdlc receipt check KIND [--verdict V] [--max-age-min N]
12
+ sdlc receipt show KIND
13
+ sdlc status where this unit of work stands
14
+ sdlc guard PreToolUse hook: reads tool JSON on stdin, exit 2 blocks
15
+ sdlc doctor check the install, the config and the repo
16
+
17
+ Python 3.8+, standard library only, no network. Exit codes: 0 fine, 1 a check failed,
18
+ 2 blocked (the hook contract), 3 misuse.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import calendar
24
+ import fnmatch
25
+ import glob
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import re
30
+ import shutil
31
+ import subprocess
32
+ import sys
33
+ import time
34
+
35
+ CONFIG_NAMES = ("sdlc.config.json", ".opencode/sdlc.json")
36
+ RECEIPT_DIR = ".sdlc/receipts"
37
+ RECEIPT_KINDS = ("spec", "verification", "review", "security", "quality", "gate",
38
+ "readiness", "approval_merge", "approval_release", "approval_exception",
39
+ "release", "postdeploy")
40
+ AUDIT_LOG = ".sdlc/audit.log"
41
+ FEEDBACK = ".sdlc/feedback.json"
42
+ DEFAULT_MAX_AGE_MIN = 180
43
+ FINDING_DIR = ".sdlc/findings"
44
+ BASELINE = ".sdlc/baseline.json"
45
+ BUDGET = ".sdlc/budget.json"
46
+ CHECKPOINT = ".sdlc/checkpoint.json"
47
+
48
+ # The finding vocabularies. Severity is how bad it is if real; confidence is how sure we are
49
+ # that it is real. They are separate axes on purpose — see docs/control-spec.md §1.
50
+ SEVERITIES = ("critical", "high", "medium", "low", "advisory")
51
+ CONFIDENCES = ("high", "medium", "low")
52
+ DETECTIONS = ("deterministic", "test", "runtime", "llm")
53
+ FINDING_OUTCOMES = ("open", "fixed", "refused", "deferred", "unexecuted", "escalated")
54
+ EXPLOITABILITY = ("remote", "local", "authenticated", "theoretical", "not-applicable")
55
+
56
+ # The repair policy, as a table rather than as a paragraph an agent can reinterpret.
57
+ # True: an agent may repair it unattended. False: report or escalate. "repro": only once the
58
+ # finding has a reproduction, because at medium confidence the finding itself is in question.
59
+ # docs/control-spec.md §2 is the readable copy; a test keeps the two from drifting.
60
+ AUTOFIX = {
61
+ ("critical", "high"): True, ("critical", "medium"): False, ("critical", "low"): False,
62
+ ("high", "high"): True, ("high", "medium"): "repro", ("high", "low"): False,
63
+ ("medium", "high"): True, ("medium", "medium"): True, ("medium", "low"): False,
64
+ ("low", "high"): True, ("low", "medium"): True, ("low", "low"): False,
65
+ ("advisory", "high"): False, ("advisory", "medium"): False, ("advisory", "low"): False,
66
+ }
67
+
68
+ # --------------------------------------------------------------------------------------- git
69
+
70
+
71
+ def git(*args: str, cwd: str | None = None) -> str:
72
+ try:
73
+ p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=20)
74
+ return p.stdout.strip()
75
+ except Exception:
76
+ return ""
77
+
78
+
79
+ def repo_root(start: str | None = None) -> str:
80
+ r = git("rev-parse", "--show-toplevel", cwd=start or os.getcwd())
81
+ return r or (start or os.getcwd())
82
+
83
+
84
+ def head(root: str) -> str:
85
+ return git("rev-parse", "HEAD", cwd=root)
86
+
87
+
88
+ def branch(root: str) -> str:
89
+ return git("rev-parse", "--abbrev-ref", "HEAD", cwd=root)
90
+
91
+
92
+ def tree_clean(root: str) -> bool:
93
+ return git("status", "--porcelain", "--untracked-files=no", cwd=root) == ""
94
+
95
+
96
+ # ------------------------------------------------------------------------------------ config
97
+
98
+
99
+ def exists(root: str, *names: str) -> bool:
100
+ return any(os.path.exists(os.path.join(root, n)) for n in names)
101
+
102
+
103
+ def which(name: str) -> bool:
104
+ return shutil.which(name) is not None
105
+
106
+
107
+ def config_path(root: str) -> str | None:
108
+ for n in CONFIG_NAMES:
109
+ p = os.path.join(root, n)
110
+ if os.path.exists(p):
111
+ return p
112
+ return None
113
+
114
+
115
+ def load_config(root: str) -> dict:
116
+ p = config_path(root)
117
+ if not p:
118
+ return {}
119
+ try:
120
+ return json.load(open(p))
121
+ except Exception as e:
122
+ print(f"sdlc: {p} is not valid JSON: {e}", file=sys.stderr)
123
+ return {}
124
+
125
+
126
+ def detect_trunk(root: str) -> str:
127
+ ref = git("symbolic-ref", "--short", "refs/remotes/origin/HEAD", cwd=root)
128
+ if ref:
129
+ return ref.split("/")[-1]
130
+ for cand in ("main", "master", "develop", "Development"):
131
+ if git("rev-parse", "--verify", "--quiet", f"refs/heads/{cand}", cwd=root):
132
+ return cand
133
+ return branch(root) or "main"
134
+
135
+
136
+ # Cross-cutting standards worth proposing, by what the repository evidently does. Proposed,
137
+ # never imposed: a concern the maintainers did not choose produces noise on every review, which
138
+ # is how a review tool gets switched off.
139
+ CONCERN_HINTS = (
140
+ ("concurrency", (r"\bgo\s+func\b", r"\basync\s+def\b", r"\bawait\b", r"\bthread\b",
141
+ r"\bMutex\b", r"\btokio\b", r"\bgoroutine\b", r"sync\.")),
142
+ ("data", (r"\bCREATE\s+TABLE\b", r"\bALTER\s+TABLE\b", r"\bmigrations?\b",
143
+ r"\bprisma\b", r"\bsqlalchemy\b", r"\bdiesel\b", r"\bgorm\b")),
144
+ ("memory", (r"\bunsafe\b", r"\bmalloc\b", r"\bfree\(", r"\braw pointer\b",
145
+ r"#include <stdlib", r"\bBox::leak\b")),
146
+ )
147
+
148
+
149
+ def suggest_concerns(root: str) -> list:
150
+ """Which cross-cutting profiles this repository looks like it needs.
151
+
152
+ A shallow scan of tracked source, because a wrong guess here costs a reviewer's noise
153
+ budget. The maintainer edits the list; detection never overwrites it (see cmd_init).
154
+ """
155
+ names = [n for n in git("ls-files", cwd=root).splitlines()
156
+ if re.search(r"\.(go|rs|py|ts|tsx|js|jsx|java|kt|c|cc|cpp|h|sql|rb|cs|swift)$", n)]
157
+ if not names:
158
+ return []
159
+ sample, found = names[:400], []
160
+ for concern, patterns in CONCERN_HINTS:
161
+ hits = 0
162
+ for n in sample:
163
+ try:
164
+ body = open(os.path.join(root, n), errors="replace").read(20000)
165
+ except OSError:
166
+ continue
167
+ if any(re.search(pat, body, re.I) for pat in patterns):
168
+ hits += 1
169
+ if hits >= 2: # one match is a coincidence
170
+ found.append(concern)
171
+ break
172
+ return found
173
+
174
+
175
+ def detect(root: str) -> dict:
176
+ """Infer the project's bindings. Everything here is a guess the user may correct."""
177
+ pm = monorepo = None
178
+ stack = "unknown"
179
+ test = {}
180
+ verify = []
181
+
182
+ if exists(root, "pnpm-lock.yaml"):
183
+ pm = "pnpm"
184
+ elif exists(root, "yarn.lock"):
185
+ pm = "yarn"
186
+ elif exists(root, "bun.lockb", "bun.lock"):
187
+ pm = "bun"
188
+ elif exists(root, "package-lock.json"):
189
+ pm = "npm"
190
+ elif exists(root, "package.json"):
191
+ pm = "npm"
192
+
193
+ if pm:
194
+ stack = "node"
195
+ monorepo = ("turbo" if exists(root, "turbo.json") else
196
+ "nx" if exists(root, "nx.json") else
197
+ "lerna" if exists(root, "lerna.json") else
198
+ "workspaces" if exists(root, "pnpm-workspace.yaml") else None)
199
+ pkg = {}
200
+ try:
201
+ pkg = json.load(open(os.path.join(root, "package.json")))
202
+ except Exception:
203
+ pass
204
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
205
+ scripts = pkg.get("scripts", {})
206
+ for runner in ("vitest", "jest", "mocha", "ava", "node:test"):
207
+ if runner.split(":")[0] in deps:
208
+ test["unit"] = runner
209
+ break
210
+ for e2e in ("playwright", "@playwright/test", "cypress"):
211
+ if e2e in deps:
212
+ test["e2e"] = e2e.replace("@playwright/test", "playwright")
213
+ break
214
+ for s in ("lint", "typecheck", "type-check", "test"):
215
+ if s in scripts:
216
+ verify.append(f"{pm} run {s}" if s != "test" else f"{pm} test")
217
+ elif exists(root, "Cargo.toml"):
218
+ stack, pm = "rust", "cargo"
219
+ test["unit"] = "cargo test"
220
+ verify = ["cargo fmt --all -- --check",
221
+ "cargo clippy --all-targets --all-features -- -D warnings",
222
+ "cargo test --workspace"]
223
+ elif exists(root, "go.mod"):
224
+ stack, pm = "go", "go"
225
+ test["unit"] = "go test"
226
+ verify = ["gofmt -l .", "go vet ./...", "go test ./..."]
227
+ elif exists(root, "pyproject.toml", "setup.cfg", "requirements.txt"):
228
+ stack = "python"
229
+ pm = "uv" if exists(root, "uv.lock") else "poetry" if exists(root, "poetry.lock") else "pip"
230
+ test["unit"] = "pytest"
231
+ verify = (["ruff check ."] if exists(root, "ruff.toml") or which("ruff") else []) + ["pytest -q"]
232
+ elif exists(root, "pom.xml"):
233
+ stack, pm, verify = "java", "maven", ["mvn -q verify"]
234
+ elif exists(root, "build.gradle", "build.gradle.kts"):
235
+ stack, pm, verify = "java", "gradle", ["./gradlew check"]
236
+ elif exists(root, "Gemfile"):
237
+ stack, pm, verify = "ruby", "bundler", ["bundle exec rubocop", "bundle exec rspec"]
238
+ elif exists(root, "composer.json"):
239
+ stack, pm, verify = "php", "composer", ["composer test"]
240
+ elif exists(root, "book.toml"):
241
+ stack, verify = "mdbook", ["mdbook build"]
242
+ elif exists(root, "action.yml", "action.yaml"):
243
+ stack = "github-action"
244
+
245
+ engine = "openspec" if exists(root, "openspec") or which("openspec") else "prd-frd"
246
+
247
+ commits = "conventional" if exists(
248
+ root, "commitlint.config.js", "commitlint.config.cjs", "commitlint.config.mjs",
249
+ ".commitlintrc", ".commitlintrc.json", ".husky") else "descriptive"
250
+
251
+ secret_scan = ('gitleaks git --no-banner --redact --log-opts="-n 50"' if which("gitleaks")
252
+ else "trufflehog git file://. --since-commit HEAD~50 --fail" if which("trufflehog")
253
+ else None)
254
+
255
+ cfg = {
256
+ "$schema": "./schema/sdlc.config.schema.json",
257
+ "version": 2,
258
+ "trunk": detect_trunk(root),
259
+ "stack": stack,
260
+ "pm": pm,
261
+ "monorepo": monorepo,
262
+ "commits": commits,
263
+ "test": test,
264
+ "spec": {
265
+ "engine": engine,
266
+ "docsRoot": "openspec/changes" if engine == "openspec" else "docs/specs",
267
+ "strict": True,
268
+ },
269
+ "branchScheme": "{kind}/{slug}",
270
+ "verifyCmd": " && ".join(verify) if verify else None,
271
+ "gateCmd": None,
272
+ "secretScan": secret_scan,
273
+ "heavy": None,
274
+ "profile": None,
275
+ "concerns": suggest_concerns(root),
276
+ "quality": {
277
+ "cmd": None,
278
+ "coverageNewPct": None,
279
+ "note": "Deterministic analysis on NEW code. Research on agent-written code reports "
280
+ "rising warnings and complexity, so the budget is on what this change adds, "
281
+ "not on the whole repository.",
282
+ },
283
+ "release": {
284
+ "deployCmd": None,
285
+ "smokeCmd": None,
286
+ "rollbackCmd": None,
287
+ "environments": [],
288
+ },
289
+ "approvals": {"merge": "agent", "release": "human"},
290
+ "attributeCommits": True,
291
+ "reviewers": ["sdlc-code-reviewer"] + (["sdlc-security-reviewer"] if stack in ("rust", "go", "node", "python", "java") else []),
292
+ "protected": ["main", "master", "release/*"],
293
+ "receipts": {"maxAgeMin": DEFAULT_MAX_AGE_MIN},
294
+ "enforce": {
295
+ "pushToTrunkNeedsGate": True,
296
+ "mergeNeedsReadiness": True,
297
+ "branchNeedsApprovedSpec": True,
298
+ "blockDestructiveGit": True,
299
+ "blockPublishing": True,
300
+ "blockCredentialReads": True,
301
+ },
302
+ "neverByAgent": ["tag", "release", "publish", "force-push", "deploy"],
303
+ }
304
+ return cfg
305
+
306
+
307
+ # ---------------------------------------------------------------------------------- receipts
308
+
309
+
310
+ def receipt_path(root: str, kind: str) -> str:
311
+ return os.path.join(root, RECEIPT_DIR, f"{kind}.json")
312
+
313
+
314
+ def subject_digest(root: str, subject: str) -> str | None:
315
+ if not subject:
316
+ return None
317
+ p = os.path.join(root, subject)
318
+ h = hashlib.sha256()
319
+ if os.path.isfile(p):
320
+ h.update(open(p, "rb").read())
321
+ elif os.path.isdir(p):
322
+ for dirpath, dirnames, names in os.walk(p):
323
+ dirnames.sort()
324
+ for n in sorted(names):
325
+ f = os.path.join(dirpath, n)
326
+ h.update(os.path.relpath(f, p).encode())
327
+ try:
328
+ h.update(open(f, "rb").read())
329
+ except OSError:
330
+ pass
331
+ else:
332
+ return None
333
+ return "sha256:" + h.hexdigest()[:16]
334
+
335
+
336
+ def audit(root: str, event: str, detail: str) -> None:
337
+ """Append-only record of everything the pipeline decided. Never rotated by this tool."""
338
+ try:
339
+ p = os.path.join(root, AUDIT_LOG)
340
+ os.makedirs(os.path.dirname(p), exist_ok=True)
341
+ line = "\t".join([
342
+ time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
343
+ event,
344
+ (head(root) or "")[:9],
345
+ branch(root) or "-",
346
+ os.environ.get("CLAUDE_AGENT") or os.environ.get("OPENCODE_AGENT") or "-",
347
+ re.sub(r"\s+", " ", str(detail))[:400],
348
+ ])
349
+ with open(p, "a") as fh:
350
+ fh.write(line + "\n")
351
+ except Exception:
352
+ pass
353
+
354
+
355
+ def receipt_write(root: str, kind: str, verdict: str, subject: str, detail: str, by: str) -> int:
356
+ r = {
357
+ "kind": kind,
358
+ "verdict": verdict,
359
+ "commit": head(root),
360
+ "branch": branch(root),
361
+ "subject": subject or None,
362
+ "subjectDigest": subject_digest(root, subject),
363
+ "detail": detail,
364
+ "by": by,
365
+ "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
366
+ "treeClean": tree_clean(root),
367
+ "env": environment(root),
368
+ }
369
+ p = receipt_path(root, kind)
370
+ os.makedirs(os.path.dirname(p), exist_ok=True)
371
+ with open(p, "w") as fh:
372
+ json.dump(r, fh, indent=2)
373
+ fh.write("\n")
374
+ audit(root, f"receipt.{kind}", f"{verdict} by {by}: {detail}")
375
+ print(f"{kind}: {verdict} -> {os.path.relpath(p, root)}")
376
+ return 0
377
+
378
+
379
+ def receipt_problem(root: str, kind: str, verdict: str | None, max_age_min: int,
380
+ require_commit: bool = True) -> str | None:
381
+ """Why this receipt does not hold, or None."""
382
+ p = receipt_path(root, kind)
383
+ if not os.path.exists(p):
384
+ return f"there is no {kind} receipt"
385
+ try:
386
+ r = json.load(open(p))
387
+ except Exception as e:
388
+ return f"the {kind} receipt is unreadable ({e})"
389
+ if verdict and r.get("verdict") != verdict:
390
+ return f'the {kind} receipt says "{r.get("verdict")}", not "{verdict}"'
391
+ if require_commit:
392
+ h = head(root)
393
+ if r.get("commit") != h:
394
+ return (f"the {kind} receipt is for {str(r.get('commit'))[:9]} but HEAD is {h[:9]} — "
395
+ f"the work changed after it was granted")
396
+ if r.get("subject") and r.get("subjectDigest"):
397
+ now = subject_digest(root, r["subject"])
398
+ if now and now != r["subjectDigest"]:
399
+ return (f"the {kind} receipt is for a different version of {r['subject']} — "
400
+ f"it was edited after approval")
401
+ try:
402
+ age = (time.time() - calendar.timegm(time.strptime(r["at"], "%Y-%m-%dT%H:%M:%SZ"))) / 60
403
+ except Exception:
404
+ return f"the {kind} receipt has no readable timestamp"
405
+ if age > max_age_min:
406
+ return f"the {kind} receipt is {int(age)} minutes old (limit {max_age_min})"
407
+ return None
408
+
409
+
410
+ # ------------------------------------------------------------------------------------- guard
411
+
412
+ DESTRUCTIVE = [
413
+ (r"\bgit\s+stash\b(?!\s+(list|show))", "git stash hides uncommitted work; commit path-scoped instead"),
414
+ (r"\bgit\s+reset\s+(--hard|--merge|--keep)\b", "git reset --hard destroys uncommitted work"),
415
+ (r"\bgit\s+clean\b", "git clean deletes untracked files"),
416
+ (r"\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)", "this discards every uncommitted change"),
417
+ (r"\bgit\s+(push|branch)\b[^|;&\n]*--force(-with-lease)?\b", "force-push rewrites published history"),
418
+ (r"\bgit\s+filter-(branch|repo)\b", "history rewriting is the maintainer's decision"),
419
+ (r"\brm\s+(-[a-zA-Z]*\s+)*(-rf|-fr)\s+(/|~|\$HOME)(\s|$)", "refusing rm -rf of a root or home path"),
420
+ ]
421
+ PUBLISHING = [
422
+ (r"\bgit\s+tag\b(?!\s+(-l|--list|-n))", "a tag is a public promise — the maintainer's call"),
423
+ (r"\bgit\s+push\b[^|;&\n]*(--tags|\brefs/tags/)", "pushing tags is the maintainer's call"),
424
+ (r"\b(npm|pnpm|yarn|bun)\s+publish\b", "publishing is the maintainer's call"),
425
+ (r"\bcargo\s+publish\b", "publishing is the maintainer's call"),
426
+ (r"\b(twine|poetry)\s+(upload|publish)\b", "publishing is the maintainer's call"),
427
+ (r"\bgh\s+release\s+(create|edit|delete|upload)\b", "cutting a release is the maintainer's call"),
428
+ (r"\bdocker\s+push\b", "pushing an image is a deploy — the maintainer's call"),
429
+ ]
430
+ STAGING = [
431
+ (r"\bgit\s+add\s+(-A|--all|\.)(\s|$)", "commit path-scoped: git add <explicit paths>"),
432
+ (r"\bgit\s+commit(\s+-{1,2}[\w-]+)*\s+(-[a-zA-Z]*a[a-zA-Z]*|--all)(\s|$)",
433
+ "git commit -a stages the whole tree; stage explicit paths"),
434
+ ]
435
+ # Commands that put a change in front of users. Detected conservatively: a false positive costs
436
+ # one approval, a false negative costs an unreviewed deployment.
437
+ DEPLOY_PATTERNS = [
438
+ r"\b(kubectl|helm)\s+(apply|rollout|upgrade|install|delete)\b",
439
+ r"\b(terraform|tofu)\s+(apply|destroy)\b",
440
+ r"\b(serverless|sls)\s+deploy\b",
441
+ r"\baws\s+(deploy|ecs|lambda|cloudformation|s3\s+sync)\b",
442
+ r"\b(gcloud|az)\s+\w+\s+deploy\b",
443
+ r"\b(flyctl|fly|vercel|netlify|heroku|railway|render)\s+(deploy|releases?)\b",
444
+ r"\bdocker\s+(compose\s+)?(up|stack\s+deploy)\b[^|;&\n]*(prod|production)",
445
+ r"\bansible-playbook\b",
446
+ r"\bgh\s+workflow\s+run\b[^|;&\n]*(deploy|release|prod)",
447
+ ]
448
+
449
+ CREDENTIALS = [
450
+ # [^|;&\n] rather than [^|;&]: the negated class matched newlines, so in a multi-line
451
+ # command a reader verb on line 1 paired with a credential word forty lines later. The
452
+ # verb list stays to tools whose whole job is to emit a file's contents -- adding
453
+ # interpreters made it fire on any script that merely mentions one.
454
+ (r"(^|[\s;|&(])(cat|bat|less|more|head|tail|strings|xxd|od|cp|scp|rsync|curl)\b[^|;&\n]*"
455
+ r"(\.env(?!\.(?:example|sample|template|dist))[\w.-]*|/\.ssh/|/\.aws/|/\.gnupg/|/\.kube/|"
456
+ r"/\.docker/|\.npmrc|\.pypirc|\.netrc|id_rsa|id_ed25519|\.pem|\.p12|\.kdbx|"
457
+ r"credentials(\.json)?|secrets?\.ya?ml|service[-_]account[\w.-]*\.json)",
458
+ "reading a credential file is forbidden"),
459
+ (r"(^|[\s;|&(])(printenv|env)\b[^|;&\n]*\b\w*(SECRET|TOKEN|PASSWORD|PASSWD|"
460
+ r"API[-_]?KEY|PRIVATE[-_]?KEY|CREDENTIAL)\w*",
461
+ "printing a secret from the environment is forbidden"),
462
+ (r"(^|[\s;|&(])echo\s+\"?\$\{?\w*(SECRET|TOKEN|PASSWORD|API[-_]?KEY|"
463
+ r"PRIVATE[-_]?KEY)\w*",
464
+ "echoing a secret from the environment is forbidden"),
465
+ (r"\b(sk-[A-Za-z0-9]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{12,})",
466
+ "a credential appears in this command line"),
467
+ ]
468
+
469
+
470
+ def guard_problem(root: str, command: str, cfg: dict) -> str | None:
471
+ enforce = cfg.get("enforce") or {}
472
+ groups = []
473
+ if enforce.get("blockDestructiveGit", True):
474
+ groups += DESTRUCTIVE + STAGING
475
+ if enforce.get("blockPublishing", True):
476
+ groups += PUBLISHING
477
+ if enforce.get("blockCredentialReads", True):
478
+ groups += CREDENTIALS
479
+ for pattern, why in groups:
480
+ if re.search(pattern, command):
481
+ return f"{why}."
482
+
483
+ protected = cfg.get("protected") or ["main", "master"]
484
+ trunk = cfg.get("trunk") or "main"
485
+ prot_re = "|".join(re.escape(p).replace(r"\*", r"[^\s]*") for p in set(protected + [trunk]))
486
+ max_age = int(((cfg.get("receipts") or {}).get("maxAgeMin")) or DEFAULT_MAX_AGE_MIN)
487
+ approvals = cfg.get("approvals") or {}
488
+ cur = branch(root)
489
+
490
+ if enforce.get("pushToTrunkNeedsGate", True) and re.search(r"\bgit\s+push\b", command):
491
+ named = re.search(r"\bgit\s+push\b\s+\S+\s+\S", command)
492
+ targets_protected = bool(re.search(rf"\b({prot_re})\b", command)) or (
493
+ not named and re.fullmatch(rf"({prot_re})", cur or "") is not None)
494
+ if targets_protected:
495
+ why = receipt_problem(root, "gate", "passed", max_age)
496
+ if why:
497
+ return (f"pushing a protected branch needs a passed gate receipt, and {why}.\n"
498
+ f"Run `/agentic-sdlc:gate` (the mechanical checks and the secret scan), or "
499
+ f"push a feature branch and open a pull request instead — that needs no "
500
+ f"receipt.")
501
+
502
+ if enforce.get("mergeNeedsReadiness", True):
503
+ merging = re.search(r"\bgh\s+pr\s+merge\b", command) or (
504
+ re.search(r"\bgit\s+merge\b", command)
505
+ and not re.search(r"--(abort|continue|quit)\b", command)
506
+ and re.fullmatch(rf"({prot_re})", cur or "") is not None)
507
+ if merging:
508
+ why = receipt_problem(root, "readiness", "ready", max_age)
509
+ if why:
510
+ return (f"merging needs a READY readiness receipt, and {why}.\n"
511
+ f"Run `/agentic-sdlc:readiness`. It runs the reviewers, the secret scan and "
512
+ f"the verify command, and checks every acceptance criterion against evidence.")
513
+ if approvals.get("merge") == "human":
514
+ why = receipt_problem(root, "approval_merge", "granted", max_age)
515
+ if why:
516
+ return (f"this project requires a human to approve a merge, and {why}.\n"
517
+ f"A person runs: sdlc approve merge --as '<their name>'\n"
518
+ f"An agent must not run it for them — the approval records who is "
519
+ f"accountable, and an agent approving its own work records nothing.")
520
+
521
+ # X7: a ceiling the agent cannot raise for itself. Checked last, so a destructive command
522
+ # is still refused for the right reason when the budget also happens to be spent.
523
+ over = budget_problem(root, cfg)
524
+ if over:
525
+ return over
526
+
527
+ # Deployment is a separate authority from merging, and defaults to a human's.
528
+ deploying = any(re.search(pat, command) for pat in DEPLOY_PATTERNS)
529
+ if deploying:
530
+ if approvals.get("release", "human") == "human":
531
+ why = receipt_problem(root, "approval_release", "granted", max_age)
532
+ if why:
533
+ return (f"deploying needs a human approval, and {why}.\n"
534
+ f"A person runs: sdlc approve release --as '<their name>'\n"
535
+ f"Deployment changes what users are running. No agent grants itself that.")
536
+ why = receipt_problem(root, "readiness", "ready", max_age)
537
+ if why:
538
+ return f"deploying needs a READY readiness receipt, and {why}."
539
+ return None
540
+
541
+
542
+ def cmd_guard(args) -> int:
543
+ """The refusal point. Reads a host's tool JSON on stdin, or one command with --command.
544
+
545
+ The same rules serve every host: Claude Code, Codex and Cline send the same wire shape,
546
+ opencode's plugin reshapes into it, and the git hooks pass --command.
547
+ """
548
+ if getattr(args, "command", None):
549
+ root = repo_root()
550
+ problem = guard_problem(root, args.command, load_config(root))
551
+ if problem:
552
+ audit(root, "blocked", f"{problem.splitlines()[0]} :: {args.command}")
553
+ print(f"BLOCKED by agentic-sdlc: {problem}", file=sys.stderr)
554
+ return 2
555
+ return 0
556
+ try:
557
+ payload = json.load(sys.stdin)
558
+ except Exception:
559
+ return 0 # never break a session over a malformed payload
560
+ tool = payload.get("tool_name") or payload.get("tool") or ""
561
+ inp = payload.get("tool_input") or payload.get("input") or {}
562
+ cwd = payload.get("cwd") or os.getcwd()
563
+ root = repo_root(cwd)
564
+ cfg = load_config(root)
565
+
566
+ if tool in ("Bash", "bash", "shell"):
567
+ command = str(inp.get("command", ""))
568
+ if not command:
569
+ return 0
570
+ problem = guard_problem(root, command, cfg)
571
+ if problem:
572
+ audit(root, "blocked", f"{problem.splitlines()[0]} :: {command}")
573
+ print(f"BLOCKED by agentic-sdlc: {problem}\n"
574
+ f"This is a pipeline rule, not a preference. Do not work around it — choose "
575
+ f"another route or stop and report.", file=sys.stderr)
576
+ return 2
577
+ elif tool in ("Edit", "Write", "NotebookEdit", "edit", "write"):
578
+ path = str(inp.get("file_path") or inp.get("path") or "")
579
+ if re.search(r"(^|/)(\.env(\.(?!example|sample|template)[\w.-]+)?|credentials\.json|"
580
+ r"secrets\.ya?ml|id_rsa|id_ed25519|.*\.pem)$|(^|/)\.(ssh|aws)/", path):
581
+ print(f"BLOCKED by agentic-sdlc: {path} holds credentials.", file=sys.stderr)
582
+ return 2
583
+ return 0
584
+
585
+
586
+ # ------------------------------------------------------------------------------- subcommands
587
+
588
+
589
+ def cmd_init(args) -> int:
590
+ root = repo_root()
591
+ cfg = detect(root)
592
+ existing = load_config(root)
593
+ if existing:
594
+ # Keep what a human has set; only fill what detection newly knows.
595
+ for k, v in existing.items():
596
+ if k != "$schema":
597
+ cfg[k] = v
598
+ cfg.setdefault("version", 2)
599
+ out = json.dumps(cfg, indent=2) + "\n"
600
+ target = os.path.join(root, "sdlc.config.json")
601
+ if not args.write:
602
+ print(out, end="")
603
+ print(f"\n# not written — re-run with --write to save {os.path.relpath(target, root)}",
604
+ file=sys.stderr)
605
+ return 0
606
+ open(target, "w").write(out)
607
+ print(f"wrote {os.path.relpath(target, root)}")
608
+ if cfg["spec"]["engine"] == "openspec" and not os.path.isdir(os.path.join(root, "openspec")):
609
+ print("openspec is available but this repo has no openspec/ — run: openspec init")
610
+ if not cfg.get("verifyCmd"):
611
+ print("no verify command could be detected — set \"verifyCmd\" by hand; the pipeline "
612
+ "cannot gate anything without it")
613
+ return 0
614
+
615
+
616
+ def cmd_config(_args) -> int:
617
+ root = repo_root()
618
+ p = config_path(root)
619
+ if not p:
620
+ print("no sdlc.config.json — run: sdlc init --write", file=sys.stderr)
621
+ return 1
622
+ print(f"# {os.path.relpath(p, root)}")
623
+ print(json.dumps(load_config(root), indent=2))
624
+ return 0
625
+
626
+
627
+ def cmd_receipt(args) -> int:
628
+ root = repo_root()
629
+ if args.receipt_action == "write":
630
+ if not args.verdict:
631
+ print("sdlc receipt write needs --verdict", file=sys.stderr)
632
+ return 3
633
+ return receipt_write(root, args.kind, args.verdict, args.subject, args.detail, args.by)
634
+ if args.receipt_action == "show":
635
+ p = receipt_path(root, args.kind)
636
+ if not os.path.exists(p):
637
+ print(f"no {args.kind} receipt", file=sys.stderr)
638
+ return 1
639
+ print(open(p).read(), end="")
640
+ return 0
641
+ cfg = load_config(root)
642
+ max_age = args.max_age_min or int(((cfg.get("receipts") or {}).get("maxAgeMin")) or DEFAULT_MAX_AGE_MIN)
643
+ why = receipt_problem(root, args.kind, args.verdict, max_age, require_commit=not args.any_commit)
644
+ if why:
645
+ print(f"{args.kind} receipt does not hold: {why}", file=sys.stderr)
646
+ return 1
647
+ print(f"{args.kind} receipt holds")
648
+ return 0
649
+
650
+
651
+ def fingerprint_line(env: dict) -> str:
652
+ keys = [k for k in ("os", "arch", "python", "node", "go", "cargo", "git") if k in env]
653
+ return " ".join(f"{k} {env[k]}" for k in keys)
654
+
655
+
656
+ def cmd_status(_args) -> int:
657
+ root = repo_root()
658
+ cfg = load_config(root)
659
+ print(f"repo {os.path.basename(root)}")
660
+ print(f"branch {branch(root)} (trunk: {cfg.get('trunk', '?')})")
661
+ print(f"head {head(root)[:9]} tree {'clean' if tree_clean(root) else 'DIRTY'}")
662
+ spec = cfg.get("spec") or {}
663
+ print(f"spec engine {spec.get('engine', '?')}, root {spec.get('docsRoot', '?')}")
664
+ print(f"verify {cfg.get('verifyCmd') or '(none set — the gate cannot run)'}")
665
+ if cfg.get("gateCmd"):
666
+ print(f"gate {cfg['gateCmd']}")
667
+ print("receipts")
668
+ for kind in RECEIPT_KINDS:
669
+ p = receipt_path(root, kind)
670
+ if not os.path.exists(p):
671
+ continue
672
+ try:
673
+ r = json.load(open(p))
674
+ except Exception:
675
+ continue
676
+ why = receipt_problem(root, kind, None, 10 ** 9)
677
+ mark = "stale" if why else "current"
678
+ print(f" {kind:<13} {r.get('verdict','?'):<18} {str(r.get('commit',''))[:9]} {r.get('at','')} {mark}")
679
+ print(f"toolchain {fingerprint_line(environment(root))}")
680
+ return 0
681
+
682
+
683
+ def cmd_doctor(_args) -> int:
684
+ root = repo_root()
685
+ cfg = load_config(root)
686
+ problems, notes = [], []
687
+
688
+ if not git("rev-parse", "--is-inside-work-tree", cwd=root):
689
+ problems.append("not inside a git repository — the pipeline needs git")
690
+ if not cfg:
691
+ problems.append("no sdlc.config.json — run: sdlc init --write")
692
+ else:
693
+ if not cfg.get("verifyCmd"):
694
+ problems.append('"verifyCmd" is not set — nothing can be gated')
695
+ if not cfg.get("secretScan"):
696
+ notes.append('"secretScan" is not set — install gitleaks and re-run `sdlc init`')
697
+ if not (cfg.get("quality") or {}).get("cmd"):
698
+ notes.append('"quality.cmd" is not set — the gate has no deterministic analysis on '
699
+ "new code, so maintainability and complexity are reviewed by reading "
700
+ "only. An LLM reviewer is a complement to a static analyser, not a "
701
+ "substitute for one.")
702
+ engine = (cfg.get("spec") or {}).get("engine")
703
+ if engine == "openspec":
704
+ if not which("openspec"):
705
+ problems.append('spec.engine is "openspec" but the openspec CLI is not installed')
706
+ elif not os.path.isdir(os.path.join(root, "openspec")):
707
+ problems.append("openspec is configured but this repo has no openspec/ — run: openspec init")
708
+ if cfg.get("trunk") and cfg["trunk"] != detect_trunk(root):
709
+ notes.append(f'config says trunk "{cfg["trunk"]}" but git says "{detect_trunk(root)}"')
710
+
711
+ present = detect_platforms()
712
+ here = [k for k, v in present.items() if v]
713
+ print(f"toolchain {fingerprint_line(environment(root))}")
714
+ print(f"hosts detected {', '.join(here) if here else 'none'}")
715
+ installed = []
716
+ for rel, label in ((".cursor/rules/agentic-sdlc.mdc", "cursor"),
717
+ (".github/copilot-instructions.md", "copilot"),
718
+ (".clinerules/agentic-sdlc.md", "cline"),
719
+ (".roo/rules/agentic-sdlc.md", "roo"),
720
+ (".kilocode/rules/agentic-sdlc.md", "kilo"),
721
+ (".windsurf/rules/agentic-sdlc.md", "windsurf"),
722
+ (".continue/rules/agentic-sdlc.md", "continue"),
723
+ (".gemini/policies/agentic-sdlc.toml", "gemini"),
724
+ ("AGENTS.md", "agents.md"),
725
+ (".github/workflows/sdlc-gate.yml", "ci")):
726
+ if os.path.exists(os.path.join(root, rel)):
727
+ installed.append(label)
728
+ hooks_dir = git("rev-parse", "--git-path", "hooks", cwd=root) or ".git/hooks"
729
+ if not os.path.isabs(hooks_dir):
730
+ hooks_dir = os.path.join(root, hooks_dir)
731
+ hooked = [n for n in ("pre-commit", "pre-push")
732
+ if os.path.exists(os.path.join(hooks_dir, n))
733
+ and "agentic-sdlc" in open(os.path.join(hooks_dir, n), errors="replace").read()]
734
+ if hooked:
735
+ installed.append("git-hooks(" + ",".join(hooked) + ")")
736
+ print(f"installed here {', '.join(installed) if installed else 'nothing yet — run: sdlc install'}")
737
+ missing = [h for h in here if h in TARGETS and TARGETS[h][0] and h not in " ".join(installed)]
738
+ if missing:
739
+ notes.append(f"detected but not installed: {', '.join(missing)} — run `sdlc install "
740
+ f"{' '.join(missing)}`")
741
+ if not hooked:
742
+ notes.append("no git hooks — they are the only local enforcement on hosts without a "
743
+ "tool hook (Cursor, Copilot, Windsurf). Run `sdlc install git-hooks`")
744
+ if not os.path.exists(os.path.join(root, ".github/workflows/sdlc-gate.yml")):
745
+ notes.append("no CI gate — the only layer an agent cannot reach. Run `sdlc install ci`")
746
+
747
+ openf = [f for f in finding_load_all(root) if f.get("outcome") in ("open", "unexecuted")]
748
+ if openf:
749
+ crit = sum(1 for f in openf
750
+ if (f.get("classification") or {}).get("severity") in ("critical", "high"))
751
+ notes.append(f"{len(openf)} finding(s) still open or unexecuted"
752
+ + (f", {crit} of them critical or high" if crit else "")
753
+ + " — see `sdlc finding list --open`")
754
+
755
+ hooks = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "hooks", "hooks.json")
756
+ if os.path.exists(hooks):
757
+ notes.append(f"hooks shipped at {hooks} — enforcement is active when the plugin is installed")
758
+ else:
759
+ problems.append("hooks/hooks.json is missing — the pipeline would be advisory only")
760
+
761
+ for name, why in (("git", "required"), ("gh", "pull requests"), ("gitleaks", "secret scanning"),
762
+ ("openspec", "the OpenSpec engine")):
763
+ if not which(name):
764
+ (problems if why == "required" else notes).append(f"{name} not found ({why})")
765
+
766
+ for n in notes:
767
+ print(f"note {n}")
768
+ for p in problems:
769
+ print(f"PROBLEM {p}")
770
+ print("doctor: ok" if not problems else f"doctor: {len(problems)} problem(s)")
771
+ return 0 if not problems else 1
772
+
773
+
774
+
775
+
776
+ # ============================================================================== install
777
+ #
778
+ # One pipeline, many hosts. The canonical sources are agents/, commands/ and docs/rules.md;
779
+ # every adapter below is a pure mapping from those into one platform's layout. Nothing is
780
+ # authored twice, so nothing can drift.
781
+ #
782
+ # Three enforcement layers, because platforms differ in what they allow:
783
+ # 1. a native tool hook — Claude Code, Codex, Cline, opencode: refuses before the action
784
+ # 2. git hooks — everywhere, because every agent shells out to git
785
+ # 3. CI — the only layer an agent cannot reach at all
786
+ # What a platform cannot do is written down in docs/platforms.md rather than implied away.
787
+
788
+ BEGIN = "BEGIN agentic-sdlc (generated — edit docs/rules.md or sdlc.config.json, then re-run)"
789
+ END = "END agentic-sdlc"
790
+
791
+
792
+ def plugin_root() -> str:
793
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
794
+
795
+
796
+ def read_plugin(*parts: str) -> str:
797
+ try:
798
+ return open(os.path.join(plugin_root(), *parts), errors="replace").read()
799
+ except OSError:
800
+ return ""
801
+
802
+
803
+ class Writer:
804
+ """Collects writes so --dry-run can show them and nothing is half-applied."""
805
+
806
+ def __init__(self, dry: bool):
807
+ self.dry, self.written, self.skipped = dry, [], []
808
+ self.pending = {} # several adapters write AGENTS.md; it is one file, not five
809
+
810
+ def put(self, path: str, content: str, mode: int = 0o644) -> None:
811
+ if path in self.pending:
812
+ self.skipped.append(path)
813
+ return
814
+ try:
815
+ old = open(path, errors="replace").read()
816
+ except OSError:
817
+ old = None
818
+ self.pending[path] = content
819
+ if old == content:
820
+ self.skipped.append(path)
821
+ return
822
+ self.written.append(path)
823
+ if self.dry:
824
+ return
825
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
826
+ with open(path, "w") as fh:
827
+ fh.write(content)
828
+ os.chmod(path, mode)
829
+
830
+ def block(self, path: str, body: str, comment: str = "<!-- {} -->") -> None:
831
+ """Write our section into a file we do not own, leaving everything else alone."""
832
+ begin, end = comment.format(BEGIN), comment.format(END)
833
+ section = f"{begin}\n{body.strip()}\n{end}\n"
834
+ if path in self.pending:
835
+ old = self.pending[path]
836
+ else:
837
+ try:
838
+ old = open(path, errors="replace").read()
839
+ except OSError:
840
+ old = ""
841
+ if begin in old and end in old:
842
+ head, _, rest = old.partition(begin)
843
+ _, _, tail = rest.partition(end)
844
+ new = head + section + tail.lstrip("\n")
845
+ elif old.strip():
846
+ new = old.rstrip("\n") + "\n\n" + section
847
+ else:
848
+ new = section
849
+ self.put(path, new)
850
+
851
+ def report(self) -> None:
852
+ verb = "would write" if self.dry else "wrote"
853
+ for p in self.written:
854
+ print(f" {verb} {p}")
855
+ if self.skipped:
856
+ print(f" ({len(self.skipped)} already current)")
857
+
858
+
859
+ def detect_platforms() -> dict:
860
+ """Which hosts are present. A binary on PATH or a config directory both count."""
861
+ home = os.path.expanduser("~")
862
+ checks = {
863
+ "claude-code": which("claude") or os.path.isdir(f"{home}/.claude"),
864
+ "opencode": which("opencode") or os.path.isdir(f"{home}/.config/opencode"),
865
+ "codex": which("codex") or os.path.isdir(f"{home}/.codex"),
866
+ "gemini": which("gemini") or os.path.isdir(f"{home}/.gemini"),
867
+ "cursor": which("cursor-agent") or which("cursor") or os.path.isdir(f"{home}/.cursor"),
868
+ "copilot": os.path.isdir(f"{home}/.vscode") or which("code"),
869
+ "cline": bool(glob.glob(f"{home}/.vscode/extensions/saoudrizwan.claude-dev-*")),
870
+ "roo": bool(glob.glob(f"{home}/.vscode/extensions/rooveterinaryinc.roo-cline-*")),
871
+ "kilo": bool(glob.glob(f"{home}/.vscode/extensions/kilocode.kilo-code-*")),
872
+ "windsurf": os.path.isdir("/Applications/Windsurf.app") or os.path.isdir(f"{home}/.codeium"),
873
+ "continue": os.path.isdir(f"{home}/.continue"),
874
+ "aider": which("aider"),
875
+ "zed": which("zed") or os.path.isdir(f"{home}/.config/zed"),
876
+ "amp": which("amp"),
877
+ "warp": os.path.isdir("/Applications/Warp.app"),
878
+ "factory": which("droid"),
879
+ }
880
+ return {k: bool(v) for k, v in checks.items()}
881
+
882
+
883
+ def interpreter() -> str:
884
+ """The command that will run this CLI from a hook. Windows often lacks `python3`."""
885
+ for cand in ("python3", "python"):
886
+ if which(cand):
887
+ return cand
888
+ return sys.executable or "python3"
889
+
890
+
891
+ def cli_path() -> str:
892
+ return os.path.join(plugin_root(), "bin", "sdlc")
893
+
894
+
895
+ def guard_command() -> str:
896
+ return f'{interpreter()} "{cli_path()}" guard'
897
+
898
+
899
+
900
+ # ---------------------------------------------------------------- what each platform is told
901
+
902
+ def rules_body(cfg: dict) -> str:
903
+ """The pipeline's rules, plus this project's bindings. One source: docs/rules.md."""
904
+ rules = read_plugin("docs", "rules.md")
905
+ rules = re.sub(r"^# .*\n+", "", rules, count=1) # the host file supplies the title
906
+ lines = ["## The agentic SDLC pipeline", "",
907
+ "This project uses the agentic-sdlc pipeline. Work moves through stages, each owned",
908
+ "by one agent and closed by a receipt bound to a commit. Do not skip a stage: on some",
909
+ "hosts the skip is refused outright, and on all of them a git hook and CI will catch",
910
+ "it later, more expensively.", ""]
911
+ if cfg:
912
+ lines += ["### This project", "",
913
+ f"- trunk: `{cfg.get('trunk', 'main')}`",
914
+ f"- verify: `{cfg.get('verifyCmd') or '(not set — set verifyCmd in sdlc.config.json)'}`"]
915
+ if cfg.get("gateCmd"):
916
+ lines.append(f"- gate: `{cfg['gateCmd']}`")
917
+ spec = cfg.get("spec") or {}
918
+ lines.append(f"- specifications: {spec.get('engine', 'prd-frd')} in `{spec.get('docsRoot', 'docs/specs')}`")
919
+ if cfg.get("profile"):
920
+ lines.append(f"- profile: `{cfg['profile']}` — its standards apply to reviews")
921
+ if cfg.get("heavy"):
922
+ lines.append(f"- heavy work: {cfg['heavy']}")
923
+ lines.append("")
924
+ lines += ["### Commands", "",
925
+ "```sh",
926
+ "sdlc status # which stages have left evidence, which have not",
927
+ "sdlc trace # requirement -> commit -> test -> receipts",
928
+ "sdlc receipt check readiness --verdict ready",
929
+ "sdlc feedback read # why the last attempt failed, before you retry",
930
+ "```", ""]
931
+ return "\n".join(lines) + "\n" + rules.strip() + "\n"
932
+
933
+
934
+ def stage_prompts() -> list:
935
+ """(name, description, body) for each pipeline stage, from commands/."""
936
+ out = []
937
+ for f in sorted(glob.glob(os.path.join(plugin_root(), "commands", "*.md"))):
938
+ text = open(f, errors="replace").read()
939
+ m = re.match(r"^---\n([\s\S]*?)\n---\n?", text)
940
+ fm, body = (m.group(1), text[m.end():]) if m else ("", text)
941
+ desc = ""
942
+ d = re.search(r"^description:\s*(.+)$", fm, re.M)
943
+ if d:
944
+ desc = d.group(1).strip()
945
+ # Shell injections and plugin-root paths mean nothing outside Claude Code.
946
+ body = re.sub(r"^!`.*`\s*$", "", body, flags=re.M)
947
+ body = body.replace("${CLAUDE_PLUGIN_ROOT}/bin/sdlc", "sdlc")
948
+ body = re.sub(r"\n{3,}", "\n\n", body).strip()
949
+ out.append((os.path.basename(f)[:-3], desc, body))
950
+ return out
951
+
952
+
953
+ def agent_summary() -> str:
954
+ """Who owns what — so a host without subagents still knows the division of labour."""
955
+ rows = []
956
+ for f in sorted(glob.glob(os.path.join(plugin_root(), "agents", "*.md"))):
957
+ text = open(f, errors="replace").read()
958
+ m = re.search(r"^description:\s*>?\s*\n?([\s\S]*?)(?=^\w+:|^---)", text, re.M)
959
+ desc = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
960
+ rows.append(f"- **{os.path.basename(f)[:-3]}** — {desc[:160]}")
961
+ return "\n".join(rows)
962
+
963
+
964
+
965
+ # ------------------------------------------------------------------------------- adapters
966
+ #
967
+ # Each adapter takes (root, cfg, w: Writer) and writes that platform's files. Every path is
968
+ # verified against the real tool on 2026-10-25 — see docs/platforms.md for the evidence.
969
+
970
+ def _agents_md(root: str, cfg: dict, w: Writer, name: str = "AGENTS.md") -> None:
971
+ """The universal layer: agents.md is read by 20+ hosts, so this is most tools' whole story."""
972
+ w.block(os.path.join(root, name), rules_body(cfg))
973
+
974
+
975
+ def _rules_file(root: str, cfg: dict, w: Writer, relpath: str) -> None:
976
+ w.block(os.path.join(root, relpath), rules_body(cfg))
977
+
978
+
979
+ def _prompt_files(root: str, w: Writer, directory: str, suffix: str = ".md",
980
+ prefix: str = "sdlc-", frontmatter=None) -> None:
981
+ for name, desc, body in stage_prompts():
982
+ head = frontmatter(name, desc) if frontmatter else ""
983
+ w.put(os.path.join(root, directory, f"{prefix}{name}{suffix}"), head + body + "\n")
984
+
985
+
986
+ def adapt_opencode(root: str, cfg: dict, w: Writer) -> None:
987
+ """opencode: agents with real permissions, commands, and a plugin that calls the guard."""
988
+ prefix = os.path.expanduser("~/.config/opencode")
989
+ for f in sorted(glob.glob(os.path.join(plugin_root(), "agents", "*.md"))):
990
+ text = open(f, errors="replace").read().replace("${CLAUDE_PLUGIN_ROOT}", plugin_root())
991
+ m = re.match(r"^---\n([\s\S]*?)\n---", text)
992
+ if m:
993
+ fm = m.group(1)
994
+ tools = re.search(r"^tools:\s*\[(.*?)\]\s*$", fm, re.M)
995
+ edits = True
996
+ if tools:
997
+ listed = [t.strip() for t in tools.group(1).split(",")]
998
+ edits = any(t in ("Write", "Edit", "NotebookEdit") for t in listed)
999
+ fm = re.sub(r"^tools:.*$\n?", "", fm, flags=re.M)
1000
+ mode = "primary" if re.search(r"(implementer|spec-writer)", f) else "subagent"
1001
+ text = (f"---\n{fm.rstrip()}\nmode: {mode}\ntemperature: 0.1\n"
1002
+ f"permission:\n edit: {'allow' if edits else 'deny'}\n bash: allow\n---"
1003
+ + text[m.end():])
1004
+ w.put(os.path.join(prefix, "agents", os.path.basename(f)), text)
1005
+ for f in sorted(glob.glob(os.path.join(plugin_root(), "commands", "*.md"))):
1006
+ text = open(f, errors="replace").read().replace("${CLAUDE_PLUGIN_ROOT}", plugin_root())
1007
+ w.put(os.path.join(prefix, "commands", "sdlc-" + os.path.basename(f)), text)
1008
+ tpl = read_plugin("opencode", "plugin", "agentic-sdlc.ts")
1009
+ w.put(os.path.join(prefix, "plugins", "agentic-sdlc.ts"),
1010
+ tpl.replace("__PLUGIN_ROOT__", plugin_root()))
1011
+ _agents_md(root, cfg, w)
1012
+
1013
+
1014
+ def adapt_codex(root: str, cfg: dict, w: Writer) -> None:
1015
+ """Codex CLI: PreToolUse hooks with the same wire contract as Claude Code."""
1016
+ hooks_path = os.path.expanduser("~/.codex/hooks.json")
1017
+ try:
1018
+ existing = json.load(open(hooks_path))
1019
+ except Exception:
1020
+ existing = {}
1021
+ hooks = existing.setdefault("hooks", {})
1022
+ entries = [e for e in hooks.get("PreToolUse", [])
1023
+ if not any("sdlc" in (h.get("command") or "") for h in e.get("hooks", []))]
1024
+ entries.append({"hooks": [{"type": "command", "command": guard_command(), "timeout": 10}]})
1025
+ hooks["PreToolUse"] = entries # every other event the user configured is untouched
1026
+ w.put(hooks_path, json.dumps(existing, indent=2) + "\n")
1027
+ _prompt_files(os.path.expanduser("~/.codex"), w, "prompts")
1028
+ _agents_md(root, cfg, w)
1029
+
1030
+
1031
+ def adapt_gemini(root: str, cfg: dict, w: Writer) -> None:
1032
+ """Gemini CLI: no PreToolUse, but a policy engine that denies static patterns."""
1033
+ lines = ["# agentic-sdlc — generated. Gemini has no pre-tool hook, so only the static",
1034
+ "# refusals live here; the receipt-dependent gates are enforced by the git hooks.",
1035
+ "# Re-run `sdlc install gemini` to regenerate.", ""]
1036
+ groups = [("destructive", DESTRUCTIVE), ("publishing", PUBLISHING), ("staging", STAGING),
1037
+ ("credentials", CREDENTIALS)]
1038
+ pri = 100
1039
+ for label, rules in groups:
1040
+ for pattern, why in rules:
1041
+ lines += ["[[rules]]", 'toolName = "run_shell_command"',
1042
+ f"argsPattern = {json.dumps(pattern)}", 'decision = "deny"',
1043
+ f"priority = {pri}", f"# {label}: {why}", ""]
1044
+ pri += 1
1045
+ w.put(os.path.join(root, ".gemini", "policies", "agentic-sdlc.toml"), "\n".join(lines))
1046
+ for name, desc, body in stage_prompts():
1047
+ toml = (f'# agentic-sdlc — generated\ndescription = {json.dumps(desc or name)}\n'
1048
+ f'prompt = """\n{body}\n"""\n')
1049
+ w.put(os.path.join(root, ".gemini", "commands", "sdlc", f"{name}.toml"), toml)
1050
+ _agents_md(root, cfg, w)
1051
+ _agents_md(root, cfg, w, "GEMINI.md")
1052
+
1053
+
1054
+ def adapt_cline(root: str, cfg: dict, w: Writer) -> None:
1055
+ """Cline: .clinerules, plus PreToolUse hooks as executables named for the event."""
1056
+ _rules_file(root, cfg, w, os.path.join(".clinerules", "agentic-sdlc.md"))
1057
+ hook = ("#!/bin/sh\n"
1058
+ "# agentic-sdlc — refuses a tool call that would skip a pipeline stage.\n"
1059
+ "# Cline runs this with the hook payload on stdin; exit 2 blocks the call.\n"
1060
+ f'exec {interpreter()} "{cli_path()}" guard\n')
1061
+ w.put(os.path.join(root, ".clinerules", "hooks", "PreToolUse"), hook, mode=0o755)
1062
+ for name, desc, body in stage_prompts():
1063
+ w.put(os.path.join(root, ".clinerules", "workflows", f"sdlc-{name}.md"),
1064
+ f"# {desc or name}\n\n{body}\n")
1065
+ _agents_md(root, cfg, w)
1066
+
1067
+
1068
+ def adapt_cursor(root: str, cfg: dict, w: Writer) -> None:
1069
+ """Cursor: .cursor/rules/*.mdc with alwaysApply, and .cursor/commands/*.md."""
1070
+ body = ("---\n"
1071
+ "description: The agentic-sdlc pipeline — stages, receipts and what is refused\n"
1072
+ "alwaysApply: true\n"
1073
+ "---\n\n" + rules_body(cfg))
1074
+ w.put(os.path.join(root, ".cursor", "rules", "agentic-sdlc.mdc"), body)
1075
+ if cfg.get("profile"):
1076
+ prof = read_plugin("profiles", f"{cfg['profile']}.md")
1077
+ if prof:
1078
+ w.put(os.path.join(root, ".cursor", "rules", f"agentic-sdlc-{cfg['profile']}.mdc"),
1079
+ f"---\ndescription: Standards for a {cfg['profile']} project\n"
1080
+ f"alwaysApply: true\n---\n\n{prof}")
1081
+ _prompt_files(root, w, os.path.join(".cursor", "commands"))
1082
+ _agents_md(root, cfg, w)
1083
+
1084
+
1085
+ def adapt_copilot(root: str, cfg: dict, w: Writer) -> None:
1086
+ """GitHub Copilot: repository instructions plus prompt files."""
1087
+ _rules_file(root, cfg, w, os.path.join(".github", "copilot-instructions.md"))
1088
+ def fm(name, desc):
1089
+ return f"---\nmode: agent\ndescription: {desc or name}\n---\n\n"
1090
+ _prompt_files(root, w, os.path.join(".github", "prompts"), suffix=".prompt.md",
1091
+ frontmatter=fm)
1092
+ _agents_md(root, cfg, w)
1093
+
1094
+
1095
+ def adapt_roo(root: str, cfg: dict, w: Writer) -> None:
1096
+ _rules_file(root, cfg, w, os.path.join(".roo", "rules", "agentic-sdlc.md"))
1097
+ _agents_md(root, cfg, w)
1098
+
1099
+
1100
+ def adapt_kilo(root: str, cfg: dict, w: Writer) -> None:
1101
+ _rules_file(root, cfg, w, os.path.join(".kilocode", "rules", "agentic-sdlc.md"))
1102
+ _agents_md(root, cfg, w)
1103
+
1104
+
1105
+ def adapt_windsurf(root: str, cfg: dict, w: Writer) -> None:
1106
+ _rules_file(root, cfg, w, os.path.join(".windsurf", "rules", "agentic-sdlc.md"))
1107
+ for name, desc, body in stage_prompts():
1108
+ w.put(os.path.join(root, ".windsurf", "workflows", f"sdlc-{name}.md"),
1109
+ f"---\ndescription: {desc or name}\n---\n\n{body}\n")
1110
+ _agents_md(root, cfg, w)
1111
+
1112
+
1113
+ def adapt_continue(root: str, cfg: dict, w: Writer) -> None:
1114
+ _rules_file(root, cfg, w, os.path.join(".continue", "rules", "agentic-sdlc.md"))
1115
+ _agents_md(root, cfg, w)
1116
+
1117
+
1118
+ def adapt_aider(root: str, cfg: dict, w: Writer) -> None:
1119
+ _rules_file(root, cfg, w, "CONVENTIONS.md")
1120
+ _agents_md(root, cfg, w)
1121
+
1122
+
1123
+ def adapt_generic(root: str, cfg: dict, w: Writer) -> None:
1124
+ """Zed, Amp, Warp, Junie, Factory, Devin, Jules and anything else that reads AGENTS.md."""
1125
+ _agents_md(root, cfg, w)
1126
+
1127
+
1128
+
1129
+ def adapt_git_hooks(root: str, cfg: dict, w: Writer) -> None:
1130
+ """The only local enforcement every platform shares: every agent shells out to git.
1131
+
1132
+ An existing hook is never destroyed — our part goes in a guarded block, so a repo that
1133
+ already uses husky or pre-commit keeps working.
1134
+ """
1135
+ hooks_dir = git("rev-parse", "--git-path", "hooks", cwd=root) or ".git/hooks"
1136
+ if not os.path.isabs(hooks_dir):
1137
+ hooks_dir = os.path.join(root, hooks_dir)
1138
+ marker_begin = "# >>> agentic-sdlc >>>"
1139
+ marker_end = "# <<< agentic-sdlc <<<"
1140
+
1141
+ bodies = {
1142
+ "pre-commit": (
1143
+ f'{marker_begin}\n'
1144
+ f'# Staging hygiene and a secret scan on what is actually staged.\n'
1145
+ f'staged=$(git diff --cached --name-only)\n'
1146
+ f'[ -z "$staged" ] || {interpreter()} "{cli_path()}" guard --command '
1147
+ f'"git commit" || exit 2\n'
1148
+ f'{marker_end}\n'),
1149
+ "pre-push": (
1150
+ f'{marker_begin}\n'
1151
+ f'# git feeds "<local ref> <local sha> <remote ref> <remote sha>" on stdin.\n'
1152
+ f'while read -r _ _ remote_ref _; do\n'
1153
+ f' [ -n "$remote_ref" ] || continue\n'
1154
+ f' {interpreter()} "{cli_path()}" guard --command "git push origin ${{remote_ref##*/}}" '
1155
+ f'|| exit 2\n'
1156
+ f'done\n'
1157
+ f'{marker_end}\n'),
1158
+ }
1159
+
1160
+ for name, block in bodies.items():
1161
+ path = os.path.join(hooks_dir, name)
1162
+ try:
1163
+ old = open(path, errors="replace").read()
1164
+ except OSError:
1165
+ old = ""
1166
+ if marker_begin in old and marker_end in old:
1167
+ head, _, rest = old.partition(marker_begin)
1168
+ _, _, tail = rest.partition(marker_end)
1169
+ new = head + block.rstrip("\n") + tail
1170
+ elif old.strip():
1171
+ new = old.rstrip("\n") + "\n\n" + block # keep whatever was already there
1172
+ else:
1173
+ new = "#!/bin/sh\n" + block
1174
+ w.put(path, new, mode=0o755)
1175
+
1176
+
1177
+ def adapt_ci(root: str, cfg: dict, w: Writer) -> None:
1178
+ """The layer an agent cannot reach. It re-runs the checks rather than trusting receipts."""
1179
+ verify = cfg.get("gateCmd") or cfg.get("verifyCmd") or "echo 'set verifyCmd in sdlc.config.json' && false"
1180
+ secret = cfg.get("secretScan") or ""
1181
+ quality = (cfg.get("quality") or {}).get("cmd") or ""
1182
+ y = ["# agentic-sdlc — generated by `sdlc install ci`. The authoritative gate: it runs",
1183
+ "# where no agent can reach it, and it re-runs the checks rather than trusting a",
1184
+ "# receipt, because receipts are local to a working copy by design.",
1185
+ "name: SDLC gate", "",
1186
+ "on:", " pull_request:", " workflow_dispatch:", "",
1187
+ "permissions:", " contents: read", "",
1188
+ "jobs:", " gate:", " runs-on: ubuntu-latest", " steps:",
1189
+ " - uses: actions/checkout@v6", " with:", " fetch-depth: 0",
1190
+ " - name: Verify", f" run: {verify}"]
1191
+ if secret:
1192
+ y += [" - name: Secret scan", f" run: {secret}"]
1193
+ if quality:
1194
+ y += [" - name: Quality budget on new code", f" run: {quality}"]
1195
+ y += [" - name: Traceability",
1196
+ " run: |",
1197
+ ' python3 "$GITHUB_WORKSPACE/bin/sdlc" trace >> "$GITHUB_STEP_SUMMARY" 2>/dev/null \\',
1198
+ ' || echo "sdlc not vendored in this repo; skipping the trace summary" >> "$GITHUB_STEP_SUMMARY"']
1199
+ w.put(os.path.join(root, ".github", "workflows", "sdlc-gate.yml"), "\n".join(y) + "\n")
1200
+
1201
+
1202
+ TARGETS = {
1203
+ "claude-code": (None, "the plugin itself — install it with /plugin install"),
1204
+ "opencode": (adapt_opencode, "agents, commands and a plugin that calls the guard"),
1205
+ "codex": (adapt_codex, "PreToolUse hook, prompts, AGENTS.md"),
1206
+ "gemini": (adapt_gemini, "policy denies, TOML commands, GEMINI.md"),
1207
+ "cline": (adapt_cline, "rules, PreToolUse hook, workflows"),
1208
+ "cursor": (adapt_cursor, "always-on .mdc rules and commands"),
1209
+ "copilot": (adapt_copilot, "repository instructions and prompt files"),
1210
+ "roo": (adapt_roo, ".roo/rules"),
1211
+ "kilo": (adapt_kilo, ".kilocode/rules"),
1212
+ "windsurf": (adapt_windsurf, "rules and workflows"),
1213
+ "continue": (adapt_continue, ".continue/rules"),
1214
+ "aider": (adapt_aider, "CONVENTIONS.md and AGENTS.md"),
1215
+ "generic": (adapt_generic, "AGENTS.md — Zed, Amp, Warp, Junie, Factory, Devin, Jules"),
1216
+ "git-hooks": (adapt_git_hooks, "pre-commit and pre-push — the universal local floor"),
1217
+ "ci": (adapt_ci, "a GitHub Actions gate that no agent can reach"),
1218
+ }
1219
+
1220
+ # What each host can actually do. Stated here so --list cannot drift from the documentation.
1221
+ ENFORCEMENT = {
1222
+ "claude-code": "refuses the tool call", "opencode": "refuses the tool call",
1223
+ "codex": "refuses the tool call", "cline": "refuses the tool call",
1224
+ "gemini": "static policy denies only", "git-hooks": "refuses the git command",
1225
+ "ci": "fails the pull request",
1226
+ }
1227
+
1228
+
1229
+ def cmd_install(args) -> int:
1230
+ root = repo_root()
1231
+ cfg = load_config(root)
1232
+ present = detect_platforms()
1233
+
1234
+ if args.list:
1235
+ print("target enforcement what it writes")
1236
+ for name, (fn, what) in TARGETS.items():
1237
+ mark = "·" if not present.get(name, name in ("generic", "git-hooks", "ci")) else "✓"
1238
+ print(f"{mark} {name:<12} {ENFORCEMENT.get(name, 'instructions only'):<28} {what}")
1239
+ print("\n✓ = detected on this machine. Enforcement differs by host — see docs/platforms.md.")
1240
+ return 0
1241
+
1242
+ targets = list(args.targets)
1243
+ if not targets:
1244
+ targets = [k for k, v in present.items() if v and k in TARGETS and TARGETS[k][0]]
1245
+ if not targets:
1246
+ targets = ["generic"]
1247
+ print(f"detected: {', '.join(targets)}")
1248
+ unknown = [t for t in targets if t not in TARGETS]
1249
+ if unknown:
1250
+ print(f"unknown target(s): {', '.join(unknown)}. Try `sdlc install --list`.", file=sys.stderr)
1251
+ return 3
1252
+ if not args.no_git_hooks and "git-hooks" not in targets:
1253
+ targets.append("git-hooks")
1254
+
1255
+ w = Writer(args.dry_run)
1256
+ for t in targets:
1257
+ fn = TARGETS[t][0]
1258
+ if fn is None:
1259
+ print(f"{t}: nothing to install — {TARGETS[t][1]}")
1260
+ continue
1261
+ print(f"{t}:")
1262
+ fn(root, cfg, w)
1263
+ w.report()
1264
+ if not args.dry_run:
1265
+ audit(root, "install", ", ".join(targets))
1266
+ if args.dry_run:
1267
+ print("\nnothing was written — drop --dry-run to apply")
1268
+ else:
1269
+ print("\nNext: `sdlc doctor`, then read docs/platforms.md for what each host can "
1270
+ "actually enforce.")
1271
+ return 0
1272
+
1273
+
1274
+ def cmd_uninstall(args) -> int:
1275
+ """Remove only our marker blocks and only files we own. Never a whole file we share."""
1276
+ root = repo_root()
1277
+ removed = []
1278
+ owned = [".cursor/rules/agentic-sdlc.mdc", ".gemini/policies/agentic-sdlc.toml",
1279
+ ".clinerules/hooks/PreToolUse", ".github/workflows/sdlc-gate.yml"]
1280
+ for rel in owned:
1281
+ p = os.path.join(root, rel)
1282
+ if os.path.exists(p):
1283
+ removed.append(rel)
1284
+ if not args.dry_run:
1285
+ os.remove(p)
1286
+ for pattern in (".cursor/commands/sdlc-*", ".github/prompts/sdlc-*", ".gemini/commands/sdlc/*",
1287
+ ".clinerules/workflows/sdlc-*", ".windsurf/workflows/sdlc-*"):
1288
+ for p in glob.glob(os.path.join(root, pattern)):
1289
+ removed.append(os.path.relpath(p, root))
1290
+ if not args.dry_run:
1291
+ os.remove(p)
1292
+ shared = ["AGENTS.md", "GEMINI.md", "CONVENTIONS.md", ".github/copilot-instructions.md",
1293
+ ".clinerules/agentic-sdlc.md", ".roo/rules/agentic-sdlc.md",
1294
+ ".kilocode/rules/agentic-sdlc.md", ".windsurf/rules/agentic-sdlc.md",
1295
+ ".continue/rules/agentic-sdlc.md"]
1296
+ for rel in shared:
1297
+ p = os.path.join(root, rel)
1298
+ if not os.path.exists(p):
1299
+ continue
1300
+ text = open(p, errors="replace").read()
1301
+ if BEGIN not in text:
1302
+ continue
1303
+ head, _, rest = text.partition(f"<!-- {BEGIN} -->")
1304
+ _, _, tail = rest.partition(f"<!-- {END} -->")
1305
+ new = (head.rstrip() + "\n" + tail.lstrip("\n")).strip()
1306
+ removed.append(f"{rel} (our block)")
1307
+ if not args.dry_run:
1308
+ if new:
1309
+ open(p, "w").write(new + "\n")
1310
+ else:
1311
+ os.remove(p)
1312
+ hooks_dir = git("rev-parse", "--git-path", "hooks", cwd=root) or ".git/hooks"
1313
+ if not os.path.isabs(hooks_dir):
1314
+ hooks_dir = os.path.join(root, hooks_dir)
1315
+ for name in ("pre-commit", "pre-push"):
1316
+ p = os.path.join(hooks_dir, name)
1317
+ if not os.path.exists(p):
1318
+ continue
1319
+ text = open(p, errors="replace").read()
1320
+ if "# >>> agentic-sdlc >>>" not in text:
1321
+ continue
1322
+ head, _, rest = text.partition("# >>> agentic-sdlc >>>")
1323
+ _, _, tail = rest.partition("# <<< agentic-sdlc <<<")
1324
+ new = (head.rstrip() + "\n" + tail.lstrip("\n")).strip()
1325
+ removed.append(f".git/hooks/{name} (our block)")
1326
+ if not args.dry_run:
1327
+ if new.replace("#!/bin/sh", "").strip():
1328
+ open(p, "w").write(new + "\n")
1329
+ else:
1330
+ os.remove(p)
1331
+ verb = "would remove" if args.dry_run else "removed"
1332
+ for r in removed:
1333
+ print(f" {verb} {r}")
1334
+ print(f"{verb} {len(removed)} item(s)")
1335
+ return 0
1336
+
1337
+
1338
+ # ------------------------------------------------------------------- the hybrid retry loop
1339
+
1340
+ def cmd_feedback(args) -> int:
1341
+ """Carry a gate's failure back to the agent as context, instead of as a dead end.
1342
+
1343
+ A first-pass failure is the normal shape of agentic work; the productive pattern is
1344
+ candidate -> deterministic gate -> failure routed back as enriched context -> retry. Without
1345
+ somewhere to put that context, each retry starts as ignorant as the attempt before it.
1346
+ """
1347
+ root = repo_root()
1348
+ p = os.path.join(root, FEEDBACK)
1349
+ if args.feedback_action == "clear":
1350
+ if os.path.exists(p):
1351
+ os.remove(p)
1352
+ print("feedback cleared")
1353
+ return 0
1354
+ if args.feedback_action == "read":
1355
+ if not os.path.exists(p):
1356
+ print("no feedback — this is the first attempt")
1357
+ return 0
1358
+ d = json.load(open(p))
1359
+ print(f"# Attempt {d['attempts'] + 1}. Previous attempts failed as follows — read this "
1360
+ f"before changing anything.\n")
1361
+ for i, e in enumerate(d["entries"], 1):
1362
+ print(f"## Attempt {i} — {e['stage']} ({e['at']})")
1363
+ print(e["detail"].strip())
1364
+ print()
1365
+ return 0
1366
+ # write
1367
+ d = {"attempts": 0, "entries": []}
1368
+ if os.path.exists(p):
1369
+ try:
1370
+ d = json.load(open(p))
1371
+ except Exception:
1372
+ pass
1373
+ d["attempts"] = d.get("attempts", 0) + 1
1374
+ d["entries"] = (d.get("entries") or [])[-4:] + [{
1375
+ "stage": args.stage,
1376
+ "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1377
+ "commit": head(root),
1378
+ "detail": args.detail,
1379
+ }]
1380
+ os.makedirs(os.path.dirname(p), exist_ok=True)
1381
+ with open(p, "w") as fh:
1382
+ json.dump(d, fh, indent=2)
1383
+ audit(root, "feedback", f"{args.stage}: attempt {d['attempts']}")
1384
+ print(f"feedback recorded — attempt {d['attempts']} at stage {args.stage}")
1385
+ ceiling = int(budget_config(load_config(root)).get("maxRetries") or 3)
1386
+ if d["attempts"] >= ceiling:
1387
+ print(f"{d['attempts']} attempts at the same stage, and budget.maxRetries is "
1388
+ f"{ceiling}. Stop and report rather than trying another: the specification or "
1389
+ f"the approach is probably the problem, not the code.")
1390
+ return 0
1391
+
1392
+
1393
+ # ------------------------------------------------------------------- budget and checkpoints
1394
+ #
1395
+ # X5 and X7: an agent that cannot stop is a cost centre with a git token. X10: a long task that
1396
+ # crashes halfway must not leave a half-applied refactor nobody can reason about.
1397
+ #
1398
+ # The honest limit, stated once: this process cannot see the host's token meter. It counts what
1399
+ # it can observe — tool calls through the hook, wall-clock since the task began, and repair
1400
+ # attempts — and refuses when a ceiling is reached. A ceiling that stops the work is worth more
1401
+ # than a token count that only describes it afterwards.
1402
+
1403
+ DEFAULT_BUDGET = {"maxToolCalls": 0, "maxWallClockMin": 0, "maxRetries": 3, "stopPolicy": "refuse"}
1404
+
1405
+
1406
+ def budget_config(cfg: dict) -> dict:
1407
+ b = dict(DEFAULT_BUDGET)
1408
+ b.update(cfg.get("budget") or {})
1409
+ return b
1410
+
1411
+
1412
+ def budget_state(root: str) -> dict:
1413
+ try:
1414
+ return json.load(open(os.path.join(root, BUDGET)))
1415
+ except Exception:
1416
+ return {"toolCalls": 0, "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1417
+ "task": branch(root)}
1418
+
1419
+
1420
+ def budget_save(root: str, st: dict) -> None:
1421
+ try:
1422
+ p = os.path.join(root, BUDGET)
1423
+ os.makedirs(os.path.dirname(p), exist_ok=True)
1424
+ with open(p, "w") as fh:
1425
+ json.dump(st, fh, indent=2)
1426
+ except Exception:
1427
+ pass
1428
+
1429
+
1430
+ def budget_problem(root: str, cfg: dict) -> str | None:
1431
+ """Why this task must stop now, or None. Counts one tool call as a side effect."""
1432
+ b = budget_config(cfg)
1433
+ if not (b.get("maxToolCalls") or b.get("maxWallClockMin")):
1434
+ return None
1435
+ st = budget_state(root)
1436
+ if st.get("task") != branch(root): # a new branch is a new task
1437
+ st = {"toolCalls": 0, "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1438
+ "task": branch(root)}
1439
+ st["toolCalls"] = st.get("toolCalls", 0) + 1
1440
+ budget_save(root, st)
1441
+
1442
+ warn_only = (b.get("stopPolicy") or "refuse") == "warn"
1443
+ limit = int(b.get("maxToolCalls") or 0)
1444
+ if limit and st["toolCalls"] > limit and warn_only:
1445
+ if st["toolCalls"] == limit + 1: # say it once, not on every call after
1446
+ print(f"agentic-sdlc: this task has passed its budget of {limit} tool calls. "
1447
+ f"stopPolicy is 'warn', so nothing is being refused — but nothing is "
1448
+ f"stopping a runaway either.", file=sys.stderr)
1449
+ return None
1450
+ if limit and st["toolCalls"] > limit:
1451
+ return (f"this task has made {st['toolCalls']} tool calls and the budget is {limit}.\n"
1452
+ f"Stop and report what is done, what is left and what it would cost. "
1453
+ f"A person raises the ceiling in sdlc.config.json; an agent does not raise "
1454
+ f"its own.\nReset with: sdlc budget reset")
1455
+ mins = int(b.get("maxWallClockMin") or 0)
1456
+ if mins and not warn_only:
1457
+ try:
1458
+ began = calendar.timegm(time.strptime(st["startedAt"], "%Y-%m-%dT%H:%M:%SZ"))
1459
+ spent = (time.time() - began) / 60
1460
+ if spent > mins:
1461
+ return (f"this task has run for {int(spent)} minutes and the budget is {mins}.\n"
1462
+ f"Stop and report. Reset with: sdlc budget reset")
1463
+ except Exception:
1464
+ pass
1465
+ return None
1466
+
1467
+
1468
+ def cmd_budget(args) -> int:
1469
+ root = repo_root()
1470
+ cfg = load_config(root)
1471
+ b, st = budget_config(cfg), budget_state(root)
1472
+ if args.budget_action == "reset":
1473
+ if not args.as_person:
1474
+ print("sdlc budget reset needs --as '<your name>'.\n"
1475
+ "A ceiling an agent can clear for itself is not a ceiling — it is a pause. "
1476
+ "The reset records who accepted the extra cost, for the same reason "
1477
+ "`sdlc approve` does, and an agent must not run it on a person's behalf.",
1478
+ file=sys.stderr)
1479
+ return 3
1480
+ budget_save(root, {"toolCalls": 0, "task": branch(root),
1481
+ "startedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
1482
+ audit(root, "budget.reset", f"by {args.as_person}")
1483
+ print(f"budget reset for this task by {args.as_person}")
1484
+ return 0
1485
+ spent_min = ""
1486
+ try:
1487
+ began = calendar.timegm(time.strptime(st["startedAt"], "%Y-%m-%dT%H:%M:%SZ"))
1488
+ spent_min = f"{int((time.time() - began) / 60)} min"
1489
+ except Exception:
1490
+ pass
1491
+ print(f"task {st.get('task')}")
1492
+ print(f"tool calls {st.get('toolCalls', 0)}" +
1493
+ (f" of {b['maxToolCalls']}" if b.get("maxToolCalls") else " (no ceiling set)"))
1494
+ print(f"elapsed {spent_min}" +
1495
+ (f" of {b['maxWallClockMin']} min" if b.get("maxWallClockMin") else " (no ceiling set)"))
1496
+ print(f"retries ceiling {b.get('maxRetries')}")
1497
+ if not (b.get("maxToolCalls") or b.get("maxWallClockMin")):
1498
+ print("\nNo ceiling is set, so nothing will stop a runaway task. Set budget.maxToolCalls "
1499
+ "or budget.maxWallClockMin in sdlc.config.json.")
1500
+ return 0
1501
+
1502
+
1503
+ def cmd_checkpoint(args) -> int:
1504
+ """A resumable record of a long task.
1505
+
1506
+ The hard part of resuming is not storing the plan; it is knowing whether the plan still
1507
+ applies. So a checkpoint records the repository state it assumed, and resuming re-checks it
1508
+ — the same discipline receipts use, for the same reason.
1509
+ """
1510
+ root = repo_root()
1511
+ p = os.path.join(root, CHECKPOINT)
1512
+
1513
+ if args.checkpoint_action == "write":
1514
+ c = {
1515
+ "task": args.task or branch(root),
1516
+ "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1517
+ "commit": head(root),
1518
+ "branch": branch(root),
1519
+ "treeClean": tree_clean(root),
1520
+ "done": args.done or [],
1521
+ "next": args.next_step or "",
1522
+ "note": args.note or "",
1523
+ "files": {},
1524
+ "env": environment(root),
1525
+ }
1526
+ for rel in (args.file or []):
1527
+ d = subject_digest(root, rel)
1528
+ if d:
1529
+ c["files"][rel] = d
1530
+ os.makedirs(os.path.dirname(p), exist_ok=True)
1531
+ with open(p, "w") as fh:
1532
+ json.dump(c, fh, indent=2)
1533
+ fh.write("\n")
1534
+ audit(root, "checkpoint.write", f"{c['task']}: {args.next_step or ''}")
1535
+ print(f"checkpoint at {c['commit'][:9]}: {len(c['done'])} step(s) done, "
1536
+ f"next: {c['next'] or '(unstated)'}")
1537
+ if not c["treeClean"]:
1538
+ print("note: the tree was dirty when this was taken, so resuming cannot verify "
1539
+ "the uncommitted part. Commit before checkpointing if you can.")
1540
+ return 0
1541
+
1542
+ if not os.path.exists(p):
1543
+ print("no checkpoint — this is the first attempt at this task")
1544
+ return 1
1545
+ c = json.load(open(p))
1546
+
1547
+ if args.checkpoint_action == "show":
1548
+ print(json.dumps(c, indent=2))
1549
+ return 0
1550
+
1551
+ # resume: a checkpoint is not automatically safe to resume
1552
+ problems = []
1553
+ if c.get("branch") != branch(root):
1554
+ problems.append(f"it was taken on branch {c.get('branch')}, you are on {branch(root)}")
1555
+ if c.get("commit") != head(root):
1556
+ behind = subprocess.run(["git", "merge-base", "--is-ancestor", c["commit"], "HEAD"],
1557
+ cwd=root, capture_output=True).returncode == 0
1558
+ problems.append(f"HEAD has moved to {head(root)[:9]}" +
1559
+ ("" if behind else " and the checkpoint's commit is not behind it — "
1560
+ "the history was rewritten"))
1561
+ for rel, digest in (c.get("files") or {}).items():
1562
+ now = subject_digest(root, rel)
1563
+ if now is None:
1564
+ problems.append(f"{rel} no longer exists")
1565
+ elif now != digest:
1566
+ problems.append(f"{rel} changed since the checkpoint")
1567
+ env_now = environment(root)
1568
+ drift = [k for k in env_now if k in c.get("env", {}) and env_now[k] != c["env"][k]]
1569
+ if drift:
1570
+ problems.append(f"the toolchain changed ({', '.join(drift)})")
1571
+
1572
+ print(f"# Resuming {c['task']} — checkpointed {c['at']} at {c['commit'][:9]}\n")
1573
+ if c.get("done"):
1574
+ print("Already done, do not repeat:")
1575
+ for step in c["done"]:
1576
+ print(f" - {step}")
1577
+ print(f"\nNext: {c.get('next') or '(unstated)'}")
1578
+ if c.get("note"):
1579
+ print(f"Note: {c['note']}")
1580
+ if problems:
1581
+ print("\nThe repository is NOT in the state this checkpoint assumed:")
1582
+ for why in problems:
1583
+ print(f" - {why}")
1584
+ print("\nRe-establish the facts before editing anything. A checkpoint is a record of "
1585
+ "what was true, not a licence to carry on as if it still is.")
1586
+ audit(root, "checkpoint.resume", f"stale: {len(problems)} mismatch(es)")
1587
+ return 1
1588
+ print("\nThe repository matches what this checkpoint assumed. Safe to continue.")
1589
+ audit(root, "checkpoint.resume", "clean")
1590
+ return 0
1591
+
1592
+
1593
+ # --------------------------------------------------------------------------- suite integrity
1594
+ #
1595
+ # The one failure this whole pipeline exists to prevent: making the gate pass by lowering it.
1596
+ # Every agent prompt forbids deleting a test, weakening an assertion, suppressing a warning or
1597
+ # excluding a path — and a prompt is a request. This is the check that does not negotiate.
1598
+ #
1599
+ # It reads the diff textually, so it is a heuristic and says so. It is deliberately tuned to
1600
+ # over-report: a false positive costs one sentence of explanation in the pull request, and a
1601
+ # false negative costs a silently disarmed test suite.
1602
+
1603
+ TEST_PATH_RE = re.compile(
1604
+ r"(^|/)(tests?|spec|specs|__tests__|testdata)/|[._-](test|spec)\.[a-z]+$|_test\.|"
1605
+ r"(^|/)test_[^/]+\.py$", re.I)
1606
+
1607
+ # A line that declares a test. Losing one is losing a check.
1608
+ TEST_DECL = re.compile(
1609
+ r"\b(def\s+test_\w+|func\s+Test\w+|#\[test\]|#\[tokio::test\]|@Test\b|"
1610
+ r"\b(it|test|describe|context)\s*\(|\bTEST(_F|_P)?\s*\()", re.I)
1611
+
1612
+ # A line that asserts something. Losing one usually means the test now proves less.
1613
+ ASSERT_RE = re.compile(
1614
+ r"\b(assert\w*|expect|should|require\.\w+|must\w*|verify|ok\(|t\.(Error|Fatal)\w*)\b",
1615
+ re.I)
1616
+
1617
+ # A marker that stops a test running, or runs only one. Added, these are how a suite goes quiet.
1618
+ SKIP_ADDED = (
1619
+ (re.compile(r"\.(skip|only)\s*\(|\bx(it|describe|test)\s*\("), "a test is skipped or isolated"),
1620
+ (re.compile(r"@pytest\.mark\.(skip|xfail)"), "a test is skipped or expected to fail"),
1621
+ (re.compile(r"\bt\.Skip\w*\("), "a Go test skips itself"),
1622
+ (re.compile(r"#\[ignore\]"), "a Rust test is ignored"),
1623
+ (re.compile(r"@(Disabled|Ignore)\b"), "a JVM test is disabled"),
1624
+ (re.compile(r"\breturn\s*;?\s*//\s*(TODO|FIXME|skip)", re.I), "a test returns early"),
1625
+ )
1626
+
1627
+ # Silencing a tool rather than satisfying it.
1628
+ SUPPRESS_ADDED = (
1629
+ (re.compile(r"eslint-disable(-next-line)?"), "an eslint rule is disabled"),
1630
+ (re.compile(r"#\s*noqa|#\s*type:\s*ignore|#\s*pylint:\s*disable"), "a Python check is silenced"),
1631
+ (re.compile(r"#\[allow\("), "a Rust lint is allowed"),
1632
+ (re.compile(r"//\s*nolint|//lint:ignore"), "a Go linter is silenced"),
1633
+ (re.compile(r"@SuppressWarnings|@ts-(ignore|expect-error|nocheck)"), "a type or compiler check is suppressed"),
1634
+ (re.compile(r"\bcontinue-on-error:\s*true"), "a CI step is allowed to fail"),
1635
+ (re.compile(r"\|\|\s*true\s*$"), "a command's failure is swallowed"),
1636
+ )
1637
+
1638
+ NUM_SETTING = re.compile(r"([A-Za-z_][\w.\- ]{2,40})\s*[:=]\s*(\d{1,3})(?:\.\d+)?\s*%?\s*$")
1639
+
1640
+ # Markdown is scanned like anything else. In a plugin whose executable instructions ARE
1641
+ # markdown, `|| true` appended to a command in commands/gate.md is a real weakening.
1642
+ #
1643
+ # There is no content-based exemption left in this function. Three were tried — quote parity,
1644
+ # quotes on both sides, and "a line starting with + or - is a diff illustration" — and each
1645
+ # was defeated by editing the line: an apostrophe, a trailing quote, and a two-character list
1646
+ # prefix. A file that contains these patterns as subject matter rather than as behaviour
1647
+ # declares itself in suiteAudit.exclude, where a reviewer sees it.
1648
+
1649
+
1650
+ def diff_files(root: str, base: str):
1651
+ """(path, added, removed) per changed file, or None if git could not tell us.
1652
+
1653
+ Each file is diffed on its own, with explicit prefixes. Parsing one combined diff let a
1654
+ line of *content* beginning `++ b/x.md` pose as a file header and reassign every line
1655
+ after it, and a developer's long-standing `diff.noprefix` setting removed the prefix this
1656
+ parser keyed on — silently disabling the whole check. Neither is reachable per-file.
1657
+ """
1658
+ def run(*args):
1659
+ return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
1660
+
1661
+ # -z: NUL-separated and never C-quoted. With git's default core.quotePath a non-ASCII
1662
+ # name came back quoted, matched no pathspec, and produced an empty diff with returncode
1663
+ # 0 — so gutting that file reported nothing. Failing OPEN, the mode B1d removed.
1664
+ r = run("diff", "--name-only", "-z", f"{base}...HEAD")
1665
+ if r.returncode != 0:
1666
+ r = run("diff", "--name-only", "-z", base)
1667
+ if r.returncode != 0:
1668
+ return None # fail closed; the caller reports unexecuted, never "nothing found"
1669
+
1670
+ out = []
1671
+ for path in [x for x in r.stdout.split("\0") if x.strip()]:
1672
+ d = run("diff", "--no-color", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/",
1673
+ f"{base}...HEAD", "--", path)
1674
+ if d.returncode != 0:
1675
+ d = run("diff", "--no-color", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/",
1676
+ base, "--", path)
1677
+ if d.returncode != 0:
1678
+ return None
1679
+ added, removed, in_body = [], [], False
1680
+ for line in d.stdout.splitlines():
1681
+ if line.startswith("@@"):
1682
+ in_body = True
1683
+ elif in_body and line.startswith("+"):
1684
+ added.append(line[1:])
1685
+ elif in_body and line.startswith("-"):
1686
+ removed.append(line[1:])
1687
+ if not d.stdout.strip():
1688
+ return None # named as changed but produced no diff; that is not "clean"
1689
+ out.append((path, added, removed))
1690
+ return out
1691
+
1692
+
1693
+ def audit_excluded(cfg: dict, path: str) -> bool:
1694
+ """Is this path declared exempt from the suite audit?
1695
+
1696
+ Content-based exemption was tried twice and is not defensible. Counting quote parity let
1697
+ one apostrophe disarm a line; requiring a quote on both sides let `# noqa # "` do it.
1698
+ Anything decided from the text of a line can be forged by writing different text, and the
1699
+ actor writing it is the one the control exists to constrain.
1700
+
1701
+ So exemption is declared in sdlc.config.json instead: visible in the diff, reviewable, and
1702
+ not reachable by editing the line under suspicion. A file that genuinely *defines* these
1703
+ patterns — this plugin's own detector, its fixtures — says so once, out loud. Adding to
1704
+ the list is itself reported as a weakening, so the escape hatch cannot be used quietly.
1705
+ """
1706
+ for pat in ((cfg.get("suiteAudit") or {}).get("exclude") or []):
1707
+ if fnmatch.fnmatch(path, pat) or path == pat:
1708
+ return True
1709
+ return False
1710
+
1711
+
1712
+ def suite_findings(root: str, base: str, cfg: dict = None):
1713
+ """Everything in this diff that makes the gate easier to pass, or None if git failed."""
1714
+ cfg = cfg if cfg is not None else load_config(root)
1715
+ found = []
1716
+
1717
+ r = subprocess.run(["git", "diff", "--name-only", "-z", "--diff-filter=D",
1718
+ f"{base}...HEAD"], cwd=root, capture_output=True, text=True)
1719
+ if r.returncode != 0:
1720
+ return None
1721
+ for path in r.stdout.split("\0"):
1722
+ if path and TEST_PATH_RE.search(path):
1723
+ found.append((path, "a test file was deleted", path))
1724
+
1725
+ files = diff_files(root, base)
1726
+ if files is None:
1727
+ return None
1728
+
1729
+ # Widening the exemption list is itself a weakening — otherwise the escape hatch IS the
1730
+ # bypass: add one glob and the check goes quiet everywhere. Compared as parsed lists at
1731
+ # the two revisions rather than by pattern-matching the diff, so reformatting the config
1732
+ # neither hides an addition nor invents one.
1733
+ cfg_path = config_path(root)
1734
+ if cfg_path:
1735
+ name = os.path.relpath(cfg_path, root)
1736
+ before, after = set(), set()
1737
+ r = subprocess.run(["git", "show", f"{base}:{name}"], cwd=root,
1738
+ capture_output=True, text=True)
1739
+ if r.returncode == 0:
1740
+ try:
1741
+ before = set(((json.loads(r.stdout).get("suiteAudit") or {}).get("exclude")) or [])
1742
+ except Exception:
1743
+ pass
1744
+ try:
1745
+ after = set(((load_config(root).get("suiteAudit") or {}).get("exclude")) or [])
1746
+ except Exception:
1747
+ after = set()
1748
+ for added_glob in sorted(after - before):
1749
+ found.append((name, "a path was newly exempted from the suite audit's "
1750
+ "suppression-marker checks",
1751
+ f'suiteAudit.exclude += "{added_glob}"'))
1752
+
1753
+ for path, added, removed in files:
1754
+ if TEST_PATH_RE.search(path or ""):
1755
+ lost = sum(1 for l in removed if TEST_DECL.search(l))
1756
+ gained = sum(1 for l in added if TEST_DECL.search(l))
1757
+ if lost > gained:
1758
+ found.append((path, f"{lost - gained} test(s) removed and not replaced",
1759
+ next(l.strip() for l in removed if TEST_DECL.search(l))))
1760
+ lost_a = sum(1 for l in removed if ASSERT_RE.search(l))
1761
+ gained_a = sum(1 for l in added if ASSERT_RE.search(l))
1762
+ # One removed assertion counts. The old threshold of two meant a reviewer could
1763
+ # drop exactly one check per commit forever and never be reported.
1764
+ if lost_a - gained_a >= 1:
1765
+ n = lost_a - gained_a
1766
+ found.append((path, f"{n} assertion(s) removed and not replaced — the test "
1767
+ f"may now prove less",
1768
+ next(l.strip() for l in removed if ASSERT_RE.search(l))))
1769
+
1770
+ exempt = audit_excluded(cfg, path or "")
1771
+ for line in added:
1772
+ if not exempt:
1773
+ for pat, why in SKIP_ADDED + SUPPRESS_ADDED:
1774
+ if pat.search(line):
1775
+ found.append((path, why, line.strip()))
1776
+ break
1777
+
1778
+
1779
+ was = {}
1780
+ for line in removed:
1781
+ m = NUM_SETTING.search(line.strip())
1782
+ if m:
1783
+ was[m.group(1).strip().lower()] = int(m.group(2))
1784
+ for line in added:
1785
+ m = NUM_SETTING.search(line.strip())
1786
+ if m:
1787
+ key = m.group(1).strip().lower()
1788
+ if key in was and int(m.group(2)) < was[key]:
1789
+ found.append((path, f"a threshold was lowered: {key} "
1790
+ f"{was[key]} -> {m.group(2)}", line.strip()))
1791
+ return found
1792
+
1793
+
1794
+ def cmd_tests(args) -> int:
1795
+ root = repo_root()
1796
+ cfg = load_config(root)
1797
+ base = args.base or merge_base(root, cfg)
1798
+ found = suite_findings(root, base, cfg)
1799
+
1800
+ if found is None:
1801
+ print(f"suite integrity: UNEXECUTED — git could not produce a diff against "
1802
+ f"'{base}'. This is not a pass. A check that did not run is `unexecuted`; "
1803
+ f"fix the ref and run it again.", file=sys.stderr)
1804
+ audit(root, "suite.unexecuted", f"git diff failed against {base}")
1805
+ return 1
1806
+
1807
+ if not found:
1808
+ print(f"suite integrity: nothing in this diff weakens a check (against {base[:9]})")
1809
+ return 0
1810
+
1811
+ print(f"suite integrity: {len(found)} thing(s) in this diff make the gate easier to pass\n")
1812
+ for path, why, evidence in found:
1813
+ print(f" {path}")
1814
+ print(f" {why}")
1815
+ print(f" > {evidence[:150]}")
1816
+ print("\nEach of these may be correct. None of them is correct *by default*, and none may")
1817
+ print("pass without a sentence in the commit message saying why. Deleting a failing test,")
1818
+ print("weakening an assertion, skipping a case or lowering a threshold to make the gate")
1819
+ print("pass is the one failure this pipeline exists to prevent.")
1820
+
1821
+ if args.acknowledge:
1822
+ if not args.as_person:
1823
+ print("\n--acknowledge needs --as '<your name>'. An acknowledgement with nobody "
1824
+ "behind it records nothing, which is the whole reason it exists.",
1825
+ file=sys.stderr)
1826
+ return 3
1827
+ audit(root, "suite.acknowledged",
1828
+ f"{len(found)} weakening(s) accepted by {args.as_person} against {base[:9]}")
1829
+ print(f"\nAcknowledged by {args.as_person} — recorded, not waived. The reviewers still "
1830
+ f"see every one of these, and so does `sdlc metrics`.")
1831
+ return 0
1832
+
1833
+ print("\nIf a suppression is genuinely the right resolution, say so in the commit and")
1834
+ print("re-run with --acknowledge --as '<your name>' to record who decided.")
1835
+ audit(root, "suite.weakened", f"{len(found)} weakening change(s) against {base[:9]}")
1836
+ return 1
1837
+
1838
+
1839
+ # ------------------------------------------------------------------- environment + baseline
1840
+ #
1841
+ # Two blind spots with one shape: the pipeline judged a change without knowing what the
1842
+ # repository looked like before it, and without knowing what produced the result. A test that
1843
+ # was already red gets blamed on the patch; a missing binary looks like a defect. Both are
1844
+ # answered by recording the starting state and comparing against it.
1845
+
1846
+ _ENV_CACHE = None
1847
+
1848
+ # Probed in this order; absent tools are simply not reported. Version flags differ, so each
1849
+ # names its own.
1850
+ VERSION_PROBES = (
1851
+ ("git", ["git", "--version"]),
1852
+ ("python", [sys.executable, "--version"]),
1853
+ ("node", ["node", "--version"]),
1854
+ ("npm", ["npm", "--version"]),
1855
+ ("pnpm", ["pnpm", "--version"]),
1856
+ ("yarn", ["yarn", "--version"]),
1857
+ ("bun", ["bun", "--version"]),
1858
+ ("cargo", ["cargo", "--version"]),
1859
+ ("go", ["go", "version"]),
1860
+ ("uv", ["uv", "--version"]),
1861
+ ("java", ["java", "-version"]),
1862
+ )
1863
+
1864
+
1865
+ def environment(root: str = "") -> dict:
1866
+ """Which toolchain produced this result. Probed once per process — these are subprocesses.
1867
+
1868
+ Without it, 'the tests failed' cannot be told apart from 'this machine cannot run them',
1869
+ and an agent will spend its retries editing code to fix a missing binary.
1870
+ """
1871
+ global _ENV_CACHE
1872
+ if _ENV_CACHE is not None:
1873
+ return _ENV_CACHE
1874
+ env = {"os": sys.platform, "arch": os.uname().machine if hasattr(os, "uname") else "?"}
1875
+ for name, cmd in VERSION_PROBES:
1876
+ if not which(cmd[0]) and cmd[0] != sys.executable:
1877
+ continue
1878
+ try:
1879
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
1880
+ out = (r.stdout or r.stderr or "").strip().splitlines()
1881
+ if out:
1882
+ env[name] = re.sub(r"^[a-z]+ version |^v", "", out[0].strip())[:40]
1883
+ except Exception:
1884
+ pass
1885
+ _ENV_CACHE = env
1886
+ return env
1887
+
1888
+
1889
+ # Lines worth comparing between two runs: the ones a runner uses to say something went wrong.
1890
+ SIGNAL_RE = re.compile(
1891
+ r"(?:^|\s)(?:error|errors?\[|fail(?:ed|ure|ures)?|warning|warn|panic|assert(?:ion)?|"
1892
+ r"exception|traceback|FAILED|FAIL|✗|✕|×)\b", re.I)
1893
+
1894
+ # Everything here changes between two runs of the same unchanged code, so it must not count as
1895
+ # a difference. Over-scrubbing can hide a real change, which is why the full output is kept.
1896
+ NOISE = (
1897
+ (re.compile(r"\b\d+(\.\d+)?\s*(ms|s|sec|secs|seconds|m|min|minutes)\b", re.I), "<time>"),
1898
+ (re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\S*"), "<ts>"),
1899
+ (re.compile(r"0x[0-9a-fA-F]{4,}"), "<addr>"),
1900
+ (re.compile(r"/(?:tmp|var/folders|private/var)/\S+"), "<tmp>"),
1901
+ (re.compile(r":\d+:\d+"), ":<pos>"),
1902
+ (re.compile(r"\b[0-9a-f]{7,40}\b"), "<sha>"),
1903
+ (re.compile(r"\s+"), " "),
1904
+ )
1905
+
1906
+
1907
+ def signals(output: str) -> list:
1908
+ """The normalised failure and warning lines in a command's output.
1909
+
1910
+ A heuristic over text, deliberately: parsing would mean a parser per test runner, and only
1911
+ sdlc.config.json is allowed to know the stack.
1912
+ """
1913
+ out = []
1914
+ for line in output.splitlines():
1915
+ line = line.strip()
1916
+ if not line or not SIGNAL_RE.search(line):
1917
+ continue
1918
+ for pat, rep in NOISE:
1919
+ line = pat.sub(rep, line)
1920
+ line = line.strip()
1921
+ if line and line not in out:
1922
+ out.append(line[:300])
1923
+ return out
1924
+
1925
+
1926
+ def merge_base(root: str, cfg: dict) -> str:
1927
+ trunk = cfg.get("trunk") or "main"
1928
+ return (git("merge-base", "HEAD", f"origin/{trunk}", cwd=root)
1929
+ or git("merge-base", "HEAD", trunk, cwd=root)
1930
+ or git("rev-list", "--max-parents=0", "HEAD", cwd=root))
1931
+
1932
+
1933
+ # A verify command is long by nature; a runaway one is the longest thing the pipeline runs,
1934
+ # and the ceiling meant to stop a runaway could not stop it.
1935
+ VERIFY_TIMEOUT_S = 1800
1936
+
1937
+
1938
+ def run_verify(root: str, cfg: dict):
1939
+ """Run the project's verify command — through the same refusals as any other command.
1940
+
1941
+ `verifyCmd` comes from sdlc.config.json, which docs/rules.md section 7 classifies as data,
1942
+ not authority. Running it with shell=True without consulting guard_problem opened a route
1943
+ around every refusal in this file: a publish, a credential read or a deploy written into a
1944
+ config string executed unchallenged, from a stage the branch-opener runs automatically.
1945
+ The guard sees it first now, and it cannot run forever.
1946
+ """
1947
+ cmd = cfg.get("gateCmd") or cfg.get("verifyCmd")
1948
+ if not cmd:
1949
+ return None, None, None
1950
+ problem = guard_problem(root, cmd, cfg)
1951
+ if problem:
1952
+ audit(root, "blocked", f"verifyCmd: {problem.splitlines()[0]} :: {cmd}")
1953
+ return cmd, 126, (f"REFUSED before running: the configured command is not allowed.\n"
1954
+ f"{problem}\n\nThis came from sdlc.config.json, which is data, not "
1955
+ f"authority. A command in a config file is held to the same rules "
1956
+ f"as one an agent types.")
1957
+ try:
1958
+ r = subprocess.run(cmd, shell=True, cwd=root, capture_output=True, text=True,
1959
+ timeout=VERIFY_TIMEOUT_S)
1960
+ except subprocess.TimeoutExpired as e:
1961
+ out = e.stdout if isinstance(e.stdout, str) else (e.stdout or b"").decode(errors="replace")
1962
+ return cmd, 124, (f"{out}\nTIMED OUT after {VERIFY_TIMEOUT_S}s. That is `unexecuted` "
1963
+ f"— not a failure of the change, and not a pass.")
1964
+ return cmd, r.returncode, (r.stdout or "") + (r.stderr or "")
1965
+
1966
+
1967
+ def cmd_baseline(args) -> int:
1968
+ root = repo_root()
1969
+ cfg = load_config(root)
1970
+ p = os.path.join(root, BASELINE)
1971
+
1972
+ if args.baseline_action == "show":
1973
+ if not os.path.exists(p):
1974
+ print("no baseline — run `sdlc baseline capture` before starting work")
1975
+ return 1
1976
+ b = json.load(open(p))
1977
+ print(f"captured {b['at']} at base {b['base'][:9]} on {b.get('branch')}")
1978
+ print(f" command {b['cmd']}")
1979
+ print(f" exit {b['exit']} ({'green' if b['exit'] == 0 else 'ALREADY FAILING'})")
1980
+ print(f" signals {len(b['signals'])}")
1981
+ for sig in b["signals"][:20]:
1982
+ print(f" {sig[:120]}")
1983
+ return 0
1984
+
1985
+ if args.baseline_action == "capture":
1986
+ cmd, code, out = run_verify(root, cfg)
1987
+ if cmd is None:
1988
+ print("no verifyCmd in the config — nothing to baseline. Without one nothing can "
1989
+ "be gated either; see `sdlc doctor`.", file=sys.stderr)
1990
+ return 3
1991
+ base = merge_base(root, cfg)
1992
+ b = {
1993
+ "base": base,
1994
+ "branch": branch(root),
1995
+ "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1996
+ "cmd": cmd,
1997
+ "exit": code,
1998
+ "signals": signals(out),
1999
+ "output": out[-20000:],
2000
+ "env": environment(root),
2001
+ }
2002
+ os.makedirs(os.path.dirname(p), exist_ok=True)
2003
+ with open(p, "w") as fh:
2004
+ json.dump(b, fh, indent=2)
2005
+ fh.write("\n")
2006
+ audit(root, "baseline.capture", f"exit {code}, {len(b['signals'])} signals at {base[:9]}")
2007
+ print(f"baseline captured at {base[:9]}: exit {code}, {len(b['signals'])} signal(s)")
2008
+ if code != 0:
2009
+ print("This repository was NOT green when the work started. That is worth knowing "
2010
+ "now rather than discovering it as a 'regression' later.")
2011
+ return 0
2012
+
2013
+ # compare
2014
+ if not os.path.exists(p):
2015
+ print("no baseline: this comparison is `unexecuted`, not passed. Capture one at the "
2016
+ "branch point with `sdlc baseline capture`.", file=sys.stderr)
2017
+ return 1
2018
+ b = json.load(open(p))
2019
+ cmd, code, out = run_verify(root, cfg)
2020
+ if cmd is None:
2021
+ print("no verifyCmd in the config", file=sys.stderr)
2022
+ return 3
2023
+ now = signals(out)
2024
+ was = b["signals"]
2025
+ new = [x for x in now if x not in was]
2026
+ gone = [x for x in was if x not in now]
2027
+ kept = [x for x in now if x in was]
2028
+
2029
+ # A baseline goes stale when its base is no longer behind us — a rebase, or a trunk that
2030
+ # moved under the branch. It does NOT go stale merely because we committed: that is the
2031
+ # whole difference between a baseline and a receipt.
2032
+ still_behind = subprocess.run(["git", "merge-base", "--is-ancestor", b["base"], "HEAD"],
2033
+ cwd=root, capture_output=True).returncode == 0
2034
+ if not still_behind:
2035
+ print(f"note: the baseline's base {b['base'][:9]} is no longer an ancestor of HEAD — "
2036
+ f"the branch was rebased or the trunk moved. Recapture it.")
2037
+ env_now = environment(root)
2038
+ drift = [k for k in env_now if k in b.get("env", {}) and env_now[k] != b["env"][k]]
2039
+ if drift:
2040
+ print(f"note: the toolchain changed since the baseline ({', '.join(drift)}). A failure "
2041
+ f"may belong to the environment rather than to the change.")
2042
+
2043
+ print(f"baseline {b['exit']} -> now {code} "
2044
+ f"{len(new)} new, {len(kept)} pre-existing, {len(gone)} fixed")
2045
+ if new:
2046
+ print("\nNEW since the baseline — these belong to this change:")
2047
+ for x in new[:30]:
2048
+ print(f" {x[:160]}")
2049
+ if kept:
2050
+ print(f"\nPre-existing ({len(kept)}) — already there before the work started, "
2051
+ f"not caused by it:")
2052
+ for x in kept[:10]:
2053
+ print(f" {x[:160]}")
2054
+ if gone:
2055
+ print(f"\nFixed ({len(gone)}) — present at the baseline, gone now.")
2056
+ audit(root, "baseline.compare", f"{len(new)} new, {len(kept)} pre-existing, {len(gone)} fixed")
2057
+ if code in (124, 126):
2058
+ print(f"\nVerdict: UNEXECUTED. The verify command did not run, so this says nothing "
2059
+ f"about the change — in either direction.\n{out.strip()[:600]}")
2060
+ audit(root, "baseline.unexecuted", f"verify exit {code}")
2061
+ return 1
2062
+
2063
+ if new or (code != 0 and b["exit"] == 0):
2064
+ print("\nVerdict: this change introduced failures. Fix those; leave the pre-existing "
2065
+ "ones alone unless the specification asked for them.")
2066
+ return 1
2067
+ if code != 0:
2068
+ print("\nVerdict: still failing, but with nothing new. The failures predate this work — "
2069
+ "do not spend attempts on them, and do not report them as caused by the change.")
2070
+ return 0
2071
+ print("\nVerdict: nothing new.")
2072
+ return 0
2073
+
2074
+
2075
+ # ---------------------------------------------------------------------------------- findings
2076
+ #
2077
+ # A finding is a claim about the code. Prose carries the argument; this carries the parts that
2078
+ # have to be compared, counted and audited: what rule, how bad, how sure, on what evidence, by
2079
+ # whom. Validation here mirrors schema/finding.schema.json — two expressions of one contract,
2080
+ # because bin/sdlc has no dependencies and cannot run a JSON Schema validator. A test feeds the
2081
+ # same values through both so they cannot drift.
2082
+
2083
+
2084
+ def control_spec_body() -> str:
2085
+ """The control specification's text, or stop.
2086
+
2087
+ This used to swallow a read error and return nothing, which meant a missing or renamed
2088
+ docs/control-spec.md silently emptied both the rule table and the never-autofix set: any
2089
+ invented rule validated, and TEST-SUITE-WEAKENED came back autofixable. A control whose
2090
+ off-switch is an absent file is not a control, so this fails closed and loudly.
2091
+ """
2092
+ try:
2093
+ body = read_plugin("docs", "control-spec.md")
2094
+ except Exception:
2095
+ body = ""
2096
+ # read_plugin returns "" on a read error rather than raising, so checking for the
2097
+ # exception alone was dead code: a missing file produced an empty policy in silence.
2098
+ if not body.strip() or "| Rule ID |" not in body:
2099
+ e = "missing, empty, or has no rule table"
2100
+ print(f"agentic-sdlc: cannot read docs/control-spec.md ({e}).\n"
2101
+ f"It is the normative rule table: without it no rule can be validated and no "
2102
+ f"rule can be known to be un-autofixable. Refusing rather than proceeding with "
2103
+ f"an empty policy.", file=sys.stderr)
2104
+ raise SystemExit(3)
2105
+ return body
2106
+
2107
+
2108
+ def known_rules() -> set:
2109
+ """Rule IDs from the control specification's table — the one source of truth.
2110
+
2111
+ Parsed rather than duplicated, so adding a row to docs/control-spec.md is all it takes to
2112
+ make a rule usable. An unreadable table means we skip the check rather than block work.
2113
+ """
2114
+ body = control_spec_body()
2115
+ return set(re.findall(r"^\|\s*`([A-Z][A-Z0-9-]*)`\s*\|", body, re.M))
2116
+
2117
+
2118
+ def finding_id(rule: str, file: str, line, summary: str) -> str:
2119
+ """Stable identity, so the same finding raised twice is one finding and not two.
2120
+
2121
+ Deduplication is not cosmetic: a wall of repeated comments is how a review tool gets turned
2122
+ off (the brief's X9).
2123
+ """
2124
+ key = f"{rule}|{file}|{line or ''}|{summary}".encode()
2125
+ return "F-" + hashlib.sha256(key).hexdigest()[:8]
2126
+
2127
+
2128
+ def never_autofix_rules() -> set:
2129
+ """Rules the control spec does not mark autofixable, whatever the severity pair says.
2130
+
2131
+ Parsed from the same table as the rule IDs. Without this the matrix alone decided, and a
2132
+ TEST-SUITE-WEAKENED finding at critical/high came back "eligible for automatic fix" — an
2133
+ agent authorised to repair a disarmed test suite unattended, which is the exact thing the
2134
+ rule exists to stop.
2135
+ """
2136
+ body = control_spec_body()
2137
+ out = set()
2138
+ for rule, rest in re.findall(r"^\|\s*`([A-Z][A-Z0-9-]*)`\s*\|(.*)$", body, re.M):
2139
+ cols = [c.strip() for c in rest.split("|")]
2140
+ # columns: domain | detection | default severity | autofix | evidence
2141
+ autofix = cols[3].lower() if len(cols) > 3 else ""
2142
+ # Only an explicit "yes" defers to the severity x confidence matrix. Reading only
2143
+ # **never** let the 53 rows that say plain "no" — SEC-INJECTION-SQL, SEC-AUTHORITY,
2144
+ # MEM-SAFETY among them — come back autofixable at critical/high, which is the
2145
+ # permissive half of a document that contradicted itself.
2146
+ if not autofix.startswith("yes"):
2147
+ out.add(rule)
2148
+ return out
2149
+
2150
+
2151
+ def autofix_eligible(severity: str, confidence: str, has_repro: bool,
2152
+ rule: str = "") -> bool:
2153
+ if rule and rule in never_autofix_rules():
2154
+ return False
2155
+ policy = AUTOFIX.get((severity, confidence), False)
2156
+ if policy == "repro":
2157
+ return bool(has_repro)
2158
+ return bool(policy)
2159
+
2160
+
2161
+ def finding_problems(rec: dict) -> list:
2162
+ """Why this record is not a valid finding. Empty list means it is."""
2163
+ out = []
2164
+ for field in ("id", "category", "ruleId", "summary", "evidence", "classification",
2165
+ "outcome", "provenance"):
2166
+ if not rec.get(field):
2167
+ out.append(f'missing required field "{field}"')
2168
+ if out:
2169
+ return out
2170
+
2171
+ if not re.fullmatch(r"F-[0-9a-f]{8}", rec["id"]):
2172
+ out.append(f'id "{rec["id"]}" is not F- plus 8 hex digits')
2173
+ if not re.fullmatch(r"([A-U]\.(?:[1-9][0-9]{0,2})|X(?:[1-9]|1[0-2]))", rec["category"]):
2174
+ out.append(f'category "{rec["category"]}" is not a taxonomy address '
2175
+ f'(a domain letter and a check number, like D.31, or X1 to X12)')
2176
+ if not re.fullmatch(r"[A-Z][A-Z0-9]*(-[A-Z0-9]+)*", rec["ruleId"]):
2177
+ out.append(f'ruleId "{rec["ruleId"]}" is not an upper-case rule identifier')
2178
+ else:
2179
+ rules = known_rules()
2180
+ if rules and rec["ruleId"] not in rules:
2181
+ out.append(f'ruleId "{rec["ruleId"]}" is not in the rule table in '
2182
+ f'docs/control-spec.md — add the row before raising findings under it')
2183
+ if not (8 <= len(rec["summary"]) <= 200):
2184
+ out.append("summary must be between 8 and 200 characters: one sentence, the defect")
2185
+ if rec["outcome"] not in FINDING_OUTCOMES:
2186
+ out.append(f'outcome "{rec["outcome"]}" is not one of: {", ".join(FINDING_OUTCOMES)}')
2187
+
2188
+ ev = rec.get("evidence") or {}
2189
+ if not ev.get("file"):
2190
+ out.append("evidence.file is required — a finding with no location is an opinion")
2191
+ if ev.get("line") is not None and not (isinstance(ev["line"], int) and ev["line"] >= 1):
2192
+ out.append("evidence.line must be a positive integer")
2193
+
2194
+ cl = rec.get("classification") or {}
2195
+ if cl.get("severity") not in SEVERITIES:
2196
+ out.append(f'severity "{cl.get("severity")}" is not one of: {", ".join(SEVERITIES)}')
2197
+ if cl.get("confidence") not in CONFIDENCES:
2198
+ out.append(f'confidence "{cl.get("confidence")}" is not one of: {", ".join(CONFIDENCES)}')
2199
+ if cl.get("exploitability") and cl["exploitability"] not in EXPLOITABILITY:
2200
+ out.append(f'exploitability "{cl["exploitability"]}" is not one of: '
2201
+ f'{", ".join(EXPLOITABILITY)}')
2202
+
2203
+ pr = rec.get("provenance") or {}
2204
+ if not pr.get("agent"):
2205
+ out.append("provenance.agent is required — a finding with no author cannot be disputed")
2206
+ if pr.get("detection") and pr["detection"] not in DETECTIONS:
2207
+ out.append(f'detection "{pr["detection"]}" is not one of: {", ".join(DETECTIONS)}')
2208
+ return out
2209
+
2210
+
2211
+ def finding_load_all(root: str) -> list:
2212
+ d = os.path.join(root, FINDING_DIR)
2213
+ if not os.path.isdir(d):
2214
+ return []
2215
+ out = []
2216
+ for name in sorted(os.listdir(d)):
2217
+ if name.endswith(".json"):
2218
+ try:
2219
+ out.append(json.load(open(os.path.join(d, name))))
2220
+ except Exception:
2221
+ pass
2222
+ return out
2223
+
2224
+
2225
+ def cmd_finding(args) -> int:
2226
+ root = repo_root()
2227
+
2228
+ if args.finding_action == "write":
2229
+ severity, confidence = args.severity, args.confidence
2230
+ rid = finding_id(args.rule, args.file, args.line, args.summary)
2231
+ rec = {
2232
+ "id": rid,
2233
+ "category": args.category,
2234
+ "ruleId": args.rule,
2235
+ "component": args.component or "",
2236
+ "summary": args.summary,
2237
+ "evidence": {
2238
+ "file": args.file,
2239
+ "line": args.line,
2240
+ "endLine": None,
2241
+ "code": args.code or None,
2242
+ "toolOutput": args.tool_output or None,
2243
+ "repro": args.repro or None,
2244
+ },
2245
+ "classification": {
2246
+ "severity": severity,
2247
+ "confidence": confidence,
2248
+ "exploitability": args.exploitability or "not-applicable",
2249
+ "impact": args.impact or None,
2250
+ },
2251
+ "rootCause": args.root_cause or None,
2252
+ "proposedRepair": None,
2253
+ "verificationPlan": args.verification_plan or None,
2254
+ "outcome": "open",
2255
+ "closedBy": None,
2256
+ "provenance": {
2257
+ "agent": args.by,
2258
+ "model": os.environ.get("CLAUDE_MODEL") or None,
2259
+ "detection": args.detection,
2260
+ "toolVersion": args.tool_version or None,
2261
+ "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
2262
+ "commit": head(root),
2263
+ },
2264
+ }
2265
+ # The agent proposes the change; the policy table decides whether it may make it.
2266
+ # Recorded even when no repair was proposed, so the remediator always has the
2267
+ # machine-readable answer and never has to re-derive it from the prose.
2268
+ rec["proposedRepair"] = {
2269
+ "change": args.repair or "",
2270
+ "expectedBehaviour": args.expected or "",
2271
+ "sideEffects": args.side_effects or None,
2272
+ "autofixEligible": autofix_eligible(severity, confidence,
2273
+ bool(args.repro), args.rule),
2274
+ }
2275
+ problems = finding_problems(rec)
2276
+ if problems:
2277
+ for why in problems:
2278
+ print(f"invalid finding: {why}", file=sys.stderr)
2279
+ return 3
2280
+ d = os.path.join(root, FINDING_DIR)
2281
+ os.makedirs(d, exist_ok=True)
2282
+ existing = os.path.join(d, rid + ".json")
2283
+ if os.path.exists(existing):
2284
+ # The same finding raised again is one finding — but if it was closed and has
2285
+ # come back, it is NOT still closed. Re-opening it is the honest record: a defect
2286
+ # re-detected after a bad fix was previously written straight back as `fixed`,
2287
+ # and the gate reads `finding list --open`, so it vanished.
2288
+ try:
2289
+ prev = json.load(open(existing))
2290
+ was = prev.get("outcome", "open")
2291
+ if was in ("fixed", "refused", "deferred"):
2292
+ rec["outcome"] = "open"
2293
+ rec["closedBy"] = None
2294
+ rec["reopened"] = int(prev.get("reopened", 0)) + 1
2295
+ print(f" REOPENED — this was closed as {was} at "
2296
+ f"{str(prev.get('closedBy'))[:9]} and has been detected again.")
2297
+ else:
2298
+ rec["outcome"] = was
2299
+ rec["closedBy"] = prev.get("closedBy")
2300
+ if prev.get("reopened"):
2301
+ rec["reopened"] = prev["reopened"]
2302
+ except Exception:
2303
+ pass
2304
+ with open(existing, "w") as fh:
2305
+ json.dump(rec, fh, indent=2)
2306
+ fh.write("\n")
2307
+ audit(root, "finding.write", f"{rid} {args.rule} {severity}/{confidence} by {args.by}")
2308
+ auto = rec["proposedRepair"]["autofixEligible"]
2309
+ print(f"{rid} {args.rule} {severity}/{confidence} {args.file}"
2310
+ f"{':' + str(args.line) if args.line else ''}")
2311
+ if args.repair:
2312
+ print(" repair: " + ("eligible for automatic fix"
2313
+ if auto else "NOT eligible — report or escalate"))
2314
+ return 0
2315
+
2316
+ if args.finding_action == "list":
2317
+ rows = finding_load_all(root)
2318
+ total = len(rows)
2319
+ if args.open_only:
2320
+ rows = [r for r in rows if r.get("outcome") == "open"]
2321
+ if args.severity:
2322
+ rows = [r for r in rows
2323
+ if (r.get("classification") or {}).get("severity") == args.severity]
2324
+ if args.json:
2325
+ print(json.dumps(rows, indent=2))
2326
+ return 0
2327
+ if not rows:
2328
+ print("no findings" + (" open" if args.open_only else ""))
2329
+ return 0
2330
+ order = {s: i for i, s in enumerate(SEVERITIES)}
2331
+ rows.sort(key=lambda r: order.get((r.get("classification") or {}).get("severity"), 9))
2332
+ for r in rows:
2333
+ cl = r.get("classification") or {}
2334
+ ev = r.get("evidence") or {}
2335
+ loc = ev.get("file", "?") + (f":{ev['line']}" if ev.get("line") else "")
2336
+ print(f" {r['id']} {cl.get('severity','?'):<8} {cl.get('confidence','?'):<6} "
2337
+ f"{r.get('outcome','?'):<10} {r.get('ruleId','?'):<26} {loc}")
2338
+ print(f" {r.get('summary','')}")
2339
+ allrows = finding_load_all(root)
2340
+ openn = sum(1 for r in allrows if r.get("outcome") == "open")
2341
+ unex = sum(1 for r in allrows if r.get("outcome") == "unexecuted")
2342
+ shown = f"showing {len(rows)} of {total}" if len(rows) != total else f"{total} finding(s)"
2343
+ print(f"\n{shown}; {openn} open, {unex} unexecuted, of {total} raised.")
2344
+ if unex:
2345
+ print("An unexecuted finding is not closed — the fix was never verified.")
2346
+ return 0
2347
+
2348
+ if args.finding_action == "show":
2349
+ p = os.path.join(root, FINDING_DIR, args.id + ".json")
2350
+ if not os.path.exists(p):
2351
+ print(f"no finding {args.id}", file=sys.stderr)
2352
+ return 1
2353
+ print(open(p).read().rstrip())
2354
+ return 0
2355
+
2356
+ # close
2357
+ p = os.path.join(root, FINDING_DIR, args.id + ".json")
2358
+ if not os.path.exists(p):
2359
+ print(f"no finding {args.id}", file=sys.stderr)
2360
+ return 1
2361
+ if args.outcome not in FINDING_OUTCOMES or args.outcome == "open":
2362
+ print(f'outcome must be one of: {", ".join(w for w in FINDING_OUTCOMES if w != "open")}',
2363
+ file=sys.stderr)
2364
+ return 3
2365
+ rec = json.load(open(p))
2366
+ raiser = (rec.get("provenance") or {}).get("agent", "")
2367
+ if raiser and args.by and raiser != args.by:
2368
+ # Not refused — a remediator legitimately closes a reviewer's finding — but recorded,
2369
+ # because "who closed this" was previously nowhere and any actor could close any
2370
+ # finding silently.
2371
+ print(f"note: {raiser} raised this; {args.by} is closing it.")
2372
+ rec["outcome"] = args.outcome
2373
+ rec["closedBy"] = args.commit or head(root)
2374
+ rec["closedByActor"] = args.by
2375
+ if args.detail:
2376
+ rec["verificationPlan"] = rec.get("verificationPlan") or args.detail
2377
+ with open(p, "w") as fh:
2378
+ json.dump(rec, fh, indent=2)
2379
+ fh.write("\n")
2380
+ audit(root, "finding.close",
2381
+ f"{args.id} outcome={args.outcome} by {args.by}: {args.detail or ''}")
2382
+ print(f"{args.id}: {args.outcome}")
2383
+ if args.outcome == "unexecuted":
2384
+ print("unexecuted is not closed — the fix was not verified. It stays on the gate's list.")
2385
+ return 0
2386
+
2387
+
2388
+ # ------------------------------------------------------------------------------- governance
2389
+
2390
+ def cmd_metrics(args) -> int:
2391
+ """What the pipeline actually did — from the audit log, not from anyone's impression."""
2392
+ root = repo_root()
2393
+ p = os.path.join(root, AUDIT_LOG)
2394
+ if not os.path.exists(p):
2395
+ print("no audit log yet — it fills as stages run")
2396
+ return 0
2397
+ rows = []
2398
+ for line in open(p, errors="replace"):
2399
+ parts = line.rstrip("\n").split("\t")
2400
+ if len(parts) >= 6:
2401
+ rows.append(dict(zip(("at", "event", "commit", "branch", "agent", "detail"), parts)))
2402
+ if args.since:
2403
+ rows = [r for r in rows if r["at"][:10] >= args.since]
2404
+ if not rows:
2405
+ print("no events in that window")
2406
+ return 0
2407
+
2408
+ stages, blocks, agents = {}, {}, {}
2409
+ for r in rows:
2410
+ if r["event"].startswith("receipt."):
2411
+ kind = r["event"].split(".", 1)[1]
2412
+ verdict = r["detail"].split(" by ")[0]
2413
+ stages.setdefault(kind, {}).setdefault(verdict, 0)
2414
+ stages[kind][verdict] += 1
2415
+ elif r["event"] == "blocked":
2416
+ blocks[r["detail"][:70]] = blocks.get(r["detail"][:70], 0) + 1
2417
+ if r["agent"] != "-":
2418
+ agents[r["agent"]] = agents.get(r["agent"], 0) + 1
2419
+
2420
+ print(f"{len(rows)} events from {rows[0]['at'][:10]} to {rows[-1]['at'][:10]}\n")
2421
+ print("stage outcomes")
2422
+ for kind in RECEIPT_KINDS:
2423
+ if kind in stages:
2424
+ total = sum(stages[kind].values())
2425
+ detail = ", ".join(f"{v} {k}" for k, v in sorted(stages[kind].items()))
2426
+ good = stages[kind].get("passed", 0) + stages[kind].get("approved", 0) + \
2427
+ stages[kind].get("ready", 0) + stages[kind].get("granted", 0) + \
2428
+ stages[kind].get("pass", 0)
2429
+ print(f" {kind:<13} {total:>4} runs {detail} first-pass {100 * good // max(total, 1)}%")
2430
+ if blocks:
2431
+ print("\nrefusals (what the pipeline stopped)")
2432
+ for why, n in sorted(blocks.items(), key=lambda x: -x[1])[:10]:
2433
+ print(f" {n:>4}x {why}")
2434
+ if agents:
2435
+ print("\nactors")
2436
+ for a, n in sorted(agents.items(), key=lambda x: -x[1]):
2437
+ print(f" {a:<24} {n} events")
2438
+ findings = finding_load_all(root)
2439
+ if findings:
2440
+ sev, cat, outcome = {}, {}, {}
2441
+ for f in findings:
2442
+ cl = f.get("classification") or {}
2443
+ sev[cl.get("severity", "?")] = sev.get(cl.get("severity", "?"), 0) + 1
2444
+ dom = str(f.get("category", "?")).split(".")[0]
2445
+ cat[dom] = cat.get(dom, 0) + 1
2446
+ outcome[f.get("outcome", "?")] = outcome.get(f.get("outcome", "?"), 0) + 1
2447
+ print("\nfindings by severity")
2448
+ for k in SEVERITIES:
2449
+ if k in sev:
2450
+ print(f" {k:<10} {sev[k]}")
2451
+ print("findings by taxonomy domain")
2452
+ for k, v in sorted(cat.items(), key=lambda x: -x[1])[:12]:
2453
+ print(f" {k:<10} {v}")
2454
+
2455
+ # The outcome measures the brief asks for, computed only from what is recorded.
2456
+ # Each is labelled with what it actually counts, because a metric whose definition is
2457
+ # unstated is how a dashboard starts lying.
2458
+ closed = sum(outcome.get(w, 0) for w in ("fixed", "refused", "deferred", "escalated"))
2459
+ attempted = outcome.get("fixed", 0) + outcome.get("unexecuted", 0)
2460
+ print("\noutcomes")
2461
+ print(f" verified repair rate "
2462
+ f"{100 * outcome.get('fixed', 0) // max(attempted, 1)}% "
2463
+ f"({outcome.get('fixed', 0)} fixed with proof / {attempted} repairs attempted)")
2464
+ print(f" human intervention "
2465
+ f"{100 * outcome.get('escalated', 0) // max(len(findings), 1)}% "
2466
+ f"({outcome.get('escalated', 0)} escalated / {len(findings)} findings)")
2467
+ repro = sum(1 for f in findings if (f.get("evidence") or {}).get("repro"))
2468
+ print(f" reproducible "
2469
+ f"{100 * repro // max(len(findings), 1)}% "
2470
+ f"({repro} carry a reproduction)")
2471
+ unex = outcome.get("unexecuted", 0)
2472
+ if unex:
2473
+ print(f" UNVERIFIED {unex} finding(s) closed as unexecuted — the fix "
2474
+ f"was never proved. These are not closed.")
2475
+ if outcome.get("open"):
2476
+ print(f" still open {outcome['open']}")
2477
+
2478
+ # A finding re-raised after being closed as fixed did not stay fixed.
2479
+ fixed_then, refixed = set(), 0
2480
+ for r in rows:
2481
+ # Matched on the structured field, not on free text: the old `" fixed" in detail`
2482
+ # counted a close whose note said "could not be fixed yet" as a fix.
2483
+ if r["event"] == "finding.close" and "outcome=fixed" in r["detail"]:
2484
+ fixed_then.add(r["detail"].split()[0])
2485
+ elif r["event"] == "finding.write" and r["detail"].split()[0] in fixed_then:
2486
+ refixed += 1
2487
+ if fixed_then:
2488
+ print(f" false-fix rate "
2489
+ f"{100 * refixed // max(len(fixed_then), 1)}% "
2490
+ f"({refixed} of {len(fixed_then)} came back after being closed fixed)")
2491
+
2492
+ regress = [r for r in rows if r["event"] == "baseline.compare"]
2493
+ if regress:
2494
+ withnew = sum(1 for r in regress if not r["detail"].startswith("0 new"))
2495
+ print(f"\nattribution ({len(regress)} comparisons)")
2496
+ print(f" changes that introduced a new failure "
2497
+ f"{100 * withnew // max(len(regress), 1)}% ({withnew} of {len(regress)})")
2498
+ weakened = [r for r in rows if r["event"] == "suite.weakened"]
2499
+ if weakened:
2500
+ print(f" diffs that weakened a check {len(weakened)}")
2501
+
2502
+ print("\nA low first-pass rate is not a problem by itself — it is the gate doing its job.\n"
2503
+ "A first-pass rate of 100% means the gate is not checking anything.")
2504
+ if findings:
2505
+ print("Detection precision and recall are NOT here: they need a corpus of seeded\n"
2506
+ "defects with known answers, which no repository has about itself. See\n"
2507
+ "docs/maturity.md for how to measure them if you need to.")
2508
+ return 0
2509
+
2510
+
2511
+ def cmd_trace(args) -> int:
2512
+ """Requirement -> commit -> test -> pull request, for an auditor or a future maintainer."""
2513
+ root = repo_root()
2514
+ cfg = load_config(root)
2515
+ trunk = cfg.get("trunk", "main")
2516
+ base = git("merge-base", "HEAD", f"origin/{trunk}", cwd=root) or \
2517
+ git("rev-list", "--max-parents=0", "HEAD", cwd=root)
2518
+ commits = [l for l in git("log", "--format=%H|%s", f"{base}..HEAD", cwd=root).splitlines() if l]
2519
+ files = [l for l in git("diff", "--name-only", f"{base}..HEAD", cwd=root).splitlines() if l]
2520
+ tests = [f for f in files if re.search(r"(^|/)(tests?|spec)/|[._-](test|spec)\.[a-z]+$|_test\.", f)]
2521
+
2522
+ spec_subject = None
2523
+ try:
2524
+ spec_subject = json.load(open(receipt_path(root, "spec"))).get("subject")
2525
+ except Exception:
2526
+ pass
2527
+
2528
+ out = {
2529
+ "branch": branch(root),
2530
+ "head": head(root),
2531
+ "base": base,
2532
+ "specification": spec_subject,
2533
+ "commits": [{"sha": c.split("|")[0][:9], "subject": c.split("|", 1)[1]} for c in commits],
2534
+ "files_changed": files,
2535
+ "tests_touched": tests,
2536
+ "receipts": {},
2537
+ }
2538
+ for kind in RECEIPT_KINDS:
2539
+ rp = receipt_path(root, kind)
2540
+ if os.path.exists(rp):
2541
+ try:
2542
+ r = json.load(open(rp))
2543
+ out["receipts"][kind] = {"verdict": r.get("verdict"), "commit": str(r.get("commit"))[:9],
2544
+ "at": r.get("at"), "by": r.get("by")}
2545
+ except Exception:
2546
+ pass
2547
+ pr = git("config", "--get", "branch.%s.pr" % branch(root), cwd=root)
2548
+ if pr:
2549
+ out["pull_request"] = pr
2550
+
2551
+ if args.json:
2552
+ print(json.dumps(out, indent=2))
2553
+ return 0
2554
+ print(f"# Traceability — {out['branch']}\n")
2555
+ print(f"Specification: {out['specification'] or '(none recorded — the spec receipt is missing)'}")
2556
+ print(f"Base: {base[:9]} Head: {out['head'][:9]}\n")
2557
+ print(f"Commits ({len(commits)})")
2558
+ for c in out["commits"]:
2559
+ print(f" {c['sha']} {c['subject'][:90]}")
2560
+ print(f"\nFiles changed: {len(files)} Tests touched: {len(tests)}")
2561
+ if files and not tests:
2562
+ print(" ⚠ no test file in this change — the verifier should treat every criterion as unexecuted")
2563
+ print("\nReceipts")
2564
+ for kind, r in out["receipts"].items():
2565
+ mark = "current" if r["commit"] == out["head"][:9] else f"stale (for {r['commit']})"
2566
+ print(f" {kind:<13} {r['verdict']:<18} {mark}")
2567
+ missing = [k for k in ("spec", "verification", "review", "readiness") if k not in out["receipts"]]
2568
+ if missing:
2569
+ print(f"\nNo receipt for: {', '.join(missing)} — those stages did not run.")
2570
+ return 0
2571
+
2572
+
2573
+ def cmd_approve(args) -> int:
2574
+ """A person records that they approved something. Deliberately a human-run command.
2575
+
2576
+ The tool cannot prove a human typed it — nothing short of a signing key can — so it records
2577
+ *who claims* the approval, in an append-only log, and makes an unapproved action impossible
2578
+ rather than merely discouraged. That is the honest limit of this control.
2579
+ """
2580
+ root = repo_root()
2581
+ if not args.as_person:
2582
+ print("sdlc approve needs --as '<your name>' — an approval with no one behind it is "
2583
+ "not an approval", file=sys.stderr)
2584
+ return 3
2585
+ detail = f"{args.what}: {args.detail}" if args.detail else args.what
2586
+ rc = receipt_write(root, f"approval_{args.what}", "granted", args.subject or "", detail,
2587
+ args.as_person)
2588
+ audit(root, "approval", f"{args.what} granted by {args.as_person}")
2589
+ print(f"Recorded: {args.as_person} approved {args.what} at {head(root)[:9]}.\n"
2590
+ f"It expires with the next commit, which is the point.")
2591
+ return rc
2592
+
2593
+
2594
+ def main() -> int:
2595
+ ap = argparse.ArgumentParser(prog="sdlc", description=__doc__,
2596
+ formatter_class=argparse.RawDescriptionHelpFormatter)
2597
+ sub = ap.add_subparsers(dest="cmd", required=True)
2598
+
2599
+ p = sub.add_parser("init", help="detect the stack and write sdlc.config.json")
2600
+ p.add_argument("--write", action="store_true")
2601
+ p.set_defaults(fn=cmd_init)
2602
+
2603
+ sub.add_parser("config", help="print the effective configuration").set_defaults(fn=cmd_config)
2604
+ sub.add_parser("status", help="where this unit of work stands").set_defaults(fn=cmd_status)
2605
+ g = sub.add_parser("guard", help="PreToolUse hook (stdin JSON; exit 2 blocks)")
2606
+ g.add_argument("--command", help="evaluate one shell command instead of reading stdin "
2607
+ "— this is how the git hooks call it")
2608
+ g.set_defaults(fn=cmd_guard)
2609
+ sub.add_parser("doctor", help="check the install, the config and the repo").set_defaults(fn=cmd_doctor)
2610
+
2611
+ i = sub.add_parser("install", help="install the pipeline for one or more platforms")
2612
+ i.add_argument("targets", nargs="*", help="omit to install for every detected platform")
2613
+ i.add_argument("--list", action="store_true", help="what is supported, and what each gets")
2614
+ i.add_argument("--dry-run", action="store_true")
2615
+ i.add_argument("--no-git-hooks", action="store_true",
2616
+ help="skip the universal git-hook layer (it is installed by default)")
2617
+ i.set_defaults(fn=cmd_install)
2618
+
2619
+ u = sub.add_parser("uninstall", help="remove our blocks and the files we own")
2620
+ u.add_argument("--dry-run", action="store_true")
2621
+ u.set_defaults(fn=cmd_uninstall)
2622
+
2623
+ a = sub.add_parser("approve", help="a person records an approval (merge, release, exception)")
2624
+ a.add_argument("what", choices=("merge", "release", "exception"))
2625
+ a.add_argument("--as", dest="as_person", help="who is approving — required")
2626
+ a.add_argument("--subject", default="")
2627
+ a.add_argument("--detail", default="")
2628
+ a.set_defaults(fn=cmd_approve)
2629
+
2630
+ f = sub.add_parser("feedback", help="carry a failure back to the next attempt as context")
2631
+ fs = f.add_subparsers(dest="feedback_action", required=True)
2632
+ fw = fs.add_parser("write")
2633
+ fw.add_argument("--stage", required=True)
2634
+ fw.add_argument("--detail", required=True)
2635
+ fs.add_parser("read")
2636
+ fs.add_parser("clear")
2637
+ f.set_defaults(fn=cmd_feedback)
2638
+
2639
+ m = sub.add_parser("metrics", help="what the pipeline did, from the audit log")
2640
+ m.add_argument("--since", help="YYYY-MM-DD")
2641
+ m.set_defaults(fn=cmd_metrics)
2642
+
2643
+ t = sub.add_parser("trace", help="requirement to commit to test to pull request")
2644
+ t.add_argument("--json", action="store_true")
2645
+ t.set_defaults(fn=cmd_trace)
2646
+
2647
+ r = sub.add_parser("receipt", help="write, show or check a stage receipt")
2648
+ rs = r.add_subparsers(dest="receipt_action", required=True)
2649
+ for action in ("write", "show", "check"):
2650
+ q = rs.add_parser(action)
2651
+ q.add_argument("kind", choices=RECEIPT_KINDS)
2652
+ q.add_argument("--verdict")
2653
+ q.add_argument("--subject", default="")
2654
+ q.add_argument("--detail", default="")
2655
+ q.add_argument("--by", default=os.environ.get("CLAUDE_AGENT", "agent"))
2656
+ q.add_argument("--max-age-min", type=int, default=0)
2657
+ q.add_argument("--any-commit", action="store_true",
2658
+ help="do not require the receipt to name the current HEAD")
2659
+ r.set_defaults(fn=cmd_receipt)
2660
+
2661
+ fd = sub.add_parser("finding", help="raise, list and close a structured finding")
2662
+ fds = fd.add_subparsers(dest="finding_action", required=True)
2663
+
2664
+ fw = fds.add_parser("write", help="raise a finding (validated against the contract)")
2665
+ fw.add_argument("--rule", required=True, help="a ruleId from docs/control-spec.md")
2666
+ fw.add_argument("--category", required=True, help="taxonomy address, e.g. D.31 or X7")
2667
+ fw.add_argument("--summary", required=True, help="one sentence: the defect")
2668
+ fw.add_argument("--file", required=True)
2669
+ fw.add_argument("--line", type=int)
2670
+ fw.add_argument("--severity", required=True, choices=SEVERITIES)
2671
+ fw.add_argument("--confidence", required=True, choices=CONFIDENCES)
2672
+ fw.add_argument("--detection", default="llm", choices=DETECTIONS)
2673
+ fw.add_argument("--exploitability", choices=EXPLOITABILITY)
2674
+ fw.add_argument("--component", default="")
2675
+ fw.add_argument("--code", default="", help="the relevant lines, quoted")
2676
+ fw.add_argument("--tool-output", default="", help="the deterministic output behind the claim")
2677
+ fw.add_argument("--repro", default="", help="the smallest reproduction, as commands")
2678
+ fw.add_argument("--impact", default="")
2679
+ fw.add_argument("--root-cause", default="", help="why it exists, not the symptom")
2680
+ fw.add_argument("--repair", default="", help="the minimal proposed change")
2681
+ fw.add_argument("--expected", default="", help="what is true after the repair")
2682
+ fw.add_argument("--side-effects", default="")
2683
+ fw.add_argument("--verification-plan", default="",
2684
+ help="the check that would establish the repair worked")
2685
+ fw.add_argument("--tool-version", default="")
2686
+ fw.add_argument("--by", default=os.environ.get("CLAUDE_AGENT", "agent"))
2687
+
2688
+ fl = fds.add_parser("list", help="what has been found")
2689
+ fl.add_argument("--open", dest="open_only", action="store_true")
2690
+ fl.add_argument("--severity", choices=SEVERITIES)
2691
+ fl.add_argument("--json", action="store_true")
2692
+
2693
+ fsh = fds.add_parser("show")
2694
+ fsh.add_argument("id")
2695
+
2696
+ fc = fds.add_parser("close", help="record what became of a finding")
2697
+ fc.add_argument("id")
2698
+ fc.add_argument("--outcome", required=True,
2699
+ choices=[w for w in FINDING_OUTCOMES if w != "open"])
2700
+ fc.add_argument("--commit", default="")
2701
+ fc.add_argument("--detail", default="")
2702
+ fc.add_argument("--by", default=os.environ.get("CLAUDE_AGENT", "agent"),
2703
+ help="who is closing it; recorded on the finding and in the audit log")
2704
+ fd.set_defaults(fn=cmd_finding)
2705
+
2706
+ bl = sub.add_parser("baseline", help="what was already broken before the work started")
2707
+ bls = bl.add_subparsers(dest="baseline_action", required=True)
2708
+ bls.add_parser("capture", help="record the verify result at the branch point")
2709
+ bls.add_parser("compare", help="classify each failure as new, pre-existing or fixed")
2710
+ bls.add_parser("show", help="what was captured, and when")
2711
+ bl.set_defaults(fn=cmd_baseline)
2712
+
2713
+ ts = sub.add_parser("tests", help="has this diff made the gate easier to pass?")
2714
+ tss = ts.add_subparsers(dest="tests_action", required=True)
2715
+ ta = tss.add_parser("audit", help="deleted tests, weakened assertions, skips, suppressions")
2716
+ ta.add_argument("--base", default="", help="compare against this ref instead of the merge base")
2717
+ ta.add_argument("--acknowledge", action="store_true",
2718
+ help="record that a human decided each of these is correct")
2719
+ ta.add_argument("--as", dest="as_person", default="",
2720
+ help="who is accepting them; required with --acknowledge")
2721
+ ts.set_defaults(fn=cmd_tests)
2722
+
2723
+ bg = sub.add_parser("budget", help="what this task has spent, and its ceiling")
2724
+ bgs = bg.add_subparsers(dest="budget_action", required=True)
2725
+ bgs.add_parser("show")
2726
+ bgr = bgs.add_parser("reset", help="a person clears the counter for this task")
2727
+ bgr.add_argument("--as", dest="as_person", default="")
2728
+ bg.set_defaults(fn=cmd_budget)
2729
+
2730
+ cp = sub.add_parser("checkpoint", help="resume a long task without assuming it is safe to")
2731
+ cps = cp.add_subparsers(dest="checkpoint_action", required=True)
2732
+ cw = cps.add_parser("write")
2733
+ cw.add_argument("--task", default="")
2734
+ cw.add_argument("--done", action="append", help="a step already finished; repeatable")
2735
+ cw.add_argument("--next", dest="next_step", default="", help="the next step")
2736
+ cw.add_argument("--note", default="")
2737
+ cw.add_argument("--file", action="append", help="a file whose state this assumes; repeatable")
2738
+ cps.add_parser("resume", help="re-check the assumptions before continuing")
2739
+ cps.add_parser("show")
2740
+ cp.set_defaults(fn=cmd_checkpoint)
2741
+
2742
+ args = ap.parse_args()
2743
+ return args.fn(args)
2744
+
2745
+
2746
+ if __name__ == "__main__":
2747
+ sys.exit(main())