workflow-toolkit 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.agents/skills/ponytail/SKILL.md +111 -0
  2. package/.agents/skills/ponytail-audit/SKILL.md +37 -0
  3. package/.agents/skills/ponytail-debt/SKILL.md +41 -0
  4. package/.agents/skills/ponytail-gain/SKILL.md +47 -0
  5. package/.agents/skills/ponytail-help/SKILL.md +70 -0
  6. package/.agents/skills/ponytail-review/SKILL.md +52 -0
  7. package/.agents/skills/prompt-review/SKILL.md +60 -0
  8. package/.agents/skills/wtk/SKILL.md +64 -0
  9. package/.agents/skills/wtk/references/artifacts.md +82 -0
  10. package/.agents/skills/wtk/references/evidence.md +68 -0
  11. package/.agents/skills/wtk/references/git.md +75 -0
  12. package/.agents/skills/wtk/references/test-contract.md +76 -0
  13. package/.agents/skills/wtk/references/validation.md +127 -0
  14. package/.agents/skills/wtk-config/SKILL.md +102 -0
  15. package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
  16. package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
  17. package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
  18. package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
  19. package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
  20. package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
  21. package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
  22. package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
  23. package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
  24. package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
  25. package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
  26. package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
  27. package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
  28. package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
  29. package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
  30. package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
  31. package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
  32. package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
  33. package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
  34. package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
  35. package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
  36. package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
  37. package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
  38. package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
  39. package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
  40. package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
  41. package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
  42. package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
  43. package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
  44. package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
  45. package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
  46. package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
  47. package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
  48. package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
  49. package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
  50. package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
  51. package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
  52. package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
  53. package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
  54. package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
  55. package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
  56. package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
  57. package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
  58. package/.agents/skills/wtk-discover/SKILL.md +270 -0
  59. package/.agents/skills/wtk-discover/references/document-format.md +149 -0
  60. package/.agents/skills/wtk-implement/SKILL.md +95 -0
  61. package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
  62. package/.agents/skills/wtk-implement/references/screens.md +129 -0
  63. package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
  64. package/.agents/skills/wtk-implement/references/verify.md +297 -0
  65. package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
  66. package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
  67. package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
  68. package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
  69. package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
  70. package/.agents/skills/wtk-lean/NOTICE.md +18 -0
  71. package/.agents/skills/wtk-lean/SKILL.md +137 -0
  72. package/.agents/skills/wtk-lean/references/build.md +168 -0
  73. package/.agents/skills/wtk-lean/references/checks.md +253 -0
  74. package/.agents/skills/wtk-lean/references/memory.md +156 -0
  75. package/.agents/skills/wtk-lean/references/plan.md +514 -0
  76. package/.agents/skills/wtk-lean/references/verify.md +336 -0
  77. package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
  78. package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
  79. package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
  80. package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
  81. package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
  82. package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
  83. package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
  84. package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
  85. package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
  86. package/.agents/skills/wtk-plan/SKILL.md +102 -0
  87. package/.agents/skills/wtk-plan/references/document-format.md +222 -0
  88. package/.agents/skills/wtk-qa/SKILL.md +14 -0
  89. package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
  90. package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
  91. package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
  92. package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
  93. package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
  94. package/.agents/skills/wtk-ship/SKILL.md +49 -0
  95. package/.agents/skills/wtk-ship/remediation.py +170 -0
  96. package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
  97. package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
  98. package/.wtk.toml.example +93 -0
  99. package/AGENTS.md +129 -0
  100. package/NOTICE.md +12 -0
  101. package/README.md +440 -0
  102. package/bin/wtk.js +25 -0
  103. package/docs/toolkit/README.md +56 -0
  104. package/docs/toolkit/decisions.md +61 -0
  105. package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
  106. package/docs/toolkit/guidelines/DX.md +67 -0
  107. package/docs/toolkit/guidelines/FRONTEND.md +73 -0
  108. package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
  109. package/docs/toolkit/guidelines/MODELING.md +134 -0
  110. package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
  111. package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
  112. package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
  113. package/docs/toolkit/guidelines/SECURITY.md +160 -0
  114. package/docs/toolkit/guidelines/UI-UX.md +117 -0
  115. package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
  116. package/docs/toolkit/guidelines.md +50 -0
  117. package/docs/toolkit/loop.md +77 -0
  118. package/docs/toolkit/purpose.md +51 -0
  119. package/docs/toolkit/repository-intelligence.md +53 -0
  120. package/docs/toolkit/reviews.md +77 -0
  121. package/knowledge/AGENTS.md +316 -0
  122. package/knowledge/raw/README.md +17 -0
  123. package/package.json +74 -0
  124. package/scripts/install_security_skills.py +816 -0
  125. package/scripts/installer/engine.js +254 -0
  126. package/scripts/installer/knowledge.js +28 -0
  127. package/scripts/installer/packets.js +77 -0
  128. package/scripts/installer/terminal.js +98 -0
  129. package/scripts/installer/transaction.js +75 -0
  130. package/skills-lock.json +92 -0
  131. package/templates/adoption/agents/core.md +23 -0
  132. package/templates/adoption/agents/quality.md +3 -0
  133. package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
  134. package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
  135. package/templates/adoption/knowledge/wiki/design/index.md +3 -0
  136. package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
  137. package/templates/adoption/knowledge/wiki/index.md +3 -0
  138. package/templates/adoption/knowledge/wiki/log.md +3 -0
  139. package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
  140. package/templates/adoption/knowledge/wiki/product/index.md +3 -0
  141. package/templates/adoption/knowledge/wiki/research/index.md +3 -0
  142. package/templates/adoption/product/AGENT-CONTEXT.md +25 -0
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env python3
2
+ """Deep-review manifest builder (bootstrap helper).
3
+
4
+ Reads the repo (and gh for --pr), writes only under --out. Produces
5
+ manifest.json accounting for EVERY changed file as selected / ignored / skipped
6
+ / carried, applying the funnel: path filters -> binary -> generated ->
7
+ pure-rename -> whitespace-only -> incremental delta scoping. Also resolves the
8
+ repo path filters (.wtk-deep-review.yaml, else .coderabbit.yaml), records the
9
+ per-round diff_command, and pins the source-freeze baseline
10
+ (worktree_snapshot) that run_jobs.py / render_review.py enforce.
11
+
12
+ Scopes: --pr N (fetched PR head) | --base REF (committed range) | --staged |
13
+ --worktree (uncommitted + untracked work vs the base ref; always a full round).
14
+ Reviewer concurrency is resolved before dispatch (`--concurrency` > repo config > 3) and pinned
15
+ in the manifest for every execution engine.
16
+ Exit codes: 0 ok, 1 usage/environment error, 2 git/gh failure.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ sys.dont_write_bytecode = True # keep the tracked skill tree free of __pycache__
29
+
30
+ from _common import freeze_snapshot, glob_to_regex
31
+
32
+ ROUND_KEEP = {"state.json", "round.json", "rounds"}
33
+
34
+
35
+ def archive_prior_round(out_dir: Path, round_n: int) -> None:
36
+ """A new round starts clean: stale stage artifacts (prior-round agent
37
+ outputs would otherwise pass validation and pollute this round's corpus)
38
+ move to rounds/round-<n>/ for audit. Same-round re-runs archive nothing."""
39
+ marker = out_dir / "round.json"
40
+ prior = None
41
+ if marker.is_file():
42
+ try:
43
+ prior = json.loads(marker.read_text(encoding="utf-8")).get("round")
44
+ except ValueError:
45
+ prior = None
46
+ if prior is not None and prior != round_n:
47
+ archive = out_dir / "rounds" / f"round-{prior}"
48
+ archive.mkdir(parents=True, exist_ok=True)
49
+ moved = 0
50
+ for entry in sorted(out_dir.iterdir()):
51
+ if entry.name in ROUND_KEEP:
52
+ continue
53
+ shutil.move(str(entry), str(archive / entry.name))
54
+ moved += 1
55
+ print(f"archived round {prior} ({moved} artifacts) -> {archive}")
56
+ marker.write_text(json.dumps({"round": round_n}) + "\n", encoding="utf-8")
57
+
58
+
59
+ def archive_stale_outputs(out_dir: Path, round_n: int, snapshot: str) -> None:
60
+ """A same-round rebuild under a different worktree_snapshot: reviewer
61
+ outputs anchor to stale lines, so they move aside instead of validating."""
62
+ manifest_path = out_dir / "manifest.json"
63
+ if not manifest_path.is_file():
64
+ return
65
+ try:
66
+ prior = json.loads(manifest_path.read_text(encoding="utf-8"))
67
+ except ValueError:
68
+ return
69
+ old = prior.get("worktree_snapshot")
70
+ if prior.get("round") != round_n or not old or old == snapshot:
71
+ return
72
+ outputs = sorted((out_dir / "agents").glob("*.json"))
73
+ if not outputs:
74
+ return
75
+ stale = out_dir / "rounds" / f"round-{round_n}-stale-{old[:12]}"
76
+ stale.mkdir(parents=True, exist_ok=True)
77
+ for path in outputs:
78
+ shutil.move(str(path), str(stale / path.name))
79
+ print(f"stale outputs archived: {len(outputs)}")
80
+
81
+
82
+ DEFAULT_FILTERS = [
83
+ "!.wtk-deep-review/**",
84
+ "!**/*.lock", "!**/*.sum", "!**/package-lock.json", "!**/bun.lock", "!**/yarn.lock",
85
+ "!**/pnpm-lock.yaml", "!**/*.min.js", "!**/*.min.css", "!**/*.map",
86
+ "!**/vendor/**", "!**/node_modules/**", "!**/dist/**", "!**/build/**",
87
+ "!**/generated/**", "!**/*.gen.*", "!**/*_pb2.py", "!**/*.pb.go", "!**/*_string.go",
88
+ "!**/*.d.ts", "!**/testdata/**", "!**/fixtures/**", "!**/__snapshots__/**",
89
+ "!**/*.snap", "!**/*.log", "!.git/**",
90
+ ]
91
+ GENERATED_MARKERS = (b"Code generated by", b"@generated", b"DO NOT EDIT",
92
+ b"Autogenerated by", b"automatically generated")
93
+ CONFIG_NAMES = (".wtk-deep-review.yaml", ".wtk-deep-review.yml", ".coderabbit.yaml", ".coderabbit.yml")
94
+ DEFAULT_CONCURRENCY = 3
95
+ MAX_CONCURRENCY = 6
96
+
97
+
98
+ def run(cmd, cwd, check=True):
99
+ proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
100
+ if check and proc.returncode != 0:
101
+ sys.stderr.write(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr}\n")
102
+ sys.exit(2)
103
+ return proc
104
+
105
+
106
+ def parse_path_filters(config_path: Path):
107
+ """YAML-lite: extract list items under a 'path_filters:' key."""
108
+ if not config_path.is_file():
109
+ return None
110
+ lines = config_path.read_text(encoding="utf-8", errors="replace").splitlines()
111
+ filters, capturing, key_indent = [], False, 0
112
+ for line in lines:
113
+ stripped = line.strip()
114
+ if re.match(r"^path_filters\s*:", stripped):
115
+ capturing, key_indent = True, len(line) - len(line.lstrip())
116
+ continue
117
+ if capturing:
118
+ if stripped and not stripped.startswith("#"):
119
+ indent = len(line) - len(line.lstrip())
120
+ m = re.match(r"^-\s*[\"']?([^\"']+?)[\"']?\s*$", stripped)
121
+ if m and indent > key_indent:
122
+ filters.append(m.group(1))
123
+ elif indent <= key_indent:
124
+ capturing = False
125
+ return filters or None
126
+
127
+
128
+ def load_filters(repo_root: Path):
129
+ for name in CONFIG_NAMES:
130
+ parsed = parse_path_filters(repo_root / name)
131
+ if parsed is not None:
132
+ return parsed + DEFAULT_FILTERS, name
133
+ return list(DEFAULT_FILTERS), "built-ins"
134
+
135
+
136
+ def _config_path(repo_root: Path) -> Path | None:
137
+ for name in CONFIG_NAMES:
138
+ candidate = repo_root / name
139
+ if candidate.is_file():
140
+ return candidate
141
+ return None
142
+
143
+
144
+ def parse_concurrency(config_path: Path | None) -> int:
145
+ """Read the top-level YAML scalar without accepting booleans or strings."""
146
+ if config_path is None:
147
+ return DEFAULT_CONCURRENCY
148
+ for line in config_path.read_text(encoding="utf-8", errors="replace").splitlines():
149
+ if line.startswith((" ", "\t")) or not re.match(r"^concurrency\s*:", line):
150
+ continue
151
+ raw = line.split(":", 1)[1].split("#", 1)[0].strip()
152
+ if not re.fullmatch(r"[0-9]+", raw):
153
+ raise ValueError("concurrency must be an integer from 1 through 6")
154
+ value = int(raw)
155
+ if not 1 <= value <= MAX_CONCURRENCY:
156
+ raise ValueError("concurrency must be an integer from 1 through 6")
157
+ return value
158
+ return DEFAULT_CONCURRENCY
159
+
160
+
161
+ def parse_yaml_flag(config_path: Path | None, key: str) -> bool:
162
+ """Top-level `<key>: true` in the YAML-lite config; anything else is false."""
163
+ if config_path is None:
164
+ return False
165
+ for line in config_path.read_text(encoding="utf-8", errors="replace").splitlines():
166
+ if line.startswith((" ", "\t")) or not re.match(rf"^{re.escape(key)}\s*:", line):
167
+ continue
168
+ return line.split(":", 1)[1].split("#", 1)[0].strip() == "true"
169
+ return False
170
+
171
+
172
+ def resolve_concurrency(repo_root: Path, override: int | None) -> tuple[int, str]:
173
+ if override is not None:
174
+ if not 1 <= override <= MAX_CONCURRENCY:
175
+ raise ValueError("concurrency must be an integer from 1 through 6")
176
+ return override, "cli"
177
+ config_path = _config_path(repo_root)
178
+ return parse_concurrency(config_path), config_path.name if config_path else "default"
179
+
180
+
181
+ def resolve_default_base(repo_root: Path) -> str:
182
+ head_ref = run(["git", "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"], repo_root, check=False)
183
+ if head_ref.returncode == 0 and head_ref.stdout.strip():
184
+ return head_ref.stdout.strip().replace("refs/remotes/", "")
185
+ for cand in ("origin/main", "origin/master", "main", "master"):
186
+ if run(["git", "rev-parse", "--verify", "--quiet", cand], repo_root, check=False).returncode == 0:
187
+ return cand
188
+ sys.stderr.write("cannot resolve a default base branch; pass --base explicitly\n")
189
+ sys.exit(1)
190
+
191
+
192
+ def resolve_pr(repo_root: Path, pr: int):
193
+ probe = run(["gh", "auth", "status"], repo_root, check=False)
194
+ if probe.returncode != 0:
195
+ sys.stderr.write("gh is not authenticated; run: gh auth login\n")
196
+ sys.exit(1)
197
+ view = run(["gh", "pr", "view", str(pr), "--json", "baseRefOid,headRefOid,title,url"], repo_root)
198
+ data = json.loads(view.stdout)
199
+ head = data["headRefOid"]
200
+ if run(["git", "cat-file", "-e", f"{head}^{{commit}}"], repo_root, check=False).returncode != 0:
201
+ sys.stderr.write(
202
+ f"PR head {head} is not local. Fetch it first:\n git fetch origin pull/{pr}/head\n")
203
+ sys.exit(1)
204
+ base = run(["git", "merge-base", data["baseRefOid"], head], repo_root).stdout.strip()
205
+ return base, head, data
206
+
207
+
208
+ def diff_spec(base, head, staged, worktree):
209
+ """The git-diff revision arguments for the review scope."""
210
+ if staged:
211
+ return ["--staged"]
212
+ if worktree:
213
+ return [base] # single rev = committed base vs the working tree
214
+ return [f"{base}..{head}"]
215
+
216
+
217
+ def diff_name_status(repo_root: Path, spec):
218
+ entries = []
219
+ for line in run(["git", "diff", "--name-status", "-M50", *spec], repo_root).stdout.splitlines():
220
+ parts = line.split("\t")
221
+ status = parts[0]
222
+ if status.startswith("R") and len(parts) == 3:
223
+ entries.append({"path": parts[2], "status": status, "old_path": parts[1]})
224
+ elif len(parts) >= 2:
225
+ entries.append({"path": parts[1], "status": status, "old_path": None})
226
+ return entries
227
+
228
+
229
+ def numstat(repo_root: Path, spec, extra=()):
230
+ stats = {}
231
+ for line in run(["git", "diff", "--numstat", "-M50", *extra, *spec], repo_root).stdout.splitlines():
232
+ parts = line.split("\t")
233
+ if len(parts) < 3:
234
+ continue
235
+ adds, dels, path = parts[0], parts[1], parts[-1]
236
+ if "=>" in path and "{" in path: # rename syntax a/{b => c}/d
237
+ prefix, rest = path.split("{", 1)
238
+ inner, suffix = rest.split("}", 1)
239
+ path = prefix + inner.split(" => ")[1] + suffix
240
+ elif " => " in path:
241
+ path = path.split(" => ")[1]
242
+ stats[path] = (None if adds == "-" else int(adds), None if dels == "-" else int(dels))
243
+ return stats
244
+
245
+
246
+ def looks_generated(repo_root: Path, path: str) -> bool:
247
+ f = repo_root / path
248
+ if f.is_symlink():
249
+ return False
250
+ if not f.is_file():
251
+ return False
252
+ try:
253
+ with f.open("rb") as fh:
254
+ head_bytes = fh.read(2048)
255
+ except OSError as err:
256
+ sys.stderr.write(f"warn: cannot probe {path}: {err}\n")
257
+ return False
258
+ return any(marker in head_bytes for marker in GENERATED_MARKERS)
259
+
260
+
261
+ def parse_range(value: str):
262
+ start_and_lines = value[1:].split(",", 1)
263
+ start = int(start_and_lines[0])
264
+ lines = int(start_and_lines[1]) if len(start_and_lines) == 2 else 1
265
+ return start, lines
266
+
267
+
268
+ def collect_hunks(repo_root: Path, spec):
269
+ """Per-file hunk ranges: new-side for edits/additions (the publish anchors),
270
+ old-side for pure deletions so removed code still gets judged."""
271
+ hunks, current, old_path = {}, None, None
272
+ for line in run(["git", "diff", "-U0", "-M50", *spec], repo_root).stdout.splitlines():
273
+ if line.startswith("--- "):
274
+ raw = line[4:].strip()
275
+ old_path = None if raw == "/dev/null" else raw.removeprefix("a/")
276
+ elif line.startswith("+++ "):
277
+ raw = line[4:].strip()
278
+ current = old_path if raw == "/dev/null" else raw.removeprefix("b/")
279
+ elif line.startswith("@@") and current:
280
+ m = re.match(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@", line)
281
+ if not m:
282
+ continue
283
+ old_start, old_lines = int(m.group(1)), int(m.group(2) or 1)
284
+ new_start, new_lines = int(m.group(3)), int(m.group(4) or 1)
285
+ if new_lines > 0:
286
+ hunk = {"start": new_start, "lines": new_lines, "side": "new"}
287
+ else:
288
+ hunk = {"start": old_start, "lines": old_lines, "side": "old"}
289
+ hunks.setdefault(current, []).append(hunk)
290
+ return hunks
291
+
292
+
293
+ def untracked_paths(repo_root: Path, out_rel: str):
294
+ paths = []
295
+ for path in run(["git", "ls-files", "--others", "--exclude-standard"], repo_root).stdout.splitlines():
296
+ if path.startswith(".wtk-deep-review/") or (out_rel and path.startswith(out_rel + "/")):
297
+ continue
298
+ paths.append(path)
299
+ return paths
300
+
301
+
302
+ def untracked_stat(repo_root: Path, path: str):
303
+ data = (repo_root / path).read_bytes()
304
+ if b"\0" in data:
305
+ return None, None
306
+ return len(data.splitlines()), 0
307
+
308
+
309
+ def symlink_target(repo_root: Path, path: str) -> str | None:
310
+ candidate = repo_root / path
311
+ if not candidate.is_symlink():
312
+ return None
313
+ return os.readlink(candidate)
314
+
315
+
316
+ def prior_head(state_path: Path):
317
+ if not state_path.is_file():
318
+ return None, 0
319
+ try:
320
+ state = json.loads(state_path.read_text(encoding="utf-8"))
321
+ rounds = state.get("rounds", [])
322
+ return (rounds[-1]["head"], rounds[-1]["n"]) if rounds else (None, 0)
323
+ except (ValueError, KeyError) as err:
324
+ sys.stderr.write(f"warn: unreadable state {state_path} ({err}); running full\n")
325
+ return None, 0
326
+
327
+
328
+ def main():
329
+ ap = argparse.ArgumentParser(description=__doc__)
330
+ ap.add_argument("--out", required=True)
331
+ ap.add_argument("--pr", type=int)
332
+ ap.add_argument("--base")
333
+ ap.add_argument("--head", default="HEAD")
334
+ ap.add_argument("--staged", action="store_true")
335
+ ap.add_argument("--worktree", action="store_true",
336
+ help="review uncommitted + untracked work against the base ref")
337
+ ap.add_argument("--files", help="comma-separated subset")
338
+ ap.add_argument("--full", action="store_true")
339
+ ap.add_argument("--concurrency", type=int, help="bounded reviewer concurrency (1-6)")
340
+ args = ap.parse_args()
341
+ if args.pr and (args.base or args.staged or args.worktree):
342
+ ap.error("--pr conflicts with --base/--staged/--worktree")
343
+ if args.staged and args.worktree:
344
+ ap.error("--staged conflicts with --worktree")
345
+
346
+ top = run(["git", "rev-parse", "--show-toplevel"], Path.cwd())
347
+ repo_root = Path(top.stdout.strip())
348
+ try:
349
+ concurrency, concurrency_source = resolve_concurrency(repo_root, args.concurrency)
350
+ except ValueError as error:
351
+ ap.error(str(error))
352
+ out_dir = Path(args.out); out_dir.mkdir(parents=True, exist_ok=True)
353
+ try:
354
+ out_rel = str(out_dir.resolve().relative_to(repo_root))
355
+ except ValueError:
356
+ out_rel = ""
357
+
358
+ pr_meta = None
359
+ if args.pr:
360
+ base, head, pr_meta = resolve_pr(repo_root, args.pr)
361
+ target = f"pr:{args.pr}"
362
+ diff_command = f"git diff {base[:12]}..{head[:12]} -- <file>"
363
+ elif args.staged:
364
+ base, head, target = "HEAD", "STAGED", "staged"
365
+ diff_command = "git diff --staged -- <file>"
366
+ else:
367
+ base_ref = args.base or resolve_default_base(repo_root)
368
+ base = run(["git", "merge-base", base_ref, "HEAD" if args.worktree else args.head],
369
+ repo_root).stdout.strip()
370
+ head = run(["git", "rev-parse", "HEAD" if args.worktree else args.head], repo_root).stdout.strip()
371
+ if args.worktree:
372
+ target = f"worktree:{base_ref}"
373
+ diff_command = f"git diff {base[:12]} -- <file>"
374
+ else:
375
+ target = f"diff:{base_ref}"
376
+ diff_command = f"git diff {base[:12]}..{head[:12]} -- <file>"
377
+
378
+ filters, filter_source = load_filters(repo_root)
379
+ excludes = [(p, glob_to_regex(p[1:])) for p in filters if p.startswith("!")]
380
+ includes = [(p, glob_to_regex(p)) for p in filters if not p.startswith("!")]
381
+
382
+ last_head, last_n = prior_head(out_dir / "state.json")
383
+ mode, effective_base, round_n = "full", base, last_n + 1
384
+ if not args.full and not args.staged and not args.worktree:
385
+ if last_head and run(["git", "merge-base", "--is-ancestor", last_head, head],
386
+ repo_root, check=False).returncode == 0 and last_head != head:
387
+ mode, effective_base = "incremental", last_head
388
+ diff_command = f"git diff {effective_base[:12]}..{head[:12]} -- <file>"
389
+ snapshot = freeze_snapshot(repo_root, out_dir.resolve())
390
+ archive_stale_outputs(out_dir, round_n, snapshot)
391
+ archive_prior_round(out_dir, round_n)
392
+
393
+ spec = diff_spec(effective_base, head, args.staged, args.worktree)
394
+ entries = diff_name_status(repo_root, spec)
395
+ stats = numstat(repo_root, spec)
396
+ ws_stats = numstat(repo_root, spec, extra=("-w", "--ignore-blank-lines"))
397
+ hunk_map = collect_hunks(repo_root, spec)
398
+ if args.worktree:
399
+ tracked = {e["path"] for e in entries}
400
+ for path in untracked_paths(repo_root, out_rel):
401
+ if path in tracked:
402
+ continue
403
+ entries.append({"path": path, "status": "A", "old_path": None})
404
+ link_target = symlink_target(repo_root, path)
405
+ stats[path] = (1, 0) if link_target is not None else untracked_stat(repo_root, path)
406
+ ws_stats[path] = stats[path]
407
+ adds = stats[path][0]
408
+ if adds:
409
+ hunk_map[path] = [{"start": 1, "lines": adds, "side": "new"}]
410
+ delta_paths = None
411
+ if mode == "incremental":
412
+ delta_paths = {e["path"] for e in diff_name_status(repo_root, [f"{effective_base}..{head}"])}
413
+
414
+ subset = {p.strip() for p in args.files.split(",")} if args.files else None
415
+ files, counts = [], {"selected": 0, "ignored": 0, "skipped": 0, "carried": 0}
416
+ for e in sorted(entries, key=lambda x: x["path"]):
417
+ path, status = e["path"], e["status"]
418
+ adds, dels = stats.get(path, (0, 0))
419
+ rec = {"path": path, "status": status, "adds": adds, "dels": dels}
420
+ if e["old_path"]:
421
+ rec["old_path"] = e["old_path"]
422
+ link_target = symlink_target(repo_root, path)
423
+ if link_target is not None:
424
+ rec.update({"kind": "symlink", "target": link_target})
425
+
426
+ disposition, reason = "selected", None
427
+ if subset is not None and path not in subset:
428
+ disposition, reason = "ignored", "outside --files subset"
429
+ else:
430
+ exc = next((p for p, rx in excludes if rx.match(path)), None)
431
+ inc = next((p for p, rx in includes if rx.match(path)), None)
432
+ if includes and not inc:
433
+ disposition, reason = "ignored", "matches no include pattern"
434
+ elif exc and not inc:
435
+ disposition, reason = "ignored", f"excluded by `{exc}`"
436
+ elif adds is None or dels is None:
437
+ disposition, reason = "skipped", "binary"
438
+ elif status.startswith("R") and adds == 0 and dels == 0:
439
+ disposition, reason = "skipped", f"pure rename from `{e['old_path']}`"
440
+ elif (adds or dels) and ws_stats.get(path, (0, 0)) == (0, 0):
441
+ disposition, reason = "skipped", "whitespace-only change"
442
+ elif status != "D" and looks_generated(repo_root, path):
443
+ disposition, reason = "skipped", "generated-file marker in content"
444
+ elif delta_paths is not None and path not in delta_paths:
445
+ disposition, reason = "carried", f"unchanged since round {round_n - 1} head"
446
+
447
+ rec["disposition"] = disposition
448
+ if reason:
449
+ rec["reason"] = reason
450
+ if disposition == "selected":
451
+ rec["hunks"] = hunk_map.get(path, [])
452
+ counts[disposition] += 1
453
+ files.append(rec)
454
+
455
+ manifest = {
456
+ "target": target, "mode": "staged" if args.staged else mode, "round": round_n,
457
+ "base": base, "effective_base": effective_base, "head": head,
458
+ "diff_command": diff_command, "worktree_snapshot": snapshot,
459
+ "filter_source": filter_source, "counts": counts, "files": files,
460
+ "concurrency": concurrency, "concurrency_source": concurrency_source,
461
+ }
462
+ if pr_meta:
463
+ manifest["pr"] = {"number": args.pr, "title": pr_meta["title"], "url": pr_meta["url"]}
464
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
465
+
466
+ total = len(files)
467
+ print(f"manifest: {out_dir / 'manifest.json'}")
468
+ print(f"target={target} mode={manifest['mode']} round={round_n}")
469
+ print(f"base={base[:12]} effective_base={effective_base[:12] if effective_base != base else '(same)'} head={head[:12]}")
470
+ print(f"files: {total} changed -> {counts['selected']} selected, {counts['ignored']} ignored, "
471
+ f"{counts['skipped']} skipped, {counts['carried']} carried (filters: {filter_source})")
472
+ print(f"freeze: worktree_snapshot={snapshot[:12]} (run_jobs.py and render_review.py enforce it)")
473
+ print("review posture: assertive (single mode)")
474
+ if counts["selected"] == 0:
475
+ print("nothing selected — review has no reviewable surface")
476
+
477
+
478
+ if __name__ == "__main__":
479
+ main()
@@ -0,0 +1,160 @@
1
+ """Best-effort Graft context preparation for wtk-deep-review prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import hashlib
7
+ import sys
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ RI_SCRIPTS = Path(__file__).resolve().parents[2] / "wtk-config" / "scripts"
13
+ if str(RI_SCRIPTS) not in sys.path:
14
+ sys.path.insert(0, str(RI_SCRIPTS))
15
+
16
+ import repository_intelligence as ri # noqa: E402
17
+
18
+ FALLBACK_LINE = "Graft context is unavailable; use plain repository inspection."
19
+ PARTIAL_LINE = "Graft context is partial; use targeted repository inspection for uncovered paths."
20
+
21
+
22
+ def _fallback(path: Path, reason: str, dot_paths: list[str], digest: str) -> dict[str, str]:
23
+ lines = [
24
+ "# Graft context",
25
+ "",
26
+ "status: fallback",
27
+ f"question_hash: {digest}",
28
+ f"reason: {reason}",
29
+ "",
30
+ FALLBACK_LINE,
31
+ ]
32
+ if dot_paths:
33
+ lines.extend([
34
+ "",
35
+ "Graft does not index dot-directories; inspect these paths plainly:",
36
+ *[f"- `{item}`" for item in dot_paths],
37
+ ])
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
40
+ return {"status": "fallback", "path": str(path), "question_hash": digest, "reason": reason}
41
+
42
+
43
+ def _result_error(error: Exception) -> str:
44
+ if isinstance(error, ri.IntelligenceError):
45
+ return ri._redact(error.reason)
46
+ return "Graft context failed"
47
+
48
+
49
+ def _context(result: Any) -> str:
50
+ return str(result.get("context", "")).strip() if isinstance(result, dict) else ""
51
+
52
+
53
+ def _reason(result: Any, default: str) -> str:
54
+ if not isinstance(result, dict):
55
+ return default
56
+ return ri._redact(str(result.get("reason") or default))
57
+
58
+
59
+ def prepare_graft_context(repo: Path, out: Path, selected_paths: list[str]) -> dict[str, str]:
60
+ """Build and query Graft through the shared adapter without blocking review."""
61
+ context_path = out / "graft-context.md"
62
+ dot_paths = [path for path in selected_paths if path.startswith(".") or path.startswith("graft/")]
63
+ visible_paths = [path for path in selected_paths if path not in dot_paths]
64
+ query = " ".join(visible_paths[:20]) or "repository structure"
65
+ digest = hashlib.sha256(query.encode("utf-8")).hexdigest()
66
+ try:
67
+ mapped = ri._run_context(repo, "graft", "map", [])
68
+ except Exception as error: # adapter converts expected tool failures to IntelligenceError
69
+ return _fallback(context_path, _result_error(error), dot_paths, digest)
70
+ if isinstance(mapped, dict) and mapped.get("status") == "degraded":
71
+ return _fallback(context_path, _reason(mapped, "Graft map failed"), dot_paths, digest)
72
+
73
+ try:
74
+ asked = ri._run_context(repo, "graft", "ask", ["--json", "--limit", "8", query])
75
+ except Exception as error:
76
+ asked = None
77
+ ask_reason = _result_error(error)
78
+ else:
79
+ ask_reason = _reason(asked, "Graft symbol lookup failed")
80
+
81
+ symbols: list[str] = []
82
+ asked_context = _context(asked)
83
+ if asked_context:
84
+ try:
85
+ hits = json.loads(asked_context).get("hits", [])
86
+ symbols = [
87
+ str(hit.get("title", "")).split(" · ", 1)[0]
88
+ for hit in hits
89
+ if hit.get("kind") == "symbol"
90
+ ][:3]
91
+ except (TypeError, ValueError, json.JSONDecodeError):
92
+ symbols = []
93
+
94
+ partial = mapped.get("status") == "partial" if isinstance(mapped, dict) else False
95
+ partial = partial or (asked.get("status") == "partial" if isinstance(asked, dict) else False)
96
+ lines = [
97
+ "# Graft context",
98
+ "",
99
+ "status: partial" if partial else ("status: ready" if asked_context and not dot_paths else "status: ready-with-fallback"),
100
+ f"question_hash: {digest}",
101
+ "",
102
+ "Use this map as review orientation; verify every claim against the checkout.",
103
+ "",
104
+ "## Repository map",
105
+ "```text",
106
+ _context(mapped)[:12000],
107
+ "```",
108
+ ]
109
+ if asked_context:
110
+ lines.extend(["", "## Relevant symbols", "```text", asked_context[:12000], "```"])
111
+ else:
112
+ lines.extend(["", ask_reason, "Use plain repository inspection for relevant symbols and callers."])
113
+
114
+ blast_failed = False
115
+ blast_partial = False
116
+ for symbol in symbols:
117
+ try:
118
+ callers = ri._run_context(repo, "graft", "callers", ["--json", "--depth", "1", symbol])
119
+ except Exception:
120
+ blast_failed = True
121
+ continue
122
+ callers_context = _context(callers)
123
+ blast_partial = blast_partial or (isinstance(callers, dict) and callers.get("status") == "partial")
124
+ if callers_context:
125
+ lines.extend(["", f"### `{symbol}`", "```text", callers_context[:6000], "```"])
126
+ else:
127
+ blast_failed = True
128
+ if blast_failed:
129
+ lines.extend(["", "Graft blast-radius lookup failed; use plain repository inspection for callers."])
130
+ partial = partial or blast_partial
131
+ if partial:
132
+ lines[2] = "status: partial"
133
+ lines.extend(["", PARTIAL_LINE])
134
+ if dot_paths:
135
+ lines.extend([
136
+ "",
137
+ "Graft does not index dot-directories; use plain repository inspection for:",
138
+ *[f"- `{item}`" for item in dot_paths],
139
+ ])
140
+ if not _context(mapped):
141
+ return _fallback(context_path, "Graft returned insufficient context", dot_paths, digest)
142
+ context_path.parent.mkdir(parents=True, exist_ok=True)
143
+ context_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
144
+ status = "partial" if partial else ("ready" if asked_context and not dot_paths and not blast_failed else "ready-with-fallback")
145
+ return {"status": status, "path": str(context_path), "question_hash": digest}
146
+
147
+
148
+ def graft_binary(repo: Path) -> str | None:
149
+ """Compatibility inspection helper for callers that need local tool discovery."""
150
+ local = repo / "node_modules" / ".bin" / "graft"
151
+ package = repo / "node_modules" / "@nanonets" / "graft" / "package.json"
152
+ if not local.is_file() or not package.is_file() or not os.access(local, os.X_OK):
153
+ return None
154
+ try:
155
+ manifest = json.loads(package.read_text(encoding="utf-8"))
156
+ local.resolve(strict=True).relative_to(repo.resolve() / "node_modules")
157
+ package.parent.resolve(strict=True).relative_to(repo.resolve() / "node_modules")
158
+ except (OSError, ValueError, json.JSONDecodeError):
159
+ return None
160
+ return str(local) if manifest.get("version") == ri.GRAFT_VERSION else None