@nidvue/teamwork 1.1.2 → 1.4.2

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 (35) hide show
  1. package/CHANGELOG.md +326 -3
  2. package/README.md +80 -26
  3. package/package.json +1 -1
  4. package/skills/teamwork/AGENTS.md +57 -2
  5. package/skills/teamwork/SKILL.md +129 -25
  6. package/skills/teamwork/references/Adapters.md +4 -0
  7. package/skills/teamwork/references/Audit.md +3 -1
  8. package/skills/teamwork/references/Concurrency.md +22 -7
  9. package/skills/teamwork/references/Coordination.md +203 -0
  10. package/skills/teamwork/references/Dissent.md +10 -1
  11. package/skills/teamwork/references/Docs.md +3 -1
  12. package/skills/teamwork/references/Engineering.md +47 -8
  13. package/skills/teamwork/references/Evidence.md +18 -1
  14. package/skills/teamwork/references/Remotes.md +25 -7
  15. package/skills/teamwork/references/Roles.md +22 -4
  16. package/skills/teamwork/references/State.md +96 -12
  17. package/skills/teamwork/references/Testing.md +3 -1
  18. package/skills/teamwork/roles/ceo.md +50 -3
  19. package/skills/teamwork/roles/developer.md +8 -0
  20. package/skills/teamwork/roles/product-manager.md +4 -0
  21. package/skills/teamwork/roles/reviewer.md +97 -12
  22. package/skills/teamwork/scripts/check_release_consistency.py +1805 -0
  23. package/skills/teamwork/scripts/privacy_scan.py +105 -2
  24. package/skills/teamwork/src/teamwork/bundle.py +0 -7
  25. package/skills/teamwork/src/teamwork/config.py +53 -2
  26. package/skills/teamwork/src/teamwork/engine.py +2652 -385
  27. package/skills/teamwork/src/teamwork/gitutil.py +148 -10
  28. package/skills/teamwork/src/teamwork/harness.py +753 -0
  29. package/skills/teamwork/src/teamwork/hooks.py +400 -0
  30. package/skills/teamwork/src/teamwork/layout.py +10 -4
  31. package/skills/teamwork/src/teamwork/portable.py +6 -1
  32. package/skills/teamwork/src/teamwork/records.py +20 -0
  33. package/skills/teamwork/src/teamwork/resources.py +41 -7
  34. package/skills/teamwork/src/teamwork/version.py +1 -1
  35. package/skills/teamwork/templates/AGENTS.md +57 -2
@@ -22,7 +22,8 @@ Usage
22
22
  teamwork.py load AGENT [--limit N]
23
23
  teamwork.py remember AGENT TEXT [--layer short|long] [--key K]
24
24
  [--importance 0-5] [--tags a,b] [--pin]
25
- teamwork.py global TEXT [--key K] [--importance N] [--tags a,b] [--pin]
25
+ teamwork.py global TEXT [--by BY] [--src SRC] [--rev REV]
26
+ [--key K] [--importance N] [--tags a,b] [--pin]
26
27
  teamwork.py recall QUERY [--agent A] [--all] [--global] [--layer both|short|long]
27
28
  teamwork.py touch AGENT ID|KEY [--layer short|long]
28
29
  teamwork.py forget AGENT ID|KEY [--layer short|long]
@@ -107,12 +108,17 @@ from .config import (
107
108
  LAYER_CELL,
108
109
  LAYER_OWNER,
109
110
  LAYER_WHAT,
111
+ LFS_MAX_BYTES,
110
112
  LONG_CAP,
111
113
  MAX_ROUNDS,
114
+ MEMORY_ARBITER_ROLE,
115
+ MEMORY_LEASE_MINUTES,
116
+ MEMORY_STALL_MINUTES,
112
117
  MIN_USES,
113
118
  PRIORITY_ORDER,
114
119
  PROMOTE_MIN_IMPORTANCE,
115
120
  REVIEW_FAIL_LIMIT,
121
+ REVIEW_LEASE_MINUTES,
116
122
  REVIEW_STATUSES,
117
123
  ROLE_PARENT,
118
124
  ROLE_REPORT_ORDER,
@@ -149,7 +155,9 @@ from .gitutil import (
149
155
  branch_worktree,
150
156
  current_branch,
151
157
  ensure_lfs,
158
+ is_binary_content,
152
159
  is_git_repo,
160
+ lfs_text_mismatch,
153
161
  lfs_tracked,
154
162
  run_git,
155
163
  )
@@ -196,9 +204,9 @@ from .layout import (
196
204
  task_file,
197
205
  team_root_from,
198
206
  team_root_from_cwd,
199
- teams_root_unused,
200
207
  testplan_path,
201
208
  tests_path,
209
+ work_docs_dir,
202
210
  work_repo,
203
211
  )
204
212
  from .names import (
@@ -380,9 +388,9 @@ __all__ = [
380
388
  "project_of",
381
389
  "docs_dir",
382
390
  "work_repo",
391
+ "work_docs_dir",
383
392
  "team_root_from_cwd",
384
393
  "project_root",
385
- "teams_root_unused",
386
394
  "agents_dir",
387
395
  "agent_dir",
388
396
  "roles_dir",
@@ -419,6 +427,7 @@ __all__ = [
419
427
  "branch_merged",
420
428
  "branch_worktree",
421
429
  "ensure_lfs",
430
+ "lfs_text_mismatch",
422
431
  "lfs_tracked",
423
432
  "branch_name",
424
433
  "REVIEWS_HEADER",
@@ -1377,6 +1386,8 @@ def _pr_open(args, root: Path, project: Path, here: Path, path: Path, role: str)
1377
1386
  )
1378
1387
  print(f"# {pr['id']} open: {pr['title']}")
1379
1388
  print(f"# branch {pr['branch']} -> {pr['base']} author {pr['author']}")
1389
+ profile, reason = review_profile_for(root, role, t)
1390
+ print(f"# review profile: {profile} ({reason}) (packet: review {role} {t['id']} --packet)")
1380
1391
  if pushed:
1381
1392
  print(f"# {pushed}")
1382
1393
  print(
@@ -2071,17 +2082,6 @@ def _tests_list(args, root: Path, records: list) -> int:
2071
2082
  return 0
2072
2083
 
2073
2084
 
2074
- def cmd_testplan_unused(args) -> int:
2075
- root = project_root(args)
2076
- plan = parse_testplan(testplan_path(root))
2077
- print(f"# Test plan ({testplan_path(root)})")
2078
- if not plan:
2079
- print(f"# default required layers for any area: {', '.join(DEFAULT_REQUIRED_LAYERS)}")
2080
- for area in sorted(plan):
2081
- print(f"- {area:20s} {', '.join(plan[area])}")
2082
- return 0
2083
-
2084
-
2085
2085
  # --------------------------------------------------------------------------- #
2086
2086
  # Audit trail: append-only hash-chained ledger + provenance + external export
2087
2087
  #
@@ -2104,6 +2104,15 @@ LEDGER_ACTIONS = (
2104
2104
  "memory.drop",
2105
2105
  "memory.merge",
2106
2106
  "memory.evict",
2107
+ "memory.claim",
2108
+ "memory.claim.force",
2109
+ "memory.renew",
2110
+ "memory.release",
2111
+ "memory.contest",
2112
+ "memory.grant",
2113
+ "memory.revoke",
2114
+ "instance.add",
2115
+ "instance.retire",
2107
2116
  "task.add",
2108
2117
  "task.claim",
2109
2118
  "task.status",
@@ -2375,7 +2384,7 @@ def ledger_verify(root: Path, reconcile: bool = False, _locked: bool = False) ->
2375
2384
  except (ValueError, OSError, TypeError):
2376
2385
  problems.append("head cache is unreadable")
2377
2386
  # external anchor: the last exported checkpoint must not be ahead of us
2378
- manifest = docs_dir(root) / F_MANIFEST
2387
+ manifest = work_docs_dir(root) / F_MANIFEST
2379
2388
  if manifest.exists():
2380
2389
  try:
2381
2390
  import json
@@ -2790,37 +2799,50 @@ def _file_digest(path: Path) -> str:
2790
2799
  return ""
2791
2800
 
2792
2801
 
2793
- def manifest_path_hint(root: Path) -> Path:
2794
- return docs_dir(root) / F_MANIFEST
2795
-
2796
-
2797
2802
  def ledger_export(root: Path, args) -> int:
2798
- """Emit an externally verifiable manifest of the whole trail."""
2803
+ """Emit an externally verifiable manifest of the whole trail.
2804
+
2805
+ The manifest is project output, so it is written in the work tree the
2806
+ command acts on: a lane that exports from its own worktree gets a file it
2807
+ can commit on its branch, and the manifest describes that work tree's
2808
+ revision. Writing it into the project that owns the team tree instead left
2809
+ the lane's worktree clean and the main checkout dirty - the very state the
2810
+ manifest's own ``dirty`` field then misreported - and pinned the main
2811
+ checkout's head, a revision the committing branch does not have.
2812
+ """
2799
2813
  import json
2800
2814
 
2801
- project = project_of(root)
2815
+ # The work tree the command acts on: a lane that exports from its own
2816
+ # worktree gets a file it can commit on its branch, and the manifest
2817
+ # describes that work tree's revision.
2818
+ project = work_repo(root)
2819
+ docs = work_docs_dir(root)
2820
+ # The team's hidden tree (ledger, memory, task queues) lives in the project
2821
+ # that owns it, not in the work tree, so its entries stay relative to that
2822
+ # project.
2823
+ state_root = project_of(root)
2802
2824
  problems = ledger_verify(root, reconcile=True)
2803
2825
  records = ledger_read(root)
2804
2826
  tracked = []
2805
- for path in (
2806
- criteria_path(root),
2807
- tests_path(root),
2808
- testplan_path(root),
2809
- reviews_path(root),
2810
- disputes_path(root),
2811
- global_dir(root) / F_GLOBAL,
2812
- docs_dir(root) / "Project.md",
2813
- docs_dir(root) / "Plan.md",
2814
- docs_dir(root) / "Architecture.md",
2815
- docs_dir(root) / "Decisions.md",
2816
- docs_dir(root) / "Archive.md",
2827
+ for path, base in (
2828
+ (criteria_path(root), state_root),
2829
+ (tests_path(root), state_root),
2830
+ (testplan_path(root), state_root),
2831
+ (reviews_path(root), state_root),
2832
+ (disputes_path(root), state_root),
2833
+ (global_dir(root) / F_GLOBAL, state_root),
2834
+ (docs / "Project.md", project),
2835
+ (docs / "Plan.md", project),
2836
+ (docs / "Architecture.md", project),
2837
+ (docs / "Decisions.md", project),
2838
+ (docs / "Archive.md", project),
2817
2839
  ):
2818
2840
  if path.exists():
2819
2841
  tracked.append(
2820
2842
  {
2821
2843
  # posix form: the manifest is published, so a Windows
2822
2844
  # backslash would make the same revision hash differently.
2823
- "path": path.relative_to(project).as_posix(),
2845
+ "path": path.relative_to(base).as_posix(),
2824
2846
  "sha256": _file_digest(path),
2825
2847
  "bytes": path.stat().st_size,
2826
2848
  }
@@ -2831,7 +2853,7 @@ def ledger_export(root: Path, args) -> int:
2831
2853
  if p.exists():
2832
2854
  tracked.append(
2833
2855
  {
2834
- "path": p.relative_to(project).as_posix(),
2856
+ "path": p.relative_to(state_root).as_posix(),
2835
2857
  "sha256": _file_digest(p),
2836
2858
  "bytes": p.stat().st_size,
2837
2859
  }
@@ -2859,7 +2881,7 @@ def ledger_export(root: Path, args) -> int:
2859
2881
  root,
2860
2882
  "ledger.export",
2861
2883
  export_actor,
2862
- str(manifest_path_hint(root)),
2884
+ str(docs / F_MANIFEST),
2863
2885
  f"manifest export ({len(records)} records before this event)",
2864
2886
  )
2865
2887
  records = ledger_read(root)
@@ -2873,7 +2895,7 @@ def ledger_export(root: Path, args) -> int:
2873
2895
  # Archive.md is the accepted-results ledger: it gains an entry for this very
2874
2896
  # deliverable when the review passes, so a digest committed beside it is stale
2875
2897
  # by construction. Its entries are verified through the ledger, not a snapshot.
2876
- archive_rel = archive_path(root).relative_to(project).as_posix()
2898
+ archive_rel = (docs / "Archive.md").relative_to(project).as_posix()
2877
2899
  external = [a for a in tracked if not a["path"].startswith(".pi/") and a["path"] != archive_rel]
2878
2900
  internal = [a for a in tracked if a["path"].startswith(".pi/")]
2879
2901
  manifest = {
@@ -2882,8 +2904,9 @@ def ledger_export(root: Path, args) -> int:
2882
2904
  "team_id": team_identity(root) or ensure_team_identity(root),
2883
2905
  # Never embed the absolute checkout path: the manifest is committed and
2884
2906
  # published, and a machine path is both a privacy leak and useless to a
2885
- # verifier on another host.
2886
- "project": project.name,
2907
+ # verifier on another host. The name is the project's, never the work
2908
+ # tree's: a lane worktree is named after the instance that owns it.
2909
+ "project": state_root.name,
2887
2910
  "ledger": {
2888
2911
  "records": len(records),
2889
2912
  "checkpoint_n": checkpoint_n,
@@ -2911,7 +2934,7 @@ def ledger_export(root: Path, args) -> int:
2911
2934
  "pinned as an artifact; follow its entries with `trace`.",
2912
2935
  ],
2913
2936
  }
2914
- out_dir = docs_dir(root)
2937
+ out_dir = docs
2915
2938
  out_dir.mkdir(parents=True, exist_ok=True)
2916
2939
  manifest_path = out_dir / F_MANIFEST
2917
2940
  report_path = out_dir / F_AUDITREPORT
@@ -3171,10 +3194,99 @@ def cmd_skill(args) -> int:
3171
3194
  return 0
3172
3195
 
3173
3196
 
3197
+ def guard_committer_identity(project: Path, rev_range: str = "HEAD") -> int:
3198
+ """Fail when a commit on the default branch carries a non-role committer.
3199
+
3200
+ A host merge button - squash or rebase - writes the *signed-in account* into
3201
+ the commit it creates, as author (squash) or as committer (rebase). The privacy
3202
+ gate scans the history and fails on that personal mailbox; because the same
3203
+ gate is an acceptance proof, the traceability guard fails with it and the
3204
+ whole test matrix goes red. Measured on this repository: three rewrites of
3205
+ ``main`` on 2026-10-20, all from this one cause.
3206
+
3207
+ This is the early signal for it: it reads the *committer* of the commits in
3208
+ the range, so it fires the moment a merge button stamps the account, and it
3209
+ prints the repair instead of leaving the next person to discover the red
3210
+ build.
3211
+
3212
+ Accepted committers: a role mailbox of this team, the host's own noreply
3213
+ service address, and an author whose committer was rewritten by the host
3214
+ (the host stamp is transport, not identity - but the *author* still has to be
3215
+ a role mailbox, which ``identity --audit`` enforces separately).
3216
+ """
3217
+ canonical = canonical_identities()
3218
+ proc = run_git(
3219
+ project, "log", "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%P%x1f%B%x1e", rev_range
3220
+ )
3221
+ if proc.returncode != 0:
3222
+ # A range whose base does not resolve is not a usage error: a push after a
3223
+ # rewrite, or a pull_request event's all-zero "before", leaves exactly that.
3224
+ # The tip is what can be measured, so measure it and say so.
3225
+ tip = rev_range.split("..", 1)[1].strip() if ".." in rev_range else rev_range
3226
+ print(
3227
+ f"# {rev_range} cannot be read ({proc.stderr.strip()}); "
3228
+ f"auditing the tip only, the span before it is unmeasurable"
3229
+ )
3230
+ proc = run_git(
3231
+ project, "log", "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%P%x1f%B%x1e", tip or "HEAD"
3232
+ )
3233
+ rev_range = tip or "HEAD"
3234
+ if proc.returncode != 0:
3235
+ print(f"teamwork: cannot read {rev_range}: {proc.stderr.strip()}")
3236
+ return 2
3237
+ problems = []
3238
+ seen = 0
3239
+ for record in proc.stdout.split("\x1e"):
3240
+ record = record.strip("\n")
3241
+ if not record:
3242
+ continue
3243
+ parts = record.split("\x1f")
3244
+ if len(parts) < 5:
3245
+ continue
3246
+ sha, an, ae, cn, ce = parts[0], parts[1], parts[2], parts[3], parts[4]
3247
+ subject = parts[6].splitlines()[0] if len(parts) > 6 and parts[6] else ""
3248
+ parents = parts[5].split() if len(parts) > 5 and parts[5] else []
3249
+ # A forge's synthetic pull-request merge ref is an artefact of the forge,
3250
+ # not a commit anyone pushed: the host authors it as the PR author and
3251
+ # commits it as itself, and it never lands on the default branch. Guarding
3252
+ # it would fail every pull request on a repository that merges through the
3253
+ # host - the same reason the identity audit skips it.
3254
+ if len(parents) >= 2 and _RE_FORGE_MERGE_SUBJECT.match(subject.strip()):
3255
+ print(f"- {sha[:12]} skipped: forge pull-request merge ref")
3256
+ continue
3257
+ seen += 1
3258
+ if ce in canonical:
3259
+ continue
3260
+ if _RE_FORGE_COMMITTER_EMAIL.match(ce) and ae in canonical:
3261
+ continue
3262
+ problems.append((sha[:12], an, ae, cn, ce))
3263
+ if not problems:
3264
+ print(f"# committer guard: {seen} commit(s) in {rev_range} carry a role committer")
3265
+ return 0
3266
+ print(f"# committer guard: {len(problems)} commit(s) in {rev_range} carry a foreign committer")
3267
+ for sha, an, ae, cn, ce in problems:
3268
+ print(f"- {sha} FAIL committer {cn} <{ce}> (author {an} <{ae}>)")
3269
+ print(
3270
+ "# A host merge button (squash or rebase) writes the signed-in account into "
3271
+ "the commit it creates, and the privacy gate fails on that personal mailbox "
3272
+ "in the history."
3273
+ )
3274
+ print(
3275
+ "# Repair: merge locally as the reviewing role and push - "
3276
+ "`teamwork.py pr merge --pr <id> --by <reviewer>` commits as a role "
3277
+ "mailbox. To repair an existing commit, amend it to a role mailbox, prove "
3278
+ "its tree is byte-identical, and push it with --force-with-lease."
3279
+ )
3280
+ return 1
3281
+
3282
+
3174
3283
  def cmd_identity(args) -> int:
3175
3284
  root = project_root(args)
3176
- if args.audit:
3285
+ if getattr(args, "guard_committer", False):
3177
3286
  project = project_of(root)
3287
+ return guard_committer_identity(project, args.rev_range or "HEAD")
3288
+ if args.audit:
3289
+ project = audit_repository(root, args)
3178
3290
  return audit_commit_identities(
3179
3291
  project,
3180
3292
  args.rev_range or default_audit_range(project),
@@ -3199,6 +3311,27 @@ _RE_BOT_EMAIL = re.compile(
3199
3311
  )
3200
3312
  _RE_FORGE_MERGE_SUBJECT = re.compile(r"^Merge [0-9a-f]{40} into [0-9a-f]{40}$")
3201
3313
  _RE_ACTING_ROLE = re.compile(r"^Acting role:\s*(.+?)\s*$", re.MULTILINE)
3314
+ #: ``Role: <display name>`` is the same machine-written fact as ``Acting role:``:
3315
+ #: ``build_commit_message`` emits both, so a message that carries only the
3316
+ #: structured trailer still names its acting role.
3317
+ _RE_ROLE_FIELD = re.compile(r"^Role:\s*(.+?)\s*$", re.MULTILINE)
3318
+ #: The subject of a team merge commit that lands no content of its own. Both the
3319
+ #: scripted form (``chore(merge): ...``) and a plain ``git merge`` subject count,
3320
+ #: because integrating a peer branch is the same content-free act either way.
3321
+ _RE_MERGE_SUBJECT = re.compile(
3322
+ r"^(?:"
3323
+ r"chore\(merge\):"
3324
+ r"|Merge branch '[^']+'(?: into [^\s]+)?"
3325
+ r"|Merge commit '[0-9a-f]{7,40}'"
3326
+ r")",
3327
+ re.IGNORECASE,
3328
+ )
3329
+ #: The committer a git host stamps on a merge it performs itself. It is transport,
3330
+ #: not identity: a forge rebase merge rewrites the committer to this account and
3331
+ #: leaves the author untouched, so the role that did the work is still the author -
3332
+ #: which is what this audit checks, and what the privacy gate already accepts
3333
+ #: (``noreply@github.com`` is a service address there, never a personal mailbox).
3334
+ _RE_FORGE_COMMITTER_EMAIL = re.compile(r"^(?:noreply@github\.com|noreply@gitlab\.com)$")
3202
3335
 
3203
3336
 
3204
3337
  def canonical_identities() -> dict:
@@ -3206,6 +3339,26 @@ def canonical_identities() -> dict:
3206
3339
  return {role_identity(role)[1]: role_identity(role)[0] for role in ROLE_DISPLAY}
3207
3340
 
3208
3341
 
3342
+ def audit_repository(root: Path, args) -> Path:
3343
+ """The repository whose history ``identity --audit`` must read.
3344
+
3345
+ The audit is about the branch the actor is working on, and a linked lane
3346
+ worktree is its own work tree with its own ``HEAD``. Resolving against the
3347
+ primary checkout (``project_of(root)``) made a worktree invocation audit
3348
+ *main's* history and report a vacuous "0 problems" - the exact false green the
3349
+ same command is used to avoid. So: an explicit ``--project`` wins, otherwise
3350
+ the work tree git finds from the current directory, otherwise the project that
3351
+ owns the team tree (the historical answer, kept for a cwd inside no repository).
3352
+ """
3353
+ if getattr(args, "project", None):
3354
+ return project_path(args)
3355
+ here = Path.cwd().resolve()
3356
+ result = run_git(here, "rev-parse", "--show-toplevel")
3357
+ if result.returncode == 0 and result.stdout.strip():
3358
+ return Path(result.stdout.strip()).resolve()
3359
+ return work_repo(root, here)
3360
+
3361
+
3209
3362
  def default_audit_range(project: Path) -> str:
3210
3363
  if run_git(project, "rev-parse", "--verify", "--quiet", "origin/main").returncode == 0:
3211
3364
  return "origin/main..HEAD"
@@ -3235,7 +3388,40 @@ def rewrite_safe_range(project: Path, rev_range: str) -> str:
3235
3388
  f"# {base[:12]} is not an ancestor of this clone (history rewrite): "
3236
3389
  f"auditing the tip only, the span before it is unmeasurable"
3237
3390
  )
3238
- return rev_range.split("..", 1)[1].strip() or "HEAD"
3391
+ tip = rev_range.split("..", 1)[1].strip() or "HEAD"
3392
+ if run_git(project, "rev-parse", "--verify", "--quiet", f"{tip}^").returncode != 0:
3393
+ return tip # a root commit is its own whole history
3394
+ # ``tip`` alone means "every ancestor of tip", which would re-audit the whole
3395
+ # history the event never covered - one rewrite then turns every old blemish
3396
+ # red at once. The span that can be measured is exactly the new tip.
3397
+ return f"{tip}^..{tip}"
3398
+
3399
+
3400
+ def _matches_role_display(name: str, canonical_display: str) -> bool:
3401
+ """A role mailbox may display its role spaced or run together.
3402
+
3403
+ ``role_identity`` yields the spaced form (``Project Manager``), while the team
3404
+ commits with ``git -c user.name=ProjectManager`` - the same canonical role,
3405
+ written without spaces. Accept both; anything else is a different name.
3406
+ """
3407
+ return name.replace(" ", "") == canonical_display.replace(" ", "")
3408
+
3409
+
3410
+ def _is_content_free_team_merge(
3411
+ parents: list, name: str, email: str, body: str, canonical: dict
3412
+ ) -> bool:
3413
+ """A two-parent integration merge with no body adds no content, so it
3414
+ carries no acting role. Single-parent commits are never exempt: they do add
3415
+ content and must say who acted.
3416
+ """
3417
+ if len(parents) < 2 or email not in canonical:
3418
+ return False
3419
+ if not _matches_role_display(name, canonical[email]):
3420
+ return False
3421
+ lines = body.splitlines()
3422
+ if not lines or not _RE_MERGE_SUBJECT.match(lines[0].strip()):
3423
+ return False
3424
+ return not "".join(lines[1:]).strip()
3239
3425
 
3240
3426
 
3241
3427
  def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool = False) -> int:
@@ -3243,12 +3429,19 @@ def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool
3243
3429
 
3244
3430
  The role that does the work commits it. A forge-created pull-request merge ref
3245
3431
  carries the forge account identity, so it is skipped; its own history is
3246
- audited by the caller's range. Bot dependency updates are allowed. A public
3432
+ audited by the caller's range. A forge-side *rebase* merge is accepted too: it
3433
+ keeps the author and stamps only its own noreply committer, so the acting role
3434
+ is still the author (see ``_RE_FORGE_COMMITTER_EMAIL``). Bot dependency updates
3435
+ are allowed. A public
3247
3436
  repository also receives outside contributions: unless ``strict_authors`` is
3248
3437
  set, an author outside the team's identity domain is reported as skipped
3249
3438
  rather than failed, while a team-domain author with the wrong role still
3250
3439
  fails.
3251
3440
  """
3441
+ # Name the repository before anything else: the same command in a lane worktree
3442
+ # and in the primary checkout read different histories, so a reader who cannot
3443
+ # see which one was read cannot tell a clean branch from an unaudited one.
3444
+ print(f"# Repository read: {project}")
3252
3445
  if rev_range.startswith("-"):
3253
3446
  print(f"teamwork: invalid revision range {rev_range!r}")
3254
3447
  return 2
@@ -3290,19 +3483,42 @@ def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool
3290
3483
  print(f"- {short} skipped: external contributor {an} <{ae}>")
3291
3484
  continue
3292
3485
  reasons.append(f"not a role mailbox: {ae}")
3293
- elif an != expected:
3486
+ elif not _matches_role_display(an, expected):
3294
3487
  reasons.append(f"display name {an!r} does not match role {expected!r}")
3295
- if ce != ae or cn != an:
3488
+ # A forge-side merge stamps its own noreply committer - but only for a
3489
+ # commit whose author is a canonical role mailbox, so the identity that
3490
+ # matters (the acting role) is still enforced by every check here.
3491
+ forge_committer = expected is not None and _RE_FORGE_COMMITTER_EMAIL.match(ce) is not None
3492
+ # A merge commit lands a peer's reviewed work, so the committer need not be
3493
+ # the author. That is only acceptable when the committer is itself a
3494
+ # canonical role mailbox (the reviewer/CEO who merged): a personal address
3495
+ # stamping someone else's commit is exactly the privacy defect this gate
3496
+ # exists for, so it stays a failure.
3497
+ landing_role = canonical.get(ce) if ce in canonical and (ce != ae or cn != an) else None
3498
+ if (ce != ae or cn != an) and not forge_committer and landing_role is None:
3296
3499
  reasons.append(f"committer {cn} <{ce}> differs from author {an} <{ae}>")
3297
3500
  match = _RE_ACTING_ROLE.search(body)
3298
3501
  if not match:
3299
- reasons.append("message has no 'Acting role:' line")
3300
- elif match.group(1) != an:
3502
+ # The structured ``Role:`` trailer carries the same machine-written
3503
+ # fact; accept it, but only when it names the author's own role.
3504
+ match = _RE_ROLE_FIELD.search(body)
3505
+ if match:
3506
+ fallback_role = resolve_role(match.group(1))
3507
+ if not _matches_role_display(an, role_identity(fallback_role)[0]):
3508
+ reasons.append(f"'Role: {match.group(1)}' does not match author {an!r}")
3509
+ match = None
3510
+ elif not _matches_role_display(match.group(1), an):
3301
3511
  reasons.append(f"'Acting role: {match.group(1)}' does not match author {an!r}")
3512
+ if not match and not _is_content_free_team_merge(parents, cn, ce, body, canonical):
3513
+ reasons.append("message has no 'Acting role:' line")
3302
3514
  if reasons:
3303
3515
  problems.append((short, reasons))
3304
3516
  for reason in reasons:
3305
3517
  print(f"- {short} FAIL {an} <{ae}>: {reason}")
3518
+ elif landing_role is not None:
3519
+ print(f"- {short} ok {an} <{ae}> (landed by {landing_role})")
3520
+ elif forge_committer:
3521
+ print(f"- {short} ok {an} <{ae}> (forge committer {ce})")
3306
3522
  else:
3307
3523
  print(f"- {short} ok {an} <{ae}>")
3308
3524
  print(f"# commits: {seen} | problems: {len(problems)}")
@@ -3456,6 +3672,7 @@ def cmd_commit(args) -> int:
3456
3672
  if (
3457
3673
  fp.is_file()
3458
3674
  and fp.stat().st_size > args.lfs_threshold
3675
+ and is_binary_content(fp)
3459
3676
  and not lfs_tracked(project, line)
3460
3677
  ):
3461
3678
  big.append(f"{line} ({fp.stat().st_size // 1024} KiB)")
@@ -3469,6 +3686,17 @@ def cmd_commit(args) -> int:
3469
3686
  " --lfs-threshold",
3470
3687
  2,
3471
3688
  )
3689
+ mismatched = [line for line in staged.splitlines() if lfs_text_mismatch(project, line)]
3690
+ if mismatched:
3691
+ die(
3692
+ "refusing to commit text file(s) that .gitattributes routes into Git "
3693
+ "LFS:\n "
3694
+ + "\n ".join(mismatched)
3695
+ + "\n this file is text but a LFS pattern captures its name; LFS is "
3696
+ "for binaries, and a document in LFS is unreadable without a client."
3697
+ "\n drop the pattern from .gitattributes, or make the content binary.",
3698
+ 2,
3699
+ )
3472
3700
 
3473
3701
  r = subprocess.run(
3474
3702
  ["git", "-C", str(project)] + git_identity_args(role) + ["commit", "-F", "-"],
@@ -3594,6 +3822,262 @@ def roster_for(names: list) -> dict:
3594
3822
  return {"tier": "explicit", "roles": roles, "instances": names, "missing_required": missing}
3595
3823
 
3596
3824
 
3825
+ def scaffold_role(root: Path, role: str, staffed: set | None = None, force: bool = False) -> Path:
3826
+ """Role scope: one shared memory set, queue and charter for every instance.
3827
+
3828
+ Idempotent and non-destructive: existing files are never rewritten unless
3829
+ ``force`` names the charter. Called by `init` and by `instance add`, so a role
3830
+ that gains a fifth instance does not gain a second memory file.
3831
+ """
3832
+ role = resolve_role(role)
3833
+ rd = role_dir(root, role)
3834
+ rd.mkdir(parents=True, exist_ok=True)
3835
+ for filename in (F_SHORT, F_LONG, F_MEM_ARCHIVE):
3836
+ p = rd / filename
3837
+ if not p.exists():
3838
+ write_memory(p, [], filename, role)
3839
+ tp = rd / F_TASKS
3840
+ if not tp.exists():
3841
+ write_tasks(tp, [], role)
3842
+ role_file = ROLES_DIR / f"{role}.md"
3843
+ shared_charter = rd / F_ROLE
3844
+ if role_file.exists() and (force or not shared_charter.exists()):
3845
+ body = role_file.read_text(encoding="utf-8").replace(
3846
+ "{references}", str(SKILL_DIR / "references")
3847
+ )
3848
+ folded = charter_reports_line(role, staffed)
3849
+ if folded:
3850
+ body = fold_charter_reports(body, folded)
3851
+ write_lf(shared_charter, body)
3852
+ elif not shared_charter.exists():
3853
+ write_lf(
3854
+ shared_charter,
3855
+ f"# Role: {role}\n\n"
3856
+ f"No bundled charter found for '{role}'. Derive your duties from the\n"
3857
+ f"task packet, report blockers to the CEO, and keep to your lane.\n",
3858
+ )
3859
+ return rd
3860
+
3861
+
3862
+ def scaffold_instance(
3863
+ root: Path, name: str, role: str, staffed: set | None = None, force: bool = False
3864
+ ) -> Path:
3865
+ """Instance scope: private brief, worklog, inbox, artifacts, scratch, reports."""
3866
+ d = agent_dir(root, name)
3867
+ for sub in (DIR_ARTIFACTS, DIR_SCRATCH, DIR_REPORTS):
3868
+ (d / sub).mkdir(parents=True, exist_ok=True)
3869
+ brief = d / F_START
3870
+ if force or not brief.exists():
3871
+ write_lf(brief, start_here(name, role, root, docs_dir(root), staffed=staffed))
3872
+ for extra in (F_WORKLOG, F_INBOX):
3873
+ p = d / extra
3874
+ if p.exists():
3875
+ continue
3876
+ write_lf(
3877
+ p,
3878
+ f"# {extra[:-3]} - {name} ({role})\n\n"
3879
+ f"> Private to this instance. The {role} memory and task queue\n"
3880
+ f"> are shared with every other {role} instance, and only one instance\n"
3881
+ f"> of the role revises that memory at a time (`memory claim {role}`).\n\n"
3882
+ + AGENT_LINKS.format(**link_fields(d / extra, name))
3883
+ + "\n",
3884
+ )
3885
+ migrate_instance_memory(root, name, role)
3886
+ return d
3887
+
3888
+
3889
+ def next_instance_name(root: Path, role: str) -> str:
3890
+ """The next free instance name for a role: `role-N`, or `role` when it is alone.
3891
+
3892
+ A bare role name is the role's first instance, so a role that already has one
3893
+ gains `-2` rather than reusing a name or renaming a lane that records already
3894
+ name (`claimed_by`, branches, ledger entries).
3895
+ """
3896
+ role_p = pascal(role)
3897
+ taken = {pascal(i) for i in instances_of(root, role)}
3898
+ if not taken:
3899
+ return role_p
3900
+ n = 2 if role_p in taken else 1
3901
+ while f"{role_p}-{n}" in taken:
3902
+ n += 1
3903
+ return f"{role_p}-{n}"
3904
+
3905
+
3906
+ def cmd_instance(args) -> int:
3907
+ """Every role scales: `instance add|list|retire`.
3908
+
3909
+ Roles differ in what they own, not in how many lanes may serve it: any role
3910
+ can run several instances, they share the role's memory and queue, and the
3911
+ memory lease (`memory claim`) keeps exactly one of them revising it.
3912
+ """
3913
+ root = project_root(args)
3914
+ if not role_dir(root, "ceo").parent.is_dir() and not agents_dir(root).is_dir():
3915
+ die("no workspace here: run `teamwork.py init` first")
3916
+ if args.instance_cmd == "list":
3917
+ return _instance_list(root, args.json)
3918
+ if args.instance_cmd == "retire":
3919
+ return _instance_retire(root, args)
3920
+ return _instance_add(root, args)
3921
+
3922
+
3923
+ def _instance_add(root: Path, args) -> int:
3924
+ import json
3925
+
3926
+ role = resolve_role(args.role)
3927
+ count = max(int(args.count or 1), 1)
3928
+ names = list_roles(root)
3929
+ if role not in names and names and not args.new_role:
3930
+ die(
3931
+ f"the {role} role has no seat in this team. Staffed roles: {', '.join(names)}.\n"
3932
+ f" Add it anyway with `--new-role` (it gets the shared memory set and a "
3933
+ f"charter copy like any other role), or fix the spelling."
3934
+ )
3935
+ by = (args.by or "ceo").strip()
3936
+ staffed = set(names) | {role}
3937
+ if args.name and count > 1:
3938
+ die("--name names ONE instance; drop --count or add them one at a time", 2)
3939
+ created = []
3940
+ for _ in range(count):
3941
+ if args.name:
3942
+ if resolve_role(args.name) != role:
3943
+ die(
3944
+ f"'{args.name}' resolves to the {resolve_role(args.name)} role, "
3945
+ f"not {role}. An instance name must belong to the role it serves.",
3946
+ 2,
3947
+ )
3948
+ name = pascal(args.name)
3949
+ if agent_dir(root, name).is_dir():
3950
+ die(f"instance '{name}' already exists", 2)
3951
+ else:
3952
+ name = next_instance_name(root, role)
3953
+ scaffold_role(root, role, staffed=staffed)
3954
+ scaffold_instance(root, name, role, staffed=staffed)
3955
+ created.append(name)
3956
+ ledger_append(
3957
+ root,
3958
+ "instance.add",
3959
+ by,
3960
+ name,
3961
+ f"{role} instance {name}; shared memory Roles/{pascal(role)}",
3962
+ "",
3963
+ role,
3964
+ )
3965
+ if args.json:
3966
+ print(json.dumps({"role": role, "created": created}, indent=2))
3967
+ return 0
3968
+ inst = instances_of(root, role)
3969
+ print(f"teamwork: added {len(created)} {role} instance(s): {', '.join(created)}")
3970
+ print(f"# shared memory (ONE reviser at a time): {role_dir(root, role)}")
3971
+ print(f"# {role} instances now: {', '.join(inst)}")
3972
+ print(
3973
+ f"# start one with: teamwork.py load {created[0]} && teamwork.py task next {created[0]}"
3974
+ )
3975
+ print(f"# the role's memory pen: teamwork.py memory claim {role} --by {created[0]}")
3976
+ if len(inst) > 1:
3977
+ print(
3978
+ f"# WARNING {len(inst)} {role} instances share one queue and one memory. "
3979
+ "Two of them editing the memory at once is impossible (`memory claim` "
3980
+ "leases it), but each lane still needs its own branch: "
3981
+ f"`branch start --agent {created[0]} --task <T-id>`."
3982
+ )
3983
+ return 0
3984
+
3985
+
3986
+ def _instance_list(root: Path, as_json: bool) -> int:
3987
+ import json
3988
+
3989
+ rows = []
3990
+ for role in list_roles(root):
3991
+ claim = read_memory_claim(root, role)
3992
+ rows.append(
3993
+ {
3994
+ "role": role,
3995
+ "instances": instances_of(root, role),
3996
+ "memory_writer": pascal(claim["instance"]) if claim else "",
3997
+ "minutes_left": memory_claim_remaining(claim) if claim else 0,
3998
+ "tasks": len(parse_tasks(task_file(root, role))),
3999
+ }
4000
+ )
4001
+ if as_json:
4002
+ print(json.dumps({"roles": rows}, indent=2))
4003
+ return 0
4004
+ if not rows:
4005
+ print("teamwork: no role in this workspace yet")
4006
+ return 0
4007
+ for row in rows:
4008
+ holder = (
4009
+ f"memory-writer: {row['memory_writer']} ({row['minutes_left']}m)"
4010
+ if row["memory_writer"]
4011
+ else "memory free"
4012
+ )
4013
+ print(
4014
+ f"{row['role']:20s} instances={len(row['instances']):2d} "
4015
+ f"[{', '.join(row['instances']) or '-'}] tasks={row['tasks']:3d} {holder}"
4016
+ )
4017
+ return 0
4018
+
4019
+
4020
+ def _instance_retire(root: Path, args) -> int:
4021
+ name, role = validate_actor(root, args.name, "instance")
4022
+ d = agent_dir(root, name)
4023
+ if not d.is_dir():
4024
+ hint = (
4025
+ " The bare role name is only an instance when `Agents/<Role>/` exists - "
4026
+ f"this team's {role} lanes are: {', '.join(instances_of(root, role)) or 'none'}."
4027
+ )
4028
+ die(f"no instance directory for '{name}'.{hint}")
4029
+ by = (args.by or "ceo").strip()
4030
+ blockers = _instance_busy_reasons(root, role, name)
4031
+ if blockers and not args.reason:
4032
+ die("--reason is required to retire an instance", 2)
4033
+ if blockers and not args.force:
4034
+ die(
4035
+ f"{name} is not idle: {'; '.join(blockers)}.\n"
4036
+ f" Reclaim or finish that work, then retire it, or pass "
4037
+ f'`--force "<why>"` so the record says what was abandoned.',
4038
+ 2,
4039
+ )
4040
+ shutil.rmtree(d)
4041
+ ledger_append(
4042
+ root,
4043
+ "instance.retire",
4044
+ by,
4045
+ name,
4046
+ (args.reason or "no reason given")
4047
+ + (f" | forced past: {'; '.join(blockers)}" if blockers else "")
4048
+ + f" | {role} memory kept at Roles/{pascal(role)}",
4049
+ "",
4050
+ role,
4051
+ )
4052
+ print(f"teamwork: retired {name}; the {role} memory and queue stay in place")
4053
+ if not instances_of(root, role):
4054
+ print(
4055
+ f"# WARNING no {role} instance left: that lane owns work nobody can take. "
4056
+ f"Add one with `instance add {role}`."
4057
+ )
4058
+ return 0
4059
+
4060
+
4061
+ def _instance_busy_reasons(root: Path, role: str, name: str) -> list:
4062
+ reasons = []
4063
+ mine = pascal(name)
4064
+ for t in parse_tasks(task_file(root, role)):
4065
+ if pascal(t.get("claimed_by") or "") == mine and t.get("status") in (
4066
+ "doing",
4067
+ "selfcheck",
4068
+ "review",
4069
+ ):
4070
+ reasons.append(f"{t['id']} is {t.get('status')} by {t.get('claimed_by')}")
4071
+ claim = read_memory_claim(root, role)
4072
+ if claim and pascal(claim["instance"]) == mine:
4073
+ reasons.append(f"it holds the {role} memory pen ({memory_claim_remaining(claim)} min left)")
4074
+ slots = root / DIR_RUNTIME / DIR_SLOTS
4075
+ if slots.is_dir():
4076
+ for slot in slots.glob(f"*/{safe_component(mine)}.slot"):
4077
+ reasons.append(f"a provider slot is leased ({slot.parent.name})")
4078
+ return reasons
4079
+
4080
+
3597
4081
  def cmd_init(args) -> int:
3598
4082
  root = project_root(args)
3599
4083
  project = project_of(root)
@@ -3684,60 +4168,10 @@ def cmd_init(args) -> int:
3684
4168
  staffed = {resolve_role(n) for n in names}
3685
4169
  for name in names:
3686
4170
  role = resolve_role(name)
3687
- # role scope: memory + task queue, shared by every instance of the role
3688
- rd = role_dir(root, role)
3689
- rd.mkdir(parents=True, exist_ok=True)
3690
- for filename in (F_SHORT, F_LONG, F_MEM_ARCHIVE):
3691
- p = rd / filename
3692
- if not p.exists():
3693
- write_memory(p, [], filename, role)
3694
- tp = rd / F_TASKS
3695
- if not tp.exists():
3696
- write_tasks(tp, [], role)
4171
+ scaffold_role(root, role, staffed=staffed, force=args.force)
3697
4172
  if role not in roles_seen:
3698
4173
  roles_seen.append(role)
3699
-
3700
- # instance scope: private charter copy, worklog, inbox, scratch, outputs
3701
- d = agent_dir(root, name)
3702
- for sub in (DIR_ARTIFACTS, DIR_SCRATCH, DIR_REPORTS):
3703
- (d / sub).mkdir(parents=True, exist_ok=True)
3704
- role_file = ROLES_DIR / f"{role}.md"
3705
- shared_charter = rd / F_ROLE
3706
- if role_file.exists() and (args.force or not shared_charter.exists()):
3707
- body = role_file.read_text(encoding="utf-8").replace(
3708
- "{references}", str(SKILL_DIR / "references")
3709
- )
3710
- folded = charter_reports_line(role, staffed)
3711
- if folded:
3712
- body = fold_charter_reports(body, folded)
3713
- write_lf(shared_charter, body)
3714
- elif not shared_charter.exists():
3715
- write_lf(
3716
- shared_charter,
3717
- f"# Role: {role}\n\n"
3718
- f"No bundled charter found for '{role}'. Derive your duties from the\n"
3719
- f"task packet, report blockers to the CEO, and keep to your lane.\n",
3720
- )
3721
- brief = d / F_START
3722
- if args.force or not brief.exists():
3723
- write_lf(
3724
- brief,
3725
- start_here(
3726
- name, role, root, docs_dir(root), staffed={resolve_role(n) for n in names}
3727
- ),
3728
- )
3729
- for extra in (F_WORKLOG, F_INBOX):
3730
- p = d / extra
3731
- if not p.exists():
3732
- write_lf(
3733
- p,
3734
- f"# {extra[:-3]} - {name} ({role})\n\n"
3735
- f"> Private to this instance. The {role} memory and task queue\n"
3736
- f"> are shared with every other {role} instance.\n\n"
3737
- + AGENT_LINKS.format(**link_fields(d / extra, name))
3738
- + "\n",
3739
- )
3740
- migrate_instance_memory(root, name, role)
4174
+ scaffold_instance(root, name, role, staffed=staffed, force=args.force)
3741
4175
  created.append(name)
3742
4176
 
3743
4177
  print(f"teamwork: workspace ready at {root}")
@@ -4048,7 +4482,7 @@ def cmd_load(args) -> int:
4048
4482
  d = role_dir(root, role)
4049
4483
  if not d.is_dir() and not agents_dir(root).is_dir():
4050
4484
  die(f"unknown role '{role}' (run init or check `agents`)")
4051
- consolidate_agent(root, role, dry_run=False, quiet=True)
4485
+ consolidate_agent(root, role, dry_run=False, quiet=True, actor=args.agent, strict=False)
4052
4486
  limit = args.limit or SHORT_CAP
4053
4487
  entries = parse_memory(d / F_SHORT)
4054
4488
  entries.sort(key=lambda e: parse_date(e["last_used"]), reverse=True)
@@ -4060,6 +4494,19 @@ def cmd_load(args) -> int:
4060
4494
  f" ({len(keep)}/{len(entries)} entries)"
4061
4495
  )
4062
4496
  print(f"# shared by: {', '.join(inst) or role}")
4497
+ claim = read_memory_claim(root, role)
4498
+ if claim:
4499
+ print(
4500
+ f"# READ-ONLY right now: {memory_holder_line(claim)}. "
4501
+ f"Remember/touch/forget will be refused until that lease is released "
4502
+ f"(`memory status {role}`); reads and `recall` always work."
4503
+ )
4504
+ else:
4505
+ print(
4506
+ f"# memory is free: `memory claim {role} --by {args.agent} --reason '<what you "
4507
+ f"are about to update>'` holds the pen for a multi-entry update; a single "
4508
+ f"`remember` takes it automatically."
4509
+ )
4063
4510
  print(
4064
4511
  f"# Long-term entries are NOT loaded; query them with:"
4065
4512
  f' teamwork.py recall "<query>" --agent {role}'
@@ -4089,67 +4536,347 @@ def _memory_path(root: Path, agent: str, layer: str) -> tuple:
4089
4536
  return d / filename, filename
4090
4537
 
4091
4538
 
4092
- def cmd_remember(args) -> int:
4093
- root = project_root(args)
4094
- path, filename = _memory_path(root, args.agent, args.layer)
4095
- if not args.text.strip():
4096
- die("empty memory text")
4097
- with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{resolve_role(args.agent)}.lock"):
4098
- entries = parse_memory(path)
4099
- entry = new_entry(
4100
- args.text,
4101
- args.key,
4102
- args.importance,
4103
- args.tags,
4104
- args.pin,
4105
- by=args.by or "",
4106
- src=args.src or "",
4107
- rev=args.rev or "",
4108
- )
4109
- entries, merged, added = merge_entries(entries, [entry])
4110
- write_memory(path, entries, filename, args.agent)
4111
- ledger_append(
4112
- root,
4113
- "memory.add" if added else "memory.update",
4114
- args.by or args.agent,
4115
- args.key or args.agent,
4116
- f"{'added' if added else 'updated'} {args.layer} entry: {args.text[:80]}",
4117
- args.rev,
4539
+ # --------------------------------------------------------------------------- #
4540
+ # Role memory: ONE revising instance at a time
4541
+ # --------------------------------------------------------------------------- #
4542
+ # `Runtime/Locks/role-<Role>.lock` makes a single write atomic, and that is all it
4543
+ # does. Two instances of one role can still interleave their edits: A adds entry
4544
+ # 1, B consolidates and evicts it, A adds entry 2 against a file it never saw - and
4545
+ # the role's memory ends up as a merge nobody reasoned about. The lease below is
4546
+ # the missing rule: one instance of the role holds the pen, every other instance of
4547
+ # that role is read-only until the pen is released or its lease expires. Several
4548
+ # roles revise their own memory in parallel; instances within a role do not.
4549
+ #
4550
+ # Two ways to hold it, both under the same state file:
4551
+ # * explicit - `memory claim` / `memory release`; held across several commands so
4552
+ # one coherent update lands before a peer looks at the file ("until the memory
4553
+ # for that role is fully updated");
4554
+ # * implicit - one mutating command (`remember`, `forget`, `touch`,
4555
+ # `consolidate`) takes the pen for its own duration and gives it back. Two
4556
+ # peers still serialise instead of interleaving, without a second ceremony.
4557
+
4558
+
4559
+ def memory_claim_path(root: Path, role: str) -> Path:
4560
+ return root / DIR_RUNTIME / DIR_LOCKS / f"memory-{resolve_role(role)}.claim"
4561
+
4562
+
4563
+ def memory_lease_minutes(root: Path) -> int:
4564
+ try:
4565
+ return max(int(_load_settings(root).get("MemoryLeaseMinutes", MEMORY_LEASE_MINUTES)), 1)
4566
+ except (TypeError, ValueError):
4567
+ return MEMORY_LEASE_MINUTES
4568
+
4569
+
4570
+ def memory_stall_minutes(root: Path) -> int:
4571
+ """How long a lease is held before the role's peers may call it stalled."""
4572
+ try:
4573
+ return max(int(_load_settings(root).get("MemoryStallMinutes", MEMORY_STALL_MINUTES)), 1)
4574
+ except (TypeError, ValueError):
4575
+ return MEMORY_STALL_MINUTES
4576
+
4577
+
4578
+ def memory_claim_age(claim: dict) -> int | None:
4579
+ return claim_age_minutes(claim.get("acquired") or "")
4580
+
4581
+
4582
+ def memory_claim_is_slow(root: Path, claim: dict) -> bool:
4583
+ age = memory_claim_age(claim)
4584
+ return age is not None and age >= memory_stall_minutes(root)
4585
+
4586
+
4587
+ def memory_arbiter(root: Path, holder_role: str = "") -> tuple:
4588
+ """Who rules on a contested memory lease on THIS roster: (role, skipped).
4589
+
4590
+ The CTO owns engineering standards and the build and lock policy, so a stuck
4591
+ writer is an engineering question. On a roster without a CTO the request folds
4592
+ up the reporting line to the CEO (AGENTS.md: a vacant manager escalates to the
4593
+ CEO), and a party never rules its own lease - a CTO's own memory being contested
4594
+ goes to the CEO.
4595
+ """
4596
+ staffed = set(list_roles(root))
4597
+ chain = [MEMORY_ARBITER_ROLE]
4598
+ while chain[-1] != "ceo" and chain[-1] in ROLE_PARENT:
4599
+ chain.append(ROLE_PARENT[chain[-1]] or "ceo")
4600
+ held_by = resolve_role(holder_role) if holder_role else ""
4601
+ skipped = []
4602
+ for role in chain:
4603
+ if role == held_by:
4604
+ skipped.append(role)
4605
+ continue
4606
+ if role in staffed:
4607
+ return role, skipped
4608
+ skipped.append(role)
4609
+ return "", skipped
4610
+
4611
+
4612
+ def read_memory_claim(root: Path, role: str) -> dict:
4613
+ """The live revision lease for this role's shared memory, or {} when free."""
4614
+ import json
4615
+
4616
+ path = memory_claim_path(root, role)
4617
+ try:
4618
+ data = json.loads(path.read_text(encoding="utf-8"))
4619
+ except (OSError, ValueError):
4620
+ return {}
4621
+ if not isinstance(data, dict) or not data.get("instance"):
4622
+ return {}
4623
+ try:
4624
+ expires = float(data.get("expires_ts") or 0)
4625
+ except (TypeError, ValueError):
4626
+ return {}
4627
+ if expires <= time.time():
4628
+ return {}
4629
+ return data
4630
+
4631
+
4632
+ def memory_claim_remaining(claim: dict) -> int:
4633
+ try:
4634
+ return max(int(round((float(claim.get("expires_ts") or 0) - time.time()) / 60.0)), 0)
4635
+ except (TypeError, ValueError):
4636
+ return 0
4637
+
4638
+
4639
+ def memory_holder_line(claim: dict) -> str:
4640
+ role = claim.get("role") or resolve_role(claim.get("instance", ""))
4641
+ return (
4642
+ f"{pascal(claim['instance'])} is revising the {role} memory "
4643
+ f"({memory_claim_remaining(claim)} min left on its lease, "
4644
+ f"since {claim.get('acquired') or '-'}: {claim.get('reason') or 'no reason given'})"
4118
4645
  )
4119
- consolidate_agent(root, args.agent, dry_run=False, quiet=True)
4120
- print(f"teamwork: remembered {added} new / {merged} merged entry in {path}")
4121
- return 0
4122
4646
 
4123
4647
 
4124
- def cmd_global(args) -> int:
4125
- root = project_root(args)
4126
- path = global_dir(root) / F_GLOBAL
4648
+ def _write_memory_claim(root: Path, claim: dict) -> dict:
4649
+ import json
4650
+
4651
+ path = memory_claim_path(root, claim["role"])
4127
4652
  path.parent.mkdir(parents=True, exist_ok=True)
4128
- with lock(root / DIR_RUNTIME / DIR_LOCKS / "global.lock"):
4129
- entries = parse_memory(path)
4130
- entry = new_entry(args.text, args.key, args.importance, args.tags, args.pin)
4131
- entries, merged, added = merge_entries(entries, [entry])
4132
- entries.sort(key=score, reverse=True)
4133
- pinned = [e for e in entries if e["pinned"]]
4134
- rest = [e for e in entries if not e["pinned"]]
4135
- cap = max(LONG_CAP - len(pinned), 0)
4136
- if len(rest) > cap:
4137
- archive_path = global_dir(root) / F_GLOBAL_ARCHIVE
4138
- archived = parse_memory(archive_path)
4139
- archived, _, _ = merge_entries(archived, rest[cap:])
4140
- archived.sort(key=score, reverse=True)
4141
- write_memory(archive_path, archived[:ARCHIVE_CAP], F_GLOBAL_ARCHIVE, "all agents")
4142
- rest = rest[:cap]
4143
- write_memory(path, pinned + rest, F_GLOBAL, "all agents")
4144
- ledger_append(
4145
- root,
4146
- "memory.global",
4147
- "ceo",
4148
- args.key or "global",
4149
- f"{added} new / {merged} merged: {args.text[:80]}",
4150
- )
4151
- print(f"teamwork: global memory {added} new / {merged} merged ({path})")
4152
- return 0
4653
+ atomic_write(path, json.dumps(claim, indent=2, sort_keys=True) + "\n")
4654
+ return claim
4655
+
4656
+
4657
+ def _clear_memory_claim(root: Path, role: str, token: str) -> bool:
4658
+ """Remove the lease only if this process still owns it."""
4659
+ path = memory_claim_path(root, role)
4660
+ try:
4661
+ import json
4662
+
4663
+ data = json.loads(path.read_text(encoding="utf-8"))
4664
+ except (OSError, ValueError):
4665
+ return False
4666
+ if not isinstance(data, dict) or data.get("token") != token:
4667
+ return False
4668
+ try:
4669
+ path.unlink()
4670
+ except OSError:
4671
+ return False
4672
+ return True
4673
+
4674
+
4675
+ def memory_actor(root: Path, role: str, actor: str = "", require_instance: bool = False) -> str:
4676
+ """The instance acting on a role's memory, validated against that role.
4677
+
4678
+ A bare role name is accepted (it means the role's own pool) unless the caller
4679
+ needs one accountable instance - `memory claim` does, because the lease names
4680
+ who holds the pen. The CEO may hold it too: the shared tree is the CEO's, and a
4681
+ seat that holds the pen still excludes every peer.
4682
+ """
4683
+ role = resolve_role(role)
4684
+ raw = (actor or "").strip()
4685
+ if not raw:
4686
+ return pascal(role)
4687
+ resolved = resolve_role(raw)
4688
+ if resolved != role:
4689
+ if resolved == "ceo":
4690
+ return pascal(raw)
4691
+ die(
4692
+ f"'{raw}' belongs to the {resolved} role, not {role}. A role's shared "
4693
+ f"memory is revised by its own instances (or the CEO), so a lane cannot "
4694
+ f"rewrite a peer role's memory."
4695
+ )
4696
+ inst = pascal(raw)
4697
+ insts = instances_of(root, role)
4698
+ names = {pascal(i) for i in insts}
4699
+ # A name that IS an instance is never ambiguous, even when it equals the role's
4700
+ # own name (`memory claim cto --by CTO` with CTO and CTO-2 both staffed). Only a
4701
+ # role name with no such directory means "the role's pool", and a lease must not
4702
+ # name a pool.
4703
+ if inst == pascal(role) and require_instance and inst not in names:
4704
+ if len(insts) > 1:
4705
+ die(
4706
+ f"name the {role} instance holding the pen: '{raw}' is the role, and it "
4707
+ f"has {', '.join(insts)}. Only one instance of a role revises its shared "
4708
+ f"memory at a time, so the lease must name one of them "
4709
+ f"(`--by {insts[-1]}`)."
4710
+ )
4711
+ return pascal(insts[0]) if insts else inst
4712
+ known = names
4713
+ if known and inst not in known and inst != pascal(role):
4714
+ die(
4715
+ f"unknown {role} instance '{raw}'. Known: {', '.join(sorted(known))}. "
4716
+ f"Add one with `teamwork.py instance add {role}`."
4717
+ )
4718
+ return inst
4719
+
4720
+
4721
+ @contextlib.contextmanager
4722
+ def memory_session(
4723
+ root: Path,
4724
+ role: str,
4725
+ actor: str = "",
4726
+ reason: str = "",
4727
+ strict: bool = True,
4728
+ minutes: int | None = None,
4729
+ ):
4730
+ """Hold the role's memory pen for the body, then give it back.
4731
+
4732
+ Yields the live claim, or ``None`` when ``strict`` is false and a peer holds an
4733
+ explicit lease - a read path (`load` consolidating in the background) degrades
4734
+ to "skip maintenance" instead of failing the lane that only wanted to read.
4735
+ """
4736
+ role = resolve_role(role)
4737
+ inst = memory_actor(root, role, actor)
4738
+ lock_path = root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"
4739
+ ttl = max(int(minutes or memory_lease_minutes(root)), 1)
4740
+
4741
+ def peer_holds() -> dict:
4742
+ held = read_memory_claim(root, role)
4743
+ if held and held.get("explicit") and pascal(held["instance"]) != inst:
4744
+ return held
4745
+ return {}
4746
+
4747
+ blocked = peer_holds()
4748
+ if blocked:
4749
+ if strict:
4750
+ die(
4751
+ f"{memory_holder_line(blocked)}. Only one instance of the {role} role "
4752
+ f"revises its shared memory at a time. Read it (`recall`) and retry "
4753
+ f"when the lease is released, or take the pen deliberately: "
4754
+ f'`memory claim {role} --by {inst} --force "<why>"`.',
4755
+ 2,
4756
+ )
4757
+ yield None
4758
+ return
4759
+
4760
+ with lock(lock_path):
4761
+ blocked = peer_holds()
4762
+ if blocked:
4763
+ if not strict:
4764
+ yield None
4765
+ return
4766
+ die(
4767
+ f"{memory_holder_line(blocked)}. Only one instance of the {role} role "
4768
+ f"revises its shared memory at a time.",
4769
+ 2,
4770
+ )
4771
+ held = read_memory_claim(root, role)
4772
+ explicit = bool(held.get("explicit")) and pascal(held["instance"]) == inst
4773
+ # Joining my own lease keeps what the arbiter and the peers put on it:
4774
+ # a pending contest must not vanish because the holder wrote one more
4775
+ # entry, and an arbiter's ruling must stay on the record.
4776
+ claim = dict(held) if explicit else {}
4777
+ claim.update(
4778
+ {
4779
+ "role": role,
4780
+ "instance": inst,
4781
+ "token": f"{os.getpid()}.{time.time():.6f}",
4782
+ "acquired": (held.get("acquired") if explicit else "")
4783
+ or datetime.datetime.now().isoformat(timespec="seconds"),
4784
+ "expires_ts": time.time() + ttl * 60.0,
4785
+ "minutes": ttl,
4786
+ "explicit": explicit,
4787
+ "reason": (held.get("reason") if explicit else "") or reason or "memory update",
4788
+ }
4789
+ )
4790
+ _write_memory_claim(root, claim)
4791
+ try:
4792
+ yield claim
4793
+ finally:
4794
+ # An explicit lease outlives the command that created it; it is the
4795
+ # whole point of `memory claim`. An implicit one dies with the write.
4796
+ if not claim["explicit"]:
4797
+ _clear_memory_claim(root, role, claim["token"])
4798
+
4799
+
4800
+ def _role_memory_paths(root: Path, role: str) -> tuple:
4801
+ d = role_dir(root, role)
4802
+ return d, d / F_SHORT, d / F_LONG, d / F_MEM_ARCHIVE
4803
+
4804
+
4805
+ def cmd_remember(args) -> int:
4806
+ root = project_root(args)
4807
+ path, filename = _memory_path(root, args.agent, args.layer)
4808
+ if not args.text.strip():
4809
+ die("empty memory text")
4810
+ role = resolve_role(args.agent)
4811
+ with memory_session(root, role, args.by or args.agent, reason=f"remember {args.key or 'note'}"):
4812
+ entries = parse_memory(path)
4813
+ entry = new_entry(
4814
+ args.text,
4815
+ args.key,
4816
+ args.importance,
4817
+ args.tags,
4818
+ args.pin,
4819
+ by=args.by or "",
4820
+ src=args.src or "",
4821
+ rev=args.rev or "",
4822
+ )
4823
+ entries, merged, added = merge_entries(entries, [entry])
4824
+ write_memory(path, entries, filename, args.agent)
4825
+ ledger_append(
4826
+ root,
4827
+ "memory.add" if added else "memory.update",
4828
+ args.by or args.agent,
4829
+ args.key or args.agent,
4830
+ f"{'added' if added else 'updated'} {args.layer} entry: {args.text[:80]}",
4831
+ args.rev,
4832
+ )
4833
+ d, short_path, long_path, arch_path = _role_memory_paths(root, role)
4834
+ _consolidate_locked(
4835
+ root, role, role, d, short_path, long_path, arch_path, dry_run=False, quiet=True
4836
+ )
4837
+ print(f"teamwork: remembered {added} new / {merged} merged entry in {path}")
4838
+ return 0
4839
+
4840
+
4841
+ def cmd_global(args) -> int:
4842
+ root = project_root(args)
4843
+ path = global_dir(root) / F_GLOBAL
4844
+ path.parent.mkdir(parents=True, exist_ok=True)
4845
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / "global.lock"):
4846
+ entries = parse_memory(path)
4847
+ entry = new_entry(
4848
+ args.text,
4849
+ args.key,
4850
+ args.importance,
4851
+ args.tags,
4852
+ args.pin,
4853
+ by=args.by or "",
4854
+ src=args.src or "",
4855
+ rev=args.rev or "",
4856
+ )
4857
+ entries, merged, added = merge_entries(entries, [entry])
4858
+ entries.sort(key=score, reverse=True)
4859
+ pinned = [e for e in entries if e["pinned"]]
4860
+ rest = [e for e in entries if not e["pinned"]]
4861
+ cap = max(LONG_CAP - len(pinned), 0)
4862
+ if len(rest) > cap:
4863
+ archive_path = global_dir(root) / F_GLOBAL_ARCHIVE
4864
+ archived = parse_memory(archive_path)
4865
+ archived, _, _ = merge_entries(archived, rest[cap:])
4866
+ archived.sort(key=score, reverse=True)
4867
+ write_memory(archive_path, archived[:ARCHIVE_CAP], F_GLOBAL_ARCHIVE, "all agents")
4868
+ rest = rest[:cap]
4869
+ write_memory(path, pinned + rest, F_GLOBAL, "all agents")
4870
+ ledger_append(
4871
+ root,
4872
+ "memory.global",
4873
+ args.by or "ceo",
4874
+ args.key or "global",
4875
+ f"{added} new / {merged} merged: {args.text[:80]}",
4876
+ args.rev,
4877
+ )
4878
+ print(f"teamwork: global memory {added} new / {merged} merged ({path})")
4879
+ return 0
4153
4880
 
4154
4881
 
4155
4882
  def cmd_recall(args) -> int:
@@ -4231,7 +4958,9 @@ def _find_entry(entries: list, handle: str):
4231
4958
  def cmd_touch(args) -> int:
4232
4959
  root = project_root(args)
4233
4960
  path, filename = _memory_path(root, args.agent, args.layer)
4234
- with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{resolve_role(args.agent)}.lock"):
4961
+ role = resolve_role(args.agent)
4962
+ actor = getattr(args, "by", "") or args.agent
4963
+ with memory_session(root, role, actor, reason=f"touch {args.handle}"):
4235
4964
  entries = parse_memory(path)
4236
4965
  idx = _find_entry(entries, args.handle)
4237
4966
  if idx is None:
@@ -4239,13 +4968,13 @@ def cmd_touch(args) -> int:
4239
4968
  entries[idx]["uses"] += 1
4240
4969
  entries[idx]["last_used"] = today_iso()
4241
4970
  write_memory(path, entries, filename, args.agent)
4242
- ledger_append(
4243
- root,
4244
- "memory.touch",
4245
- args.agent,
4246
- entries[idx]["key"] or args.handle,
4247
- f"uses={entries[idx]['uses']}",
4248
- )
4971
+ ledger_append(
4972
+ root,
4973
+ "memory.touch",
4974
+ actor,
4975
+ entries[idx]["key"] or args.handle,
4976
+ f"uses={entries[idx]['uses']}",
4977
+ )
4249
4978
  print(f"teamwork: touched {entries[idx]['key'] or args.handle} (uses={entries[idx]['uses']})")
4250
4979
  return 0
4251
4980
 
@@ -4253,35 +4982,413 @@ def cmd_touch(args) -> int:
4253
4982
  def cmd_forget(args) -> int:
4254
4983
  root = project_root(args)
4255
4984
  path, filename = _memory_path(root, args.agent, args.layer)
4256
- with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{resolve_role(args.agent)}.lock"):
4985
+ role = resolve_role(args.agent)
4986
+ actor = getattr(args, "by", "") or args.agent
4987
+ with memory_session(root, role, actor, reason=f"forget {args.handle}"):
4257
4988
  entries = parse_memory(path)
4258
4989
  idx = _find_entry(entries, args.handle)
4259
4990
  if idx is None:
4260
4991
  die(f"no entry '{args.handle}' in {path}")
4261
4992
  removed = entries.pop(idx)
4262
4993
  write_memory(path, entries, filename, args.agent)
4994
+ ledger_append(
4995
+ root,
4996
+ "memory.forget",
4997
+ actor,
4998
+ removed["key"] or args.handle,
4999
+ f"removed: {(removed.get('body') or '')[:80]}",
5000
+ )
5001
+ print(f"teamwork: forgot {removed['key'] or args.handle}")
5002
+ return 0
5003
+
5004
+
5005
+ def cmd_memory(args) -> int:
5006
+ """`memory claim|release|renew|contest|extend|revoke|status`.
5007
+
5008
+ Who may revise a role's memory, who may call a long update stalled, and who
5009
+ rules on it.
5010
+ """
5011
+ root = project_root(args)
5012
+ sub = args.memory_cmd
5013
+ if sub == "status":
5014
+ return _memory_status(root, getattr(args, "agent", "") or "", args.json)
5015
+ role = resolve_role(args.agent)
5016
+ if not role_dir(root, role).is_dir() and not agents_dir(root).is_dir():
5017
+ die("no workspace here: run `teamwork.py init` first")
5018
+ if sub == "claim":
5019
+ actor = memory_actor(root, role, args.by, require_instance=True)
5020
+ return _memory_claim(root, role, actor, args.ttl, args.reason, args.force)
5021
+ if sub == "contest":
5022
+ actor = memory_actor(root, role, args.by, require_instance=True)
5023
+ return _memory_contest(root, role, actor, args.reason)
5024
+ if sub in ("extend", "revoke"):
5025
+ # The arbiter is deliberately ANOTHER role (the CTO, folded up to the CEO),
5026
+ # so this path bypasses `memory_actor`: a peer role may not write the
5027
+ # memory, but it may rule on who is allowed to.
5028
+ if not (args.by or "").strip():
5029
+ die(f"--by is required for `memory {sub}`: name the arbiter ruling", 2)
5030
+ actor, _actor_role = validate_actor(root, args.by, "arbiter")
5031
+ return _memory_arbitrate(
5032
+ root, role, actor, sub, getattr(args, "ttl", 0), getattr(args, "reason", "")
5033
+ )
5034
+ actor = memory_actor(root, role, args.by)
5035
+ if sub == "renew":
5036
+ return _memory_renew(root, role, actor, args.ttl)
5037
+ if sub == "release":
5038
+ return _memory_release(root, role, actor, args.force)
5039
+ die(f"unknown memory subcommand '{sub}'")
5040
+
5041
+
5042
+ def _memory_contest(root: Path, role: str, actor: str, reason: str) -> int:
5043
+ """A peer of the role asks the arbiter to rule on a lease that is running long."""
5044
+ if not reason.strip():
5045
+ die("--reason is required: what makes this update look stalled?", 2)
5046
+ task_id = ""
5047
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
5048
+ held = read_memory_claim(root, role)
5049
+ if not held:
5050
+ print(
5051
+ f"teamwork: no live lease on the {role} memory - nothing to contest. "
5052
+ f"`memory status {role}` shows the truth."
5053
+ )
5054
+ return 0
5055
+ if pascal(held["instance"]) == actor:
5056
+ die(
5057
+ f"{actor} holds the {role} memory pen; a holder does not contest its own "
5058
+ f"lease. Release it (`memory release {role} --by {actor}`) or finish the "
5059
+ f"update.",
5060
+ 2,
5061
+ )
5062
+ age = memory_claim_age(held)
5063
+ arbiter, _skipped = memory_arbiter(root, role)
5064
+ if not arbiter:
5065
+ die(
5066
+ "no seat on this roster may rule on that lease (the arbiter and every "
5067
+ "manager above it are the holder's own role). Open a dispute instead: "
5068
+ f"`dispute open --by {actor} --against {held['instance']} --topic "
5069
+ f"'stalled {role} memory lease' --evidence '<age, task, lease>'`.",
5070
+ 2,
5071
+ )
5072
+ contests = [c for c in (held.get("contests") or []) if isinstance(c, dict)]
5073
+ contests.append(
5074
+ {
5075
+ "by": actor,
5076
+ "at": datetime.datetime.now().isoformat(timespec="seconds"),
5077
+ "reason": reason.strip(),
5078
+ "age_minutes": age,
5079
+ }
5080
+ )
5081
+ held["contests"] = contests
5082
+ _write_memory_claim(root, held)
5083
+ title = f"Rule on the {role} memory lease held by {pascal(held['instance'])}"
5084
+ body = (
5085
+ f"{actor} contests the {role} memory lease.\n\n"
5086
+ f"- holder: {pascal(held['instance'])}\n"
5087
+ f"- held since: {held.get('acquired') or '-'} ({age if age is not None else '?'} min)\n"
5088
+ f"- lease left: {memory_claim_remaining(held)} min (of {held.get('minutes')})\n"
5089
+ f"- reason given by the holder: {held.get('reason') or '-'}\n"
5090
+ f"- why it looks stalled: {reason.strip()}\n\n"
5091
+ f"Rule with one of:\n"
5092
+ f"- `memory extend {role} --by <you> --ttl 30 --reason '<why more time is fair>'`\n"
5093
+ f"- `memory revoke {role} --by <you> --reason '<why the pen is removed>'`"
5094
+ )
5095
+ task_id = _file_arbiter_task(root, arbiter, title, body, actor, role)
4263
5096
  ledger_append(
4264
5097
  root,
4265
- "memory.forget",
4266
- args.agent,
4267
- removed["key"] or args.handle,
4268
- f"removed: {(removed.get('body') or '')[:80]}",
5098
+ "memory.contest",
5099
+ actor,
5100
+ role,
5101
+ f"contested {pascal(held['instance'])}'s lease ({age if age is not None else '?'} min)"
5102
+ f" -> {arbiter}{' ' + task_id if task_id else ''}: {reason.strip()[:120]}",
5103
+ "",
5104
+ role,
4269
5105
  )
4270
- print(f"teamwork: forgot {removed['key'] or args.handle}")
5106
+ who = ROLE_DISPLAY.get(arbiter, pascal(arbiter))
5107
+ print(
5108
+ f"teamwork: contest recorded - {pascal(held['instance'])} has held the {role} "
5109
+ f"memory pen for {age if age is not None else '?'} min."
5110
+ )
5111
+ print(f"# arbiter: {who}" + (f", task {task_id} on its queue" if task_id else ""))
5112
+ print(
5113
+ f"# {who} rules: `memory extend {role} --by {who} --ttl N` or `memory revoke {role} --by {who} --reason '<why>'`"
5114
+ )
5115
+ return 0
5116
+
5117
+
5118
+ def _memory_arbitrate(root: Path, role: str, actor: str, action: str, ttl: int, reason: str) -> int:
5119
+ """`extend` (more time for this holder) or `revoke` (free the role now)."""
5120
+ arbiter, skipped = memory_arbiter(root, role)
5121
+ actor_role = resolve_role(actor)
5122
+ if not arbiter or actor_role not in (arbiter, "ceo"):
5123
+ expected = ROLE_DISPLAY.get(arbiter, pascal(arbiter)) if arbiter else "no seat"
5124
+ folded = f" ({', '.join(skipped)} unstaffed or a party)" if skipped else ""
5125
+ die(
5126
+ f"only the {expected}{folded} rules on the {role} memory lease: the CTO "
5127
+ f"owns engineering standards and the lock policy, and a peer cannot "
5128
+ f"extend or remove another instance's lease. Contest it first: "
5129
+ f"`memory contest {role} --by <you> --reason '<why>'`.",
5130
+ 2,
5131
+ )
5132
+ if not reason.strip():
5133
+ die(f"--reason is required for `memory {action}`: the ruling is evidence", 2)
5134
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
5135
+ held = read_memory_claim(root, role)
5136
+ if not held:
5137
+ print(
5138
+ f"teamwork: no live lease on the {role} memory - it has already "
5139
+ f"expired or been released; nothing to {action}."
5140
+ )
5141
+ return 0
5142
+ holder = pascal(held["instance"])
5143
+ if action == "extend":
5144
+ minutes = max(int(ttl or held.get("minutes") or memory_lease_minutes(root)), 1)
5145
+ held["expires_ts"] = time.time() + minutes * 60.0
5146
+ held["minutes"] = minutes
5147
+ held["arbitrated_by"] = actor
5148
+ held["arbitrated_at"] = datetime.datetime.now().isoformat(timespec="seconds")
5149
+ held["contests"] = []
5150
+ _write_memory_claim(root, held)
5151
+ detail = (
5152
+ f"extended {holder}'s lease by {minutes} min (contests answered): "
5153
+ f"{reason.strip()[:120]}"
5154
+ )
5155
+ else:
5156
+ _clear_memory_claim(root, role, held["token"])
5157
+ detail = f"revoked {holder}'s lease, {role} memory free: {reason.strip()[:120]}"
5158
+ ledger_append(
5159
+ root,
5160
+ "memory.grant" if action == "extend" else "memory.revoke",
5161
+ actor,
5162
+ role,
5163
+ detail + (f" | skipped from the arbiter chain: {', '.join(skipped)}" if skipped else ""),
5164
+ "",
5165
+ role,
5166
+ )
5167
+ if action == "extend":
5168
+ print(
5169
+ f"teamwork: {holder} keeps the {role} memory pen for {minutes} more min "
5170
+ f"(ruled by {actor})"
5171
+ )
5172
+ else:
5173
+ print(f"teamwork: {holder}'s lease on the {role} memory is revoked; the role is free")
5174
+ return 0
5175
+
5176
+
5177
+ def _file_arbiter_task(root: Path, arbiter: str, title: str, body: str, by: str, role: str) -> str:
5178
+ """Put the request on the arbiter's queue, once, without duplicating an open one."""
5179
+ if not instances_of(root, arbiter):
5180
+ return ""
5181
+ path = task_file(root, arbiter)
5182
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{arbiter}.lock"):
5183
+ tasks = parse_tasks(path)
5184
+ for existing in tasks:
5185
+ if existing["title"].strip().lower() == title.strip().lower() and existing[
5186
+ "status"
5187
+ ] not in ("done", "dropped"):
5188
+ return existing["id"]
5189
+ t = new_task(
5190
+ title,
5191
+ "P0",
5192
+ tags="memory-lease,arbitration",
5193
+ body=body,
5194
+ review="na",
5195
+ )
5196
+ t["area"] = "memory-lease"
5197
+ t["claimed_by"] = ""
5198
+ tasks.append(t)
5199
+ write_tasks(path, tasks, arbiter)
5200
+ task_id = tasks[-1]["id"]
5201
+ ledger_append(
5202
+ root,
5203
+ "task.add",
5204
+ by,
5205
+ task_id,
5206
+ f"arbitration request on the {role} memory lease -> {arbiter}",
5207
+ "",
5208
+ arbiter,
5209
+ )
5210
+ return task_id
5211
+
5212
+
5213
+ def _memory_claim(root: Path, role: str, actor: str, ttl: int, reason: str, force: str) -> int:
5214
+ minutes = max(int(ttl or memory_lease_minutes(root)), 1)
5215
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
5216
+ held = read_memory_claim(root, role)
5217
+ if held and pascal(held["instance"]) != actor:
5218
+ if not force:
5219
+ die(
5220
+ f"{memory_holder_line(held)}. Only one instance of the {role} role "
5221
+ f"revises its shared memory at a time. Wait for the release "
5222
+ f"(`memory status {role}`), or take the pen deliberately with "
5223
+ f'`--force "<why>"`.',
5224
+ 2,
5225
+ )
5226
+ _clear_memory_claim(root, role, held["token"])
5227
+ ledger_append(
5228
+ root,
5229
+ "memory.claim.force",
5230
+ actor,
5231
+ role,
5232
+ f"took the pen from {pascal(held['instance'])}: {force}",
5233
+ "",
5234
+ role,
5235
+ )
5236
+ claim = {
5237
+ "role": role,
5238
+ "instance": actor,
5239
+ "token": f"{os.getpid()}.{time.time():.6f}",
5240
+ "acquired": datetime.datetime.now().isoformat(timespec="seconds"),
5241
+ "expires_ts": time.time() + minutes * 60.0,
5242
+ "minutes": minutes,
5243
+ "explicit": True,
5244
+ "reason": reason or "memory update",
5245
+ }
5246
+ _write_memory_claim(root, claim)
5247
+ ledger_append(
5248
+ root,
5249
+ "memory.claim",
5250
+ actor,
5251
+ role,
5252
+ f"explicit {minutes}-minute lease: {claim['reason']}",
5253
+ "",
5254
+ role,
5255
+ )
5256
+ print(
5257
+ f"teamwork: {actor} now revises the {role} memory ({minutes} min lease, "
5258
+ f"expires in {memory_claim_remaining(claim)} min).\n"
5259
+ f"# every other {role} instance is read-only until "
5260
+ f"`memory release {role} --by {actor}` or the lease expires.\n"
5261
+ f"# renew with `memory renew {role} --by {actor}` if the update runs long."
5262
+ )
5263
+ return 0
5264
+
5265
+
5266
+ def _memory_renew(root: Path, role: str, actor: str, ttl: int) -> int:
5267
+ minutes = max(int(ttl or memory_lease_minutes(root)), 1)
5268
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
5269
+ held = read_memory_claim(root, role)
5270
+ if not held:
5271
+ die(
5272
+ f"no live lease on the {role} memory: nothing to renew. Take it with "
5273
+ f"`memory claim {role} --by {actor}`.",
5274
+ 2,
5275
+ )
5276
+ if pascal(held["instance"]) != actor:
5277
+ die(
5278
+ f"{memory_holder_line(held)}. Only the holder renews it.",
5279
+ 2,
5280
+ )
5281
+ held["expires_ts"] = time.time() + minutes * 60.0
5282
+ held["minutes"] = minutes
5283
+ held["explicit"] = True
5284
+ _write_memory_claim(root, held)
5285
+ ledger_append(root, "memory.renew", actor, role, f"lease extended by {minutes} min", "", role)
5286
+ print(f"teamwork: {actor} holds the {role} memory pen for another {minutes} min")
5287
+ return 0
5288
+
5289
+
5290
+ def _memory_release(root: Path, role: str, actor: str, force: str) -> int:
5291
+ with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
5292
+ held = read_memory_claim(root, role)
5293
+ if not held:
5294
+ print(f"teamwork: nothing to release - no instance is revising the {role} memory")
5295
+ return 0
5296
+ holder = pascal(held["instance"])
5297
+ if holder != actor and not force:
5298
+ die(
5299
+ f"{memory_holder_line(held)}. Only the holder releases the pen; "
5300
+ f'take it over deliberately with `--force "<why>"` if that lane is gone.',
5301
+ 2,
5302
+ )
5303
+ _clear_memory_claim(root, role, held["token"])
5304
+ ledger_append(
5305
+ root,
5306
+ "memory.release",
5307
+ actor,
5308
+ role,
5309
+ f"released the pen held by {holder}" + (f" (forced: {force})" if force else ""),
5310
+ "",
5311
+ role,
5312
+ )
5313
+ print(f"teamwork: the {role} memory is free; any {role} instance may revise it now")
5314
+ return 0
5315
+
5316
+
5317
+ def _memory_status(root: Path, agent: str, as_json: bool) -> int:
5318
+ import json
5319
+
5320
+ roles = [resolve_role(agent)] if agent else list_roles(root)
5321
+ if not roles:
5322
+ print("teamwork: no workspace here (run `teamwork.py init`)")
5323
+ return 0
5324
+ rows = []
5325
+ for role in roles:
5326
+ claim = read_memory_claim(root, role)
5327
+ age = memory_claim_age(claim) if claim else None
5328
+ contests = (
5329
+ [c for c in (claim.get("contests") or []) if isinstance(c, dict)] if claim else []
5330
+ )
5331
+ rows.append(
5332
+ {
5333
+ "role": role,
5334
+ "instances": instances_of(root, role),
5335
+ "holder": pascal(claim["instance"]) if claim else "",
5336
+ "minutes_left": memory_claim_remaining(claim) if claim else 0,
5337
+ "held_minutes": age if age is not None else 0,
5338
+ "since": claim.get("acquired", "") if claim else "",
5339
+ "reason": claim.get("reason", "") if claim else "",
5340
+ "explicit": bool(claim.get("explicit")) if claim else False,
5341
+ "slow": bool(claim) and memory_claim_is_slow(root, claim),
5342
+ "contests": contests,
5343
+ "arbiter": memory_arbiter(root, role)[0] if contests else "",
5344
+ }
5345
+ )
5346
+ if as_json:
5347
+ print(json.dumps({"roles": rows, "stall_minutes": memory_stall_minutes(root)}, indent=2))
5348
+ return 0
5349
+ print(f"# who may revise each role's shared memory now ({len(rows)} role(s))")
5350
+ print(f"# a lease held longer than {memory_stall_minutes(root)} min is contestable")
5351
+ for row in rows:
5352
+ if not row["holder"]:
5353
+ print(f"- {row['role']:18s} free (instances: {', '.join(row['instances']) or '-'})")
5354
+ continue
5355
+ flags = []
5356
+ if row["slow"]:
5357
+ flags.append("SLOW")
5358
+ if row["contests"]:
5359
+ flags.append(f"CONTESTED by {row['contests'][-1].get('by', '?')}")
5360
+ print(
5361
+ f"- {row['role']:18s} revising: {row['holder']:18s} "
5362
+ f"{row['minutes_left']:3d} min left, held {row['held_minutes']:3d} min, "
5363
+ f"since {row['since']}: {row['reason']}" + (f" [{' / '.join(flags)}]" if flags else "")
5364
+ )
5365
+ if row["contests"]:
5366
+ last = row["contests"][-1]
5367
+ print(
5368
+ f" contest: {last.get('by', '?')} at {last.get('at', '?')}: "
5369
+ f"{last.get('reason', '')[:120]} -> arbiter {row['arbiter'] or 'none'}"
5370
+ )
4271
5371
  return 0
4272
5372
 
4273
5373
 
4274
- def consolidate_agent(root: Path, agent: str, dry_run: bool = False, quiet: bool = False) -> dict:
5374
+ def consolidate_agent(
5375
+ root: Path,
5376
+ agent: str,
5377
+ dry_run: bool = False,
5378
+ quiet: bool = False,
5379
+ actor: str = "",
5380
+ strict: bool = True,
5381
+ ) -> dict:
4275
5382
  role = resolve_role(agent)
4276
5383
  agent = role
4277
- d = role_dir(root, role)
4278
- short_path = d / F_SHORT
4279
- long_path = d / F_LONG
4280
- arch_path = d / F_MEM_ARCHIVE
4281
-
4282
- lock_path = root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"
4283
- ctx = contextlib.nullcontext() if dry_run else lock(lock_path)
4284
- with ctx:
5384
+ d, short_path, long_path, arch_path = _role_memory_paths(root, role)
5385
+ if dry_run:
5386
+ return _consolidate_locked(
5387
+ root, role, agent, d, short_path, long_path, arch_path, dry_run, quiet
5388
+ )
5389
+ with memory_session(root, role, actor or agent, reason="consolidate", strict=strict) as claim:
5390
+ if claim is None:
5391
+ return {"agent": agent, "skipped": "another instance is revising this memory"}
4285
5392
  return _consolidate_locked(
4286
5393
  root, role, agent, d, short_path, long_path, arch_path, dry_run, quiet
4287
5394
  )
@@ -4401,8 +5508,9 @@ def cmd_consolidate(args) -> int:
4401
5508
  names = list_roles(root) if (args.all or not args.agent) else [resolve_role(args.agent)]
4402
5509
  if not names:
4403
5510
  die("no agents in this workspace")
5511
+ actor = getattr(args, "by", "") or ""
4404
5512
  for name in names:
4405
- consolidate_agent(root, name, dry_run=args.dry_run)
5513
+ consolidate_agent(root, name, dry_run=args.dry_run, actor=actor)
4406
5514
  return 0
4407
5515
 
4408
5516
 
@@ -4447,12 +5555,18 @@ def cmd_agents(args) -> int:
4447
5555
  return 0
4448
5556
  for role in roles:
4449
5557
  inst = instances_of(root, role)
5558
+ claim = read_memory_claim(root, role)
5559
+ writer = (
5560
+ f"pen={pascal(claim['instance'])}({memory_claim_remaining(claim)}m)"
5561
+ if claim
5562
+ else "pen=free"
5563
+ )
4450
5564
  print(
4451
5565
  f"{role:20s} instances={len(inst):2d} "
4452
5566
  f"[{', '.join(inst) if inst else '-'}] "
4453
5567
  f"mem={len(parse_memory(role_dir(root, role) / F_SHORT))}"
4454
5568
  f"/{len(parse_memory(role_dir(root, role) / F_LONG))} "
4455
- f"tasks={len(parse_tasks(task_file(root, role)))}"
5569
+ f"tasks={len(parse_tasks(task_file(root, role)))} {writer}"
4456
5570
  )
4457
5571
  return 0
4458
5572
 
@@ -4853,6 +5967,7 @@ def cmd_task_add(args) -> int:
4853
5967
  args.body or "",
4854
5968
  args.evidence,
4855
5969
  "na" if args.no_review else "required",
5970
+ getattr(args, "review_profile", ""),
4856
5971
  )
4857
5972
  if args.claimed_by:
4858
5973
  t["claimed_by"] = check_claimant(root, args.claimed_by, "claim", role)
@@ -5102,13 +6217,30 @@ def cmd_task_reclaim(args) -> int:
5102
6217
  die(f"no task '{args.handle}' for {role}")
5103
6218
  t = tasks[idx]
5104
6219
  previous = t.get("claimed_by") or "-"
6220
+ # A live lease is the one honest signal that the owner is working, not
6221
+ # silent. Denying it turns a takeover into a turf war: two lanes undoing
6222
+ # each other's work while both are alive.
6223
+ if t.get("claimed_by") and pascal(t["claimed_by"]) != pascal(actor):
6224
+ reap_slots(root)
6225
+ provider = find_slot_provider(root, t["claimed_by"])
6226
+ if provider and not args.force:
6227
+ die(
6228
+ f"refusing to reclaim {role}/{t['id']} from {t['claimed_by']}: "
6229
+ f"that lane still holds a live {provider} slot, so it is working, "
6230
+ "not silent. Coordinate with it; if you have confirmed the lane "
6231
+ "is dead, repeat with --force <reason>.",
6232
+ 2,
6233
+ )
6234
+ if provider:
6235
+ args.force = require_reason(args.force, "reclaiming from a live lane")
5105
6236
  t["status"] = "todo"
5106
6237
  t["claimed_by"] = ""
5107
6238
  t["branch"] = t.get("branch") or ""
5108
6239
  t["updated"] = today_iso()
5109
6240
  prev_note = t.get("findings", "")
6241
+ forced_note = f" (forced: {args.force})" if args.force else ""
5110
6242
  t["findings"] = (
5111
- prev_note + f" | reclaimed from {previous} by {actor}: {args.reason}"
6243
+ prev_note + f" | reclaimed from {previous} by {actor}{forced_note}: {args.reason}"
5112
6244
  ).strip(" |")
5113
6245
  write_tasks(path, tasks, role)
5114
6246
  ledger_append(
@@ -5258,9 +6390,9 @@ def find_slot_provider(root: Path, agent: str) -> str:
5258
6390
  base = root / DIR_RUNTIME / DIR_SLOTS
5259
6391
  if not base.is_dir():
5260
6392
  return ""
5261
- wanted = safe_component(agent)
6393
+ wanted = {safe_component(agent).lower(), safe_component(pascal(agent)).lower()}
5262
6394
  for prov in sorted(p.name for p in base.iterdir() if p.is_dir()):
5263
- if wanted in used_slots(root, prov):
6395
+ if any(name.lower() in wanted for name in used_slots(root, prov)):
5264
6396
  return prov
5265
6397
  return ""
5266
6398
 
@@ -5423,7 +6555,157 @@ def claim_age_minutes(value: str) -> int | None:
5423
6555
  return max(int((datetime.datetime.now() - taken).total_seconds() // 60), 0)
5424
6556
 
5425
6557
 
5426
- def review_claim_state(t: dict) -> tuple:
6558
+ # Review throughput: the queue, not the depth, is the limit.
6559
+ def review_lease_minutes(root: Path) -> int:
6560
+ """A review claim is a short work lease; Settings may override the default."""
6561
+ try:
6562
+ return max(int(_load_settings(root).get("ReviewLeaseMinutes", REVIEW_LEASE_MINUTES)), 1)
6563
+ except (TypeError, ValueError):
6564
+ return REVIEW_LEASE_MINUTES
6565
+
6566
+
6567
+ PROSE_REVIEW_PREFIXES = ("docs/", "assets/", "references/")
6568
+ PROSE_REVIEW_FILES = (
6569
+ "README.md",
6570
+ "CHANGELOG.md",
6571
+ "CONTRIBUTING.md",
6572
+ "SECURITY.md",
6573
+ "CODE_OF_CONDUCT.md",
6574
+ "CITATION.cff",
6575
+ "LICENSE",
6576
+ "AGENTS.md",
6577
+ "mkdocs.yml",
6578
+ )
6579
+ DEEP_REVIEW_TAGS = {"security", "migration", "auth", "crypto", "release", "ci", "infra", "data"}
6580
+ DEEP_REVIEW_PREFIXES = (".github/workflows/", ".github/actions/", "migrations/")
6581
+ DEEP_REVIEW_MARKERS = ("security", "auth", "crypto", "migration", "release")
6582
+
6583
+
6584
+ def _is_prose_review_path(path: str) -> bool:
6585
+ return path in PROSE_REVIEW_FILES or path.startswith(PROSE_REVIEW_PREFIXES)
6586
+
6587
+
6588
+ def _is_deep_review_path(path: str) -> bool:
6589
+ low = path.lower()
6590
+ return path.startswith(DEEP_REVIEW_PREFIXES) or any(
6591
+ marker in low for marker in DEEP_REVIEW_MARKERS
6592
+ )
6593
+
6594
+
6595
+ def review_profile_for(root: Path, role: str, task: dict) -> tuple:
6596
+ """(profile, reason): fast for prose-only, deep for security/runtime, else standard."""
6597
+ explicit = (task.get("review_profile") or "").strip().lower()
6598
+ if explicit in ("fast", "standard", "deep"):
6599
+ return explicit, "explicitly set"
6600
+ tags = {t.strip().lower() for t in (task.get("tags") or "").split(",") if t.strip()}
6601
+ deep_tags = sorted(tags & DEEP_REVIEW_TAGS)
6602
+ if deep_tags:
6603
+ return "deep", "tag(s): " + ", ".join(deep_tags)
6604
+ area = (task.get("area") or "").lower()
6605
+ if any(marker in area for marker in ("security", "migration", "auth", "release", "ci")):
6606
+ return "deep", f"area: {area}"
6607
+ branch = (task.get("branch") or "").strip()
6608
+ project = project_of(root)
6609
+ if branch and is_git_repo(project) and branch_exists(project, branch):
6610
+ base = task.get("base") or DEFAULT_BASE
6611
+ diff = run_git(project, "diff", "--no-renames", "--name-only", f"{base}...{branch}")
6612
+ files = [line.strip() for line in diff.stdout.splitlines() if line.strip()]
6613
+ if files:
6614
+ if all(_is_prose_review_path(path) for path in files):
6615
+ return "fast", f"{len(files)} prose file(s)"
6616
+ deep = [p for p in files if _is_deep_review_path(p)]
6617
+ if deep:
6618
+ return "deep", f"{len(deep)} security/release/workflow file(s)"
6619
+ return "standard", f"{len(files)} file(s)"
6620
+ return "standard", "no diff profile available"
6621
+
6622
+
6623
+ def review_gate_hint(profile: str) -> str:
6624
+ if profile == "fast":
6625
+ return (
6626
+ "link check, docs/contract tests and the tree privacy scan; the full code "
6627
+ "matrix is not needed for a prose-only diff"
6628
+ )
6629
+ if profile == "deep":
6630
+ return (
6631
+ "the full suite, lint/typecheck, privacy over tree and history, and the "
6632
+ "security scan; this is a security/release/data/workflow change"
6633
+ )
6634
+ return "the full test suite, lint and type-check; a green CI run on the branch SHA may be cited"
6635
+
6636
+
6637
+ def review_latency(root: Path) -> dict:
6638
+ """Claim -> verdict durations from the ledger, in minutes."""
6639
+ claims: dict = {}
6640
+ durations = []
6641
+ for record in ledger_read(root):
6642
+ action = record.get("action")
6643
+ key = (record.get("role") or "", record.get("target") or "")
6644
+ try:
6645
+ stamp = datetime.datetime.fromisoformat(record.get("ts") or "")
6646
+ except ValueError:
6647
+ continue
6648
+ if action == "review.claim":
6649
+ claims[key] = stamp
6650
+ elif action in ("review.pass", "review.fail") and key in claims:
6651
+ durations.append((stamp - claims.pop(key)).total_seconds() / 60)
6652
+ durations.sort()
6653
+
6654
+ def pct(share: float):
6655
+ if not durations:
6656
+ return None
6657
+ return round(durations[min(len(durations) - 1, int(len(durations) * share))], 1)
6658
+
6659
+ return {"count": len(durations), "p50": pct(0.5), "p90": pct(0.9)}
6660
+
6661
+
6662
+ def review_packet(root: Path, role: str, task: dict, full: bool = False) -> str:
6663
+ """Everything a Reviewer needs to start, in one screen."""
6664
+ profile, reason = review_profile_for(root, role, task)
6665
+ lease = review_lease_minutes(root)
6666
+ lines = [
6667
+ f"# Review packet: {role}/{task['id']} - {task['title']}",
6668
+ f"- priority: {task['priority']} | status: {task['status']} | "
6669
+ f"self_check: {task.get('self_check')} | review: {task.get('review_status')}",
6670
+ f"- profile: {profile} ({reason})",
6671
+ f"- author: {task.get('claimed_by') or '-'} | branch: {task.get('branch') or '-'} "
6672
+ f"| base: {task.get('base') or DEFAULT_BASE} | pr: {task.get('pr') or '-'}",
6673
+ f"- gates: {review_gate_hint(profile)}",
6674
+ ]
6675
+ project = project_of(root)
6676
+ branch = task.get("branch") or ""
6677
+ if branch and is_git_repo(project) and branch_exists(project, branch):
6678
+ base = task.get("base") or DEFAULT_BASE
6679
+ names = run_git(project, "diff", "--no-renames", "--name-only", f"{base}...{branch}")
6680
+ files = [line.strip() for line in names.stdout.splitlines() if line.strip()]
6681
+ if files:
6682
+ lines.append("- changed files:")
6683
+ lines += [f" - {path}" for path in files[:20]]
6684
+ stat = run_git(
6685
+ project, "diff", "--no-renames", "--stat", f"{base}...{branch}"
6686
+ ).stdout.strip()
6687
+ if stat:
6688
+ lines.append(f"- diffstat: {one_line(stat.splitlines()[-1])}")
6689
+ for label, field in (
6690
+ ("self-check note", "self_note"),
6691
+ ("author evidence", "evidence"),
6692
+ ("self-check artifacts", "self_artifacts"),
6693
+ ):
6694
+ value = (task.get(field) or "").strip()
6695
+ if value:
6696
+ lines.append(f"- {label}: {view_text(value, full)}")
6697
+ lines += [
6698
+ "- checklist: delivery/conformance; fidelity; internal consistency; "
6699
+ "operational fitness; finish; evidence reproduces",
6700
+ f"- claim: review {role} {task['id']} --claim --by <lane> "
6701
+ f"(lease {lease}m; --renew to extend)",
6702
+ f"- verdict: review {role} {task['id']} --by <lane> --result pass "
6703
+ "--evidence '<cmd @rev -> result>'",
6704
+ ]
6705
+ return "\n".join(lines)
6706
+
6707
+
6708
+ def review_claim_state(t: dict, minutes: int | None = None) -> tuple:
5427
6709
  """Return (lane, age_minutes, held) for a deliverable's review claim.
5428
6710
 
5429
6711
  A claim is a lease, not a lock: an undated or unreadable stamp counts as
@@ -5435,21 +6717,22 @@ def review_claim_state(t: dict) -> tuple:
5435
6717
  age = claim_age_minutes(t.get("review_claim_ts") or "")
5436
6718
  if age is None:
5437
6719
  return lane, None, False
5438
- return lane, age, age <= SLOT_TTL_MINUTES
6720
+ return lane, age, age <= (REVIEW_LEASE_MINUTES if minutes is None else minutes)
5439
6721
 
5440
6722
 
5441
- def review_is_held(t: dict) -> bool:
6723
+ def review_is_held(t: dict, minutes: int | None = None) -> bool:
5442
6724
  """True while a live claim pins a deliverable to one Reviewer lane."""
5443
- return review_claim_state(t)[2]
6725
+ return review_claim_state(t, minutes)[2]
5444
6726
 
5445
6727
 
5446
- def _claim_note(t: dict) -> str:
6728
+ def _claim_note(t: dict, minutes: int | None = None) -> str:
5447
6729
  """One line of claim state for the pulse and queue listings."""
5448
- lane, age, held = review_claim_state(t)
6730
+ limit = REVIEW_LEASE_MINUTES if minutes is None else minutes
6731
+ lane, age, held = review_claim_state(t, limit)
5449
6732
  if not lane:
5450
6733
  return "unclaimed"
5451
6734
  if held:
5452
- return f"held by {lane} ({age}m into the {SLOT_TTL_MINUTES}m lease)"
6735
+ return f"held by {lane} ({age}m into the {limit}m lease, {limit - age}m left)"
5453
6736
  return f"claim by {lane} expired ({'undated' if age is None else f'{age}m'}) - take it over"
5454
6737
 
5455
6738
 
@@ -5469,8 +6752,9 @@ def busy_instances(root: Path) -> set:
5469
6752
  continue
5470
6753
  for agent in used_slots(root, prov):
5471
6754
  busy.add(pascal(agent))
6755
+ lease = review_lease_minutes(root)
5472
6756
  for _role, t in iter_tasks(root):
5473
- if review_is_held(t):
6757
+ if review_is_held(t, lease):
5474
6758
  busy.add(pascal(t["review_claim"]))
5475
6759
  return busy
5476
6760
 
@@ -5503,8 +6787,22 @@ def awaiting_review(root: Path) -> list:
5503
6787
 
5504
6788
 
5505
6789
  def open_review_items(root: Path) -> list:
5506
- """Deliverables awaiting review that no Reviewer lane currently holds."""
5507
- return [(name, t) for name, t in awaiting_review(root) if not review_is_held(t)]
6790
+ """Deliverables awaiting review that no Reviewer lane currently holds.
6791
+
6792
+ Highest priority first, then oldest: a P0 prose fix must not wait behind a
6793
+ P2 code review just because its role sorts later.
6794
+ """
6795
+ lease = review_lease_minutes(root)
6796
+ items = [(name, t) for name, t in awaiting_review(root) if not review_is_held(t, lease)]
6797
+ items.sort(
6798
+ key=lambda item: (
6799
+ PRIORITY_ORDER.get(item[1]["priority"], 9),
6800
+ item[1]["created"],
6801
+ item[0],
6802
+ item[1]["id"],
6803
+ )
6804
+ )
6805
+ return items
5508
6806
 
5509
6807
 
5510
6808
  def review_lane_notes(root: Path) -> list:
@@ -5544,7 +6842,7 @@ def _pulse_review_claim(root: Path, owner: str, handle: str, lane: str) -> bool:
5544
6842
  t.get("review", "required") == "na"
5545
6843
  or t.get("review_status") == "passed"
5546
6844
  or t["status"] not in ("review", "done")
5547
- or review_is_held(t)
6845
+ or review_is_held(t, review_lease_minutes(root))
5548
6846
  or pascal(t.get("claimed_by") or "") == pascal(lane)
5549
6847
  ):
5550
6848
  return False
@@ -5556,18 +6854,19 @@ def _pulse_review_claim(root: Path, owner: str, handle: str, lane: str) -> bool:
5556
6854
  "review.claim",
5557
6855
  lane,
5558
6856
  handle,
5559
- f"pulse dispatch: {owner}/{handle} (lease {SLOT_TTL_MINUTES}m)",
6857
+ f"pulse dispatch: {owner}/{handle} (lease {review_lease_minutes(root)}m)",
5560
6858
  "",
5561
- "reviewer",
6859
+ owner,
5562
6860
  )
5563
6861
  return True
5564
6862
 
5565
6863
 
5566
6864
  def held_review_items(root: Path) -> list:
5567
6865
  """Every claim on the board, live or expired: (owner_role, task, lane, age)."""
6866
+ lease = review_lease_minutes(root)
5568
6867
  out = []
5569
6868
  for name, t in awaiting_review(root):
5570
- lane, age, _held = review_claim_state(t)
6869
+ lane, age, _held = review_claim_state(t, lease)
5571
6870
  if lane:
5572
6871
  out.append((name, t, lane, age))
5573
6872
  return out
@@ -5693,13 +6992,21 @@ def cmd_pulse(args: object) -> int:
5693
6992
  if not ready_by_role:
5694
6993
  print("- none")
5695
6994
  print(f"- awaiting independent review: {len(awaiting)} ({len(open_items)} unclaimed)")
6995
+ review_lanes = len(instances_of(root, "reviewer"))
6996
+ if open_items:
6997
+ oldest = max(days_since(t.get("created") or "") for _, t in open_items)
6998
+ print(
6999
+ f"# review capacity: {review_lanes} lane(s), {len(open_items)} unclaimed, "
7000
+ f"oldest {oldest}d - add a lane when unclaimed > {2 * max(review_lanes, 1)}"
7001
+ )
5696
7002
  print()
5697
7003
 
5698
7004
  if awaiting:
5699
7005
  print(f"## deliverables awaiting review ({len(awaiting)})")
5700
7006
  for name, t in awaiting:
5701
7007
  print(
5702
- f"- {name}/{t['id']} [{t['priority']}/{t['status']}] {t['title']} {_claim_note(t)}"
7008
+ f"- {name}/{t['id']} [{t['priority']}/{t['status']}] {t['title']} "
7009
+ f"{_claim_note(t, review_lease_minutes(root))}"
5703
7010
  )
5704
7011
  print()
5705
7012
 
@@ -5709,7 +7016,9 @@ def cmd_pulse(args: object) -> int:
5709
7016
  # parallel instead of one task per lane per wake.
5710
7017
  # only a live claim blocks a lane; an expired one is up for takeover, which
5711
7018
  # is exactly the work the next pulse should hand out
5712
- claim_holders = {pascal(lane) for _own, _t, lane, age in claims if age < SLOT_TTL_MINUTES}
7019
+ claim_holders = {
7020
+ pascal(lane) for _own, _t, lane, age in claims if age < review_lease_minutes(root)
7021
+ }
5713
7022
  capacity: dict[str, int] = {}
5714
7023
  for a in list_agents(root):
5715
7024
  role_name = resolve_role(a)
@@ -5743,15 +7052,33 @@ def cmd_pulse(args: object) -> int:
5743
7052
  capacity[inst] -= 1
5744
7053
 
5745
7054
  limits = load_providers(root)
7055
+ slot_holders = {pascal(a) for a in list_agents(root) if find_slot_provider(root, a)}
5746
7056
  free_slots = sum(
5747
7057
  max(cap - len(used_slots(root, prov)), 0)
5748
7058
  for prov, cap in limits.items()
5749
7059
  if prov != "default"
5750
7060
  )
5751
- if len(plan) > free_slots:
7061
+ # A lane that already holds a lease does not need a second one; a lane without
7062
+ # one does. Planning more new lanes than there are free slots is the article's
7063
+ # stampede: every lane polls, few get served, and the retries cost more than the
7064
+ # work. Defer the tail instead of handing out work nobody can start.
7065
+ deferred = []
7066
+ new_slot_lanes = [entry for entry in plan if pascal(entry[1]) not in slot_holders]
7067
+ if len(new_slot_lanes) > free_slots:
7068
+ allowed = free_slots
7069
+ kept = []
7070
+ for entry in plan:
7071
+ if pascal(entry[1]) not in slot_holders:
7072
+ if allowed <= 0:
7073
+ deferred.append(entry)
7074
+ continue
7075
+ allowed -= 1
7076
+ kept.append(entry)
7077
+ plan = kept
5752
7078
  print(
5753
- f"# capacity: {len(plan)} assignment(s) ready, {free_slots} free provider "
5754
- "slot(s) - the rest waits for a lease to free"
7079
+ f"# capacity: {len(new_slot_lanes)} lane(s) need a new slot, {free_slots} "
7080
+ f"free - deferring {len(deferred)} assignment(s) instead of "
7081
+ "over-subscribing the provider"
5755
7082
  )
5756
7083
  print("## dispatch plan")
5757
7084
  if not plan:
@@ -5759,6 +7086,13 @@ def cmd_pulse(args: object) -> int:
5759
7086
  for role, inst, task, title, review_owner in plan:
5760
7087
  tid = f"{review_owner}/{task['id']}" if review_owner else (task["id"] if task else "Review")
5761
7088
  print(f"- {inst:16s} <- {tid} [{role}] {title}")
7089
+ if deferred:
7090
+ print(
7091
+ "# deferred until a lease frees: "
7092
+ + ", ".join(
7093
+ f"{entry[1]}<-{entry[2]['id'] if entry[2] else 'review'}" for entry in deferred
7094
+ )
7095
+ )
5762
7096
  print()
5763
7097
 
5764
7098
  if args.commit and plan:
@@ -5878,42 +7212,518 @@ def cmd_parallel(args) -> int:
5878
7212
  print(f"- TOTAL ready tasks: {ready_total}")
5879
7213
  print()
5880
7214
 
5881
- awaiting = 0
5882
- for name in list_roles(root):
5883
- for t in parse_tasks(task_file(root, name)):
5884
- if (
5885
- t.get("review", "required") != "na"
5886
- and t.get("review_status") != "passed"
5887
- and t["status"] in ("review", "done")
5888
- ):
5889
- awaiting += 1
5890
- print("## guidance")
5891
- focused = (
5892
- "ollama-cloud"
5893
- if "ollama-cloud" in limits
5894
- else next((p for p in limits if p != "default"), "ollama-cloud")
7215
+ awaiting = 0
7216
+ for name in list_roles(root):
7217
+ for t in parse_tasks(task_file(root, name)):
7218
+ if (
7219
+ t.get("review", "required") != "na"
7220
+ and t.get("review_status") != "passed"
7221
+ and t["status"] in ("review", "done")
7222
+ ):
7223
+ awaiting += 1
7224
+ print("## guidance")
7225
+ focused = (
7226
+ "ollama-cloud"
7227
+ if "ollama-cloud" in limits
7228
+ else next((p for p in limits if p != "default"), "ollama-cloud")
7229
+ )
7230
+ focused_free = max(limits.get(focused, 8) - len(used_slots(root, focused)), 0)
7231
+ bulk = "deepseek" if "deepseek" in limits else focused
7232
+ bulk_free = max(limits.get(bulk, 0) - len(used_slots(root, bulk)), 0)
7233
+ running = sum(len(used_slots(root, p)) for p in limits if p != "default")
7234
+ lanes = min(ready_total, focused_free + bulk_free)
7235
+ print(f"- focused reasoning lanes ({focused}): {focused_free} free")
7236
+ print(f"- bulk fanout lanes ({bulk}): {bulk_free} free")
7237
+ print(f"- start up to {lanes} new lane(s) now (ready tasks: {ready_total})")
7238
+ if running:
7239
+ print(
7240
+ f"- {running} lane(s) already hold a slot: hand the next task to a"
7241
+ " running lane before spawning another (keeps memory and context warm)"
7242
+ )
7243
+ if awaiting:
7244
+ review_lanes = len(instances_of(root, "reviewer"))
7245
+ print(
7246
+ f"- review queue: {awaiting} deliverable(s), {review_lanes} lane(s); "
7247
+ f"add a Reviewer lane when the queue exceeds {2 * max(review_lanes, 1)}"
7248
+ )
7249
+ if ready_total > lanes:
7250
+ print("- more ready work than slots: queue it by priority, do not oversubscribe")
7251
+ print(
7252
+ "- independent sub-tasks only: one writer per file, no two agents on the"
7253
+ " same file, and never more lanes than the provider cap allows"
7254
+ )
7255
+ return 0
7256
+
7257
+
7258
+ # --------------------------------------------------------------------------- #
7259
+ # Coordination health
7260
+ #
7261
+ # Source: Anthropic, "Measuring multiagent systems" (accessed 2026-10-27),
7262
+ # https://www.anthropic.com/research/multiagent-systems - see
7263
+ # references/Coordination.md. The finding that shaped these checks is structural:
7264
+ # lanes that share a model, a context and a queue fail together, and a review
7265
+ # lane with no cross-check turns one blind spot into a systemic one. So this is
7266
+ # instrumentation, not advice: every check reads records the team already writes.
7267
+ # --------------------------------------------------------------------------- #
7268
+
7269
+ COORD_OPEN = ("todo", "doing", "selfcheck", "blocked", "review")
7270
+ COORD_VERIFICATION_ROLES = ("reviewer", "security-auditor", "tester")
7271
+ COORD_VERDICT_FLOOR = 6
7272
+ COORD_MERGE_FLOOR = 4
7273
+ COORD_TRUST_FLOOR = 8
7274
+ COORD_VERDICT_SHARE = 0.75
7275
+ COORD_RECLAIM_DAYS = 7
7276
+
7277
+ COORD_CHECK_TITLE = {
7278
+ "diversity": "diversity - lanes that share a model, a context and a queue fail together",
7279
+ "trust": "trust - a single source is provisional, a copied check is no check",
7280
+ "turf": "turf - a conflicting directive is not a hostile agent",
7281
+ "merge": "merge health - work that conflicts gets abandoned, not fixed",
7282
+ "capacity": "capacity - a starved queue makes every lane poll and race",
7283
+ "review": "review throughput - the queue cannot move faster than its lanes",
7284
+ }
7285
+
7286
+
7287
+ def _coord_finding(
7288
+ code: str, check: str, severity: str, subject: str, detail: str, fix: str
7289
+ ) -> dict:
7290
+ return {
7291
+ "code": code,
7292
+ "check": check,
7293
+ "severity": severity,
7294
+ "subject": subject,
7295
+ "detail": detail,
7296
+ "fix": fix,
7297
+ }
7298
+
7299
+
7300
+ def _signal(text) -> str:
7301
+ """A comparison key that survives wording noise: lowercase words only."""
7302
+ return re.sub(r"[^a-z0-9]+", " ", str(text or "").lower()).strip()
7303
+
7304
+
7305
+ def _evidence_body(value) -> str:
7306
+ """The command/result line without the engine's appended evidence packet."""
7307
+ text = str(value or "")
7308
+ return re.split(r"\s\|\s(?:artifacts|baseline|replay):", text, maxsplit=1)[0].strip()
7309
+
7310
+
7311
+ def _ledger_verdicts(root: Path) -> dict:
7312
+ """Reviewer verdicts by instance, from the append-only trail."""
7313
+ out: dict = {}
7314
+ for record in ledger_read(root):
7315
+ action = record.get("action")
7316
+ if action not in ("review.pass", "review.fail"):
7317
+ continue
7318
+ actor = pascal(record.get("actor") or "")
7319
+ if not actor:
7320
+ continue
7321
+ row = out.setdefault(actor, {"pass": 0, "fail": 0})
7322
+ row["pass" if action.endswith("pass") else "fail"] += 1
7323
+ return out
7324
+
7325
+
7326
+ def _model_label(root: Path, lane: str) -> str:
7327
+ entry = resolved_model(root, lane)
7328
+ provider = (entry.get("provider") or "").strip()
7329
+ model = (entry.get("model") or "").strip()
7330
+ if provider and model:
7331
+ return f"{provider}/{model}"
7332
+ return model or provider
7333
+
7334
+
7335
+ def duplicate_task_pairs(root: Path, threshold: float = 0.6) -> list:
7336
+ """Near-duplicate open tasks, as ``(percent, role, task, role, task)``.
7337
+
7338
+ One implementation for both `audit` (which prints them) and `coordination`
7339
+ (which counts them as a diversity failure): two detectors drifting apart is
7340
+ how a duplicate survives one gate and passes the other.
7341
+ """
7342
+ items = []
7343
+ for name in list_roles(root):
7344
+ for task in parse_tasks(task_file(root, name)):
7345
+ if task["status"] in ("done", "dropped"):
7346
+ continue
7347
+ words = {w for w in re.findall(r"[a-z0-9]+", task["title"].lower()) if len(w) > 3}
7348
+ items.append((name, task, words))
7349
+ pairs = []
7350
+ for i in range(len(items)):
7351
+ for j in range(i + 1, len(items)):
7352
+ name1, task1, words1 = items[i]
7353
+ name2, task2, words2 = items[j]
7354
+ if not words1 or not words2:
7355
+ continue
7356
+ jaccard = len(words1 & words2) / len(words1 | words2)
7357
+ if jaccard >= threshold:
7358
+ pairs.append((int(jaccard * 100), name1, task1, name2, task2))
7359
+ return pairs
7360
+
7361
+
7362
+ def _recent_reclaims(root: Path) -> list:
7363
+ cutoff = datetime.datetime.now() - datetime.timedelta(days=COORD_RECLAIM_DAYS)
7364
+ rows = []
7365
+ for record in ledger_read(root):
7366
+ if record.get("action") != "task.reclaim":
7367
+ continue
7368
+ try:
7369
+ stamp = datetime.datetime.fromisoformat(record.get("ts") or "")
7370
+ except ValueError:
7371
+ continue
7372
+ if stamp >= cutoff:
7373
+ rows.append(record)
7374
+ return rows
7375
+
7376
+
7377
+ def coordination_report(root: Path) -> dict:
7378
+ """Measure the multi-agent failure modes the records can actually show.
7379
+
7380
+ Every finding names the record it came from and the action that clears it;
7381
+ nothing here invents a metric the team does not already write down.
7382
+ """
7383
+ tasks = all_tasks(root)
7384
+ open_tasks = [(role, t) for role, t in tasks if t.get("status") in COORD_OPEN]
7385
+ findings: list = []
7386
+
7387
+ # --- diversity: the same branch, the same work, the same blind spot -------
7388
+ branches: dict = {}
7389
+ shared_refs: dict = {}
7390
+ for role, task in open_tasks:
7391
+ branch = (task.get("branch") or "").strip()
7392
+ if branch:
7393
+ branches.setdefault(branch, []).append(f"{role}/{task['id']}")
7394
+ shared = _signal(task.get("shared") or "")
7395
+ if shared:
7396
+ shared_refs.setdefault(shared, []).append(f"{role}/{task['id']}")
7397
+
7398
+ for branch, holders in sorted(branches.items()):
7399
+ if len(holders) > 1:
7400
+ findings.append(
7401
+ _coord_finding(
7402
+ "C-DIV-BRANCH",
7403
+ "diversity",
7404
+ "high",
7405
+ branch,
7406
+ f"one branch is attached to {len(holders)} open task(s): " + ", ".join(holders),
7407
+ "give each task its own branch (`branch start` names it from the "
7408
+ "task id); a shared branch guarantees the second PR conflicts",
7409
+ )
7410
+ )
7411
+ for pct, role1, task1, role2, task2 in duplicate_task_pairs(root):
7412
+ findings.append(
7413
+ _coord_finding(
7414
+ "C-DIV-TASK",
7415
+ "diversity",
7416
+ "medium",
7417
+ f"{role1}/{task1['id']} vs {role2}/{task2['id']}",
7418
+ f"{pct}% similar titles: {task1['title']!r} / {task2['title']!r}",
7419
+ "merge or re-scope the duplicate before two lanes produce the same "
7420
+ "artifact; `task add` a link to the original instead",
7421
+ )
7422
+ )
7423
+ for shared, holders in sorted(shared_refs.items()):
7424
+ if len(holders) > 1:
7425
+ findings.append(
7426
+ _coord_finding(
7427
+ "C-DIV-SHARED",
7428
+ "diversity",
7429
+ "medium",
7430
+ shared,
7431
+ "one shared board/PR reference is claimed by " + ", ".join(holders),
7432
+ "one task per reference, or record which lane owns which part",
7433
+ )
7434
+ )
7435
+
7436
+ # A verification lane is independent only if it does not share the base
7437
+ # model's blind spot; two Reviewer lanes on one model is the article's
7438
+ # measured conformity failure, not a stylistic preference.
7439
+ for role in COORD_VERIFICATION_ROLES:
7440
+ lanes = instances_of(root, role)
7441
+ if len(lanes) < 2:
7442
+ continue
7443
+ labels = {lane: _model_label(root, lane) for lane in lanes}
7444
+ known = {label for label in labels.values() if label}
7445
+ if len(known) == 1:
7446
+ label = next(iter(known))
7447
+ findings.append(
7448
+ _coord_finding(
7449
+ "C-DIV-MODEL",
7450
+ "diversity",
7451
+ "high" if role == "reviewer" else "medium",
7452
+ ", ".join(lanes),
7453
+ f"every {role} lane resolves to the same model ({label}), so the "
7454
+ "independent check shares one set of blind spots",
7455
+ "set TEAMWORK_MODEL_ALT to a second model and run `model auto "
7456
+ "--apply`, or pin one lane with `model set <lane> --provider ... "
7457
+ "--model ...`",
7458
+ )
7459
+ )
7460
+
7461
+ # --- trust: echo, monoculture, no-finding-ever ---------------------------
7462
+ for role, task in tasks:
7463
+ if task.get("review_status") != "passed":
7464
+ continue
7465
+ author = _signal(_evidence_body(task.get("evidence") or ""))
7466
+ reviewer = _signal(_evidence_body(task.get("review_evidence") or ""))
7467
+ if len(author) >= 20 and author == reviewer:
7468
+ findings.append(
7469
+ _coord_finding(
7470
+ "C-TRUST-ECHO",
7471
+ "trust",
7472
+ "medium",
7473
+ f"{role}/{task['id']}",
7474
+ "the reviewer's evidence is the author's evidence verbatim, so the "
7475
+ "check may have been copied instead of run",
7476
+ "re-run the verification yourself and record your own command and "
7477
+ "result; the reviewer's line is the independent signal",
7478
+ )
7479
+ )
7480
+
7481
+ verdicts = _ledger_verdicts(root)
7482
+ total_verdicts = sum(row["pass"] + row["fail"] for row in verdicts.values())
7483
+ if total_verdicts >= COORD_VERDICT_FLOOR:
7484
+ top_lane, top_row = max(
7485
+ verdicts.items(), key=lambda item: item[1]["pass"] + item[1]["fail"]
7486
+ )
7487
+ top_total = top_row["pass"] + top_row["fail"]
7488
+ if top_total / total_verdicts >= COORD_VERDICT_SHARE:
7489
+ findings.append(
7490
+ _coord_finding(
7491
+ "C-TRUST-MONOCULTURE",
7492
+ "trust",
7493
+ "medium",
7494
+ top_lane,
7495
+ f"{top_total} of {total_verdicts} verdicts came from one Reviewer lane",
7496
+ "fan the review queue across the Reviewer lanes (`pulse --commit` "
7497
+ "does this one per lane); one lane is one blind spot",
7498
+ )
7499
+ )
7500
+ for lane, row in sorted(verdicts.items()):
7501
+ if row["pass"] >= COORD_TRUST_FLOOR and row["fail"] == 0:
7502
+ findings.append(
7503
+ _coord_finding(
7504
+ "C-TRUST-NOFAIL",
7505
+ "trust",
7506
+ "low",
7507
+ lane,
7508
+ f"{row['pass']} passes and no finding ever recorded",
7509
+ "sample one passed deliverable end to end with a second Reviewer, "
7510
+ "or confirm the sample is genuinely defect-free",
7511
+ )
7512
+ )
7513
+
7514
+ # --- turf: reclaims are the visible edge of a lane war --------------------
7515
+ reclaims = _recent_reclaims(root)
7516
+ if reclaims:
7517
+ who = ", ".join(
7518
+ f"{record.get('actor') or '?'}->{record.get('target') or '?'}"
7519
+ for record in reclaims[-4:]
7520
+ )
7521
+ findings.append(
7522
+ _coord_finding(
7523
+ "C-TURF-RECLAIM",
7524
+ "turf",
7525
+ "low",
7526
+ f"{len(reclaims)} reclaim(s) in {COORD_RECLAIM_DAYS} days",
7527
+ "recent take-overs: " + who,
7528
+ "reclaim only a silent lane; the engine now refuses while the owner "
7529
+ "holds a live slot. A disputed scope goes to the CEO, never to force",
7530
+ )
7531
+ )
7532
+
7533
+ # --- merge health: the article's measured collapse ------------------------
7534
+ prs = parse_reviews(reviews_path(root))
7535
+ live_states = ("open", "approved", "changes-requested")
7536
+ total_prs = sum(1 for pr in prs if pr.get("status") in live_states + ("merged",))
7537
+ merged = sum(1 for pr in prs if pr.get("status") == "merged")
7538
+ if total_prs >= COORD_MERGE_FLOOR and merged / total_prs < 0.5:
7539
+ findings.append(
7540
+ _coord_finding(
7541
+ "C-MERGE-RATE",
7542
+ "merge",
7543
+ "medium",
7544
+ f"{merged}/{total_prs} merged",
7545
+ "most opened PRs never landed; conflicting work is being abandoned "
7546
+ "instead of rebased",
7547
+ "rebase each branch on the default branch before review, keep one "
7548
+ "writer per file, and re-scope a PR that conflicts twice",
7549
+ )
7550
+ )
7551
+ project = project_of(root)
7552
+ if is_git_repo(project):
7553
+ abandoned = [
7554
+ pr
7555
+ for pr in prs
7556
+ if pr.get("status") != "merged"
7557
+ and pr.get("branch")
7558
+ and not branch_exists(project, pr["branch"])
7559
+ ]
7560
+ if abandoned:
7561
+ findings.append(
7562
+ _coord_finding(
7563
+ "C-MERGE-ABANDONED",
7564
+ "merge",
7565
+ "high",
7566
+ f"{len(abandoned)} abandoned PR(s)",
7567
+ "branch gone while the PR is open: "
7568
+ + ", ".join(pr["id"] for pr in abandoned[:6]),
7569
+ "re-open the branch from the PR head or close the PR with a reason; "
7570
+ "silent abandonment is how work disappears",
7571
+ )
7572
+ )
7573
+
7574
+ # --- capacity: starved lanes race, over-subscribed lanes fail -------------
7575
+ limits = load_providers(root)
7576
+ reap_slots(root)
7577
+ over, free = [], 0
7578
+ for provider, cap in sorted(limits.items()):
7579
+ if provider == "default":
7580
+ continue
7581
+ used = len(used_slots(root, provider))
7582
+ free += max(cap - used, 0)
7583
+ if used > cap:
7584
+ over.append(f"{provider} {used}>{cap}")
7585
+ if over:
7586
+ findings.append(
7587
+ _coord_finding(
7588
+ "C-CAP-OVER",
7589
+ "capacity",
7590
+ "high",
7591
+ ", ".join(over),
7592
+ "more leases are held than the provider cap allows",
7593
+ "release stale leases (`slot reap`) and never acquire past the cap",
7594
+ )
7595
+ )
7596
+ latency = review_latency(root)
7597
+ ready = 0
7598
+ for role in list_roles(root):
7599
+ queue = parse_tasks(task_file(root, role))
7600
+ lookup = local_dep_map(queue)
7601
+ cache: dict = {}
7602
+ ready += sum(1 for task in queue if executable(task, lookup, cache, root))
7603
+ if ready and free == 0:
7604
+ findings.append(
7605
+ _coord_finding(
7606
+ "C-CAP-STARVED",
7607
+ "capacity",
7608
+ "medium",
7609
+ f"{ready} ready / 0 free",
7610
+ "executable work exists but every provider slot is leased",
7611
+ "release finished leases, take work that needs no model call, or fan "
7612
+ "out on another provider; do not let lanes poll for a slot",
7613
+ )
7614
+ )
7615
+
7616
+ open_reviews = open_review_items(root)
7617
+ review_lanes = len(instances_of(root, "reviewer"))
7618
+ if open_reviews:
7619
+ oldest_review = max(days_since(t.get("created") or "") for _, t in open_reviews)
7620
+ if len(open_reviews) > 2 * max(review_lanes, 1) or oldest_review >= 2:
7621
+ findings.append(
7622
+ _coord_finding(
7623
+ "C-REVIEW-BACKLOG",
7624
+ "review",
7625
+ "medium",
7626
+ f"{len(open_reviews)} unclaimed / {review_lanes} lane(s), "
7627
+ f"oldest {oldest_review}d",
7628
+ "the Reviewer queue is the team's throughput limit",
7629
+ "staff another Reviewer lane, send prose-only items to a fast "
7630
+ "review, and renew claims instead of re-claiming",
7631
+ )
7632
+ )
7633
+
7634
+ return {
7635
+ "findings": findings,
7636
+ "metrics": {
7637
+ "open_tasks": len(open_tasks),
7638
+ "ready_tasks": ready,
7639
+ "prs": total_prs,
7640
+ "merged": merged,
7641
+ "merge_fraction": round(merged / total_prs, 2) if total_prs else None,
7642
+ "verdicts": total_verdicts,
7643
+ "reviewer_lanes": len(instances_of(root, "reviewer")),
7644
+ "review_queue": len(
7645
+ [
7646
+ item
7647
+ for item in awaiting_review(root)
7648
+ if not review_is_held(item[1], review_lease_minutes(root))
7649
+ ]
7650
+ ),
7651
+ "review_claims": len(held_review_items(root)),
7652
+ "review_p50_minutes": latency["p50"],
7653
+ "review_p90_minutes": latency["p90"],
7654
+ "free_slots": free,
7655
+ "reclaims": len(reclaims),
7656
+ },
7657
+ }
7658
+
7659
+
7660
+ def _coordination_print(report: dict, root: Path) -> int:
7661
+ findings = report["findings"]
7662
+ severity_rank = {"high": 0, "medium": 1, "low": 2}
7663
+ counts = {key: 0 for key in severity_rank}
7664
+ for finding in findings:
7665
+ counts[finding["severity"]] += 1
7666
+ print(f"# Coordination health ({root})")
7667
+ print(f"# {counts['high']} high, {counts['medium']} medium, {counts['low']} low finding(s)")
7668
+ print()
7669
+ for check, title in COORD_CHECK_TITLE.items():
7670
+ rows = [f for f in findings if f["check"] == check]
7671
+ rows.sort(key=lambda f: (severity_rank.get(f["severity"], 9), f["code"]))
7672
+ print(f"## {check}")
7673
+ print(f"# {title}")
7674
+ if not rows:
7675
+ print("- no finding")
7676
+ for finding in rows:
7677
+ print(
7678
+ f"- [{finding['severity']}] {finding['code']} {finding['subject']}: "
7679
+ f"{finding['detail']}"
7680
+ )
7681
+ print(f" fix: {finding['fix']}")
7682
+ print()
7683
+ metrics = report["metrics"]
7684
+ fraction = metrics["merge_fraction"]
7685
+ print("## metrics")
7686
+ print(f"- open tasks: {metrics['open_tasks']} ({metrics['ready_tasks']} ready)")
7687
+ print(
7688
+ f"- PRs: {metrics['merged']}/{metrics['prs']} merged"
7689
+ + (f" ({fraction})" if fraction is not None else "")
5895
7690
  )
5896
- focused_free = max(limits.get(focused, 8) - len(used_slots(root, focused)), 0)
5897
- bulk = "deepseek" if "deepseek" in limits else focused
5898
- bulk_free = max(limits.get(bulk, 0) - len(used_slots(root, bulk)), 0)
5899
- running = sum(len(used_slots(root, p)) for p in limits if p != "default")
5900
- lanes = min(ready_total, focused_free + bulk_free)
5901
- print(f"- focused reasoning lanes ({focused}): {focused_free} free")
5902
- print(f"- bulk fanout lanes ({bulk}): {bulk_free} free")
5903
- print(f"- start up to {lanes} new lane(s) now (ready tasks: {ready_total})")
5904
- if running:
5905
- print(
5906
- f"- {running} lane(s) already hold a slot: hand the next task to a"
5907
- " running lane before spawning another (keeps memory and context warm)"
5908
- )
5909
- if awaiting:
5910
- print(f"- {awaiting} deliverable(s) await review; the Reviewer is a lane too")
5911
- if ready_total > lanes:
5912
- print("- more ready work than slots: queue it by priority, do not oversubscribe")
5913
7691
  print(
5914
- "- independent sub-tasks only: one writer per file, no two agents on the"
5915
- " same file, and never more lanes than the provider cap allows"
7692
+ f"- Reviewer lanes: {metrics['reviewer_lanes']} | verdicts: "
7693
+ f"{metrics['verdicts']} | free slots: {metrics['free_slots']}"
7694
+ )
7695
+ print(
7696
+ f"- review queue: {metrics['review_queue']} unclaimed | claims: "
7697
+ f"{metrics['review_claims']} | claim->verdict p50/p90: "
7698
+ f"{metrics['review_p50_minutes']}/{metrics['review_p90_minutes']} min"
5916
7699
  )
7700
+ print(f"- reclaims in {COORD_RECLAIM_DAYS} days: {metrics['reclaims']}")
7701
+ return 0
7702
+
7703
+
7704
+ def cmd_coordination(args) -> int:
7705
+ """`teamwork coordination [--strict] [--json] [--commit]` - health, not ceremony."""
7706
+ import json
7707
+
7708
+ root = project_root(args)
7709
+ report = coordination_report(root)
7710
+ if getattr(args, "json", False):
7711
+ print(json.dumps({"command": "coordination", **report}, indent=2, sort_keys=True))
7712
+ else:
7713
+ _coordination_print(report, root)
7714
+ counts = {key: 0 for key in ("high", "medium", "low")}
7715
+ for finding in report["findings"]:
7716
+ counts[finding["severity"]] += 1
7717
+ if getattr(args, "commit", False):
7718
+ ledger_append(
7719
+ root,
7720
+ "coordination.check",
7721
+ "ceo",
7722
+ "",
7723
+ f"{counts['high']} high / {counts['medium']} medium / {counts['low']} low finding(s)",
7724
+ )
7725
+ if getattr(args, "strict", False) and counts["high"]:
7726
+ return 1
5917
7727
  return 0
5918
7728
 
5919
7729
 
@@ -5953,10 +7763,19 @@ EXTERNAL_SCHEMES = ("http://", "https://", "mailto:", "doi:", "ftp://")
5953
7763
 
5954
7764
 
5955
7765
  def iter_markdown(project: Path) -> list:
5956
- """Every markdown file a human might read, excluding caches and dependencies."""
7766
+ """Every markdown file a human might read, excluding caches and dependencies.
7767
+
7768
+ The skip test is relative to ``project``. Testing the absolute parts instead
7769
+ silently skipped every file when the project itself lives under ``.pi`` (a
7770
+ per-instance worktree), which made ``links`` pass vacuously there.
7771
+ """
5957
7772
  out = []
5958
7773
  for p in project.rglob("*.md"):
5959
- if any(part in SKIP_DIRS for part in p.parts):
7774
+ try:
7775
+ parts = p.relative_to(project).parts
7776
+ except ValueError:
7777
+ parts = p.parts
7778
+ if any(part in SKIP_DIRS for part in parts):
5960
7779
  continue
5961
7780
  out.append(p)
5962
7781
  return sorted(out)
@@ -6070,7 +7889,9 @@ def link_report(project: Path, root: Path) -> dict:
6070
7889
 
6071
7890
  def cmd_links(args) -> int:
6072
7891
  root = project_root(args)
6073
- project = project_of(root)
7892
+ # Inside one of our worktrees this is the worktree, so a lane checks the links
7893
+ # it is about to submit rather than the checkout it happens to share.
7894
+ project = work_repo(root)
6074
7895
  rep = link_report(project, root)
6075
7896
  issues = len(rep["broken"]) + len(rep["external_bad"]) + len(rep["bare"])
6076
7897
  if not args.no_reciprocity:
@@ -6159,7 +7980,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6159
7980
  names = list_roles(root)
6160
7981
  tasks = all_tasks(root)
6161
7982
 
6162
- # 1 - acceptance criteria -------------------------------------------------
6163
7983
  s = _section("criteria", "acceptance criteria (machine records)")
6164
7984
  criteria = parse_criteria(criteria_path(root))
6165
7985
  if not criteria:
@@ -6196,7 +8016,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6196
8016
  )
6197
8017
  sections.append(s)
6198
8018
 
6199
- # 2 - layered tests -------------------------------------------------------
6200
8019
  s = _section("tests", "test layers (each owned and run by its own role)")
6201
8020
  records = parse_tests(tests_path(root))
6202
8021
  if not records:
@@ -6240,7 +8059,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6240
8059
  )
6241
8060
  sections.append(s)
6242
8061
 
6243
- # 3 - work in flight ------------------------------------------------------
6244
8062
  s = _section("work", "work in flight")
6245
8063
  buckets = {k: [] for k in ("todo", "doing", "selfcheck", "blocked", "review")}
6246
8064
  invalid = []
@@ -6265,7 +8083,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6265
8083
  s["blockers"].append(f"{len(invalid)} task(s) have an unknown status")
6266
8084
  sections.append(s)
6267
8085
 
6268
- # 4 - awaiting review -----------------------------------------------------
6269
8086
  s = _section("review", "awaiting independent review")
6270
8087
  awaiting = [
6271
8088
  (r, t)
@@ -6284,7 +8101,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6284
8101
  s["lines"].append("none")
6285
8102
  sections.append(s)
6286
8103
 
6287
- # 5 - review churn (a failing loop must escalate, not spin) ---------------
6288
8104
  s = _section("churn", "review churn")
6289
8105
  churn = [
6290
8106
  (r, t)
@@ -6310,7 +8126,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6310
8126
  s["lines"].append("none")
6311
8127
  sections.append(s)
6312
8128
 
6313
- # 6 - branches -----------------------------------------------------------
6314
8129
  s = _section("branches", "branches (merge and retire)")
6315
8130
  project = project_of(root)
6316
8131
  unmerged, leftover = [], []
@@ -6343,7 +8158,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6343
8158
  s["lines"].append("none")
6344
8159
  sections.append(s)
6345
8160
 
6346
- # 7 - overrides, provenance, liveness ------------------------------------
6347
8161
  s = _section("integrity", "gate overrides, provenance and liveness")
6348
8162
  forced, unprovenanced, silent = [], [], []
6349
8163
  for role, t in tasks:
@@ -6398,7 +8212,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6398
8212
  s["lines"].append("none")
6399
8213
  sections.append(s)
6400
8214
 
6401
- # 8 - disputes -----------------------------------------------------------
6402
8215
  s = _section("disputes", "open disputes")
6403
8216
  open_disputes = [d for d in disputes if d["status"] != "resolved"]
6404
8217
  for d in open_disputes:
@@ -6429,7 +8242,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
6429
8242
  s["lines"].append("none")
6430
8243
  sections.append(s)
6431
8244
 
6432
- # 9 - audit trail --------------------------------------------------------
6433
8245
  s = _section("ledger", "audit trail")
6434
8246
  if ledger_cache_behind(root):
6435
8247
  s["lines"].append(
@@ -6564,17 +8376,27 @@ def archive_ensure_entry(root: Path, owner: str, t: dict, repo: Path | None = No
6564
8376
  return f"# archive: wrote the accepted-results entry for {branch} on the base"
6565
8377
 
6566
8378
 
6567
- #: Credential rules of the shipped privacy gate. The archive mask borrows the
6568
- #: gate's own rule table rather than re-declaring shapes: a hand-written mask
6569
- #: drifted from the scanner twice (it covered 2 of the 5 credential rules and
6570
- #: masked ``sk-learn-pipeline``, which the gate never reports), and the archive is
6571
- #: the one place where a divergence between mask and gate is unrecoverable.
8379
+ #: Rules of the shipped privacy gate that the archive mask covers. The mask
8380
+ #: borrows the gate's own rule table rather than re-declaring shapes: a
8381
+ #: hand-written mask drifted from the scanner twice (it covered 2 of the 5
8382
+ #: credential rules and masked ``sk-learn-pipeline``, which the gate never
8383
+ #: reports), and the archive is the one place where a divergence between mask
8384
+ #: and gate is unrecoverable.
8385
+ #:
8386
+ #: The set is every rule the gate reports except the two it silences inside a
8387
+ #: quotation (``_QUOTED_PATH_RULES``): a path *shape* is not personal data, but
8388
+ #: an address is, and the gate keeps the identity rules armed on a quoted
8389
+ #: evidence line on purpose. Naming the two identity rules here is what stops
8390
+ #: the engine from writing an archive record the gate rejects - the review
8391
+ #: record is where a quoted address becomes permanent.
6572
8392
  CREDENTIAL_RULES = (
6573
8393
  "private-key",
6574
8394
  "api-token",
6575
8395
  "env-secret",
6576
8396
  "json-secret",
6577
8397
  "high-entropy-secret",
8398
+ "personal-email",
8399
+ "private-domain",
6578
8400
  )
6579
8401
 
6580
8402
 
@@ -6585,8 +8407,16 @@ def _privacy_scan(): # noqa: ANN202 - module handle, cached below
6585
8407
  directory can neither shadow the ``teamwork`` package nor leak into the
6586
8408
  process. ``None`` means the bundle is incomplete (an install that dropped
6587
8409
  ``scripts/``); the caller then records the text unmasked rather than guessing.
8410
+
8411
+ A scanner that is present but *fails to load* is a different case: it is a
8412
+ defect, not an incomplete install, and silently returning ``None`` would
8413
+ switch credential masking off in every record the team writes. So the
8414
+ failure is reported once on ``stderr`` - naming the scanner path and the
8415
+ exception - and not again, because the outcome is cached (``_PRIVACY_SCAN``
8416
+ becomes ``None``, ``_PRIVACY_WARNED`` latches) and does not depend on the
8417
+ value being masked.
6588
8418
  """
6589
- global _PRIVACY_SCAN
8419
+ global _PRIVACY_SCAN, _PRIVACY_WARNED
6590
8420
  if _PRIVACY_SCAN is not _UNLOADED:
6591
8421
  return _PRIVACY_SCAN
6592
8422
  path = _resources.asset("scripts") / "privacy_scan.py"
@@ -6598,13 +8428,26 @@ def _privacy_scan(): # noqa: ANN202 - module handle, cached below
6598
8428
  module = importlib.util.module_from_spec(spec)
6599
8429
  spec.loader.exec_module(module)
6600
8430
  _PRIVACY_SCAN = module
6601
- except Exception: # noqa: BLE001 - a broken scanner must not break records
8431
+ except Exception as exc: # noqa: BLE001 - a broken scanner must not break records
8432
+ # Keep the intent (records still get written) but not the silence:
8433
+ # masking is OFF for the rest of the process, so the writer's caller
8434
+ # must be able to see why. Same shape as the other warnings this
8435
+ # module prints on stderr while writing records.
6602
8436
  _PRIVACY_SCAN = None
8437
+ if not _PRIVACY_WARNED:
8438
+ _PRIVACY_WARNED = True
8439
+ print(
8440
+ f"teamwork: WARNING the privacy scanner {path} failed to load "
8441
+ f"({type(exc).__name__}: {exc}): records will be written "
8442
+ "with credential masking DISABLED until it is fixed",
8443
+ file=sys.stderr,
8444
+ )
6603
8445
  return _PRIVACY_SCAN
6604
8446
 
6605
8447
 
6606
8448
  _UNLOADED = object()
6607
8449
  _PRIVACY_SCAN = _UNLOADED
8450
+ _PRIVACY_WARNED = False
6608
8451
 
6609
8452
 
6610
8453
  def mask_secret_shapes(text: str) -> str:
@@ -6612,14 +8455,29 @@ def mask_secret_shapes(text: str) -> str:
6612
8455
 
6613
8456
  An archive entry quotes another agent's words verbatim, so a verdict that
6614
8457
  proved a credential rule fires by quoting a fake token puts that token into
6615
- history. The mask is exactly the gate's own finding set: a value the gate
6616
- reports becomes ``<redacted-RULE>``, and a value it does not report is left
6617
- byte-identical, so ordinary prose, commit shas, ``sk-something`` helper names
6618
- and a JSON placeholder the gate accepts survive. Every value comes from the
6619
- gate's own ``scan_lines`` - the JSON rule included - so the mask cannot drift
6620
- by taking a regex directly and skipping the gate's placeholder, code-shape and
6621
- allow filters. The quoted claim ("a token on the exempted line is still
6622
- reported") remains readable.
8458
+ history. The mask is the gate's own finding set minus the two path *shapes*
8459
+ the gate itself silences inside a quotation (``home-path``,
8460
+ ``agent-config``): a value the gate reports becomes ``<redacted-RULE>``, and
8461
+ a value it does not report is left byte-identical, so ordinary prose, commit
8462
+ shas, ``sk-something`` helper names, a JSON placeholder the gate accepts and
8463
+ a role mailbox survive. Every value comes from the gate's own ``scan_lines``
8464
+ - the JSON rule included - so the mask cannot drift by taking a regex
8465
+ directly and skipping the gate's placeholder, code-shape and allow filters.
8466
+ The quoted claim ("a token on the exempted line is still reported") remains
8467
+ readable, and so does the finding around a redacted address: the ADDRESS is
8468
+ replaced, not the evidence.
8469
+
8470
+ Contract when the scanner is unavailable (this function then returns the
8471
+ text byte-identical, so the caller must not assume a mask happened):
8472
+
8473
+ * a bundle without ``scripts/privacy_scan.py`` - an incomplete install - is
8474
+ a known state, and the text is recorded unmasked on purpose;
8475
+ * a scanner that is present but raises while loading is a defect: the text
8476
+ is still recorded unmasked (a broken scanner must not break records), but
8477
+ ``_privacy_scan`` prints one ``teamwork: WARNING`` on stderr naming the
8478
+ scanner path and the exception. Masking stays disabled for the rest of the
8479
+ process and the warning is not repeated, so a caller that wants a hard
8480
+ failure must surface that warning itself.
6623
8481
  """
6624
8482
  if not text:
6625
8483
  return text
@@ -6858,16 +8716,6 @@ def commit_archive_merge_sha(
6858
8716
  return f"{message}\n# committed the archive rewrite on {base} at {close_sha}"
6859
8717
 
6860
8718
 
6861
- def find_review_role_unused(root: Path, handle: str):
6862
- """(role, task) for a task id / title across every role queue."""
6863
- for role in list_roles(root):
6864
- tasks = parse_tasks(task_file(root, role))
6865
- idx = find_task(tasks, handle)
6866
- if idx is not None:
6867
- return role, tasks[idx], tasks, task_file(root, role), idx
6868
- return None, None, None, None, None
6869
-
6870
-
6871
8719
  def _review_claim(root: Path, args) -> int:
6872
8720
  """Take a deliverable off the shared review queue, or hand it back.
6873
8721
 
@@ -6876,17 +8724,22 @@ def _review_claim(root: Path, args) -> int:
6876
8724
  that the lanes collide on one deliverable and leave the rest unreviewed.
6877
8725
  """
6878
8726
  claim, release = bool(args.claim), bool(args.release)
6879
- if claim and release:
6880
- die("--claim and --release are mutually exclusive", 2)
8727
+ renew = bool(getattr(args, "renew", False))
8728
+ if sum((claim, release, renew)) > 1:
8729
+ die("--claim, --release and --renew are mutually exclusive", 2)
6881
8730
  if not (args.agent and args.handle):
6882
- die("usage: review <role> <T-id> --claim|--release --by <Reviewer instance>", 2)
8731
+ die("usage: review <role> <T-id> --claim|--release|--renew --by <Reviewer instance>", 2)
6883
8732
  if args.result:
6884
- die(f"--claim/--release do not take --result {args.result}; the verdict is separate", 2)
8733
+ die(
8734
+ f"--claim/--release/--renew do not take --result {args.result}; the verdict is separate",
8735
+ 2,
8736
+ )
6885
8737
  role = resolve_role(args.agent)
6886
8738
  reviewer, rrole = validate_actor(root, args.by or "reviewer", "reviewer", allow_role=False)
6887
8739
  if rrole != "reviewer":
6888
8740
  die(f"only the Reviewer role may hold a review (you passed {reviewer}).", 2)
6889
- verb = "claim" if claim else "release"
8741
+ verb = "renew" if renew else ("claim" if claim else "release")
8742
+ lease = review_lease_minutes(root)
6890
8743
  path = task_file(root, role)
6891
8744
  with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
6892
8745
  tasks = parse_tasks(path)
@@ -6908,12 +8761,30 @@ def _review_claim(root: Path, args) -> int:
6908
8761
  die(
6909
8762
  f"{reviewer} produced {t['id']} and cannot review it; another lane must take it.", 2
6910
8763
  )
6911
- lane, age, held = review_claim_state(t)
8764
+ lane, age, held = review_claim_state(t, lease)
8765
+ if renew:
8766
+ if not lane:
8767
+ die(f"{role}/{t['id']} is not claimed; take it with --claim", 2)
8768
+ if pascal(lane) != pascal(reviewer):
8769
+ die(f"{role}/{t['id']} is held by {lane}; only that lane renews its lease", 2)
8770
+ t["review_claim_ts"] = claim_stamp()
8771
+ write_tasks(path, tasks, role)
8772
+ ledger_append(
8773
+ root,
8774
+ "review.renew",
8775
+ reviewer,
8776
+ t["id"],
8777
+ f"{role}/{t['id']} lease renewed for {lease}m",
8778
+ "",
8779
+ role,
8780
+ )
8781
+ print(f"teamwork: {reviewer} renewed {role}/{t['id']} for {lease} min")
8782
+ return 0
6912
8783
  if claim:
6913
8784
  if held and pascal(lane) != pascal(reviewer):
6914
8785
  die(
6915
8786
  f"{role}/{t['id']} is held by {lane} ({age}m into the "
6916
- f"{SLOT_TTL_MINUTES}m lease): let it finish, or wait for the lease "
8787
+ f"{lease}m lease): let it finish, renew it, or wait for the lease "
6917
8788
  "to expire and take it over",
6918
8789
  2,
6919
8790
  )
@@ -6930,14 +8801,14 @@ def _review_claim(root: Path, args) -> int:
6930
8801
  "review.claim",
6931
8802
  reviewer,
6932
8803
  t["id"],
6933
- f"{role}/{t['id']} claimed for review (lease {SLOT_TTL_MINUTES}m){took_over}",
8804
+ f"{role}/{t['id']} claimed for review (lease {lease}m){took_over}",
6934
8805
  "",
6935
- "reviewer",
8806
+ role,
6936
8807
  )
6937
8808
  print(
6938
- f"teamwork: {reviewer} holds {role}/{t['id']} for {SLOT_TTL_MINUTES} min: "
8809
+ f"teamwork: {reviewer} holds {role}/{t['id']} for {lease} min: "
6939
8810
  f"review {role} {t['id']} --by {reviewer} --result pass --evidence "
6940
- "'<cmd @rev -> result>' or hand it back with --release"
8811
+ "'<cmd @rev -> result>', renew with --renew, or hand it back with --release"
6941
8812
  )
6942
8813
  return 0
6943
8814
  if not lane:
@@ -6966,7 +8837,7 @@ def _review_claim(root: Path, args) -> int:
6966
8837
  t["id"],
6967
8838
  f"{role}/{t['id']} released by {lane}{forced}",
6968
8839
  "",
6969
- "reviewer",
8840
+ role,
6970
8841
  )
6971
8842
  print(f"teamwork: {role}/{t['id']} is back on the shared review queue")
6972
8843
  return 0
@@ -6974,9 +8845,31 @@ def _review_claim(root: Path, args) -> int:
6974
8845
 
6975
8846
  def cmd_review(args) -> int:
6976
8847
  root = project_root(args)
8848
+ if getattr(args, "packet", False) and (
8849
+ args.claim or args.release or getattr(args, "renew", False) or args.result
8850
+ ):
8851
+ die("--packet is read-only; claim or verdict separately", 2)
6977
8852
  if args.queue:
6978
- return _review_queue(root, full=bool(getattr(args, "full", False)))
6979
- if getattr(args, "claim", False) or getattr(args, "release", False):
8853
+ return _review_queue(
8854
+ root,
8855
+ full=bool(getattr(args, "full", False)),
8856
+ packet=bool(getattr(args, "packet", False)),
8857
+ )
8858
+ if getattr(args, "packet", False):
8859
+ if not (args.agent and args.handle):
8860
+ die("usage: review <role> <T-id> --packet [--full]", 2)
8861
+ role = resolve_role(args.agent)
8862
+ tasks = parse_tasks(task_file(root, role))
8863
+ idx = find_task(tasks, args.handle)
8864
+ if idx is None:
8865
+ die(f"no task '{args.handle}' for {role}", 2)
8866
+ print(review_packet(root, role, tasks[idx], full=bool(getattr(args, "full", False))))
8867
+ return 0
8868
+ if (
8869
+ getattr(args, "claim", False)
8870
+ or getattr(args, "release", False)
8871
+ or getattr(args, "renew", False)
8872
+ ):
6980
8873
  return _review_claim(root, args)
6981
8874
  if not (args.agent and args.handle):
6982
8875
  die("usage: review <role> <T-id> --by <Reviewer> --result pass|fail [--evidence ...]")
@@ -7012,7 +8905,7 @@ def cmd_review(args) -> int:
7012
8905
  f"{reviewer} produced {t['id']} and cannot review it; "
7013
8906
  "an independent instance must review."
7014
8907
  )
7015
- lane, age, held = review_claim_state(t)
8908
+ lane, age, held = review_claim_state(t, review_lease_minutes(root))
7016
8909
  if lane and pascal(lane) != pascal(reviewer):
7017
8910
  print(
7018
8911
  f"teamwork: NOTE {role}/{t['id']} is held by {lane} "
@@ -7206,7 +9099,8 @@ def cmd_review(args) -> int:
7206
9099
  return 0
7207
9100
 
7208
9101
 
7209
- def _review_queue(root: Path, full: bool = False) -> int:
9102
+ def _review_queue(root: Path, full: bool = False, packet: bool = False) -> int:
9103
+ lease = review_lease_minutes(root)
7210
9104
  rows = []
7211
9105
  for name in list_roles(root):
7212
9106
  for t in parse_tasks(task_file(root, name)):
@@ -7214,17 +9108,25 @@ def _review_queue(root: Path, full: bool = False) -> int:
7214
9108
  continue
7215
9109
  if t.get("review_status") == "passed" or t["status"] == "dropped":
7216
9110
  continue
7217
- rows.append((PRIORITY_ORDER.get(t["priority"], 9), name, t))
9111
+ rows.append((PRIORITY_ORDER.get(t["priority"], 9), t.get("created") or "", name, t))
7218
9112
  print(f"# Reviewer queue ({root})")
7219
9113
  if not rows:
7220
9114
  print("- nothing awaiting review")
7221
9115
  return 0
7222
- rows.sort(key=lambda r: (r[0], r[1], r[2]["id"]))
7223
- for _prio, name, t in rows:
9116
+ rows.sort(key=lambda r: (r[0], r[1], r[2], r[3]["id"]))
9117
+ if packet:
9118
+ for _prio, _created, name, t in rows:
9119
+ print(review_packet(root, name, t, full))
9120
+ print()
9121
+ return 0
9122
+ for _prio, _created, name, t in rows:
9123
+ profile, _reason = review_profile_for(root, name, t)
9124
+ age = days_since(t.get("created") or "") if t.get("created") else 0
7224
9125
  print(
7225
9126
  f"- {name} {t['id']} [{t['priority']}/{t['status']}] {t['title']}"
7226
- f" review_status={t.get('review_status', 'pending')}"
7227
- f" claim: {_claim_note(t)}"
9127
+ f" profile={profile} aged={age}d"
9128
+ f" review_status={t.get('review_status', 'pending')}"
9129
+ f" claim: {_claim_note(t, lease)}"
7228
9130
  f" self_check={t.get('self_check', 'pending')}"
7229
9131
  + (f" branch={t['branch']}" if t.get("branch") else "")
7230
9132
  + (f" pr={t['pr']}" if t.get("pr") else "")
@@ -7235,10 +9137,20 @@ def _review_queue(root: Path, full: bool = False) -> int:
7235
9137
  print(f" findings so far: {view_text(t['findings'], full)}")
7236
9138
  print()
7237
9139
  lanes = review_lane_notes(root)
7238
- unclaimed = sum(1 for _prio, _name, t in rows if not review_is_held(t))
7239
- print(f"# {len(rows)} deliverable(s) to review ({unclaimed} unclaimed)")
9140
+ lane_count = len(instances_of(root, "reviewer"))
9141
+ unclaimed = sum(1 for _prio, _created, _name, t in rows if not review_is_held(t, lease))
9142
+ oldest = max(
9143
+ (days_since(t.get("created") or "") for _p, _c, _n, t in rows if t.get("created")),
9144
+ default=0,
9145
+ )
9146
+ print(f"# {len(rows)} deliverable(s) to review ({unclaimed} unclaimed), oldest {oldest}d")
7240
9147
  print("# lanes: " + (", ".join(lanes) if lanes else "no Reviewer instance staffed"))
9148
+ print(
9149
+ f"# capacity: {lane_count} lane(s); add a Reviewer lane when unclaimed > "
9150
+ f"{2 * max(lane_count, 1)}"
9151
+ )
7241
9152
  print("# a review is claimed work: one lane takes one deliverable, then verdicts it")
9153
+ print("# review <role> <T-id> --packet # profile, diff, gates, checklist")
7242
9154
  print("# review <role> <T-id> --claim --by <Reviewer-instance> # pin it to your lane")
7243
9155
  print(
7244
9156
  "# review <role> <T-id> --by <Reviewer-instance> --result pass --evidence '<cmd @rev -> result>'"
@@ -7246,9 +9158,10 @@ def _review_queue(root: Path, full: bool = False) -> int:
7246
9158
  print(
7247
9159
  "# review <role> <T-id> --by <Reviewer-instance> --result fail --findings '<defect + evidence>'"
7248
9160
  )
9161
+ print("# review <role> <T-id> --renew --by <Reviewer-instance> # extend a long review")
7249
9162
  print(
7250
9163
  f"# review <role> <T-id> --release --by <Reviewer-instance> # hand it back "
7251
- f"(a claim lapses after {SLOT_TTL_MINUTES}m)"
9164
+ f"(a claim lapses after {lease}m)"
7252
9165
  )
7253
9166
  return 0
7254
9167
 
@@ -7722,6 +9635,16 @@ def _audit_section(root: Path, names: list, args) -> int:
7722
9635
  return hits
7723
9636
 
7724
9637
 
9638
+ def _task_has_execution_evidence(t: dict) -> bool:
9639
+ """True when the task record carries proof that something was executed.
9640
+
9641
+ A review pass writes review_evidence and a self-check writes self_note; a task
9642
+ closed by hand writes evidence. The audit's question is "was this verified?",
9643
+ not "was the evidence field used?", so all three count.
9644
+ """
9645
+ return any((t.get(key) or "").strip() for key in ("evidence", "review_evidence", "self_note"))
9646
+
9647
+
7725
9648
  def _audit_submitted_for_review_without_a_self_chec(root: Path, names: list, args) -> int:
7726
9649
  print("## submitted for review without a self-check (skipped their own test)")
7727
9650
  hits = 0
@@ -7731,6 +9654,9 @@ def _audit_submitted_for_review_without_a_self_chec(root: Path, names: list, arg
7731
9654
  t.get("review", "required") != "na"
7732
9655
  and t.get("self_check") not in ("passed", "na")
7733
9656
  and t["status"] in ("review", "done")
9657
+ # A forced review pass records why the self-check was skipped; the
9658
+ # force is the audit trail, so re-reporting it every run is noise.
9659
+ and not (t.get("forced") or "").strip()
7734
9660
  ):
7735
9661
  print(
7736
9662
  f"- {name} {t['id']} [{t['status']}] {t['title']} "
@@ -7747,7 +9673,12 @@ def _audit_done_without_evidence_fabricated_complet(root: Path, names: list, arg
7747
9673
  hits = 0
7748
9674
  for name in names:
7749
9675
  for t in parse_tasks(task_file(root, name)):
7750
- if t["status"] == "done" and not t.get("evidence"):
9676
+ # `done` is normally set by a review pass, which records its proof in
9677
+ # review_evidence (and self_note for the author's own run); only a task
9678
+ # closed by hand relies on `evidence` alone. Any of the three is a
9679
+ # record of execution, so any of them satisfies the anti-hallucination
9680
+ # gate - testing `evidence` alone flagged every properly reviewed task.
9681
+ if t["status"] == "done" and not _task_has_execution_evidence(t):
7751
9682
  print(f"- {name} {t['id']} {t['title']}")
7752
9683
  hits += 1
7753
9684
  if not hits:
@@ -7778,31 +9709,16 @@ def _audit_stale_in_progress(root: Path, names: list, args) -> int:
7778
9709
 
7779
9710
  def _audit_possible_duplicate_tasks_across_agents(root: Path, names: list, args) -> int:
7780
9711
  print("## possible duplicate tasks across agents")
7781
- items = []
7782
- for name in names:
7783
- for t in parse_tasks(task_file(root, name)):
7784
- if t["status"] in ("done", "dropped"):
7785
- continue
7786
- words = {w for w in re.findall(r"[a-z0-9]+", t["title"].lower()) if len(w) > 3}
7787
- items.append((name, t, words))
7788
- hits = 0
7789
- for i in range(len(items)):
7790
- for j in range(i + 1, len(items)):
7791
- n1, t1, w1 = items[i]
7792
- n2, t2, w2 = items[j]
7793
- if not w1 or not w2:
7794
- continue
7795
- jaccard = len(w1 & w2) / len(w1 | w2)
7796
- if jaccard >= 0.6:
7797
- print(
7798
- f"- {int(jaccard * 100)}% similar: {n1}:{t1['id']} "
7799
- f"{t1['title']!r} <-> {n2}:{t2['id']} {t2['title']!r}"
7800
- )
7801
- hits += 1
7802
- if not hits:
9712
+ pairs = duplicate_task_pairs(root)
9713
+ for pct, name1, task1, name2, task2 in pairs:
9714
+ print(
9715
+ f"- {pct}% similar: {name1}:{task1['id']} "
9716
+ f"{task1['title']!r} <-> {name2}:{task2['id']} {task2['title']!r}"
9717
+ )
9718
+ if not pairs:
7803
9719
  print("- none")
7804
9720
  print()
7805
- return hits
9721
+ return len(pairs)
7806
9722
 
7807
9723
 
7808
9724
  def _audit_memory_caps(root: Path, names: list, args) -> int:
@@ -8110,7 +10026,7 @@ def _audit_audit_trail_append_only_hash_chained(root: Path, names: list, args) -
8110
10026
  hits += 1
8111
10027
  if records and not problems_ledger:
8112
10028
  print(f"- {len(records)} records, chain intact, head {records[-1]['hash'][:16]}")
8113
- manifest = docs_dir(root) / F_MANIFEST
10029
+ manifest = work_docs_dir(root) / F_MANIFEST
8114
10030
  if manifest.exists():
8115
10031
  try:
8116
10032
  import json
@@ -8235,26 +10151,46 @@ def _audit_git_archival_lfs_large_files(root: Path, names: list, args) -> int:
8235
10151
  print("- .gitattributes has no Git LFS rules: run `teamwork.py repo`")
8236
10152
  hits += 1
8237
10153
  else:
8238
- big = []
10154
+ all_files = []
8239
10155
  for p_ in project.rglob("*"):
8240
10156
  if not p_.is_file() or any(
8241
10157
  part in {".git", ".pi"} | SKIP_DIRS for part in p_.parts
8242
10158
  ):
8243
10159
  continue
10160
+ all_files.append(p_.relative_to(project).as_posix())
10161
+ mismatched = [rel for rel in all_files if lfs_text_mismatch(project, rel)]
10162
+ if mismatched:
10163
+ print(
10164
+ f"- {len(mismatched)} text file(s) routed into Git LFS by a "
10165
+ ".gitattributes pattern, e.g. "
10166
+ + ", ".join(mismatched[:3])
10167
+ + "\n LFS is for binaries: a document in LFS is a pointer for "
10168
+ "every reader without an LFS client. Drop the pattern from "
10169
+ ".gitattributes, or make the content binary."
10170
+ )
10171
+ hits += 1
10172
+ big = []
10173
+ for rel in all_files:
8244
10174
  try:
8245
- if p_.stat().st_size >= 1_000_000:
8246
- big.append(p_.relative_to(project).as_posix())
10175
+ if (project / rel).stat().st_size > LFS_MAX_BYTES:
10176
+ big.append(rel)
8247
10177
  except OSError:
8248
10178
  pass
8249
- untracked = [b for b in big if not lfs_tracked(project, b)]
10179
+ binaries = [b for b in big if is_binary_content(project / b)]
10180
+ untracked = [b for b in binaries if not lfs_tracked(project, b)]
8250
10181
  if untracked:
8251
10182
  print(
8252
- f"- {len(untracked)} large file(s) not LFS-tracked, e.g. "
10183
+ f"- {len(untracked)} large binary file(s) not LFS-tracked, e.g. "
8253
10184
  + ", ".join(untracked[:3])
8254
10185
  )
8255
10186
  hits += 1
8256
- else:
8257
- print(f"- LFS rules present; {len(big)} large file(s), all tracked")
10187
+ if not untracked:
10188
+ text = len(big) - len(binaries)
10189
+ note = f"; {text} large text record(s) exempt from the threshold" if text else ""
10190
+ print(
10191
+ f"- LFS rules present; {len(binaries)} large binary file(s), "
10192
+ f"all LFS-tracked{note}"
10193
+ )
8258
10194
  else:
8259
10195
  print("- no git repository yet: run `teamwork.py repo`")
8260
10196
  hits += 1
@@ -8266,7 +10202,10 @@ def _audit_git_archival_lfs_large_files(root: Path, names: list, args) -> int:
8266
10202
  def _audit_markdown_link_health_broken_malformed_ba(root: Path, names: list, args) -> int:
8267
10203
  hits = 0
8268
10204
  print("## markdown link health (broken / malformed / bare / missing back-links)")
8269
- rep = link_report(project_of(root), root)
10205
+ # Same rule as cmd_links: measure the tree the caller is in. project_of()
10206
+ # answers for the main checkout, so inside a worktree this section reported
10207
+ # "none" no matter what the lane's own documents contained.
10208
+ rep = link_report(work_repo(root), root)
8270
10209
  link_issues = (
8271
10210
  len(rep["broken"]) + len(rep["external_bad"]) + len(rep["bare"]) + len(rep["missing_back"])
8272
10211
  )
@@ -8299,14 +10238,78 @@ def _print_dispute(d: dict) -> None:
8299
10238
  print(f" ruling: signed=[{', '.join(d['signed'])}] {d['decision']}")
8300
10239
 
8301
10240
 
8302
- def _dispute_parties(dispute: dict = None) -> set:
8303
- """The roles a dispute is between; neither may rule it."""
10241
+ def _dispute_party_instances(root: Path, dispute: dict = None) -> set:
10242
+ """The instances a dispute is between, in their canonical spelling.
10243
+
10244
+ A dispute names agents, not roles: ``raised_by``/``against`` hold the instance
10245
+ that opened it and the instance it was opened against. Comparing them at role
10246
+ level (the old ``resolve_role`` test) dropped a whole role from the panel as
10247
+ soon as one of its instances was a party, which locked the uninvolved
10248
+ instances of that same role out of the neutral seat.
10249
+
10250
+ A dispute raised against a bare role name speaks for every instance of that
10251
+ role, so the whole role counts as a party here.
10252
+ """
8304
10253
  if not dispute:
8305
10254
  return set()
8306
- return {
8307
- resolve_role(dispute.get("raised_by") or ""),
8308
- resolve_role(dispute.get("against") or ""),
8309
- }
10255
+ out = set()
10256
+ for field in ("raised_by", "against"):
10257
+ name = pascal(dispute.get(field) or "")
10258
+ if not name:
10259
+ continue
10260
+ role = resolve_role(name)
10261
+ instances = {pascal(i) for i in instances_of(root, role)}
10262
+ if name in instances:
10263
+ out.add(name)
10264
+ else:
10265
+ out.update(instances)
10266
+ return out
10267
+
10268
+
10269
+ def _dispute_party_instance(root: Path, dispute: dict, instance: str) -> bool:
10270
+ """True when *this instance* is named in ``raised_by``/``against``.
10271
+
10272
+ The guarantee is per agent, not per role: Reviewer-2 may be a party to a
10273
+ dispute while Reviewer-1 is not, and only Reviewer-2 is locked out of it.
10274
+ """
10275
+ if not dispute:
10276
+ return False
10277
+ return pascal(instance or "") in _dispute_party_instances(root, dispute)
10278
+
10279
+
10280
+ def _actor_is_dispute_party(root: Path, dispute: dict, actor: str, role: str) -> bool:
10281
+ """True when the actor may not sign or rule the dispute it is party to.
10282
+
10283
+ A concrete instance is a party when it is named in the dispute. A bare role
10284
+ name speaks for the whole role, so it is a party when ANY of its instances
10285
+ is - otherwise a party could sign under its role name and slip past the
10286
+ instance-level rule.
10287
+ """
10288
+ instances = instances_of(root, role)
10289
+ if pascal(actor or "") in {pascal(i) for i in instances}:
10290
+ return _dispute_party_instance(root, dispute, actor)
10291
+ return any(_dispute_party_instance(root, dispute, inst) for inst in instances)
10292
+
10293
+
10294
+ def _role_has_neutral_instance(root: Path, role: str, dispute: dict) -> bool:
10295
+ """True when the role has an instance that is not a party to the dispute.
10296
+
10297
+ A role leaves the panel only when EVERY one of its instances is a party:
10298
+ then no agent of that role can sit, so the role cannot be staffed neutrally.
10299
+ """
10300
+ instances = instances_of(root, role)
10301
+ if not instances:
10302
+ return False
10303
+ if not dispute:
10304
+ return True
10305
+ return any(not _dispute_party_instance(root, dispute, inst) for inst in instances)
10306
+
10307
+
10308
+ def _role_is_party_role(root: Path, role: str, dispute: dict) -> bool:
10309
+ """True when at least one instance of the role is a party to the dispute."""
10310
+ if not dispute:
10311
+ return False
10312
+ return any(_dispute_party_instance(root, dispute, inst) for inst in instances_of(root, role))
8310
10313
 
8311
10314
 
8312
10315
  def required_tribunal(root: Path, dispute: dict = None) -> list:
@@ -8314,19 +10317,40 @@ def required_tribunal(root: Path, dispute: dict = None) -> list:
8314
10317
 
8315
10318
  The CEO always sits. CTO and CFO join when staffed. The Reviewer takes the
8316
10319
  remaining neutral seat, because a slim roster staffs neither CTO nor CFO and a
8317
- panel of one would let the CEO sign and resolve their own case. A role that is
8318
- itself a party never sits, so a ruling is never issued by the side it decides
8319
- for. Only roles that really have an instance are returned - an invented signer
8320
- cannot sign and would deadlock every escalated dispute.
10320
+ panel of one would let the CEO sign and resolve their own case. A role is
10321
+ excluded only when EVERY one of its instances is a party, so an uninvolved
10322
+ instance of a party role can still take the neutral seat - the case that
10323
+ locked the third Reviewer out of a Reviewer-vs-Reviewer dispute. Only roles
10324
+ that really have an instance are returned - an invented signer cannot sign and
10325
+ would deadlock every escalated dispute.
8321
10326
  """
8322
- parties = _dispute_parties(dispute)
8323
- members = [r for r in ("ceo", "cto", "cfo") if r not in parties and instances_of(root, r)]
8324
- if len(members) < TRIBUNAL_MIN_SIGNERS and "reviewer" not in parties:
8325
- if instances_of(root, "reviewer"):
8326
- members.append("reviewer")
10327
+ members = [r for r in ("ceo", "cto", "cfo") if _role_has_neutral_instance(root, r, dispute)]
10328
+ if _role_has_neutral_instance(root, "reviewer", dispute) and (
10329
+ len(members) < TRIBUNAL_MIN_SIGNERS or _role_is_party_role(root, "reviewer", dispute)
10330
+ ):
10331
+ # The Reviewer takes the neutral seat. It also takes it when it is a party
10332
+ # role with an uninvolved instance: the role was only on the panel by
10333
+ # accident then, and that instance is the neutral seat the charter names.
10334
+ members.append("reviewer")
8327
10335
  return members
8328
10336
 
8329
10337
 
10338
+ def _neutral_signer(root: Path, dispute: dict, members: list) -> str:
10339
+ """A concrete instance that may sign and rule: on the panel, not a party.
10340
+
10341
+ The panel lists roles, but the party rule is per instance, so a role on the
10342
+ panel can still be the party - and the notice must not name it as the signer.
10343
+ """
10344
+ for role in members:
10345
+ instances = instances_of(root, role)
10346
+ if not any(_dispute_party_instance(root, dispute, inst) for inst in instances):
10347
+ return role
10348
+ for inst in instances:
10349
+ if not _dispute_party_instance(root, dispute, inst):
10350
+ return inst
10351
+ return ""
10352
+
10353
+
8330
10354
  def _dispute_tribunal_notice(root: Path, d: dict) -> None:
8331
10355
  members = required_tribunal(root, d)
8332
10356
  print("teamwork: this dispute needs the tribunal - escalate it now.")
@@ -8334,9 +10358,9 @@ def _dispute_tribunal_notice(root: Path, d: dict) -> None:
8334
10358
  print(f" 1) python3 teamwork.py dispute escalate {d['id']} --by {d['raised_by']}")
8335
10359
  print(f" 2) the tribunal ({', '.join(members)}) discusses and each signs:")
8336
10360
  print(f" dispute sign {d['id']} --by {'|'.join(members)}")
8337
- signers = [r for r in members if r not in _dispute_parties(d)] or members
8338
- if signers:
8339
- print(f" 3) dispute resolve {d['id']} --by {signers[0]} --decision '<binding ruling>'")
10361
+ signer = _neutral_signer(root, d, members)
10362
+ if signer:
10363
+ print(f" 3) dispute resolve {d['id']} --by {signer} --decision '<binding ruling>'")
8340
10364
  else:
8341
10365
  print(" 3) nobody independent is staffed: add a reviewer, then resolve")
8342
10366
  print(" 4) record the ruling in docs/Team/Decisions.md")
@@ -8449,6 +10473,16 @@ def cmd_dispute_sign(args) -> int:
8449
10473
  members = required_tribunal(root, d)
8450
10474
  if who not in members:
8451
10475
  die(f"only the tribunal may sign: {', '.join(members)}")
10476
+ if _actor_is_dispute_party(root, d, actor, arole):
10477
+ # The party rule is per instance: a role-mate is not a party, but the
10478
+ # agent the dispute was raised against never signs its own case. The
10479
+ # panel check runs first, so an actor off the panel still gets the
10480
+ # panel message it always got.
10481
+ die(
10482
+ f"{d['id']}: {actor} is a party to this dispute and cannot sign it. "
10483
+ f"Escalate it and let {', '.join(members)} decide",
10484
+ 2,
10485
+ )
8452
10486
  if who not in d["signed"]:
8453
10487
  d["signed"].append(who)
8454
10488
  d["updated"] = today_iso()
@@ -8480,19 +10514,16 @@ def cmd_dispute_resolve(args) -> int:
8480
10514
  panel = required_tribunal(root, d)
8481
10515
  if arole not in panel:
8482
10516
  die(f"only the tribunal may resolve: {', '.join(panel)}")
8483
- parties = {
8484
- resolve_role(d.get("raised_by") or ""),
8485
- resolve_role(d.get("against") or ""),
8486
- }
8487
- if who in parties:
8488
- # A dispute is never decided by the side it decides for. Escalate it so a
8489
- # neutral member signs first; forcing it needs a written reason (F3).
8490
- what = (
8491
- f"{d['id']}: {who} is a party to this dispute and cannot rule it. "
8492
- f"Escalate it and let {', '.join(required_tribunal(root, d))} decide"
8493
- )
8494
- if not (args.force or "").strip():
8495
- die(f"{what}. Forcing it needs a written reason.", 2)
10517
+ if _actor_is_dispute_party(root, d, actor, arole):
10518
+ # A dispute is never decided by the side it decides for. The party test
10519
+ # is per instance (a role-mate is not a party), and it is absolute:
10520
+ # --force waives the signature requirement, never the party rule, so
10521
+ # no party can rule its own dispute however good its reason reads.
10522
+ die(
10523
+ f"{d['id']}: {actor} is a party to this dispute and cannot rule it. "
10524
+ f"Escalate it and let {', '.join(panel)} decide",
10525
+ 2,
10526
+ )
8496
10527
  if d["escalated"]:
8497
10528
  members = required_tribunal(root, d)
8498
10529
  missing = [r for r in members if r not in d["signed"]]
@@ -8555,11 +10586,6 @@ def cmd_dispute_list(args) -> int:
8555
10586
  return 0
8556
10587
 
8557
10588
 
8558
- def reporting_order() -> list[str]:
8559
- """Roles in reporting order, parents before children."""
8560
- return [r for r in ROLE_REPORT_ORDER if r in ROLE_PARENT]
8561
-
8562
-
8563
10589
  def reporting_children(parent: str) -> list[str]:
8564
10590
  """Direct reports of ``parent``, in reporting order."""
8565
10591
  return [r for r in ROLE_REPORT_ORDER if ROLE_PARENT.get(r) == parent]
@@ -8678,20 +10704,64 @@ def cmd_agents_md(args) -> int:
8678
10704
  return 0
8679
10705
 
8680
10706
 
8681
- def _fetch_source(source: Path, remote: str, branch: str) -> tuple[bool, str]:
8682
- """Fetch ``remote/branch`` into ``source``. Returns ``(ok, detail)``."""
10707
+ def _source_ref(source: Path, remote: str, branch: str) -> tuple[str, str]:
10708
+ """Resolve a refresh pin to ``(kind, commit)``, or ``("", "")``.
10709
+
10710
+ A pin names either a **branch** - fetched into ``refs/remotes/<remote>/<name>``
10711
+ by a plain ``git fetch`` - or a **tag**, which a fetch by name leaves in
10712
+ ``FETCH_HEAD`` only (``git fetch origin v1.1.2`` never creates
10713
+ ``refs/remotes/origin/v1.1.2``, and ``git clone --branch`` accepts a tag
10714
+ exactly once). Both spellings are tried, branch first, and the caller is told
10715
+ which one answered so it can move the checkout the way that kind allows.
10716
+ """
10717
+ for kind, ref in (
10718
+ ("branch", f"refs/remotes/{remote}/{branch}"),
10719
+ ("tag", f"refs/tags/{branch}"),
10720
+ ):
10721
+ got = run_git(source, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}")
10722
+ if got.returncode == 0 and got.stdout.strip():
10723
+ return kind, got.stdout.strip()
10724
+ return "", ""
10725
+
10726
+
10727
+ def _fetch_source(source: Path, remote: str, branch: str) -> tuple[bool, str, str, str]:
10728
+ """Fetch the ``branch`` pin into ``source`` -> ``(ok, detail, kind, commit)``.
10729
+
10730
+ ``kind`` is ``"branch"`` or ``"tag"`` and ``commit`` the full sha, so
10731
+ ``_update_source`` can pick a fast-forward or a detached move without guessing
10732
+ from the printed detail.
10733
+ """
8683
10734
  got = run_git(source, "fetch", "--prune", "--quiet", remote, branch)
8684
10735
  if got.returncode != 0:
8685
10736
  tail = (got.stderr or got.stdout).strip().splitlines()
8686
- return False, tail[-1] if tail else "fetch failed"
8687
- rev = run_git(source, "rev-parse", "--short", f"{remote}/{branch}")
8688
- if rev.returncode != 0:
8689
- return False, f"no {remote}/{branch} after fetch"
8690
- return True, rev.stdout.strip()
10737
+ missing = tail[-1] if tail else "fetch failed"
10738
+ return False, f"no branch or tag {branch} on {remote}: {missing}", "", ""
10739
+ kind, commit = _source_ref(source, remote, branch)
10740
+ if not kind:
10741
+ # The name exists (the fetch above succeeded) but is not a branch: fetch it
10742
+ # as a tag ref so ``refs/tags/<name>`` resolves and stays identifiable.
10743
+ tagged = run_git(
10744
+ source, "fetch", "--quiet", remote, f"refs/tags/{branch}:refs/tags/{branch}"
10745
+ )
10746
+ if tagged.returncode == 0:
10747
+ kind, commit = _source_ref(source, remote, branch)
10748
+ if not kind:
10749
+ return False, f"no branch {remote}/{branch} and no tag {branch} after fetch", "", ""
10750
+ short = run_git(source, "rev-parse", "--short", commit).stdout.strip() or commit[:7]
10751
+ if kind == "tag":
10752
+ return True, f"pinned to tag {branch} {short}", kind, commit
10753
+ return True, short, kind, commit
8691
10754
 
8692
10755
 
8693
10756
  def _update_source(source: Path, remote: str, branch: str) -> tuple[bool, str]:
8694
- """Move ``source`` to ``remote/branch`` without touching a dirty tree."""
10757
+ """Move ``source`` to the ``branch`` pin without touching a dirty tree.
10758
+
10759
+ A branch pin fast-forwards. A **tag** pin is a fixed revision, so the checkout
10760
+ is moved onto that commit *detached* - the only move that can go backwards (an
10761
+ older tag, a rollback) as well as forwards. Neither path is ``reset --hard``,
10762
+ a clean tree loses nothing because the commits stay in the refs, and a dirty
10763
+ tree keeps the "mirror it as it stands" behaviour above.
10764
+ """
8695
10765
  dirty = run_git(source, "status", "--porcelain").stdout.strip()
8696
10766
  if dirty:
8697
10767
  # Never clobber uncommitted work, but do not refuse to mirror either: the
@@ -8700,14 +10770,38 @@ def _update_source(source: Path, remote: str, branch: str) -> tuple[bool, str]:
8700
10770
  f"local changes ({dirty.splitlines()[0]}) - fast-forward skipped, "
8701
10771
  "mirroring this checkout as it stands"
8702
10772
  )
8703
- ok, detail = _fetch_source(source, remote, branch)
10773
+ ok, detail, kind, commit = _fetch_source(source, remote, branch)
8704
10774
  if not ok:
8705
10775
  return False, detail
8706
10776
  head = run_git(source, "rev-parse", "--short", "HEAD").stdout.strip()
8707
- target = detail
10777
+ target = run_git(source, "rev-parse", "--short", commit).stdout.strip()
10778
+ if kind == "tag":
10779
+ # A tag pin is a fixed revision, not a line of development, so the
10780
+ # checkout has to sit on it rather than on a tracking branch. Detaching
10781
+ # is the only move that can go backwards as well as forwards; the tree is
10782
+ # clean here, so nothing is lost (the commits stay in the refs).
10783
+ moved = run_git(source, "checkout", "--quiet", "--detach", commit)
10784
+ if moved.returncode != 0:
10785
+ tail = (moved.stderr or moved.stdout).strip().splitlines()
10786
+ return False, f"cannot move {head} -> {detail}: {tail[-1] if tail else '?'}"
10787
+ after = run_git(source, "rev-parse", "--short", "HEAD").stdout.strip()
10788
+ if after == head:
10789
+ return True, detail
10790
+ return True, f"{head} -> {after} ({detail})"
10791
+ label = f"{remote}/{branch} {detail}"
10792
+ detached = run_git(source, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == "HEAD"
10793
+ if detached:
10794
+ # Leaving a tag pin for a branch: re-attach first, or the fast-forward
10795
+ # would only move a detached HEAD and the branch pin would not stick.
10796
+ reattach = run_git(source, "checkout", "--quiet", branch)
10797
+ if reattach.returncode != 0:
10798
+ tail = (reattach.stderr or reattach.stdout).strip().splitlines()
10799
+ return False, (
10800
+ f"cannot re-attach {branch} from the detached tag pin: {tail[-1] if tail else '?'}"
10801
+ )
8708
10802
  if head == target:
8709
- return True, f"already at {remote}/{branch} {target}"
8710
- pulled = run_git(source, "merge", "--ff-only", f"{remote}/{branch}")
10803
+ return True, f"already at {label}"
10804
+ pulled = run_git(source, "merge", "--ff-only", commit)
8711
10805
  if pulled.returncode != 0:
8712
10806
  tail = (pulled.stderr or pulled.stdout).strip().splitlines()
8713
10807
  return False, f"cannot fast-forward {head} -> {target}: {tail[-1] if tail else '?'}"
@@ -8986,8 +11080,10 @@ def save_model_config(root: Path, config: dict) -> None:
8986
11080
  def resolved_model(root: Path, agent: str) -> dict:
8987
11081
  """The model the given instance should use: agent override, else role default."""
8988
11082
  config = load_model_config(root)
8989
- if agent in config["agents"]:
8990
- return dict(config["agents"][agent], source="agent")
11083
+ wanted = pascal(agent)
11084
+ for key, entry in config["agents"].items():
11085
+ if pascal(key) == wanted:
11086
+ return dict(entry, source="agent")
8991
11087
  role = resolve_role(agent) if agent else ""
8992
11088
  if role and role in config["roles"]:
8993
11089
  return dict(config["roles"][role], source="role")
@@ -9083,14 +11179,37 @@ def resolve_model_plan(root: Path, env=None) -> dict:
9083
11179
  env.get("PI_PROVIDER") or ""
9084
11180
  ).strip()
9085
11181
  assignments, missing = {}, []
11182
+ alternate = (env.get("TEAMWORK_MODEL_ALT") or "").strip()
9086
11183
  for role in sorted(list_roles(root)):
9087
11184
  tier = LANE_TIER.get(role, "economy")
9088
11185
  model = strong if tier == "strong" else (economy or strong)
9089
11186
  if not model:
9090
11187
  missing.append(role)
9091
11188
  continue
9092
- assignments[role] = {"provider": provider, "model": model, "tier": tier}
11189
+ lanes = instances_of(root, role)
11190
+ if role in COORD_VERIFICATION_ROLES and len(lanes) > 1 and alternate:
11191
+ # Two lanes that share weights, context and queue make the same
11192
+ # mistake; independent verification needs a second model when one is
11193
+ # available (references/Coordination.md).
11194
+ for index, lane in enumerate(lanes):
11195
+ assignments[lane] = {
11196
+ "provider": provider,
11197
+ "model": alternate if index % 2 else model,
11198
+ "tier": tier + ("-alt" if index % 2 else ""),
11199
+ "scope": "agent",
11200
+ }
11201
+ assignments[role] = {
11202
+ "provider": provider,
11203
+ "model": model,
11204
+ "tier": tier,
11205
+ "scope": "role",
11206
+ }
9093
11207
  notes = [f"host: {host}"]
11208
+ if len(instances_of(root, "reviewer")) > 1 and not alternate:
11209
+ notes.append(
11210
+ "reviewer lanes share one model: set TEAMWORK_MODEL_ALT to a second "
11211
+ "model so `model auto --apply` decorrelates independent review"
11212
+ )
9094
11213
  if missing:
9095
11214
  notes.append(
9096
11215
  "no model found in the environment for: "
@@ -9132,7 +11251,8 @@ def cmd_model_auto(args) -> int:
9132
11251
  die("nothing to apply: no model found in the environment")
9133
11252
  config = load_model_config(root)
9134
11253
  for role, entry in plan["assignments"].items():
9135
- config["roles"][role] = {"provider": entry["provider"], "model": entry["model"]}
11254
+ target = config["agents"] if entry.get("scope") == "agent" else config["roles"]
11255
+ target[role] = {"provider": entry["provider"], "model": entry["model"]}
9136
11256
  save_model_config(root, config)
9137
11257
  ledger_append(
9138
11258
  root,
@@ -9259,11 +11379,12 @@ def _parsers_workspace(sub, common) -> None:
9259
11379
  s = sub.add_parser(
9260
11380
  "consolidate",
9261
11381
  parents=[common],
9262
- help="promote stale short-term entries, evict long-term overflow",
11382
+ help="promote stale short-term entries, evict long-term overflow (takes the role memory pen)",
9263
11383
  )
9264
11384
  s.add_argument("--agent")
9265
11385
  s.add_argument("--all", action="store_true")
9266
11386
  s.add_argument("--dry-run", action="store_true")
11387
+ s.add_argument("--by", default="", help="which instance consolidates (default: the role)")
9267
11388
  s.set_defaults(func=cmd_consolidate)
9268
11389
 
9269
11390
  s = sub.add_parser("stats", parents=[common], help="memory sizes and cap violations")
@@ -9274,6 +11395,36 @@ def _parsers_workspace(sub, common) -> None:
9274
11395
  s = sub.add_parser("agents", parents=[common], help="list agent directories")
9275
11396
  s.set_defaults(func=cmd_agents)
9276
11397
 
11398
+ s = sub.add_parser(
11399
+ "instance",
11400
+ parents=[common],
11401
+ help="add/list/retire instances of ANY role (they share the role's memory)",
11402
+ )
11403
+ isub = s.add_subparsers(dest="instance_cmd", required=True)
11404
+ p = isub.add_parser(
11405
+ "add", parents=[common], help="launch one or more extra instances of a role"
11406
+ )
11407
+ p.add_argument("role", help="role slug to scale, e.g. architect, cto, designer")
11408
+ p.add_argument("--count", type=int, default=1, help="how many instances to add (default 1)")
11409
+ p.add_argument("--name", default="", help="explicit instance name instead of the next free one")
11410
+ p.add_argument("--new-role", action="store_true", help="staff a role this team does not have")
11411
+ p.add_argument("--by", default="ceo", help="who adds it (provenance)")
11412
+ p.add_argument("--json", action="store_true", help="machine-readable output")
11413
+ p.set_defaults(func=cmd_instance)
11414
+ p = isub.add_parser(
11415
+ "list", parents=[common], help="every role, its instances and memory writer"
11416
+ )
11417
+ p.add_argument("--json", action="store_true", help="machine-readable output")
11418
+ p.set_defaults(func=cmd_instance)
11419
+ p = isub.add_parser(
11420
+ "retire", parents=[common], help="remove an idle instance (the role's memory stays)"
11421
+ )
11422
+ p.add_argument("name", help="instance to remove, e.g. reviewer-3")
11423
+ p.add_argument("--by", default="ceo", help="who retires it (provenance)")
11424
+ p.add_argument("--reason", default="", help="why it goes (required once it holds work)")
11425
+ p.add_argument("--force", action="store_true", help="retire even while it holds work")
11426
+ p.set_defaults(func=cmd_instance)
11427
+
9277
11428
  # --- tasks ----------------------------------------------------------- #
9278
11429
  st = sub.add_parser("task", parents=[common], help="manage an agent's priority task list")
9279
11430
  tsub = st.add_subparsers(dest="task_cmd", required=True)
@@ -9291,6 +11442,13 @@ def _parsers_workspace(sub, common) -> None:
9291
11442
  s.add_argument("--deps", default="", help="comma list; cross-role as role:T-0001")
9292
11443
  s.add_argument("--shared", default="", help="shared board / issue / PR reference")
9293
11444
  s.add_argument("--tags", default="")
11445
+ s.add_argument(
11446
+ "--review-profile",
11447
+ default="",
11448
+ choices=["", "fast", "standard", "deep"],
11449
+ dest="review_profile",
11450
+ help="override the profile inferred from the diff (fast/standard/deep)",
11451
+ )
9294
11452
  s.add_argument("--body", default="", help="acceptance criteria / notes")
9295
11453
  s.add_argument(
9296
11454
  "--bug",
@@ -9359,6 +11517,12 @@ def _parsers_workspace(sub, common) -> None:
9359
11517
  s.add_argument("handle")
9360
11518
  s.add_argument("--as", dest="by", required=True, help="instance taking it over")
9361
11519
  s.add_argument("--reason", required=True)
11520
+ s.add_argument(
11521
+ "--force",
11522
+ default="",
11523
+ metavar="REASON",
11524
+ help="take over from a lane that still holds a live slot; reason required",
11525
+ )
9362
11526
  s.set_defaults(func=cmd_task_reclaim)
9363
11527
 
9364
11528
  s = tsub.add_parser("rm", parents=[common], help="remove a task (own role only)")
@@ -9500,6 +11664,9 @@ def _parsers_memory(sub, common) -> None:
9500
11664
  s.add_argument("--importance", type=int, default=3)
9501
11665
  s.add_argument("--tags", default="")
9502
11666
  s.add_argument("--pin", action="store_true")
11667
+ s.add_argument("--by", default="", help="who recorded it (provenance)")
11668
+ s.add_argument("--src", default="", help="what it comes from (task, doc, run)")
11669
+ s.add_argument("--rev", default="", help="revision it was verified at")
9503
11670
  s.set_defaults(func=cmd_global)
9504
11671
 
9505
11672
  s = sub.add_parser("recall", parents=[common], help="search memory")
@@ -9514,14 +11681,77 @@ def _parsers_memory(sub, common) -> None:
9514
11681
  s.add_argument("agent")
9515
11682
  s.add_argument("handle", help="M-0003 or key or substring")
9516
11683
  s.add_argument("--layer", choices=["short", "long"], default="short")
11684
+ s.add_argument("--by", default="", help="which instance touches it (default: the agent)")
9517
11685
  s.set_defaults(func=cmd_touch)
9518
11686
 
9519
11687
  s = sub.add_parser("forget", parents=[common], help="delete an entry")
9520
11688
  s.add_argument("agent")
9521
11689
  s.add_argument("handle")
9522
11690
  s.add_argument("--layer", choices=["short", "long"], default="short")
11691
+ s.add_argument("--by", default="", help="which instance forgets it (default: the agent)")
9523
11692
  s.set_defaults(func=cmd_forget)
9524
11693
 
11694
+ # The role memory lease: exactly one instance revises a role's memory at a
11695
+ # time, and it keeps the pen until the whole update is in.
11696
+ s = sub.add_parser(
11697
+ "memory",
11698
+ parents=[common],
11699
+ help="the role memory pen: claim/release/renew/contest/extend/revoke",
11700
+ )
11701
+ msub = s.add_subparsers(dest="memory_cmd", required=True)
11702
+ p = msub.add_parser(
11703
+ "claim", parents=[common], help="hold the pen for the role's memory until you release it"
11704
+ )
11705
+ p.add_argument("agent", help="role (or instance) whose memory you will revise")
11706
+ p.add_argument("--by", default="", help="the instance taking the pen (required by the lease)")
11707
+ p.add_argument("--ttl", type=int, default=0, help="lease minutes (default MemoryLeaseMinutes)")
11708
+ p.add_argument("--reason", default="", help="what update you are about to make")
11709
+ p.add_argument(
11710
+ "--force",
11711
+ default="",
11712
+ metavar="REASON",
11713
+ help="take the pen from a stalled holder; reason required and recorded",
11714
+ )
11715
+ p.set_defaults(func=cmd_memory)
11716
+ p = msub.add_parser("release", parents=[common], help="give the pen back (holder only)")
11717
+ p.add_argument("agent")
11718
+ p.add_argument("--by", default="", help="the instance releasing it")
11719
+ p.add_argument("--force", default="", metavar="REASON", help="release a dead lane's pen")
11720
+ p.set_defaults(func=cmd_memory)
11721
+ p = msub.add_parser("renew", parents=[common], help="extend the lease while the update runs")
11722
+ p.add_argument("agent")
11723
+ p.add_argument("--by", default="", help="the holder")
11724
+ p.add_argument("--ttl", type=int, default=0, help="lease minutes (default MemoryLeaseMinutes)")
11725
+ p.set_defaults(func=cmd_memory)
11726
+ p = msub.add_parser(
11727
+ "contest",
11728
+ parents=[common],
11729
+ help="peer: ask the CTO to rule on a lease that is running unusually long",
11730
+ )
11731
+ p.add_argument("agent", help="role whose memory is held")
11732
+ p.add_argument("--by", default="", help="the peer instance raising it")
11733
+ p.add_argument("--reason", default="", help="why the update looks stalled (required)")
11734
+ p.set_defaults(func=cmd_memory)
11735
+ p = msub.add_parser(
11736
+ "extend", parents=[common], help="CTO/CEO: rule that the holder keeps the pen longer"
11737
+ )
11738
+ p.add_argument("agent")
11739
+ p.add_argument("--by", default="", help="the arbiter ruling")
11740
+ p.add_argument("--ttl", type=int, default=0, help="extra minutes (default: a full lease)")
11741
+ p.add_argument("--reason", default="", help="why more time is fair (required)")
11742
+ p.set_defaults(func=cmd_memory)
11743
+ p = msub.add_parser(
11744
+ "revoke", parents=[common], help="CTO/CEO: remove the lease and free the role now"
11745
+ )
11746
+ p.add_argument("agent")
11747
+ p.add_argument("--by", default="", help="the arbiter ruling")
11748
+ p.add_argument("--reason", default="", help="why the pen is removed (required)")
11749
+ p.set_defaults(func=cmd_memory)
11750
+ p = msub.add_parser("status", parents=[common], help="who may revise which role's memory now")
11751
+ p.add_argument("agent", nargs="?", default="", help="one role (default: every role)")
11752
+ p.add_argument("--json", action="store_true", help="machine-readable output")
11753
+ p.set_defaults(func=cmd_memory)
11754
+
9525
11755
 
9526
11756
  def _parsers_task(sub, common) -> None:
9527
11757
  """`task` command surface."""
@@ -9548,6 +11778,12 @@ def _parsers_quality(sub, common) -> None:
9548
11778
  s.add_argument(
9549
11779
  "--release", action="store_true", help="hand a claimed deliverable back to the queue"
9550
11780
  )
11781
+ s.add_argument("--renew", action="store_true", help="extend the lease on a claim you hold")
11782
+ s.add_argument(
11783
+ "--packet",
11784
+ action="store_true",
11785
+ help="print the review packet (profile, diff, gates, checklist) instead of a verdict",
11786
+ )
9551
11787
  s.add_argument("--by", default="reviewer")
9552
11788
  s.add_argument("--result", choices=["pass", "fail"])
9553
11789
  s.add_argument("--evidence", default="", help="verification command @rev -> result")
@@ -9806,6 +12042,11 @@ def _parsers_integration(sub, common) -> None:
9806
12042
  action="store_true",
9807
12043
  help="also fail commits authored outside the team identity domain",
9808
12044
  )
12045
+ s.add_argument(
12046
+ "--guard-committer",
12047
+ action="store_true",
12048
+ help="fail when a commit carries a committer outside the team identity domain",
12049
+ )
9809
12050
  s.set_defaults(func=cmd_identity)
9810
12051
 
9811
12052
  s = sub.add_parser(
@@ -9840,11 +12081,17 @@ def _parsers_integration(sub, common) -> None:
9840
12081
  s.add_argument(
9841
12082
  "--lfs-threshold",
9842
12083
  type=int,
9843
- default=1_000_000,
12084
+ default=LFS_MAX_BYTES,
9844
12085
  help="bytes above which a file must be LFS-tracked",
9845
12086
  )
9846
12087
  s.set_defaults(func=cmd_commit)
9847
12088
 
12089
+ # `hooks` installs and inspects the git hooks; `hook` is what those hooks call
12090
+ # back into. The rules act on the commit/push flow, so they register here.
12091
+ from .hooks import add_parsers as _add_hook_parsers
12092
+
12093
+ _add_hook_parsers(sub, common)
12094
+
9848
12095
 
9849
12096
  def _parsers_governance(sub, common) -> None:
9850
12097
  """`governance` command surface."""
@@ -9939,6 +12186,20 @@ def _parsers_governance(sub, common) -> None:
9939
12186
 
9940
12187
  def _parsers_audit(sub, common) -> None:
9941
12188
  """`audit` command surface."""
12189
+ s = sub.add_parser(
12190
+ "coordination",
12191
+ parents=[common],
12192
+ help="coordination health: conformity, rubber-stamping, turf, merge rate",
12193
+ )
12194
+ s.add_argument("--json", action="store_true", help="machine-readable report")
12195
+ s.add_argument(
12196
+ "--strict",
12197
+ action="store_true",
12198
+ help="exit 1 when a high-severity finding is open (default: report only)",
12199
+ )
12200
+ s.add_argument("--commit", action="store_true", help="append the result to the ledger")
12201
+ s.set_defaults(func=cmd_coordination)
12202
+
9942
12203
  s = sub.add_parser(
9943
12204
  "links", parents=[common], help="check markdown cross-links, URLs and back-links"
9944
12205
  )
@@ -10124,6 +12385,12 @@ def _parsers_meta(sub, common) -> None:
10124
12385
  p.add_argument("--json", action="store_true", help="machine-readable output")
10125
12386
  p.set_defaults(func=cmd_imaging)
10126
12387
 
12388
+ # The harness surface registry and its lint live in their own module: they
12389
+ # measure the tree at run time and must not grow this command layer.
12390
+ from .harness import add_parsers as _add_harness_parsers
12391
+
12392
+ _add_harness_parsers(sub, common)
12393
+
10127
12394
 
10128
12395
  def main(argv=None) -> int:
10129
12396
  args = build_parser().parse_args(argv)