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,64 @@
|
|
|
1
|
+
# State and Learnings
|
|
2
|
+
|
|
3
|
+
The tracker that makes rounds incremental and the feedback loop that stops repeated mistakes.
|
|
4
|
+
|
|
5
|
+
## Fingerprint — a result's identity
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
fp = first 16 hex of sha256("<result-kind>|<file>|<category>|<normalized title>")
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`normalized title` = lowercase, alphanumerics and single spaces only. Line numbers are deliberately excluded — anchors drift between pushes; identity must survive that. The single implementation is `fingerprint()` in `scripts/_common.py` (applied by merge_findings.py); compute one by hand only when recovering fingerprints from a PR thread:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
printf '%s' "defect|internal/store/queue.go|potential-issue|dont hard fail preferredmodel when config options are unrelated" \
|
|
15
|
+
| shasum -a 256 | cut -c1-16
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## state.json (per target, in `<out>/`)
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"target": "pr:312",
|
|
23
|
+
"rounds": [{ "n": 2, "base": "<sha>", "head": "<sha>", "verdict": "FIX_BEFORE_SHIP", "reviewed_at": "<ISO-8601>" }],
|
|
24
|
+
"ledger": {
|
|
25
|
+
"<fp>": { "file": "...", "title": "...", "severity": "major", "result_kind": "defect", "status": "open",
|
|
26
|
+
"round": 1, "comment_id": 123456, "resolved_in": null }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`status`: `open` → `resolved` only when an incremental review records an evidence-bearing `prior_findings` disposition; absence from new output never resolves it | `dismissed` (user rejected — capture a learning; never re-raised). `comment_id` only when published.
|
|
32
|
+
|
|
33
|
+
## Round reconciliation (Step 4)
|
|
34
|
+
|
|
35
|
+
Implemented by merge_findings.py (round status) and render_review.py (ledger update); this is the rule they follow. For each defect/advisory this round, compute `fp` and look it up:
|
|
36
|
+
|
|
37
|
+
- **absent** (or previously `resolved`) → `new`; add to ledger as `open`.
|
|
38
|
+
- **present, `open`** → `duplicate`; render once in the Duplicates section, keep ledger row.
|
|
39
|
+
- **present, `dismissed`** → keep suppressed from the active results and expose it in the dismissed/suppression audit trail.
|
|
40
|
+
|
|
41
|
+
Then sweep the ledger's `open` rows *not* re-found this round: a row whose `prior_findings` disposition says `resolved` becomes `resolved` (`resolved_in` = head; publish mode adds the ✅ edit); every other row, including an explicit `open` disposition or no disposition, stays `open`, is listed under Duplicates, and counts in the verdict.
|
|
42
|
+
|
|
43
|
+
## learnings.md (at `.wtk-deep-review/learnings.md`, repo-committable)
|
|
44
|
+
|
|
45
|
+
Append-only entries, one per correction:
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
## <fp-or-slug> — <one-line rule>
|
|
49
|
+
- Scope: <glob> (e.g. internal/store/**)
|
|
50
|
+
- Rule: <the distilled instruction a future reviewer must follow, imperative>
|
|
51
|
+
- Why: <the rationale the user gave — the invariant, the design intent>
|
|
52
|
+
- Origin: <pr:312 | session, date>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Capture triggers:** the user (or a PR reply from the author) rebuts a finding with a reason; the user says a class of findings is unwanted; a dismissal reveals a repo convention the rubric missed. Distill the *rule*, not the anecdote — "ReserveQueuedRun is the authoritative one-open-run enforcer; do not flag missing pre-checks in enqueue paths" beats a story about one PR.
|
|
56
|
+
|
|
57
|
+
**Application:** learnings are rubric input (context-pack.md §2) for every later round, scoped by their glob. Path instructions outrank learnings on conflict; a learning that contradicts project instructions or a selected skill signals that doctrine needs editing — surface the conflict.
|
|
58
|
+
|
|
59
|
+
## Storage conventions
|
|
60
|
+
|
|
61
|
+
- `<out>` holds manifest.json, knowledge.json, rules.json, context-pack.md, plan.json, prompts/, jobs.json, agents/, runs/, walkthrough.md, findings.json, review-stats.json, review.md, review.html, state.json, and round.json.
|
|
62
|
+
- When build_manifest.py starts a new round it archives everything except state.json/round.json/rounds/ into `<out>/rounds/round-<n>/` — the per-round audit trail; only state.json carries memory forward.
|
|
63
|
+
- `.wtk-deep-review/learnings.md` is shared across targets and worth committing — it is team review doctrine.
|
|
64
|
+
- Recommend adding `.wtk-deep-review/` to `.gitignore` with `!.wtk-deep-review/learnings.md` — suggest it once when the directory is first created; the decision belongs to the user.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Subagent Runtimes (`--subagent`)
|
|
2
|
+
|
|
3
|
+
How Step 3 review agents (defect cohorts, sweeps) execute. `native` — the default — uses the Workflow/Agent engines in orchestration.md; every other value runs the same materialized prompts cross-LLM through `compozy exec`, driven by the bundled runner. Step 2 context assembly stays orchestrator-side in every mode.
|
|
4
|
+
|
|
5
|
+
## Runtime map
|
|
6
|
+
|
|
7
|
+
| Value | Invocation |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `claude-opus` | `compozy exec --ide claude --model opus --reasoning-effort max` |
|
|
10
|
+
| `grok` | `compozy exec --ide cursor-agent --model 'grok-4.5[effort=high,fast=true]'` — effort/fast ride inside the model value (no reasoning flag); requesting `grok-4.5` resolves to the same advertised variant |
|
|
11
|
+
| `codex` | `compozy exec --ide codex --model gpt-5.6-luna --reasoning-effort high` |
|
|
12
|
+
|
|
13
|
+
## Invocation shape (per stage)
|
|
14
|
+
|
|
15
|
+
The stage scripts already materialized every prompt (schema + output contract embedded — external runtimes have no schema-enforcement layer, so the output-file contract replaces it). Execute a stage's jobs with the bundled runner from the repo root:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 <skill-dir>/scripts/run_jobs.py --out <out> [--jobs-file <out>/<stage>-jobs.json] \
|
|
19
|
+
--command "compozy exec <runtime flags from the map> --format json --timeout 30m --prompt-file {prompt}"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The runner executes reviewer jobs with the concurrency bound pinned in the manifest (default `3`, maximum `6`). An adapter may append metrics flags to collect cumulative snapshots. The runner refills completed worker slots, keeps retries inside the owning slot, stops scheduling after a provider block while active attempts finish, and owns output validation, the source-freeze check, and resume (valid outputs are never re-run). Each job's output file is the agent's only product; JSONL/stderr logs are operational evidence — never parse them as review output.
|
|
23
|
+
|
|
24
|
+
## Failure handling
|
|
25
|
+
|
|
26
|
+
- **Runner exit 2 (blocked)** — a stream matched a block pattern (default `usageLimitExceeded`); `<out>/run-blocker.json` lists the pending jobs. Re-run the same command when the limit clears; add `--block-on <pattern>` for providers that phrase limits differently.
|
|
27
|
+
- **Runner exit 1 with FAIL jobs** — the agent kept producing missing/invalid output through its attempts. Read `<out>/runs/<label>.attempt-*.err`, then run that one agent on the `native` path (orchestration.md engines) and record the substitution in review.md — the no-skip invariant outranks runtime purity.
|
|
28
|
+
- **`model "X" is not available`** — the error lists the runtime's advertised options. Surface them and stop; never substitute a model silently (L-010).
|
|
29
|
+
- **`did not advertise an ACP model option`**, or `compozy` missing from PATH — stop and name the gap; external review has no alternate transport.
|
|
30
|
+
|
|
31
|
+
## Cost
|
|
32
|
+
|
|
33
|
+
Every external invocation spends `compozy exec` credit — a large PR fans out dozens of agents. `native` fits exploratory runs; external runtimes earn their spend on gate rounds (e.g. loop Phase D's `codex` lane).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Review Taxonomy
|
|
2
|
+
|
|
3
|
+
The grammar for defects, advisories, evidence, and objective suppressions. Every review uses one assertive posture: report every specific, actionable survivor regardless of size.
|
|
4
|
+
|
|
5
|
+
## Result classes
|
|
6
|
+
|
|
7
|
+
| Class | Categories | Verdict impact | Decision rule |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| **Defect** | `⚠️ Potential issue` | Critical/Major block SHIP | The change can produce a wrong result, crash, leak, vulnerability, broken contract, or failing-capable test gap under a concrete input/state. |
|
|
10
|
+
| **Advisory** | `🛠️ Refactor suggestion`, `🧹 Nitpick` | Never | The code can remain functional, but a bounded change measurably improves maintainability, simplicity, clarity, naming, documentation, idiom, or conformance with a project rule/skill. |
|
|
11
|
+
|
|
12
|
+
There is no advisory quota. A small advisory is valid when the premise is observed, the benefit is specific, and the fix is bounded. Formatter-owned style and vague preferences are suppressions, not advisories.
|
|
13
|
+
|
|
14
|
+
## Severity
|
|
15
|
+
|
|
16
|
+
| Badge | Class | Bar |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `🔴 Critical` | defect | Plausible production incident, data loss, or security compromise. |
|
|
19
|
+
| `🟠 Major` | defect | Wrong behavior, user-visible degradation, unsafe rollout, or a bug awaiting a realistic trigger. |
|
|
20
|
+
| `🟡 Minor` | defect or advisory | Narrow real defect, safety erosion, or a meaningful non-local maintainability improvement. |
|
|
21
|
+
| `🔵 Trivial` | advisory | Cheap clarity, naming, documentation, small deduplication, or idiom improvement. |
|
|
22
|
+
|
|
23
|
+
Severity measures impact if unfixed, not confidence or effort. Choose the lower level when between two levels.
|
|
24
|
+
|
|
25
|
+
## Effort modifier
|
|
26
|
+
|
|
27
|
+
`⚡ Quick win` means the fix is local and mechanical (roughly ≤15 lines at one site). A larger advisory remains reportable; effort never suppresses it.
|
|
28
|
+
|
|
29
|
+
## Source attribution
|
|
30
|
+
|
|
31
|
+
Every result names what produced it through evidence and optional rule ids: repository rule/skill, learning, linter interpretation, verification command, or direct code review. Quote bound rules verbatim through their registry ids and source paths.
|
|
32
|
+
|
|
33
|
+
## Evidence certificates
|
|
34
|
+
|
|
35
|
+
Defects start with a causal certificate:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
Premise: <observed fact at file:line> → Path: <named caller/input/control flow> → Verdict: <resulting failure>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Advisories start with an improvement certificate:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
Premise: <observed fact at file:line> → Improvement: <specific measurable benefit> → Fix: <bounded change>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Later evidence entries record one `command or file:line → what it showed` check each. Advisories do not invent a runtime failure to clear the defect evidence bar.
|
|
48
|
+
|
|
49
|
+
## Outside-diff results
|
|
50
|
+
|
|
51
|
+
A result on untouched lines is allowed only when the diff breaks that code or when a sibling path must mirror the changed invariant. Mark it `in_diff: false` and `hunk: null`; it belongs in the summary rather than an inline comment.
|
|
52
|
+
|
|
53
|
+
## Objective suppressions
|
|
54
|
+
|
|
55
|
+
When an investigated candidate is rejected, record it in `suppressions` with one of these reasons and a concrete note:
|
|
56
|
+
|
|
57
|
+
1. `linter-overlap` — a linter/typechecker lane already reports it.
|
|
58
|
+
2. `intentional` — an adjacent justified disable, ADR, comment, or behavior-locking test proves intent.
|
|
59
|
+
3. `generated-vendored` — the manifest excludes ownership of generated/vendor code.
|
|
60
|
+
4. `formatting` — a configured formatter owns the proposed change.
|
|
61
|
+
5. `speculative` — a defect claim has no concrete failure path and no valid advisory premise.
|
|
62
|
+
6. `pre-existing` — untouched debt satisfies neither outside-diff clause.
|
|
63
|
+
7. `phantom-knowledge` — the claim depends on uninspected code or an irrelevant framework generality.
|
|
64
|
+
8. `duplicate-within-job` — the candidate is represented by another result and its anchor appears under `also_applies`.
|
|
65
|
+
|
|
66
|
+
Profile, volume, low severity, and personal taste are not suppression reasons.
|
|
67
|
+
|
|
68
|
+
## Volume discipline
|
|
69
|
+
|
|
70
|
+
There is no numeric cap. Find broadly, refute actively, report every survivor, and account for every investigated rejection. One root cause becomes one result; search every occurrence and list the rest under `also_applies`.
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Shared contracts for the wtk-deep-review pipeline scripts (library, not a CLI).
|
|
2
|
+
|
|
3
|
+
Single source for: path resolution, JSON I/O, the JSON-Schema-subset validator,
|
|
4
|
+
job-output validation, finding fingerprints, hunk text, and the source-freeze
|
|
5
|
+
snapshot. Every sibling script imports it; invoke them as files
|
|
6
|
+
(python3 <path>/script.py) so Python puts this directory on sys.path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
SKILL_DIR = Path(__file__).resolve().parent.parent
|
|
19
|
+
ASSETS_DIR = SKILL_DIR / "assets"
|
|
20
|
+
SEVERITY_RANK = {"trivial": 0, "minor": 1, "major": 2, "critical": 3}
|
|
21
|
+
KNOWN_KINDS = {"cohort", "sweep"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------- paths / IO ----------
|
|
25
|
+
|
|
26
|
+
def repo_root() -> Path:
|
|
27
|
+
proc = subprocess.run(
|
|
28
|
+
["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True
|
|
29
|
+
)
|
|
30
|
+
if proc.returncode != 0:
|
|
31
|
+
raise RuntimeError(f"not inside a git repository: {proc.stderr.strip()}")
|
|
32
|
+
return Path(proc.stdout.strip())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_json(path: Path):
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
raise RuntimeError(f"missing artifact: {path}") from None
|
|
40
|
+
except json.JSONDecodeError as error:
|
|
41
|
+
raise RuntimeError(f"invalid JSON in {path}: {error}") from error
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_json(path: Path, payload) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(
|
|
47
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def rel(path: Path, repo: Path) -> str:
|
|
52
|
+
"""Path as agents/prompts should type it: repo-relative when inside the repo."""
|
|
53
|
+
resolved = path.resolve()
|
|
54
|
+
try:
|
|
55
|
+
return str(resolved.relative_to(repo.resolve()))
|
|
56
|
+
except ValueError:
|
|
57
|
+
return str(resolved)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def skill_rel(repo: Path) -> str:
|
|
61
|
+
return rel(SKILL_DIR, repo)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_schema(name: str) -> dict:
|
|
65
|
+
return read_json(ASSETS_DIR / f"{name}.schema.json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def glob_to_regex(pat: str) -> re.Pattern:
|
|
69
|
+
out, i = [], 0
|
|
70
|
+
while i < len(pat):
|
|
71
|
+
c = pat[i]
|
|
72
|
+
if c == "*":
|
|
73
|
+
if pat[i : i + 3] == "**/":
|
|
74
|
+
out.append("(?:.*/)?")
|
|
75
|
+
i += 3
|
|
76
|
+
elif pat[i : i + 2] == "**":
|
|
77
|
+
out.append(".*")
|
|
78
|
+
i += 2
|
|
79
|
+
else:
|
|
80
|
+
out.append("[^/]*")
|
|
81
|
+
i += 1
|
|
82
|
+
elif c == "?":
|
|
83
|
+
out.append("[^/]")
|
|
84
|
+
i += 1
|
|
85
|
+
else:
|
|
86
|
+
out.append(re.escape(c))
|
|
87
|
+
i += 1
|
|
88
|
+
return re.compile("^" + "".join(out) + "$")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------- JSON-Schema subset validator ----------
|
|
92
|
+
|
|
93
|
+
def schema_errors(value, schema: dict, path: str = "$") -> list[str]:
|
|
94
|
+
"""Validates the subset the bundled schemas use: type (incl. unions),
|
|
95
|
+
required, properties, items, enum, maxLength, minItems."""
|
|
96
|
+
types = schema.get("type")
|
|
97
|
+
if types is not None:
|
|
98
|
+
allowed = types if isinstance(types, list) else [types]
|
|
99
|
+
if not any(_matches_type(value, t) for t in allowed):
|
|
100
|
+
return [f"{path}: expected {allowed}, got {type(value).__name__}"]
|
|
101
|
+
errors: list[str] = []
|
|
102
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
103
|
+
errors.append(f"{path}: {value!r} not in enum")
|
|
104
|
+
if isinstance(value, dict):
|
|
105
|
+
for key in schema.get("required", []):
|
|
106
|
+
if key not in value:
|
|
107
|
+
errors.append(f"{path}: missing required key {key!r}")
|
|
108
|
+
for key, sub in (schema.get("properties") or {}).items():
|
|
109
|
+
if key in value:
|
|
110
|
+
errors.extend(schema_errors(value[key], sub, f"{path}.{key}"))
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
if "minItems" in schema and len(value) < schema["minItems"]:
|
|
113
|
+
errors.append(f"{path}: needs at least {schema['minItems']} items")
|
|
114
|
+
items = schema.get("items")
|
|
115
|
+
if items:
|
|
116
|
+
for index, item in enumerate(value):
|
|
117
|
+
errors.extend(schema_errors(item, items, f"{path}[{index}]"))
|
|
118
|
+
if isinstance(value, str) and "maxLength" in schema and len(value) > schema["maxLength"]:
|
|
119
|
+
errors.append(f"{path}: exceeds maxLength {schema['maxLength']}")
|
|
120
|
+
return errors
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _matches_type(value, name: str) -> bool:
|
|
124
|
+
if name == "object":
|
|
125
|
+
return isinstance(value, dict)
|
|
126
|
+
if name == "array":
|
|
127
|
+
return isinstance(value, list)
|
|
128
|
+
if name == "string":
|
|
129
|
+
return isinstance(value, str)
|
|
130
|
+
if name == "integer":
|
|
131
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
132
|
+
if name == "number":
|
|
133
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
134
|
+
if name == "boolean":
|
|
135
|
+
return isinstance(value, bool)
|
|
136
|
+
if name == "null":
|
|
137
|
+
return value is None
|
|
138
|
+
raise RuntimeError(f"unsupported schema type {name!r}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------- findings identity ----------
|
|
142
|
+
|
|
143
|
+
def normalize_title(title: str) -> str:
|
|
144
|
+
return " ".join(re.sub(r"[^a-z0-9]+", " ", title.lower()).split())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def fingerprint(finding: dict) -> str:
|
|
148
|
+
identity = "|".join(
|
|
149
|
+
[
|
|
150
|
+
finding.get("result_kind", "defect"),
|
|
151
|
+
finding["file"],
|
|
152
|
+
finding["category"],
|
|
153
|
+
normalize_title(finding["title"]),
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
return hashlib.sha256(identity.encode()).hexdigest()[:16]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------- hunks ----------
|
|
160
|
+
|
|
161
|
+
def hunk_text(hunk: dict) -> str:
|
|
162
|
+
start, lines = int(hunk["start"]), int(hunk["lines"])
|
|
163
|
+
return f"{hunk.get('side', 'new')}:{start}-{start + lines - 1}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def manifest_selected(manifest: dict) -> dict[str, dict]:
|
|
167
|
+
return {f["path"]: f for f in manifest["files"] if f["disposition"] == "selected"}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------- job outputs ----------
|
|
171
|
+
|
|
172
|
+
def load_jobs(path: Path) -> list[dict]:
|
|
173
|
+
jobs = read_json(path)["jobs"]
|
|
174
|
+
labels = [job["label"] for job in jobs]
|
|
175
|
+
if len(labels) != len(set(labels)):
|
|
176
|
+
raise RuntimeError(f"{path}: duplicate job labels")
|
|
177
|
+
for job in jobs:
|
|
178
|
+
if job.get("kind") not in KNOWN_KINDS:
|
|
179
|
+
raise RuntimeError(f"{path}: {job.get('label')}: unknown kind {job.get('kind')!r}")
|
|
180
|
+
return jobs
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_job_output(repo: Path, out: Path, job: dict) -> None:
|
|
184
|
+
"""Raises ValueError when the job's output file is missing or breaks the
|
|
185
|
+
findings contract. Passing silently means the output is valid."""
|
|
186
|
+
path = repo / job["output"]
|
|
187
|
+
if not path.is_file():
|
|
188
|
+
raise ValueError(f"{job['label']}: missing output {job['output']}")
|
|
189
|
+
try:
|
|
190
|
+
payload = read_json(path)
|
|
191
|
+
except RuntimeError as error:
|
|
192
|
+
raise ValueError(f"{job['label']}: {error}") from error
|
|
193
|
+
errors = findings_contract_errors(payload)
|
|
194
|
+
if not errors:
|
|
195
|
+
errors.extend(job_contract_errors(payload, job))
|
|
196
|
+
if errors:
|
|
197
|
+
head = "; ".join(errors[:6])
|
|
198
|
+
tail = f" (+{len(errors) - 6} more)" if len(errors) > 6 else ""
|
|
199
|
+
raise ValueError(f"{job['label']}: {head}{tail}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
CERTIFICATE_RE = re.compile(
|
|
203
|
+
r"^Premise:\s+.+\s+→\s+Path:\s+.+\s+→\s+Verdict:\s+.+$"
|
|
204
|
+
)
|
|
205
|
+
ADVISORY_CERTIFICATE_RE = re.compile(
|
|
206
|
+
r"^Premise:\s+.+\s+→\s+Improvement:\s+.+\s+→\s+Fix:\s+.+$"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def findings_contract_errors(payload: dict) -> list[str]:
|
|
211
|
+
"""Validate the review-output schema plus class-specific certificates."""
|
|
212
|
+
errors = schema_errors(payload, load_schema("findings"))
|
|
213
|
+
if errors:
|
|
214
|
+
return errors
|
|
215
|
+
for index, finding in enumerate(payload["defects"]):
|
|
216
|
+
certificate = finding["evidence"][0].strip()
|
|
217
|
+
if not CERTIFICATE_RE.fullmatch(certificate):
|
|
218
|
+
errors.append(
|
|
219
|
+
f"$.defects[{index}].evidence[0]: expected "
|
|
220
|
+
"'Premise: ... → Path: ... → Verdict: ...' certificate"
|
|
221
|
+
)
|
|
222
|
+
for index, advisory in enumerate(payload["advisories"]):
|
|
223
|
+
certificate = advisory["evidence"][0].strip()
|
|
224
|
+
if not ADVISORY_CERTIFICATE_RE.fullmatch(certificate):
|
|
225
|
+
errors.append(
|
|
226
|
+
f"$.advisories[{index}].evidence[0]: expected "
|
|
227
|
+
"'Premise: ... → Improvement: ... → Fix: ...' certificate"
|
|
228
|
+
)
|
|
229
|
+
return errors
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def job_contract_errors(payload: dict, job: dict) -> list[str]:
|
|
233
|
+
"""Validate lane ownership, hunk coverage, rule-id sanity, and
|
|
234
|
+
prior-finding dispositions (exactly one row per job.prior_fingerprints,
|
|
235
|
+
never a defect re-reported at a prior anchor)."""
|
|
236
|
+
errors: list[str] = []
|
|
237
|
+
lane = str(job.get("lane", ""))
|
|
238
|
+
expected_prior = set(job.get("prior_fingerprints", []))
|
|
239
|
+
actual_prior = [str(row.get("fingerprint")) for row in payload.get("prior_findings", [])]
|
|
240
|
+
if len(actual_prior) != len(set(actual_prior)):
|
|
241
|
+
errors.append("$.prior_findings: duplicate fingerprint rows")
|
|
242
|
+
if set(actual_prior) != expected_prior:
|
|
243
|
+
errors.append(
|
|
244
|
+
"$.prior_findings: disposition mismatch "
|
|
245
|
+
f"missing={sorted(expected_prior - set(actual_prior))[:6]} "
|
|
246
|
+
f"extra={sorted(set(actual_prior) - expected_prior)[:6]}"
|
|
247
|
+
)
|
|
248
|
+
prior_anchors = {
|
|
249
|
+
(str(row["file"]), row.get("line")): row["fingerprint"] for row in job.get("prior_anchors", [])
|
|
250
|
+
}
|
|
251
|
+
for index, item in enumerate(payload.get("defects", [])):
|
|
252
|
+
prior = prior_anchors.get((str(item.get("file")), item.get("line")))
|
|
253
|
+
if prior:
|
|
254
|
+
errors.append(
|
|
255
|
+
f"$.defects[{index}]: re-reports prior finding {prior}; disposition it in prior_findings instead"
|
|
256
|
+
)
|
|
257
|
+
expected_hunks = {
|
|
258
|
+
(str(row["file"]), str(row["hunk"])) for row in job.get("required_hunks", [])
|
|
259
|
+
}
|
|
260
|
+
rows = payload.get("coverage", {}).get("hunks", [])
|
|
261
|
+
actual_hunks = [(str(row.get("file")), str(row.get("hunk"))) for row in rows]
|
|
262
|
+
if len(actual_hunks) != len(set(actual_hunks)):
|
|
263
|
+
errors.append("$.coverage.hunks: duplicate file/hunk rows")
|
|
264
|
+
actual_set = set(actual_hunks)
|
|
265
|
+
if actual_set != expected_hunks:
|
|
266
|
+
errors.append(
|
|
267
|
+
"$.coverage.hunks: ownership mismatch "
|
|
268
|
+
f"missing={sorted(expected_hunks - actual_set)[:6]} "
|
|
269
|
+
f"extra={sorted(actual_set - expected_hunks)[:6]}"
|
|
270
|
+
)
|
|
271
|
+
coverage_check = str(job.get("coverage_check", ""))
|
|
272
|
+
if coverage_check:
|
|
273
|
+
for index, row in enumerate(rows):
|
|
274
|
+
if coverage_check not in row.get("checks", []):
|
|
275
|
+
errors.append(
|
|
276
|
+
f"$.coverage.hunks[{index}].checks: missing required check {coverage_check!r}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
expected_rules = set(job.get("rule_ids", []))
|
|
280
|
+
rule_rows = payload.get("coverage", {}).get("rules", [])
|
|
281
|
+
actual_rules = [str(row.get("rule_id")) for row in rule_rows]
|
|
282
|
+
if len(actual_rules) != len(set(actual_rules)):
|
|
283
|
+
errors.append("$.coverage.rules: duplicate rule_id rows")
|
|
284
|
+
if set(actual_rules) != expected_rules:
|
|
285
|
+
errors.append(
|
|
286
|
+
"$.coverage.rules: assignment mismatch "
|
|
287
|
+
f"missing={sorted(expected_rules - set(actual_rules))[:6]} "
|
|
288
|
+
f"extra={sorted(set(actual_rules) - expected_rules)[:6]}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
cohort_hunks = {(str(row["file"]), str(row["hunk"])) for row in job.get("cohort_hunks", [])}
|
|
292
|
+
for result_kind in ("defects", "advisories"):
|
|
293
|
+
for index, item in enumerate(payload.get(result_kind, [])):
|
|
294
|
+
if not item.get("in_diff"):
|
|
295
|
+
continue
|
|
296
|
+
anchor = (item.get("file"), item.get("hunk"))
|
|
297
|
+
if lane == "defect" and anchor not in expected_hunks:
|
|
298
|
+
errors.append(f"$.{result_kind}[{index}]: in-diff anchor is outside job ownership")
|
|
299
|
+
spread = {ref.rsplit(":", 1)[0] for ref in item.get("also_applies") or []}
|
|
300
|
+
if lane == "sweep" and anchor in cohort_hunks and len(spread) < 2:
|
|
301
|
+
errors.append(f"$.{result_kind}[{index}]: single-cohort result belongs to the cohort lane")
|
|
302
|
+
assigned_rules = expected_rules
|
|
303
|
+
for result_kind in ("defects", "advisories", "suppressions"):
|
|
304
|
+
for index, item in enumerate(payload.get(result_kind, [])):
|
|
305
|
+
unknown = set(item.get("rule_ids") or []) - assigned_rules
|
|
306
|
+
if unknown:
|
|
307
|
+
errors.append(
|
|
308
|
+
f"$.{result_kind}[{index}].rule_ids: unassigned ids {sorted(unknown)}"
|
|
309
|
+
)
|
|
310
|
+
return errors
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------- source freeze ----------
|
|
314
|
+
|
|
315
|
+
def freeze_snapshot(repo: Path, out: Path) -> str:
|
|
316
|
+
"""Fingerprint of the reviewed checkout: HEAD + tracked worktree delta +
|
|
317
|
+
untracked file bytes, excluding review artifacts (.wtk-deep-review/ and <out>)."""
|
|
318
|
+
excludes = [".wtk-deep-review"]
|
|
319
|
+
out_rel = rel(out, repo)
|
|
320
|
+
if not Path(out_rel).is_absolute() and out_rel not in (".wtk-deep-review", "."):
|
|
321
|
+
excludes.append(out_rel)
|
|
322
|
+
digest = hashlib.sha256()
|
|
323
|
+
digest.update(_git_text(repo, "rev-parse", "HEAD").encode())
|
|
324
|
+
pathspec = [".", *[f":(exclude){item}" for item in dict.fromkeys(excludes)]]
|
|
325
|
+
digest.update(_git_bytes(repo, "diff", "--binary", "HEAD", "--", *pathspec))
|
|
326
|
+
untracked = _git_text(repo, "ls-files", "--others", "--exclude-standard").splitlines()
|
|
327
|
+
for path in sorted(untracked):
|
|
328
|
+
if any(path == item or path.startswith(item + "/") for item in excludes):
|
|
329
|
+
continue
|
|
330
|
+
file_path = repo / path
|
|
331
|
+
if file_path.is_symlink():
|
|
332
|
+
digest.update(path.encode())
|
|
333
|
+
digest.update(b"\0symlink\0")
|
|
334
|
+
digest.update(os.fsencode(os.readlink(file_path)))
|
|
335
|
+
continue
|
|
336
|
+
if not file_path.is_file():
|
|
337
|
+
continue
|
|
338
|
+
digest.update(path.encode())
|
|
339
|
+
digest.update(file_path.read_bytes())
|
|
340
|
+
return digest.hexdigest()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def check_freeze(repo: Path, out: Path, stage: str) -> list[str]:
|
|
344
|
+
"""Compare the checkout against manifest.worktree_snapshot; returns error lines."""
|
|
345
|
+
manifest = read_json(out / "manifest.json")
|
|
346
|
+
expected = manifest.get("worktree_snapshot")
|
|
347
|
+
if not expected:
|
|
348
|
+
return [f"{stage}: manifest has no worktree_snapshot (rebuild the manifest)"]
|
|
349
|
+
actual = freeze_snapshot(repo, out)
|
|
350
|
+
if actual != expected:
|
|
351
|
+
return [
|
|
352
|
+
f"{stage}: source drifted — snapshot {actual[:12]} != manifest {expected[:12]}; "
|
|
353
|
+
"findings would anchor to stale lines. Commit/stash the drift or restart the round."
|
|
354
|
+
]
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _git_text(repo: Path, *args: str) -> str:
|
|
359
|
+
proc = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True)
|
|
360
|
+
if proc.returncode != 0:
|
|
361
|
+
raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
362
|
+
return proc.stdout.strip()
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _git_bytes(repo: Path, *args: str) -> bytes:
|
|
366
|
+
proc = subprocess.run(["git", *args], cwd=repo, capture_output=True)
|
|
367
|
+
if proc.returncode != 0:
|
|
368
|
+
raise RuntimeError(
|
|
369
|
+
f"git {' '.join(args)} failed: {proc.stderr.decode(errors='replace').strip()}"
|
|
370
|
+
)
|
|
371
|
+
return proc.stdout
|