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.
- package/.agents/skills/ponytail/SKILL.md +111 -0
- package/.agents/skills/ponytail-audit/SKILL.md +37 -0
- package/.agents/skills/ponytail-debt/SKILL.md +41 -0
- package/.agents/skills/ponytail-gain/SKILL.md +47 -0
- package/.agents/skills/ponytail-help/SKILL.md +70 -0
- package/.agents/skills/ponytail-review/SKILL.md +52 -0
- package/.agents/skills/prompt-review/SKILL.md +60 -0
- package/.agents/skills/wtk/SKILL.md +64 -0
- package/.agents/skills/wtk/references/artifacts.md +82 -0
- package/.agents/skills/wtk/references/evidence.md +68 -0
- package/.agents/skills/wtk/references/git.md +75 -0
- package/.agents/skills/wtk/references/test-contract.md +76 -0
- package/.agents/skills/wtk/references/validation.md +127 -0
- package/.agents/skills/wtk-config/SKILL.md +102 -0
- package/.agents/skills/wtk-config/assets/agents/claude/deep-reviewer.md +40 -0
- package/.agents/skills/wtk-config/assets/agents/claude/designer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/explorer.md +41 -0
- package/.agents/skills/wtk-config/assets/agents/claude/implementer.md +57 -0
- package/.agents/skills/wtk-config/assets/agents/claude/planner.md +68 -0
- package/.agents/skills/wtk-config/assets/agents/claude/verifier.md +80 -0
- package/.agents/skills/wtk-config/assets/agents/codex/deep-reviewer.toml +41 -0
- package/.agents/skills/wtk-config/assets/agents/codex/designer.toml +55 -0
- package/.agents/skills/wtk-config/assets/agents/codex/explorer.toml +35 -0
- package/.agents/skills/wtk-config/assets/agents/codex/implementer.toml +52 -0
- package/.agents/skills/wtk-config/assets/agents/codex/planner.toml +66 -0
- package/.agents/skills/wtk-config/assets/agents/codex/verifier.toml +78 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/deep-reviewer.md +38 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/designer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/explorer.md +39 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/implementer.md +55 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/planner.md +66 -0
- package/.agents/skills/wtk-config/assets/agents/cursor/verifier.md +79 -0
- package/.agents/skills/wtk-config/scripts/ad-index.py +123 -0
- package/.agents/skills/wtk-config/scripts/repository_intelligence.py +671 -0
- package/.agents/skills/wtk-config/scripts/workflow_config.py +972 -0
- package/.agents/skills/wtk-deep-review/SKILL.md +164 -0
- package/.agents/skills/wtk-deep-review/assets/PROMPT.md +56 -0
- package/.agents/skills/wtk-deep-review/assets/REVIEW_UI.html +1340 -0
- package/.agents/skills/wtk-deep-review/assets/findings.schema.json +113 -0
- package/.agents/skills/wtk-deep-review/references/context-pack.md +88 -0
- package/.agents/skills/wtk-deep-review/references/orchestration.md +164 -0
- package/.agents/skills/wtk-deep-review/references/output-contracts.md +149 -0
- package/.agents/skills/wtk-deep-review/references/publish-github.md +83 -0
- package/.agents/skills/wtk-deep-review/references/state-and-learnings.md +64 -0
- package/.agents/skills/wtk-deep-review/references/subagent-runtimes.md +33 -0
- package/.agents/skills/wtk-deep-review/references/taxonomy.md +70 -0
- package/.agents/skills/wtk-deep-review/scripts/_common.py +371 -0
- package/.agents/skills/wtk-deep-review/scripts/build_jobs.py +561 -0
- package/.agents/skills/wtk-deep-review/scripts/build_knowledge.py +349 -0
- package/.agents/skills/wtk-deep-review/scripts/build_manifest.py +479 -0
- package/.agents/skills/wtk-deep-review/scripts/graft_context.py +160 -0
- package/.agents/skills/wtk-deep-review/scripts/graphify_context.py +75 -0
- package/.agents/skills/wtk-deep-review/scripts/merge_findings.py +313 -0
- package/.agents/skills/wtk-deep-review/scripts/render_html.py +240 -0
- package/.agents/skills/wtk-deep-review/scripts/render_review.py +282 -0
- package/.agents/skills/wtk-deep-review/scripts/run_jobs.py +436 -0
- package/.agents/skills/wtk-deep-review/scripts/token_metrics.py +369 -0
- package/.agents/skills/wtk-discover/SKILL.md +270 -0
- package/.agents/skills/wtk-discover/references/document-format.md +149 -0
- package/.agents/skills/wtk-implement/SKILL.md +95 -0
- package/.agents/skills/wtk-implement/references/checklist-format.md +116 -0
- package/.agents/skills/wtk-implement/references/screens.md +129 -0
- package/.agents/skills/wtk-implement/references/test-policy.md +152 -0
- package/.agents/skills/wtk-implement/references/verify.md +297 -0
- package/.agents/skills/wtk-knowledge-check/SKILL.md +16 -0
- package/.agents/skills/wtk-knowledge-check/scripts/check.ts +502 -0
- package/.agents/skills/wtk-knowledge-check/scripts/cli.ts +32 -0
- package/.agents/skills/wtk-knowledge-check/scripts/frontmatter.ts +44 -0
- package/.agents/skills/wtk-lean/.skill-meta.json +6 -0
- package/.agents/skills/wtk-lean/NOTICE.md +18 -0
- package/.agents/skills/wtk-lean/SKILL.md +137 -0
- package/.agents/skills/wtk-lean/references/build.md +168 -0
- package/.agents/skills/wtk-lean/references/checks.md +253 -0
- package/.agents/skills/wtk-lean/references/memory.md +156 -0
- package/.agents/skills/wtk-lean/references/plan.md +514 -0
- package/.agents/skills/wtk-lean/references/verify.md +336 -0
- package/.agents/skills/wtk-lean/scripts/check_commit.py +121 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/checks.md +98 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/plan.md +119 -0
- package/.agents/skills/wtk-lean/scripts/fixtures/verification.md +41 -0
- package/.agents/skills/wtk-lean/scripts/lessons.py +412 -0
- package/.agents/skills/wtk-lean/scripts/selftest.py +401 -0
- package/.agents/skills/wtk-lean/scripts/validate_checks.py +417 -0
- package/.agents/skills/wtk-lean/scripts/validate_plan.py +580 -0
- package/.agents/skills/wtk-lean/scripts/validate_verification.py +357 -0
- package/.agents/skills/wtk-plan/SKILL.md +102 -0
- package/.agents/skills/wtk-plan/references/document-format.md +222 -0
- package/.agents/skills/wtk-qa/SKILL.md +14 -0
- package/.agents/skills/wtk-qa-execute/SKILL.md +115 -0
- package/.agents/skills/wtk-qa-execute/references/fix-loop.md +23 -0
- package/.agents/skills/wtk-qa-execute/references/session-protocol.md +25 -0
- package/.agents/skills/wtk-qa-plan/SKILL.md +111 -0
- package/.agents/skills/wtk-qa-plan/references/profile.md +35 -0
- package/.agents/skills/wtk-ship/SKILL.md +49 -0
- package/.agents/skills/wtk-ship/remediation.py +170 -0
- package/.agents/skills/wtk-ship/scripts/close_feature.py +83 -0
- package/.agents/skills/wtk-ship/scripts/review_convergence.py +373 -0
- package/.wtk.toml.example +93 -0
- package/AGENTS.md +129 -0
- package/NOTICE.md +12 -0
- package/README.md +440 -0
- package/bin/wtk.js +25 -0
- package/docs/toolkit/README.md +56 -0
- package/docs/toolkit/decisions.md +61 -0
- package/docs/toolkit/guidelines/CONTEXT-BUDGET.md +64 -0
- package/docs/toolkit/guidelines/DX.md +67 -0
- package/docs/toolkit/guidelines/FRONTEND.md +73 -0
- package/docs/toolkit/guidelines/KNOWLEDGE-WIKI.md +128 -0
- package/docs/toolkit/guidelines/MODELING.md +134 -0
- package/docs/toolkit/guidelines/QA-EXECUTION.md +46 -0
- package/docs/toolkit/guidelines/QA-SCENARIOS.md +165 -0
- package/docs/toolkit/guidelines/REVIEW-ROUNDS.md +160 -0
- package/docs/toolkit/guidelines/SECURITY.md +160 -0
- package/docs/toolkit/guidelines/UI-UX.md +117 -0
- package/docs/toolkit/guidelines/WORKFLOW-MEMORY.md +73 -0
- package/docs/toolkit/guidelines.md +50 -0
- package/docs/toolkit/loop.md +77 -0
- package/docs/toolkit/purpose.md +51 -0
- package/docs/toolkit/repository-intelligence.md +53 -0
- package/docs/toolkit/reviews.md +77 -0
- package/knowledge/AGENTS.md +316 -0
- package/knowledge/raw/README.md +17 -0
- package/package.json +74 -0
- package/scripts/install_security_skills.py +816 -0
- package/scripts/installer/engine.js +254 -0
- package/scripts/installer/knowledge.js +28 -0
- package/scripts/installer/packets.js +77 -0
- package/scripts/installer/terminal.js +98 -0
- package/scripts/installer/transaction.js +75 -0
- package/skills-lock.json +92 -0
- package/templates/adoption/agents/core.md +23 -0
- package/templates/adoption/agents/quality.md +3 -0
- package/templates/adoption/knowledge/wiki/architecture/index.md +3 -0
- package/templates/adoption/knowledge/wiki/decisions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/design/index.md +3 -0
- package/templates/adoption/knowledge/wiki/domain/index.md +3 -0
- package/templates/adoption/knowledge/wiki/index.md +3 -0
- package/templates/adoption/knowledge/wiki/log.md +3 -0
- package/templates/adoption/knowledge/wiki/open-questions/index.md +3 -0
- package/templates/adoption/knowledge/wiki/product/index.md +3 -0
- package/templates/adoption/knowledge/wiki/research/index.md +3 -0
- 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
|