Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion doc/architecture/SPEC/engine/executor.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# engine/executor.py —— 规范

<!-- verified-against: 2026-08-25 -->
<!-- verified-against: 2026-09-26 -->

`LOC ~293 · 引擎底座(那个循环) · refactor-status: ok`

Expand All @@ -22,6 +22,8 @@ helper:`_eval_when`、`_merge`。
- **B2**:resume 在跳过之前先恢复 `outputs.state_updates`;成功后
`state.update(state_updates)` 并按 step id 索引输出。
- `_merge` 把每个 foreach 条目的 `state_updates` **提升**到顶层(同键最后写者胜)。
- `foreach` 中会写工作区、推送或写知识库的 step 顺序执行,共享 checkout 不并发修改;
只读 fan-out 仍可并发执行。
- **B3**:`when:` 先读 TaskSpec 再读 state;**未知键 → 阻塞,绝不静默**。
- **B1**:类型化路由;未处理的异常 → BLOCKED(**绝不吞掉**)。
- 只对 RETRYABLE 重试,受 `max_step_retries` 限制。
Expand Down
10 changes: 9 additions & 1 deletion doc/architecture/SPEC/engine/steps/pr.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# engine/steps/pr/ —— 规范

<!-- verified-against: 2026-09-22 -->
<!-- verified-against: 2026-09-26 -->

`LOC ~1390(6 个文件) · step 库(PR) · refactor-status: ok`

Expand All @@ -26,6 +26,14 @@
`pr.rebase_onto_base`、`agent.debug_group`(agent/write_workspace);
`agent.verify_module`(validation/read);`pr.post_review`(script/push)。

`pr-debug` 的 report-only 路径不 checkout PR 分支。诊断组数受 playbook 参数和
settings 安全上限共同约束;执行组共享 checkout 时必须顺序修改。`agent.debug_group`
只有在观察到新 commit、干净的 tracked checkout、根因和验证证据时才算成功。
`agent.verify_module` 的咨询结论是 `verified | problem | inconclusive`,未知文本
不能标成已验证。`ci.push` 对 PR debug/rebase 还需同一 HEAD 的 patch-gate 批准。
知识 intake 的 canonical 记录使用 schema v2、完整仓库名和稳定 event ID;缺少
完整身份时保留 v1 兼容记录,由消费者显式解析别名。

## 公开契约(可从 `engine.steps.pr` import)
`extract_signature`(由 `utils` 再导出;供测试使用)。

Expand Down
5 changes: 4 additions & 1 deletion doc/architecture/SPEC/engine/steps/review.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# engine/steps/review/ —— 规范

<!-- verified-against: 2026-09-22 -->
<!-- verified-against: 2026-09-26 -->

`LOC ~900(6 个文件) · step 库(评审) · refactor-status: ok`

## 职责
条件式 patch 门 + PR 评审 agent step + 有界的 PR review-readiness 质量 step,
以及它们仓库中立的 prompt 体系。
Pre-push patch 门必须审查显式 base 到当前 HEAD 的已提交变更;空范围、脏的
tracked checkout、不可解析的 ref 或超过 reviewer 容量的 diff 都阻塞推送。
通过后记录批准的 base/head,后续推送必须验证当前 HEAD 仍匹配。
它曾是一个 341 行的模块;现在是一个把评测调优过的 prompt 数据、handler、
确定性 helper 三者分开的包。

Expand Down
4 changes: 3 additions & 1 deletion doc/architecture/SPEC/playbooks/PLAYBOOKS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# playbooks/*.yaml —— 规范

<!-- verified-against: 2026-08-31 -->
<!-- verified-against: 2026-09-26 -->

`9 个文件 · 声明式编排数据 · refactor-status: ok`

Expand All @@ -23,6 +23,8 @@ success, steps[]`。
**不要改它的 step 列表**。
- `pr-rebase`/`pr-debug`/`pr-review`/`pr-quality`/`issue-answer`/`issue-triage` —— active,
仓库中立(`repos: []`、`requires: [repo.path]`)。
- `pr-debug` 的 report-only 模式跳过 checkout 和 push;修复模式在推送前审查
已提交的变更。
- `repo-profile` —— active,仓库中立(用于接入第二个仓库)。
- `profile-consolidate` —— **candidate**
(planner 不可见;只能经 `--playbook` 运行)。
Expand Down
3 changes: 2 additions & 1 deletion doc/architecture/SPEC/playbooks/store.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# playbooks/store.py —— 规范

<!-- verified-against: 2026-08-18 -->
<!-- verified-against: 2026-09-26 -->

`LOC ~166 · 规划(注册表) · refactor-status: ok`

Expand All @@ -21,6 +21,7 @@
locked > active;高版本 > 低版本。
- `validate` 拒绝引用了未注册 step 的 playbook(**加载即失败**)。
- `save_candidate` 强制 `status=candidate`(**D1** —— 不允许自我晋升)。
- `playbook_to_doc` 保留 `mode_aware`,candidate 的序列化/解析往返不能改变模式门。

## 边界 —— 不属于这里
不执行、不含规划策略(那是 planner 的)、不含 step 逻辑。
Expand Down
4 changes: 3 additions & 1 deletion doc/architecture/SPEC/profiles/store.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# profiles/store.py —— 规范

<!-- verified-against: 2026-08-18 -->
<!-- verified-against: 2026-09-26 -->

`LOC ~268 · profile(精选层) · refactor-status: ok`

Expand All @@ -16,6 +16,8 @@
## 不变量
- op 是**唯一**的变更路径;畸形/被禁的 op **逐条**拒绝(绝不抛异常);档位不对的 op
被拒(**D4**)。
- 未知 tier、非 object op、非 `list[str]` 的证据均只拒绝对应 op;同批后续合法 op
仍可应用。
- `add_fact` 需要正文 + 证据;重复 id 视为一次**确认**(**D3**)。
- `rewrite_fact` 绝不让某条 fact 变成无证据;**稳定**(≥3 次确认)的 fact 不得丢弃已
引用的证据;被取代的正文进 `history`(**D3**)。
Expand Down
16 changes: 16 additions & 0 deletions doc/architecture/SPEC/review/change_set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# review/change_set.py —— 规范

<!-- verified-against: 2026-09-26 -->

`ChangeSet.capture(repo, base_ref)` freezes the committed range that a PR
mutation workflow intends to push. The base must be explicit and resolve to a
commit. `HEAD` is resolved once; the returned `(base_sha, head_sha, diff_text)`
is immutable. The diff includes binary changes and disables external diff
drivers. A tracked dirty checkout, unresolved ref, or failed diff raises
`ChangeSetError` so the pre-push review blocks rather than approving an
uninspected change. Untracked files are not part of a commit and are excluded.

The patch gate rejects an empty committed range and diffs over its reviewer
budget, then records approval for the exact base and head only after a passing
review. `ci.push` compares the approved head with the current checkout head
before any PR debug/rebase push. A later commit requires another review.
7 changes: 4 additions & 3 deletions doc/architecture/SPEC/sdk.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# sdk/ —— 规范

<!-- verified-against: 2026-09-22 -->
<!-- verified-against: 2026-09-26 -->


`Python SDK v1 · 跨仓库唯一 typed 边界 · refactor-status: ok`
Expand Down Expand Up @@ -54,8 +54,9 @@
- **Strict findings 绑定发布结果**:`get_result` 的 `findings` 对最终发布集合逐条返回
`finding_id`、最终 `severity`、`anchor` 与 `head_sha`,保持顺序及重复 identity。
identity 以文件路径与完整归一化文本生成,行号变动不改变 identity;它不与
`finding_dispositions` 按 anchor 拼接。当前没有 carried-finding recheck,缺失旧
finding 不代表已修复;不得承诺 `head_recheck` 或 `findings_missing_carried` 字段。
`finding_dispositions` 按 anchor 拼接。Direct 1.1 / Strict 1.3 对传入的
carried finding 返回独立、绑定当前 head 的显式 `finding_rechecks`;缺失或
`unverified` 的答案不代表已修复,详见下方 carried-finding recheck 契约。
- **知识规则由 provider 唯一定义**:catalog 只暴露当前仓库 owner 与 general 的
owner rule page document ID —— 每个 owner 的 `rules.md` 入口页及其
`type: rule` 的 `rules-<topic>.md` 专题页(`catalog_entries()` 附每页
Expand Down
2 changes: 1 addition & 1 deletion playbooks/pr-debug.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ params:
max_groups: {type: int, default: 6}
steps:
- {id: guard, step: workspace.guard_clean}
- {id: checkout, step: pr.checkout_branch, params: {force_push: false}}
- {id: checkout, step: pr.checkout_branch, params: {force_push: false}, when: not report_only}
- {id: fetch_ci, step: pr.fetch_ci_failures}
- {id: group, step: pr.group_failures}
- {id: debug, step: agent.debug_group, foreach: failure_groups, when: not report_only}
Expand Down
14 changes: 10 additions & 4 deletions src/infermatrix_copilot/engine/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,16 @@ async def run(self, playbook: "Playbook", state: dict) -> RunOutcome:
# safety-bearing (`force_push`, `pre_push`) — so they override the
# run-scoped ones rather than the other way round.
step_params = {**task_params, **pstep.params}
results = await asyncio.gather(
*(self._run_step(spec, step_params, state, item, pstep.id)
for item in items)
)
if spec.risk in {"write_workspace", "push", "knowledge"}:
# Foreach items share one checkout and run state. A writer must
# finish before the next item can edit or commit that checkout.
results = [await self._run_step(spec, step_params, state, item, pstep.id)
for item in items]
else:
results = await asyncio.gather(
*(self._run_step(spec, step_params, state, item, pstep.id)
for item in items)
)
result = _merge(results)
outcome.step_results[pstep.id] = result
self.trace.record(
Expand Down
37 changes: 34 additions & 3 deletions src/infermatrix_copilot/engine/steps/pr/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ...step import FailureKind, StepContext, StepResult
from .._common import from_state, published, record_debug_memory, require_repo, step
from .._common import gh as _gh
from .._common import git as _git
from .._common import repo_path as _repo_path
from .._common import task_spec as _task_spec
from .utils import extract_signature
Expand Down Expand Up @@ -117,7 +118,17 @@ async def _pr_group_failures(ctx: StepContext) -> StepResult:
if f.get("log") and not g["log_excerpt"]:
g["log_excerpt"] = f["log"][-4_000:]
group_list = list(groups.values())
max_groups = ctx.settings.pr_debug_max_groups
requested = ctx.params.get("max_groups")
try:
max_groups = (ctx.settings.pr_debug_max_groups if requested is None
else int(requested))
except (TypeError, ValueError):
return StepResult(False, FailureKind.BLOCKED,
"max_groups must be a positive integer")
if max_groups < 1:
return StepResult(False, FailureKind.BLOCKED,
"max_groups must be a positive integer")
max_groups = min(max_groups, ctx.settings.pr_debug_max_groups)
if len(group_list) > max_groups:
return StepResult(False, FailureKind.ESCALATE,
f"{len(group_list)} distinct failure groups exceeds the "
Expand Down Expand Up @@ -154,6 +165,10 @@ async def _pr_debug_group(ctx: StepContext) -> StepResult:
repo = require_repo(ctx, must_exist=False)
if isinstance(repo, StepResult):
return repo
before_rc, before_head = _git(repo, "rev-parse", "HEAD")
if before_rc != 0:
return StepResult(False, FailureKind.BLOCKED,
f"cannot identify checkout head before debug: {before_head[:300]}")
from ...agent_runtime import run_agent_step

result, output = await run_agent_step(
Expand All @@ -172,6 +187,14 @@ async def _pr_debug_group(ctx: StepContext) -> StepResult:
max_iters=ctx.settings.max_agent_iters,
)
if result.ok:
after_rc, after_head = _git(repo, "rev-parse", "HEAD")
dirty_rc, dirty = _git(repo, "status", "--porcelain", "--untracked-files=no")
if (after_rc != 0 or dirty_rc != 0 or before_head == after_head
or dirty.strip() or not str(output.get("root_cause") or "").strip()
or not str(output.get("verification") or "").strip()):
return StepResult(False, FailureKind.BLOCKED,
f"'{sig}': fix needs an observed commit, clean tracked "
"checkout, root cause and verification")
result.summary = (f"'{sig}': {output.get('fix_summary', '')[:150]} "
f"(verified: {output.get('verification', '?')[:80]})")
if output.get("root_cause") and output.get("verification"):
Expand Down Expand Up @@ -234,17 +257,25 @@ async def _pr_harvest_debug_knowledge(ctx: StepContext) -> StepResult:
return StepResult(True, summary="no verified fixes to harvest")
spec = ctx.state.get("task_spec") or {}
alias = str(spec.get("repo", ""))
full_name = ctx.settings.repo_full_names.get(alias, alias)
# Older installations may only know a local alias. Keep their v1 record
# readable, but never claim a canonical identity that was not configured.
canonical = isinstance(full_name, str) and full_name.count("/") == 1
record = {
"schema_version": 2 if canonical else 1,
"run_id": ctx.run_dir.name,
# Full GitHub identity when configured, so the consumer keys the
# event correctly; the local alias otherwise.
"repo": ctx.settings.repo_full_names.get(alias, alias),
"repo": full_name,
"pr": spec.get("pr"),
"kind": "bugfix_run",
"title": f"pr_debug fixes for PR #{spec.get('pr')}",
"groups": fixes,
"created_at": datetime.now(timezone.utc).isoformat(),
}
if canonical:
record["event_id"] = f"bugfix_run:{full_name.casefold()}:{ctx.run_dir.name}"
record["repository"] = {"full_name": full_name, "alias": alias}
# Two independent sinks: the local drop (same-host consumer) and the
# mailbox issue (cross-host consumer). Each is attempted when
# configured, and neither one's failure suppresses the other.
Expand Down Expand Up @@ -285,7 +316,7 @@ async def _pr_harvest_debug_knowledge(ctx: StepContext) -> StepResult:
outputs=outputs)


BUGFIX_RECORD_MARKER = "<!-- infermatrix-copilot:bugfix-record:v1 -->"
BUGFIX_RECORD_MARKER = "<!-- infermatrix-copilot:bugfix-record:v2 -->"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the v1 marker for legacy records

When repo_full_names lacks an alias, the producer deliberately emits a schema-v1 record without event_id or repository, but _publish_intake_record now labels every record with the v2 marker. Legacy mailbox consumers scan for the documented v1 marker (doc/RFC-knowledge-intake.md:219), while a v2 parser may require the new identity fields, so these compatibility records are silently skipped or rejected. Select the marker from the record's schema_version rather than using one unconditional v2 constant.

Useful? React with 👍 / 👎.

_INTAKE_ISSUE = re.compile(r"^(?P<slug>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)#(?P<number>[1-9][0-9]*)$")


Expand Down
11 changes: 11 additions & 0 deletions src/infermatrix_copilot/engine/steps/pr/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ async def _push(ctx: StepContext) -> StepResult:
decision = guard_push(policy, list(protected))
if not decision.allowed:
return StepResult(False, FailureKind.FORBIDDEN, decision.reason)
if _task_spec(ctx).get("kind") in {"pr_debug", "pr_rebase"}:
from ....review.change_set import ChangeSet, ChangeSetError

approval = ctx.state.get("approved_change_set") or {}
try:
current = ChangeSet.capture(repo, str(approval.get("base_sha") or ""))
except ChangeSetError as exc:
return StepResult(False, FailureKind.FORBIDDEN, f"pre-push approval unavailable: {exc}")
if current.head_sha != approval.get("head_sha"):
return StepResult(False, FailureKind.FORBIDDEN,
"PR head changed after patch review; review the new commit")
if not ctx.settings.allow_push:
return StepResult(True, summary=f"dry-run (ALLOW_PUSH=0): {' '.join(decision.command)}",
outputs={"dry_run": True, "command": list(decision.command)})
Expand Down
19 changes: 15 additions & 4 deletions src/infermatrix_copilot/engine/steps/pr/rebase.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ async def _pr_checkout(ctx: StepContext) -> StepResult:
rc, out = _git(repo, "checkout", "-B", local, "FETCH_HEAD")
if rc != 0:
return StepResult(False, FailureKind.BLOCKED, f"checkout failed: {out[:400]}")
rc, initial_head_sha = _git(repo, "rev-parse", "HEAD")
if rc != 0:
return StepResult(False, FailureKind.BLOCKED, f"cannot identify PR head: {initial_head_sha[:400]}")

force = bool(ctx.params.get("force_push", False))
policy = PushPolicy(
Expand All @@ -72,7 +75,8 @@ async def _pr_checkout(ctx: StepContext) -> StepResult:
)
ctx.state.update(
pr_head_ref=head_ref, pr_head_remote=remote, pr_base_branch=base,
pr_local_branch=local, push_policy=policy,
pr_local_branch=local, pr_initial_head_sha=initial_head_sha,
push_policy=policy,
)
return StepResult(True, summary=f"checked out PR #{pr} ({remote}/{head_ref} -> {local})",
outputs={"local_branch": local, "base": base, "remote": remote,
Expand All @@ -81,6 +85,7 @@ async def _pr_checkout(ctx: StepContext) -> StepResult:
"state_updates": {
"pr_head_ref": head_ref, "pr_head_remote": remote,
"pr_base_branch": base, "pr_local_branch": local,
"pr_initial_head_sha": initial_head_sha,
"push_policy": asdict(policy),
}})

Expand Down Expand Up @@ -247,6 +252,12 @@ async def _verify_module(ctx: StepContext) -> StepResult:
input_tokens=reply.usage.get("input_tokens", 0),
output_tokens=reply.usage.get("output_tokens", 0))
text = reply.text.strip()
if text.upper().startswith("PROBLEM"):
return StepResult(False, FailureKind.REPLAN, f"{module}: {text[:400]}")
return StepResult(True, summary=f"{module}: verified")
if text.upper().startswith("PROBLEM:"):
return StepResult(False, FailureKind.REPLAN, f"{module}: {text[:400]}",
outputs={"verification_status": "problem"})
if text.upper() == "OK" or text.upper().startswith("OK:"):
return StepResult(True, summary=f"{module}: verified",
outputs={"verification_status": "verified"})
return StepResult(True, summary=f"{module}: advisory result inconclusive",
outputs={"verification_status": "inconclusive",
"advisory_reply": text[:400]})
Loading
Loading