Help us improve
Share bugs, ideas, or general feedback.
From prd2impl
Contract drift detection — analyze changes in contract/interface files and assess impact on dependent tasks. Use when the user says 'check contracts', 'contract drift', 'schema changed', 'interface changed', or runs /contract-check.
npx claudepluginhub ezagent42/prd2impl --plugin prd2implHow this skill is triggered — by the user, by Claude, or both
Slash command
/prd2impl:skill-12-contract-checkThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
<SUBAGENT-STOP>
Guides technical evaluation of code review feedback: read fully, restate for understanding, verify against codebase, respond with reasoning or pushback before implementing.
Share bugs, ideas, or general feedback.
Detect changes in contract/interface files and analyze their impact on dependent tasks and code.
/contract-checkdocs/contracts/ directory (contract definitions){plans_dir}/tasks.yaml (to find affected tasks)Path resolution: Before constructing any read/write path, resolve
{plans_dir}perlib/plans-dir-resolver.md. Alldocs/plans/references (exceptdocs/plans/project.yaml, which stays at repo root) are relative to that resolved directory. Bare references totasks.yaml,task-status.md, etc. are also{plans_dir}-scoped.
Before scanning for contracts, check if this is a greenfield project:
docs/contracts/**/* — if no files found AND**/*.ts, **/protocol*.py, **/*.proto, **/openapi*.json, **/*.schema.json) — if no interface files found anywhere in the repo ANDgap_analysis.summary.coverage_pct == 0If ALL THREE conditions are true: Print: ───────────────────────────────────────────────────── ℹ️ Greenfield project detected — contract-check skipped (no contracts/ directory, no code-embedded interfaces, 0% codebase coverage) ─────────────────────────────────────────────────────
Return immediately. Do NOT proceed to Step 1-6. No report file is written.
If any condition is false (e.g., contracts/ has files, OR code-embedded interfaces exist, OR coverage > 0): Continue to Step 1 normally — there may be interface files to check.
docs/contracts/ for contract definitionsFor each contract-side file from Step 1, parse its AST to enumerate
{Module.Class.method: Signature}. This replaces the 0.3.x diff-text
parser, which could only see what changed in this run, not the
absolute current state of the contract.
import ast
def enumerate_contract(path):
"""Returns {qualified_name: signature_dict} for every public method in path."""
tree = ast.parse(open(path).read())
out = {}
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
for item in node.body:
if isinstance(item, ast.FunctionDef) and not item.name.startswith('_'):
qual = f"{path}::{node.name}.{item.name}"
args = [a.arg for a in item.args.args]
kwonly = [a.arg for a in item.args.kwonlyargs]
out[qual] = {
'positional': args,
'keyword_only': kwonly,
'has_varargs': item.args.vararg is not None,
'has_varkw': item.args.kwarg is not None,
}
return out
Store the resulting dict as the canonical contract snapshot for this run. This snapshot answers "does this method exist on the real class right now" rather than "did this method appear in the git diff".
For TypeScript / JS contracts, use a parallel tree-sitter-typescript
walk; if unavailable, fall back to the 0.3.x grep-based extraction
with a logged warning.
Also keep the diff view for change-summary output (still useful in the report):
git diff {last_milestone_tag}..HEAD -- docs/contracts/ \
"*.proto" "*.schema.json" "*protocol*.py" "*interface*.ts"
Diff is consumed for the human-readable diff_summary: block, but
gating decisions use the AST snapshot.
changes:
- file: "docs/contracts/conversation-engine.md"
type: contract
diff_summary:
added_methods: ["async handle_timeout(session_id)"]
removed_methods: []
changed_signatures: ["start_session: added 'metadata' param"]
added_fields: ["Session.metadata: dict"]
severity: medium # breaking | medium | minor
contract_snapshot_qualifies: 14 # methods enumerated by AST
- file: "docs/contracts/frontend-ws-schema.md"
type: schema
diff_summary:
added_events: ["session.timeout"]
removed_events: []
changed_events: ["message.new: added 'sentiment' field"]
severity: minor
For each consumer file found in Step 3, AST-walk it and find every
Call node whose callee resolves (via local symbol table) to a
tracked method:
def find_calls(consumer_path, contract_snapshot):
"""Yield (lineno, qualified_name, actual_signature) for every tracked call."""
tree = ast.parse(open(consumer_path).read())
for node in ast.walk(tree):
if isinstance(node, ast.Call):
target = resolve_callee(node, tree) # via local symbol table
if target in contract_snapshot:
actual = {
'positional_count': len(node.args),
'keyword_names': [kw.arg for kw in node.keywords if kw.arg],
}
yield (node.lineno, target, actual)
def signature_drift(actual, definition):
"""Return list of mismatches between actual call and definition."""
drifts = []
if (actual['positional_count'] > len(definition['positional'])
and not definition['has_varargs']):
drifts.append(
f"too many positional args: {actual['positional_count']} > "
f"{len(definition['positional'])}"
)
for kw in actual['keyword_names']:
if (kw not in definition['positional']
and kw not in definition['keyword_only']
and not definition['has_varkw']):
drifts.append(f"unknown keyword: {kw}")
# The T4M.3 pattern: keyword-only arg passed positionally
for i, pos_name in enumerate(definition['positional'][:actual['positional_count']]):
if pos_name in definition['keyword_only']:
drifts.append(f"keyword-only arg '{pos_name}' passed positionally")
return drifts
Emit a signature_drift block in the report:
signature_drift:
- file: autoservice/pipeline_v2/main_agent/runner.py
line: 142
target: autoservice.cc_pool.CCPool.acquire_for_session
issue: symbol does not exist on real class
available_methods: [acquire, acquire_sticky, acquire_async]
- file: autoservice/pipeline_v2/main_agent/runner.py
line: 218
target: autoservice.cc_pool.CCPool.session_query
issue: keyword-only arg 'tenant_id' passed positionally
This catches drift that already shipped — independent of git diff —
the cdcfdb2 bug class. See references/ast-walk-template.md for a
reusable contract-test template.
For each change, find affected code:
impact:
- change: "start_session: added 'metadata' param"
affected_files:
- path: "autoservice/conversation_engine/local_engine.py"
line: 87
usage: "def start_session(self, customer_id)"
action_needed: "Add metadata parameter"
- path: "tests/contract/test_engine.py"
line: 23
usage: "engine.start_session('cust-1')"
action_needed: "Update test call"
affected_tasks:
- id: T1A.1
status: completed
needs_update: true
breaking: false # New optional param with default
- change: "Session.metadata: dict (new field)"
affected_files:
- path: "channels/web/websocket.py"
line: 145
usage: "session = Session(id=..., customer=...)"
action_needed: "Add metadata field"
affected_tasks:
- id: T0.5
status: completed
needs_update: true
breaking: false
risk:
total_changes: 4
breaking_changes: 0
affected_files: 8
affected_tasks: 3 (all completed — need update)
test_coverage:
covered: 6/8 files have tests
uncovered:
- "channels/web/websocket.py"
- "autoservice/plugins/lifecycle_plugin.py"
overall_risk: medium
recommendation: "Non-breaking changes. Update 8 files and re-run tests."
For each contract symbol that drifted, walk the registry to find
every task whose deliverables[].path references the changed file:
/artifact-registry query --references {changed_file_path}
Emit impacted_tasks: [...] in the report. For each impacted task,
flag whether it has an executed test-plan covering the drifted symbol;
if not, recommend a re-test action:
impacted_tasks:
- id: T4M.3
deliverables_touching_change:
- autoservice/pipeline_v2/main_agent/runner.py
has_executed_test_plan_covering_symbol: false
recommended_action: "Re-run /continue-task T4M.3 with new contract; or add explicit test for the changed signature."
Graceful degradation: when dev-loop missing, fall back to grep-based task lookup (the 0.3.x behavior). Note in the report that registry walking is unavailable.
# Contract Check Report — {date}
## Changes Detected
| File | Changes | Severity |
|------|---------|----------|
| conversation-engine.md | +1 method, 1 signature change, +1 field | Medium |
| frontend-ws-schema.md | +1 event, 1 event change | Minor |
## Impact Summary
- **Files to update**: 8
- **Tasks to revisit**: 3 (T0.5, T1A.1, T1A.3)
- **Breaking changes**: 0 (all additive)
- **Test coverage**: 75% (2 files uncovered)
## Required Actions
1. `local_engine.py:87` — Add `metadata` param to `start_session`
2. `websocket.py:145` — Add `metadata` field to Session construction
3. `test_engine.py:23` — Update test call signature
... (full list)
## Recommended Workflow
1. Create branch: `contract/add-metadata`
2. Apply changes to the 8 affected files
3. Run contract test suite: `pytest tests/contract/`
4. Send review prompt to other developer line:
> Contract change: `start_session` now accepts optional `metadata` param,
> `Session` has new `metadata: dict` field, new `session.timeout` WS event.
> All additive, non-breaking. PR: #{branch_url}
5. After review → merge to dev
If changes are straightforward (additive, non-breaking):
The changes are non-breaking. Want me to auto-apply fixes?
This will:
- Update 8 files to match new contract
- Re-run contract tests
- Create a commit with all changes
Proceed? (y/n)
If yes, apply fixes and run tests. If tests pass, commit.
docs/contracts/ filesskill-5-start-task Step 4.5 for every Yellow task and any task with must_call_unchanged. See --preflight subcommand below./contract-check --preflight {task_id}Invoked BEFORE writing code, by skill-5-start-task Step 4.5 for any
task that satisfies one of:
type: yellow (always)must_call_unchanged: [...] is non-emptyaffects_files glob matches **/*contract* / **/*protocol*meta.connector_seam: true{task_id} — looked up in {plans_dir}/tasks.yamlLoad the task definition from {plans_dir}/tasks.yaml. Extract
must_call_unchanged (preferred) or, if absent, compute the
external symbol set by AST-walking each file in affects_files
for cross-module imports.
For each Module.Class.method symbol in the set:
ast.parse against the file at HEAD (per Step 2's
contract snapshot logic).unresolved_symbols: [...] with the list
of methods actually present on the target class.signature_concerns: [...].Output: short YAML report at
{plans_dir}/preflight/{task_id}.yaml.
Exit code: 0 if clean (no unresolved or concerning entries),
1 if any entries.
task_id: T4M.3
generated_at: 2026-05-09T14:00:00Z
unresolved_symbols:
- target: autoservice.cc_pool.CCPool.acquire_for_session
referenced_in_spec: docs/superpowers/specs/2026-05-07-pipeline-v2-three-color-design.md §6
available_on_target: [acquire, acquire_sticky, acquire_async]
suggested_action: confirm with user — did you mean acquire_sticky?
signature_concerns:
- target: autoservice.cc_pool.CCPool.session_query
spec_implies: positional (conv_id, prompt, tenant_id, ...)
real_signature: (conv_id, prompt, *, tenant_id=None, ...)
issue: tenant_id is keyword-only after *
verdict: STOP — implementation must not proceed until unresolved_symbols is empty
When --preflight returns non-zero, skill-5-start-task halts the
task with the report content displayed to the user. The user resolves
by either updating the task spec or correcting the underlying
assumption.
Without preflight, the cdcfdb2 bug class (subagent invents a method
name; fake test double mirrors the invention; CI green; production
AttributeError) ships routinely. AutoService PV2 paid this cost for
~14 days on a single fix. See
references/ast-walk-template.md for the contract-test pattern that
preflight auto-suggests when no contract test covers the consumer
yet.
───────────────────────────────────────────────────── ⬆ /contract-check complete ─────────────────────────────────────────────────────
📋 Next: /task-status — Check overall progress /smoke-test {M} — Run milestone gate verification /start-task {ID} — Continue implementing tasks ─────────────────────────────────────────────────────