agentflow-proof 1.2.0__tar.gz → 1.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/CHANGELOG.md +57 -1
  2. {agentflow_proof-1.2.0/src/agentflow_proof.egg-info → agentflow_proof-1.2.1}/PKG-INFO +1 -1
  3. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/pyproject.toml +1 -1
  4. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/__init__.py +1 -1
  5. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/cli.py +2 -5
  6. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/execution.py +3 -0
  7. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/execution_coverage.py +114 -36
  8. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/hunks.py +9 -0
  9. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/porcelain.py +117 -20
  10. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1/src/agentflow_proof.egg-info}/PKG-INFO +1 -1
  11. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_cli.py +37 -0
  12. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_execution_state.py +15 -0
  13. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_execution_verification.py +66 -0
  14. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_porcelain.py +495 -1
  15. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/LICENSE +0 -0
  16. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/MANIFEST.in +0 -0
  17. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/README.md +0 -0
  18. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/setup.cfg +0 -0
  19. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/__main__.py +0 -0
  20. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/aggregate.py +0 -0
  21. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/artifacts.py +0 -0
  22. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/capabilities.py +0 -0
  23. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/cli_contract.py +0 -0
  24. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/contracts.py +0 -0
  25. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/coverage.py +0 -0
  26. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/draft_plan.py +0 -0
  27. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/events.py +0 -0
  28. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/git.py +0 -0
  29. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/handoff.py +0 -0
  30. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/locks.py +0 -0
  31. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/mcp_server.py +0 -0
  32. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/packs.py +0 -0
  33. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/proof.py +0 -0
  34. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/receipts.py +0 -0
  35. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/recommend.py +0 -0
  36. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/review.py +0 -0
  37. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/review_runner.py +0 -0
  38. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/risk.py +0 -0
  39. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/runtime.py +0 -0
  40. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/stuck.py +0 -0
  41. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/validation.py +0 -0
  42. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/versioning.py +0 -0
  43. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/viewer.py +0 -0
  44. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow/workflow_contract.py +0 -0
  45. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/SOURCES.txt +0 -0
  46. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/dependency_links.txt +0 -0
  47. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/entry_points.txt +0 -0
  48. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/src/agentflow_proof.egg-info/top_level.txt +0 -0
  49. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_aggregate.py +0 -0
  50. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_ai_kit.py +0 -0
  51. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_artifact_versioning.py +0 -0
  52. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_build_zipapp.py +0 -0
  53. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_capabilities.py +0 -0
  54. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_ci_proof_bundle.py +0 -0
  55. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_config_contract.py +0 -0
  56. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_distribution.py +0 -0
  57. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_draft_plan.py +0 -0
  58. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_events.py +0 -0
  59. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_example_aggregation.py +0 -0
  60. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_example_mcp.py +0 -0
  61. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_example_workflow_pack.py +0 -0
  62. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_examples.py +0 -0
  63. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_execution_contract.py +0 -0
  64. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_handoff.py +0 -0
  65. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_hunks.py +0 -0
  66. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_lease_enforcement.py +0 -0
  67. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_lease_locking.py +0 -0
  68. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_mcp_server.py +0 -0
  69. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_packs.py +0 -0
  70. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_proof.py +0 -0
  71. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_proof_compatibility.py +0 -0
  72. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_public_project_contract.py +0 -0
  73. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_receipt_concurrency.py +0 -0
  74. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_receipts.py +0 -0
  75. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_recommend.py +0 -0
  76. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_release.py +0 -0
  77. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_review.py +0 -0
  78. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_review_runner.py +0 -0
  79. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_risk.py +0 -0
  80. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_runtime.py +0 -0
  81. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_schema_contracts.py +0 -0
  82. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_schema_soak.py +0 -0
  83. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_stability_policy.py +0 -0
  84. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_stop_gate.py +0 -0
  85. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_stuck.py +0 -0
  86. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_versioning.py +0 -0
  87. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_view_proof.py +0 -0
  88. {agentflow_proof-1.2.0 → agentflow_proof-1.2.1}/tests/test_workflow_contract.py +0 -0
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.1] - 2026-11-02
11
+
12
+ Agentflow 1.2.1 fixes three cases where `next-action` sent an agent the wrong
13
+ way. It no longer asks a step to record a file again when the file already has
14
+ a matching receipt from another step or attempt (#97). It guides an edit made
15
+ after a step completed to `amend-step` instead of a `finish-run` that always
16
+ fails (#73). It reports a ledger change only when a fresh read of the ledgers
17
+ shows one, so a deterministic bug no longer looks like a race (#94). No state,
18
+ recovery action, artifact schema, schema-version constant,
19
+ `docs/cli-contract.json` value or exit-code change.
20
+
21
+ ### Fixed
22
+
23
+ - `next-action` no longer asks a step to record a changed file that already has
24
+ a matching receipt. A file in the step's scope counts as recorded when its
25
+ latest file receipt, from any step or attempt, matches the file as it is now
26
+ (a deleted file needs a deleted receipt), the check `verify-run` makes. It
27
+ used to require a receipt from the open attempt itself. When two steps
28
+ listed the same file, following it copied the first step's hunks into a new
29
+ receipt and made the second step the file's latest owner in the proof, and
30
+ an amendment had to record again files its step had already recorded. A
31
+ file the open attempt recorded and then edited again is now
32
+ `file_receipts_missing`; `next-action` used to send it to `finish-step`, and
33
+ `finish-run` then failed. Enforced hunk attribution also requires the
34
+ current hunks to be recorded, even when the content hash still matches
35
+ after a staged rename or file type change. Working-tree inspection errors
36
+ return blocking `state_invalid` diagnostics instead of a traceback. No
37
+ state, recovery action, schema or exit-code change. (#97)
38
+ - `next-action` guides an edit made after a step completed to `amend-step`
39
+ instead of a `finish-run` that always fails. When every changed file with no
40
+ matching receipt is in exactly one step's effective scope and drift reports
41
+ no out-of-scope or blocked file, it reports `step_amendment_needed` with
42
+ `amend-step <step> --reason 'record post-completion edit to <path>'`. The
43
+ `amend` action is `automatic: false` while any changed file lacks a matching
44
+ receipt, for timeout, failed and strict amendments too, because receipts
45
+ cannot show who made the edit. Otherwise `drift_failing` and `run_unverified`
46
+ name each path and the step that could record it. `drift_failing` now lists
47
+ blocked files and unmapped hunks (path, header and fingerprint) as well as
48
+ out-of-scope files, and its reason names the categories present; it used to
49
+ say "changes fall outside plan scope" with no diagnostics for an unmapped
50
+ hunk. `record-file-change`, `run` and `verify-step` on a completed step with
51
+ no open attempt say to open one with `amend-step` instead of `claim-step`. A
52
+ git or file error while inspecting changed files reports `state_invalid`. No
53
+ state, recovery action, schema or exit-code change. (#73)
54
+ - `next-action` reports "execution ledgers changed during next-action; run it
55
+ again" only when the ledgers really changed. When a check after the
56
+ validated snapshot raises, `next-action` reads the step, command, file
57
+ receipt and verification ledgers again. It reports `state_invalid` only if
58
+ they differ from the snapshot or cannot be read; otherwise the original
59
+ error propagates with its traceback. It used to report any `AttributeError`,
60
+ `KeyError`, `TypeError`, `OSError` or `ValueError` raised while choosing the
61
+ action as a ledger change, on the claim path too, so a deterministic bug
62
+ looked like a race and the rerun it asked for gave the same answer every
63
+ time. (#94)
64
+
10
65
  ## [1.2.0] - 2026-10-29
11
66
 
12
67
  Agentflow 1.2.0 shows why a run is stuck. `next-step --explain` reports why
@@ -347,7 +402,8 @@ repository, so this heading is intentionally unlinked._
347
402
 
348
403
  - Existing v0.2 proof artifacts remain valid when no execution contract exists.
349
404
 
350
- [Unreleased]: https://github.com/kstruzzieri/agentflow/compare/v1.2.0...HEAD
405
+ [Unreleased]: https://github.com/kstruzzieri/agentflow/compare/v1.2.1...HEAD
406
+ [1.2.1]: https://github.com/kstruzzieri/agentflow/compare/v1.2.0...v1.2.1
351
407
  [1.2.0]: https://github.com/kstruzzieri/agentflow/compare/v1.1.1...v1.2.0
352
408
  [1.1.1]: https://github.com/kstruzzieri/agentflow/compare/v1.1.0...v1.1.1
353
409
  [1.1.0]: https://github.com/kstruzzieri/agentflow/compare/v1.0.0...v1.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentflow-proof
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: A model-agnostic workflow runner for drift-resistant agentic development.
5
5
  Author: Agentflow contributors
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentflow-proof"
7
- version = "1.2.0"
7
+ version = "1.2.1"
8
8
  description = "A model-agnostic workflow runner for drift-resistant agentic development."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "1.2.0"
5
+ __version__ = "1.2.1"
@@ -47,6 +47,7 @@ from .execution_coverage import verify_run, verify_step
47
47
  from .events import filter_events_since, project_events, valid_since
48
48
  from .capabilities import append_capability_receipt, build_capability_receipt
49
49
  from .handoff import export_handoff, lint_handoff_text
50
+ from .hunks import format_unmapped_hunk
50
51
  from . import porcelain
51
52
  from .locks import file_lock
52
53
  from .packs import (
@@ -639,11 +640,7 @@ def command_audit_drift(args: argparse.Namespace) -> int:
639
640
  hunk_lines = []
640
641
  for entry in report["unmapped_hunks"]:
641
642
  if isinstance(entry, dict):
642
- hunk_lines.append(
643
- f"- {entry['path']} @@ -{entry['old_start']},{entry['old_count']} "
644
- f"+{entry['new_start']},{entry['new_count']} @@ "
645
- f"{entry['hash'][:12]} ({entry['reason']})"
646
- )
643
+ hunk_lines.append(f"- {format_unmapped_hunk(entry)}")
647
644
  else:
648
645
  hunk_lines.append(f"- {entry}")
649
646
  print_lines([_terminal_safe(line) for line in hunk_lines])
@@ -604,6 +604,9 @@ def resolve_attempt(root: Path, step_id: str, attempt_id: Optional[str]) -> str:
604
604
  return open_attempts[0]
605
605
  if len(open_attempts) > 1:
606
606
  raise ValueError(f"multiple open attempts for {step_id}; pass --attempt")
607
+ if state["steps"].get(step_id, {}).get("completed"):
608
+ # #73: claim-step refuses a completed step, so point at the command that works.
609
+ raise ValueError(f"step {step_id} has completed work and no open attempt; open one with amend-step")
607
610
  raise ValueError(f"{step_id} has no open attempt; run claim-step first")
608
611
 
609
612
 
@@ -21,7 +21,7 @@ from .execution import (
21
21
  receipt_timed_out,
22
22
  resolve_attempt,
23
23
  )
24
- from .git import changed_file_records
24
+ from .git import changed_file_records, is_git_repo
25
25
  from .hunks import effective_hunk_policy, unmapped_hunks
26
26
  from .receipts import (
27
27
  command_receipts,
@@ -381,7 +381,7 @@ def _ignored(path: str, prefixes: List[str]) -> bool:
381
381
  return any(path.startswith(prefix) for prefix in prefixes)
382
382
 
383
383
 
384
- def _latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
384
+ def latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
385
385
  latest: Dict[str, Dict[str, Any]] = {}
386
386
  for receipt in file_receipts(root):
387
387
  path = receipt.get("path")
@@ -390,11 +390,30 @@ def _latest_file_receipts_by_path(root: Path) -> Dict[str, Dict[str, Any]]:
390
390
  return latest
391
391
 
392
392
 
393
+ def receipt_matches_worktree(root: Path, record: Dict[str, str], receipt: Dict[str, Any]) -> bool:
394
+ """True when receipt records record's changed path as it is now.
395
+
396
+ verify-run and next-action (#97) share this check.
397
+ """
398
+ if record["change_kind"] == "deleted":
399
+ return receipt.get("change_kind") == "deleted"
400
+ return receipt.get("after_sha256") == sha256_path(root / record["path"])
401
+
402
+
393
403
  def _reconcile_changed_files(
394
404
  root: Path,
395
405
  plan: Dict[str, Any],
396
406
  require_file_receipts: bool = True,
397
407
  ) -> List[Dict[str, str]]:
408
+ return [finding for _path, finding in _changed_file_pairs(root, plan, require_file_receipts)]
409
+
410
+
411
+ def _changed_file_pairs(
412
+ root: Path,
413
+ plan: Dict[str, Any],
414
+ require_file_receipts: bool = True,
415
+ ) -> List[Tuple[str, Dict[str, str]]]:
416
+ """(path, finding) for each changed file verify-run flags (#73 needs the path)."""
398
417
  contract: Dict[str, Any] = {}
399
418
  contract_path = root / ".agent/execution.contract.json"
400
419
  if contract_path.exists():
@@ -405,8 +424,8 @@ def _reconcile_changed_files(
405
424
  if contract is None:
406
425
  contract = {}
407
426
  ignore = contract.get("concurrency", {}).get("reconcile_ignore", [".agent/", ".git/"])
408
- latest = _latest_file_receipts_by_path(root)
409
- findings: List[Dict[str, str]] = []
427
+ latest = latest_file_receipts_by_path(root)
428
+ findings: List[Tuple[str, Dict[str, str]]] = []
410
429
  for record in changed_file_records(root):
411
430
  path = record["path"]
412
431
  if _ignored(path, ignore):
@@ -415,25 +434,19 @@ def _reconcile_changed_files(
415
434
  if receipt is None:
416
435
  if not require_file_receipts:
417
436
  continue
418
- findings.append(
419
- {
420
- "severity": "error",
421
- "message": f"changed file is not mapped to a step receipt: {path}",
422
- }
423
- )
437
+ findings.append((path, {
438
+ "severity": "error",
439
+ "message": f"changed file is not mapped to a step receipt: {path}",
440
+ }))
424
441
  continue
425
- if record["change_kind"] == "deleted":
426
- if receipt.get("change_kind") != "deleted":
427
- findings.append(
428
- {"severity": "error", "message": f"deleted file lacks deleted receipt: {path}"}
429
- )
442
+ if receipt_matches_worktree(root, record, receipt):
430
443
  continue
431
- expected_hash = receipt.get("after_sha256")
432
- actual_hash = sha256_path(root / path)
433
- if expected_hash != actual_hash:
434
- findings.append(
435
- {"severity": "error", "message": f"out-of-band edit after latest file receipt: {path}"}
436
- )
444
+ message = (
445
+ f"deleted file lacks deleted receipt: {path}"
446
+ if record["change_kind"] == "deleted"
447
+ else f"out-of-band edit after latest file receipt: {path}"
448
+ )
449
+ findings.append((path, {"severity": "error", "message": message}))
437
450
  if effective_hunk_policy(root) == "enforce":
438
451
  # Scope-filter candidates the same way audit_drift does, so verify-run
439
452
  # and audit-drift cannot diverge on which paths are hunk-checked.
@@ -447,16 +460,14 @@ def _reconcile_changed_files(
447
460
  and not matches_path(record["path"], blocked)
448
461
  ]
449
462
  for entry in unmapped_hunks(root, candidates):
450
- findings.append(
451
- {
452
- "severity": "error",
453
- "message": (
454
- f"unmapped hunk in {entry['path']} "
455
- f"(@@ -{entry['old_start']},{entry['old_count']} "
456
- f"+{entry['new_start']},{entry['new_count']} @@): no matching file receipt"
457
- ),
458
- }
459
- )
463
+ findings.append((entry["path"], {
464
+ "severity": "error",
465
+ "message": (
466
+ f"unmapped hunk in {entry['path']} "
467
+ f"(@@ -{entry['old_start']},{entry['old_count']} "
468
+ f"+{entry['new_start']},{entry['new_count']} @@): no matching file receipt"
469
+ ),
470
+ }))
460
471
  return findings
461
472
 
462
473
 
@@ -672,15 +683,25 @@ def _timeout_receipt_findings(
672
683
 
673
684
 
674
685
  def replacement_needs(
675
- root: Path, plan: Dict[str, Any], strict: bool = False
686
+ root: Path,
687
+ plan: Dict[str, Any],
688
+ strict: bool = False,
689
+ edits: Optional[Dict[str, Any]] = None,
676
690
  ) -> List[Dict[str, Any]]:
677
691
  """Plan-order completed steps that need a replacement attempt, with causes.
678
692
 
679
693
  Mirrors the verify_run errors a replacement attempt would clear: a
680
694
  recoverable timed-out receipt, a failed latest attempt, and (effective
681
695
  strict) a latest attempt that is not completed. attempt_id/status are the
682
- step's latest non-lease ledger event.
696
+ step's latest non-lease ledger event. #73: when post_completion_edits
697
+ offers them, each unreceipted change is a post_completion_edit cause on
698
+ its one candidate step, after that step's other causes.
683
699
  """
700
+ edit_causes: Dict[str, List[Dict[str, Any]]] = {}
701
+ if edits and edits.get("offered"):
702
+ for pair in edits["pairs"]:
703
+ [owner] = edits["candidates"][pair["path"]]
704
+ edit_causes.setdefault(owner, []).append({"cause": "post_completion_edit", **pair})
684
705
  effective_strict = strict or bool(_proof_policy(root).get("strict_by_default"))
685
706
  state = read_step_state(root)
686
707
  latest: Dict[str, Dict[str, Any]] = {}
@@ -716,6 +737,7 @@ def replacement_needs(
716
737
  causes.append({"cause": "failed_attempt"})
717
738
  if effective_strict and status != "completed":
718
739
  causes.append({"cause": "strict_incomplete"})
740
+ causes.extend(edit_causes.get(step_id, []))
719
741
  if causes:
720
742
  needs.append({
721
743
  "step_id": step_id,
@@ -726,6 +748,64 @@ def replacement_needs(
726
748
  return needs
727
749
 
728
750
 
751
+ def _requires_file_receipts(root: Path, strict: bool) -> bool:
752
+ policy = _proof_policy(root)
753
+ return (
754
+ strict
755
+ or bool(policy.get("strict_by_default"))
756
+ or bool(policy.get("require_file_receipts_for_changed_files", True))
757
+ )
758
+
759
+
760
+ def post_completion_edits(
761
+ root: Path, plan: Dict[str, Any], strict: bool = False
762
+ ) -> Dict[str, Any]:
763
+ """#73: changed files verify-run flags, and whether one step can absorb them.
764
+
765
+ For the selector's terminal branch only (no open attempt, nothing
766
+ eligible). Read-only. A path's candidates are the plan steps whose
767
+ effective scope holds it, the check record-file-change applies; that
768
+ permits recording, it does not prove who edited. offered is True only
769
+ when every path has exactly one candidate, drift has no out-of-scope or
770
+ blocked file, and nothing makes the run permanently unprovable. Git and
771
+ file errors propagate: the caller fails closed.
772
+ """
773
+ edits: Dict[str, Any] = {
774
+ "pairs": [], "candidates": {}, "drift": None,
775
+ "permanent": False, "scope_violation": False, "offered": False,
776
+ }
777
+ if not is_git_repo(root):
778
+ return edits
779
+ pairs = _changed_file_pairs(root, plan, _requires_file_receipts(root, strict))
780
+ if not pairs:
781
+ return edits
782
+ step_ids = [
783
+ step["id"] for step in plan.get("steps", [])
784
+ if isinstance(step, dict) and isinstance(step.get("id"), str)
785
+ ]
786
+ edits["pairs"] = [{"path": path, "message": finding["message"]} for path, finding in pairs]
787
+ for path, _finding in pairs:
788
+ if path not in edits["candidates"]:
789
+ edits["candidates"][path] = [
790
+ step_id for step_id in step_ids if path_in_effective_scope(plan, step_id, path)
791
+ ]
792
+ edits["permanent"] = bool(_terminal_attempt_violations(root)) or any(
793
+ receipt_state == "unrecoverable"
794
+ for _receipt, receipt_state, _by in timeout_receipt_states(root, plan)
795
+ )
796
+ if edits["permanent"]:
797
+ return edits
798
+ drift = audit_drift(root, plan)
799
+ edits["drift"] = drift
800
+ edits["scope_violation"] = bool(
801
+ drift.get("out_of_scope_files") or drift.get("blocked_files_changed")
802
+ )
803
+ edits["offered"] = not edits["scope_violation"] and all(
804
+ len(candidates) == 1 for candidates in edits["candidates"].values()
805
+ )
806
+ return edits
807
+
808
+
729
809
  def _unresolved_finding_refs(root: Path) -> List[Dict[str, str]]:
730
810
  """Flag amendment finding refs whose review run is not recorded.
731
811
 
@@ -792,9 +872,7 @@ def verify_run(
792
872
  result: Dict[str, Any] = {"status": "passed", "errors": [], "warnings": []}
793
873
  policy = _proof_policy(root)
794
874
  effective_strict = strict or bool(policy.get("strict_by_default"))
795
- require_file_receipts = effective_strict or bool(
796
- policy.get("require_file_receipts_for_changed_files", True)
797
- )
875
+ require_file_receipts = _requires_file_receipts(root, strict)
798
876
  for error in validate_plan(plan):
799
877
  result["errors"].append({"severity": "error", "message": error})
800
878
  drift = audit_drift(root, plan)
@@ -243,3 +243,12 @@ def unmapped_hunks(root: Path, records: List[Dict[str, Any]]) -> List[Dict[str,
243
243
  }
244
244
  )
245
245
  return out
246
+
247
+
248
+ def format_unmapped_hunk(entry: Dict[str, Any]) -> str:
249
+ """One unmapped_hunks entry as audit-drift prints it (#73: shared with next-action)."""
250
+ return (
251
+ f"{entry['path']} @@ -{entry['old_start']},{entry['old_count']} "
252
+ f"+{entry['new_start']},{entry['new_count']} @@ "
253
+ f"{entry['hash'][:12]} ({entry['reason']})"
254
+ )
@@ -38,12 +38,16 @@ from .execution import (
38
38
  validate_execution_contract,
39
39
  )
40
40
  from .execution_coverage import (
41
+ latest_file_receipts_by_path,
42
+ post_completion_edits,
43
+ receipt_matches_worktree,
41
44
  replacement_needs,
42
45
  timeout_receipt_states,
43
46
  verify_run,
44
47
  verify_step,
45
48
  )
46
49
  from .git import changed_file_records
50
+ from .hunks import effective_hunk_policy, format_unmapped_hunk, unmapped_hunks
47
51
  from .proof import verify_proof
48
52
  from .receipts import command_receipts, file_receipts, sha256_path
49
53
  from .validation import audit_drift, path_in_effective_scope, validate_plan
@@ -165,7 +169,8 @@ class _Selection:
165
169
  recorded_blockers is set (possibly []) only on the clean returns after the
166
170
  step-runs ledger has been validated; None keeps the recorded_blockers key
167
171
  absent from next-action. ledgers is the validated snapshot they came from,
168
- set on the same returns.
172
+ set on the same returns. edits is the #73 post_completion_edits result,
173
+ filled with needs, so projection and rendering share one inspection.
169
174
  """
170
175
 
171
176
  kind: Optional[str]
@@ -177,6 +182,7 @@ class _Selection:
177
182
  unrecoverable: List[Dict[str, Any]] = field(default_factory=list)
178
183
  recorded_blockers: Optional[List[Dict[str, Any]]] = None
179
184
  ledgers: Optional[Dict[str, List[Dict[str, Any]]]] = None
185
+ edits: Dict[str, Any] = field(default_factory=dict)
180
186
 
181
187
 
182
188
  def _timeout_close(
@@ -661,8 +667,14 @@ def _project_recovery_actions(
661
667
  else "claim requires an agent identity"
662
668
  )
663
669
  amend_allowed = amend and bool(agent_id)
670
+ # #73: an amendment records the working tree as the step's work, so
671
+ # with any unreceipted change someone must say whose it is first.
672
+ manual = amend and bool(selection.edits.get("pairs"))
664
673
  amend_reason = (
665
- f"step {step_id} has completed work and needs a replacement attempt"
674
+ "changed files have no matching file receipt; an operator must attribute "
675
+ "them with amend-step"
676
+ if amend_allowed and manual
677
+ else f"step {step_id} has completed work and needs a replacement attempt"
666
678
  if amend_allowed
667
679
  else "amend requires an agent identity"
668
680
  if amend
@@ -680,7 +692,7 @@ def _project_recovery_actions(
680
692
  automatic=False, break_glass=True,
681
693
  ),
682
694
  _recovery_action("block", False, "no open attempt"),
683
- _recovery_action("amend", amend_allowed, amend_reason),
695
+ _recovery_action("amend", amend_allowed, amend_reason, automatic=not manual),
684
696
  ]
685
697
 
686
698
  owner = attempt.get("agent_id")
@@ -806,14 +818,26 @@ def _resumability(
806
818
  else:
807
819
  # Nothing eligible: replace the first completed step that needs
808
820
  # it, unless an unrecoverable timeout makes the run unprovable.
821
+ # #73: an unreceipted change is one more cause. Its inspection
822
+ # reads the working tree, so a git or file error fails closed.
823
+ try:
824
+ edits = post_completion_edits(root, plan, strict=strict)
825
+ except (RuntimeError, OSError) as exc:
826
+ return _diagnose(
827
+ projection,
828
+ "execution_state_invalid",
829
+ f"cannot inspect changed files: {exc}",
830
+ "working tree",
831
+ ), _Selection(None)
809
832
  selection = _Selection(
810
833
  None,
811
- needs=replacement_needs(root, plan, strict=strict),
834
+ needs=replacement_needs(root, plan, strict=strict, edits=edits),
812
835
  unrecoverable=[
813
836
  receipt
814
837
  for receipt, receipt_state, _ in timeout_receipt_states(root, plan)
815
838
  if receipt_state == "unrecoverable"
816
839
  ],
840
+ edits=edits,
817
841
  )
818
842
  if selection.needs and not selection.unrecoverable:
819
843
  need = selection.needs[0]
@@ -1029,15 +1053,11 @@ def explain_next_step(root: Path) -> Dict[str, Any]:
1029
1053
  return {"step": None, "reason_code": code, "diagnostics": diagnostics}
1030
1054
 
1031
1055
 
1032
- def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str,
1033
- attempt_id: str) -> List[str]:
1034
- """Scoped changed files with no file receipt for this open attempt."""
1035
- recorded = {
1036
- r.get("path")
1037
- for r in file_receipts(root)
1038
- if r.get("step_id") == step_id and r.get("attempt_id") == attempt_id
1039
- }
1056
+ def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str) -> List[str]:
1057
+ """Scoped files without matching receipt content or enforced hunk coverage."""
1058
+ latest = latest_file_receipts_by_path(root)
1040
1059
  missing: List[str] = []
1060
+ scoped_records: List[Dict[str, str]] = []
1041
1061
  for record in changed_file_records(root):
1042
1062
  path = record.get("path")
1043
1063
  if not isinstance(path, str):
@@ -1046,8 +1066,12 @@ def _scoped_unrecorded_paths(root: Path, plan: Dict[str, Any], step_id: str,
1046
1066
  continue
1047
1067
  if not path_in_effective_scope(plan, step_id, path):
1048
1068
  continue
1049
- if path not in recorded:
1069
+ scoped_records.append(record)
1070
+ receipt = latest.get(path)
1071
+ if receipt is None or not receipt_matches_worktree(root, record, receipt):
1050
1072
  missing.append(path)
1073
+ if effective_hunk_policy(root) == "enforce":
1074
+ missing.extend(entry["path"] for entry in unmapped_hunks(root, scoped_records))
1051
1075
  return sorted(set(missing))
1052
1076
 
1053
1077
 
@@ -1103,6 +1127,53 @@ def _latest_run_verification(root: Path) -> Optional[Dict[str, Any]]:
1103
1127
  return latest
1104
1128
 
1105
1129
 
1130
+ def _unreceipted_hints(edits: Dict[str, Any]) -> List[str]:
1131
+ """#73: which step could record each unreceipted change no step was offered.
1132
+
1133
+ Nothing when the edits were offered (the cause lines say it) or the run is
1134
+ permanently unprovable. A blocked or out-of-scope change is never presented
1135
+ as something an amendment can accept.
1136
+ """
1137
+ if not edits.get("pairs") or edits.get("offered") or edits.get("permanent"):
1138
+ return []
1139
+ prefix = (
1140
+ "after the out-of-scope and blocked changes are resolved, "
1141
+ if edits.get("scope_violation") else ""
1142
+ )
1143
+ hints = []
1144
+ for path, candidates in edits["candidates"].items():
1145
+ if len(candidates) == 1:
1146
+ hints.append(f"{prefix}{path} is in step {candidates[0]}'s scope; "
1147
+ f"amend-step {candidates[0]} can record it")
1148
+ elif candidates:
1149
+ hints.append(f"{path} is in the scope of steps {', '.join(candidates)}; "
1150
+ "amend the step that made the edit and record it there")
1151
+ else:
1152
+ hints.append(f"{path} is in no step's scope; no amendment can record it")
1153
+ return hints
1154
+
1155
+
1156
+ def _drift_reason(root: Path, drift: Dict[str, Any]) -> str:
1157
+ """#73: name every failing drift category, not only out-of-scope files."""
1158
+ parts = []
1159
+ if drift.get("out_of_scope_files"):
1160
+ parts.append("changes fall outside plan scope")
1161
+ if drift.get("blocked_files_changed"):
1162
+ parts.append("blocked files changed")
1163
+ # Under observe, unmapped hunks are listed but do not fail drift.
1164
+ if drift.get("unmapped_hunks") and effective_hunk_policy(root) == "enforce":
1165
+ parts.append("changed hunks are not covered by file receipts")
1166
+ return "; ".join(parts) or "changes fall outside plan scope"
1167
+
1168
+
1169
+ def _unmapped_hunk_lines(drift: Dict[str, Any]) -> List[str]:
1170
+ return [
1171
+ f"unmapped hunk: {format_unmapped_hunk(entry)}"
1172
+ for entry in drift.get("unmapped_hunks", [])
1173
+ if isinstance(entry, dict)
1174
+ ]
1175
+
1176
+
1106
1177
  def _replacement_cause(
1107
1178
  step_id: str,
1108
1179
  need: Dict[str, Any],
@@ -1118,6 +1189,8 @@ def _replacement_cause(
1118
1189
  )
1119
1190
  if cause["cause"] == "failed_attempt":
1120
1191
  return f"replace failed attempt {need['attempt_id']}", f"{step_id} has failed attempt"
1192
+ if cause["cause"] == "post_completion_edit":
1193
+ return f"record post-completion edit to {cause['path']}", cause["message"]
1121
1194
  return (
1122
1195
  f"replace {need['status']} attempt {need['attempt_id']} (strict)",
1123
1196
  f"{step_id} is not completed",
@@ -1196,7 +1269,14 @@ def next_action(
1196
1269
  changed = selection.kind != "claim" and _projection_ledgers(root) != selection.ledgers
1197
1270
  except (AttributeError, KeyError, OSError, TypeError, ValueError):
1198
1271
  # A downstream reader can fail before the final comparison is reached.
1199
- changed = True
1272
+ # #94: blame the ledgers only if a fresh read differs or fails;
1273
+ # otherwise the error is Agentflow's own and a rerun cannot clear it.
1274
+ try:
1275
+ changed = _projection_ledgers(root) != selection.ledgers
1276
+ except (OSError, ValueError):
1277
+ changed = True
1278
+ if not changed:
1279
+ raise
1200
1280
  if changed:
1201
1281
  return _invalid_action(_diagnose(
1202
1282
  projection,
@@ -1237,7 +1317,9 @@ def _selected_action(
1237
1317
  command=_cmd(args, user_placeholder=not agent_id), args=args, step_id=step_id,
1238
1318
  diagnostics=[
1239
1319
  *(line for _text, line in texts),
1320
+ *_unmapped_hunk_lines(selection.edits.get("drift") or {}),
1240
1321
  *(f"also needs a replacement attempt: {other['step_id']}" for other in others),
1322
+ *_unreceipted_hints(selection.edits),
1241
1323
  ],
1242
1324
  ),
1243
1325
  projection,
@@ -1251,13 +1333,19 @@ def _selected_action(
1251
1333
  for receipt in selection.unrecoverable
1252
1334
  ]
1253
1335
  finish_args = ["finish-run", *strict_args]
1254
- drift = audit_drift(root, plan)
1336
+ hints = _unreceipted_hints(selection.edits)
1337
+ drift = selection.edits.get("drift") or audit_drift(root, plan)
1255
1338
  if drift.get("status") == "fail":
1256
1339
  return _attach(
1257
- Action("drift_failing", "changes fall outside plan scope",
1340
+ Action("drift_failing", _drift_reason(root, drift),
1258
1341
  command=_cmd(finish_args), args=finish_args,
1259
- diagnostics=[*(f"out of scope: {p}" for p in
1260
- drift.get("out_of_scope_files", [])), *stranded]),
1342
+ diagnostics=[
1343
+ *(f"out of scope: {p}" for p in drift.get("out_of_scope_files", [])),
1344
+ *(f"blocked: {p}" for p in drift.get("blocked_files_changed", [])),
1345
+ *_unmapped_hunk_lines(drift),
1346
+ *hints,
1347
+ *stranded,
1348
+ ]),
1261
1349
  projection,
1262
1350
  )
1263
1351
  run_result = verify_run(root, plan, strict=strict, record=False)
@@ -1271,7 +1359,7 @@ def _selected_action(
1271
1359
  strict and bool(run_warnings)
1272
1360
  )
1273
1361
  if run_record is None or run_failed:
1274
- diagnostics = [*(f["message"] for f in run_findings), *stranded]
1362
+ diagnostics = [*(f["message"] for f in run_findings), *hints, *stranded]
1275
1363
  if run_record is None:
1276
1364
  diagnostics.insert(0, "run verification has not been recorded")
1277
1365
  return _attach(
@@ -1346,7 +1434,16 @@ def _selected_action(
1346
1434
  ),
1347
1435
  projection,
1348
1436
  )
1349
- unrecorded = _scoped_unrecorded_paths(root, plan, step_id, attempt_id)
1437
+ try:
1438
+ unrecorded = _scoped_unrecorded_paths(root, plan, step_id)
1439
+ except (RuntimeError, OSError) as exc:
1440
+ # A concurrent ledger change must supersede this working-tree diagnostic.
1441
+ return _invalid_action(_diagnose(
1442
+ {**projection, "diagnostics": []},
1443
+ "execution_state_invalid",
1444
+ f"cannot inspect changed files: {exc}",
1445
+ "working tree",
1446
+ ))
1350
1447
  if unrecorded:
1351
1448
  args = ["record-file-change", "--step", step_id, "--path", unrecorded[0], *agent_args]
1352
1449
  return _attach_inflight(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentflow-proof
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: A model-agnostic workflow runner for drift-resistant agentic development.
5
5
  Author: Agentflow contributors
6
6
  License-Expression: MIT