@nidvue/teamwork 1.1.1 → 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.
- package/CHANGELOG.md +368 -3
- package/README.md +80 -26
- package/package.json +1 -1
- package/skills/teamwork/AGENTS.md +57 -2
- package/skills/teamwork/SKILL.md +133 -27
- package/skills/teamwork/references/Adapters.md +4 -0
- package/skills/teamwork/references/Audit.md +3 -1
- package/skills/teamwork/references/Concurrency.md +22 -7
- package/skills/teamwork/references/Coordination.md +203 -0
- package/skills/teamwork/references/Dissent.md +10 -1
- package/skills/teamwork/references/Docs.md +3 -1
- package/skills/teamwork/references/Engineering.md +47 -8
- package/skills/teamwork/references/Evidence.md +18 -1
- package/skills/teamwork/references/Remotes.md +29 -8
- package/skills/teamwork/references/Roles.md +22 -4
- package/skills/teamwork/references/State.md +96 -12
- package/skills/teamwork/references/Testing.md +3 -1
- package/skills/teamwork/roles/ceo.md +50 -3
- package/skills/teamwork/roles/developer.md +8 -0
- package/skills/teamwork/roles/product-manager.md +4 -0
- package/skills/teamwork/roles/reviewer.md +97 -12
- package/skills/teamwork/scripts/check_release_consistency.py +1805 -0
- package/skills/teamwork/scripts/privacy_scan.py +196 -82
- package/skills/teamwork/src/teamwork/bundle.py +0 -7
- package/skills/teamwork/src/teamwork/config.py +53 -2
- package/skills/teamwork/src/teamwork/engine.py +2722 -428
- package/skills/teamwork/src/teamwork/gitutil.py +148 -10
- package/skills/teamwork/src/teamwork/harness.py +753 -0
- package/skills/teamwork/src/teamwork/hooks.py +400 -0
- package/skills/teamwork/src/teamwork/layout.py +10 -4
- package/skills/teamwork/src/teamwork/portable.py +6 -1
- package/skills/teamwork/src/teamwork/records.py +20 -0
- package/skills/teamwork/src/teamwork/resources.py +41 -7
- package/skills/teamwork/src/teamwork/version.py +1 -1
- 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 [--
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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"
|
|
@@ -3214,20 +3367,85 @@ def default_audit_range(project: Path) -> str:
|
|
|
3214
3367
|
return "HEAD"
|
|
3215
3368
|
|
|
3216
3369
|
|
|
3370
|
+
def rewrite_safe_range(project: Path, rev_range: str) -> str:
|
|
3371
|
+
"""Narrow ``A..B`` to ``B`` when ``A`` no longer exists, and say so.
|
|
3372
|
+
|
|
3373
|
+
A push event hands CI the revision the branch had before the push. When that
|
|
3374
|
+
push *rewrote* history - the sanctioned repair for a commit carrying a
|
|
3375
|
+
personal mailbox - the old revision is gone, ``git log A..B`` exits 128 with
|
|
3376
|
+
"Invalid revision range", and the identity audit dies over a range that can no
|
|
3377
|
+
longer be measured. The new tip can still be audited, so audit it, name the
|
|
3378
|
+
rewrite, and never pretend the unmeasurable span was clean.
|
|
3379
|
+
"""
|
|
3380
|
+
if ".." not in rev_range:
|
|
3381
|
+
return rev_range
|
|
3382
|
+
base = rev_range.split("..", 1)[0].strip()
|
|
3383
|
+
if not base or base.startswith("-"):
|
|
3384
|
+
return rev_range
|
|
3385
|
+
if run_git(project, "cat-file", "-e", f"{base}^{{commit}}").returncode == 0:
|
|
3386
|
+
return rev_range
|
|
3387
|
+
print(
|
|
3388
|
+
f"# {base[:12]} is not an ancestor of this clone (history rewrite): "
|
|
3389
|
+
f"auditing the tip only, the span before it is unmeasurable"
|
|
3390
|
+
)
|
|
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()
|
|
3425
|
+
|
|
3426
|
+
|
|
3217
3427
|
def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool = False) -> int:
|
|
3218
3428
|
"""Fail when a commit is not authored and committed by the acting role.
|
|
3219
3429
|
|
|
3220
3430
|
The role that does the work commits it. A forge-created pull-request merge ref
|
|
3221
3431
|
carries the forge account identity, so it is skipped; its own history is
|
|
3222
|
-
audited by the caller's range.
|
|
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
|
|
3223
3436
|
repository also receives outside contributions: unless ``strict_authors`` is
|
|
3224
3437
|
set, an author outside the team's identity domain is reported as skipped
|
|
3225
3438
|
rather than failed, while a team-domain author with the wrong role still
|
|
3226
3439
|
fails.
|
|
3227
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}")
|
|
3228
3445
|
if rev_range.startswith("-"):
|
|
3229
3446
|
print(f"teamwork: invalid revision range {rev_range!r}")
|
|
3230
3447
|
return 2
|
|
3448
|
+
rev_range = rewrite_safe_range(project, rev_range)
|
|
3231
3449
|
proc = run_git(
|
|
3232
3450
|
project, "log", "--format=%H%x1f%an%x1f%ae%x1f%cn%x1f%ce%x1f%P%x1f%B%x1e", rev_range
|
|
3233
3451
|
)
|
|
@@ -3265,19 +3483,42 @@ def audit_commit_identities(project: Path, rev_range: str, strict_authors: bool
|
|
|
3265
3483
|
print(f"- {short} skipped: external contributor {an} <{ae}>")
|
|
3266
3484
|
continue
|
|
3267
3485
|
reasons.append(f"not a role mailbox: {ae}")
|
|
3268
|
-
elif an
|
|
3486
|
+
elif not _matches_role_display(an, expected):
|
|
3269
3487
|
reasons.append(f"display name {an!r} does not match role {expected!r}")
|
|
3270
|
-
|
|
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:
|
|
3271
3499
|
reasons.append(f"committer {cn} <{ce}> differs from author {an} <{ae}>")
|
|
3272
3500
|
match = _RE_ACTING_ROLE.search(body)
|
|
3273
3501
|
if not match:
|
|
3274
|
-
|
|
3275
|
-
|
|
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):
|
|
3276
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")
|
|
3277
3514
|
if reasons:
|
|
3278
3515
|
problems.append((short, reasons))
|
|
3279
3516
|
for reason in reasons:
|
|
3280
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})")
|
|
3281
3522
|
else:
|
|
3282
3523
|
print(f"- {short} ok {an} <{ae}>")
|
|
3283
3524
|
print(f"# commits: {seen} | problems: {len(problems)}")
|
|
@@ -3431,6 +3672,7 @@ def cmd_commit(args) -> int:
|
|
|
3431
3672
|
if (
|
|
3432
3673
|
fp.is_file()
|
|
3433
3674
|
and fp.stat().st_size > args.lfs_threshold
|
|
3675
|
+
and is_binary_content(fp)
|
|
3434
3676
|
and not lfs_tracked(project, line)
|
|
3435
3677
|
):
|
|
3436
3678
|
big.append(f"{line} ({fp.stat().st_size // 1024} KiB)")
|
|
@@ -3444,6 +3686,17 @@ def cmd_commit(args) -> int:
|
|
|
3444
3686
|
" --lfs-threshold",
|
|
3445
3687
|
2,
|
|
3446
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
|
+
)
|
|
3447
3700
|
|
|
3448
3701
|
r = subprocess.run(
|
|
3449
3702
|
["git", "-C", str(project)] + git_identity_args(role) + ["commit", "-F", "-"],
|
|
@@ -3569,6 +3822,262 @@ def roster_for(names: list) -> dict:
|
|
|
3569
3822
|
return {"tier": "explicit", "roles": roles, "instances": names, "missing_required": missing}
|
|
3570
3823
|
|
|
3571
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
|
+
|
|
3572
4081
|
def cmd_init(args) -> int:
|
|
3573
4082
|
root = project_root(args)
|
|
3574
4083
|
project = project_of(root)
|
|
@@ -3659,60 +4168,10 @@ def cmd_init(args) -> int:
|
|
|
3659
4168
|
staffed = {resolve_role(n) for n in names}
|
|
3660
4169
|
for name in names:
|
|
3661
4170
|
role = resolve_role(name)
|
|
3662
|
-
|
|
3663
|
-
rd = role_dir(root, role)
|
|
3664
|
-
rd.mkdir(parents=True, exist_ok=True)
|
|
3665
|
-
for filename in (F_SHORT, F_LONG, F_MEM_ARCHIVE):
|
|
3666
|
-
p = rd / filename
|
|
3667
|
-
if not p.exists():
|
|
3668
|
-
write_memory(p, [], filename, role)
|
|
3669
|
-
tp = rd / F_TASKS
|
|
3670
|
-
if not tp.exists():
|
|
3671
|
-
write_tasks(tp, [], role)
|
|
4171
|
+
scaffold_role(root, role, staffed=staffed, force=args.force)
|
|
3672
4172
|
if role not in roles_seen:
|
|
3673
4173
|
roles_seen.append(role)
|
|
3674
|
-
|
|
3675
|
-
# instance scope: private charter copy, worklog, inbox, scratch, outputs
|
|
3676
|
-
d = agent_dir(root, name)
|
|
3677
|
-
for sub in (DIR_ARTIFACTS, DIR_SCRATCH, DIR_REPORTS):
|
|
3678
|
-
(d / sub).mkdir(parents=True, exist_ok=True)
|
|
3679
|
-
role_file = ROLES_DIR / f"{role}.md"
|
|
3680
|
-
shared_charter = rd / F_ROLE
|
|
3681
|
-
if role_file.exists() and (args.force or not shared_charter.exists()):
|
|
3682
|
-
body = role_file.read_text(encoding="utf-8").replace(
|
|
3683
|
-
"{references}", str(SKILL_DIR / "references")
|
|
3684
|
-
)
|
|
3685
|
-
folded = charter_reports_line(role, staffed)
|
|
3686
|
-
if folded:
|
|
3687
|
-
body = fold_charter_reports(body, folded)
|
|
3688
|
-
write_lf(shared_charter, body)
|
|
3689
|
-
elif not shared_charter.exists():
|
|
3690
|
-
write_lf(
|
|
3691
|
-
shared_charter,
|
|
3692
|
-
f"# Role: {role}\n\n"
|
|
3693
|
-
f"No bundled charter found for '{role}'. Derive your duties from the\n"
|
|
3694
|
-
f"task packet, report blockers to the CEO, and keep to your lane.\n",
|
|
3695
|
-
)
|
|
3696
|
-
brief = d / F_START
|
|
3697
|
-
if args.force or not brief.exists():
|
|
3698
|
-
write_lf(
|
|
3699
|
-
brief,
|
|
3700
|
-
start_here(
|
|
3701
|
-
name, role, root, docs_dir(root), staffed={resolve_role(n) for n in names}
|
|
3702
|
-
),
|
|
3703
|
-
)
|
|
3704
|
-
for extra in (F_WORKLOG, F_INBOX):
|
|
3705
|
-
p = d / extra
|
|
3706
|
-
if not p.exists():
|
|
3707
|
-
write_lf(
|
|
3708
|
-
p,
|
|
3709
|
-
f"# {extra[:-3]} - {name} ({role})\n\n"
|
|
3710
|
-
f"> Private to this instance. The {role} memory and task queue\n"
|
|
3711
|
-
f"> are shared with every other {role} instance.\n\n"
|
|
3712
|
-
+ AGENT_LINKS.format(**link_fields(d / extra, name))
|
|
3713
|
-
+ "\n",
|
|
3714
|
-
)
|
|
3715
|
-
migrate_instance_memory(root, name, role)
|
|
4174
|
+
scaffold_instance(root, name, role, staffed=staffed, force=args.force)
|
|
3716
4175
|
created.append(name)
|
|
3717
4176
|
|
|
3718
4177
|
print(f"teamwork: workspace ready at {root}")
|
|
@@ -4023,7 +4482,7 @@ def cmd_load(args) -> int:
|
|
|
4023
4482
|
d = role_dir(root, role)
|
|
4024
4483
|
if not d.is_dir() and not agents_dir(root).is_dir():
|
|
4025
4484
|
die(f"unknown role '{role}' (run init or check `agents`)")
|
|
4026
|
-
consolidate_agent(root, role, dry_run=False, quiet=True)
|
|
4485
|
+
consolidate_agent(root, role, dry_run=False, quiet=True, actor=args.agent, strict=False)
|
|
4027
4486
|
limit = args.limit or SHORT_CAP
|
|
4028
4487
|
entries = parse_memory(d / F_SHORT)
|
|
4029
4488
|
entries.sort(key=lambda e: parse_date(e["last_used"]), reverse=True)
|
|
@@ -4035,6 +4494,19 @@ def cmd_load(args) -> int:
|
|
|
4035
4494
|
f" ({len(keep)}/{len(entries)} entries)"
|
|
4036
4495
|
)
|
|
4037
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
|
+
)
|
|
4038
4510
|
print(
|
|
4039
4511
|
f"# Long-term entries are NOT loaded; query them with:"
|
|
4040
4512
|
f' teamwork.py recall "<query>" --agent {role}'
|
|
@@ -4064,102 +4536,382 @@ def _memory_path(root: Path, agent: str, layer: str) -> tuple:
|
|
|
4064
4536
|
return d / filename, filename
|
|
4065
4537
|
|
|
4066
4538
|
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
write_memory(path, entries, filename, args.agent)
|
|
4086
|
-
ledger_append(
|
|
4087
|
-
root,
|
|
4088
|
-
"memory.add" if added else "memory.update",
|
|
4089
|
-
args.by or args.agent,
|
|
4090
|
-
args.key or args.agent,
|
|
4091
|
-
f"{'added' if added else 'updated'} {args.layer} entry: {args.text[:80]}",
|
|
4092
|
-
args.rev,
|
|
4093
|
-
)
|
|
4094
|
-
consolidate_agent(root, args.agent, dry_run=False, quiet=True)
|
|
4095
|
-
print(f"teamwork: remembered {added} new / {merged} merged entry in {path}")
|
|
4096
|
-
return 0
|
|
4097
|
-
|
|
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.
|
|
4098
4557
|
|
|
4099
|
-
def cmd_global(args) -> int:
|
|
4100
|
-
root = project_root(args)
|
|
4101
|
-
path = global_dir(root) / F_GLOBAL
|
|
4102
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
4103
|
-
with lock(root / DIR_RUNTIME / DIR_LOCKS / "global.lock"):
|
|
4104
|
-
entries = parse_memory(path)
|
|
4105
|
-
entry = new_entry(args.text, args.key, args.importance, args.tags, args.pin)
|
|
4106
|
-
entries, merged, added = merge_entries(entries, [entry])
|
|
4107
|
-
entries.sort(key=score, reverse=True)
|
|
4108
|
-
pinned = [e for e in entries if e["pinned"]]
|
|
4109
|
-
rest = [e for e in entries if not e["pinned"]]
|
|
4110
|
-
cap = max(LONG_CAP - len(pinned), 0)
|
|
4111
|
-
if len(rest) > cap:
|
|
4112
|
-
archive_path = global_dir(root) / F_GLOBAL_ARCHIVE
|
|
4113
|
-
archived = parse_memory(archive_path)
|
|
4114
|
-
archived, _, _ = merge_entries(archived, rest[cap:])
|
|
4115
|
-
archived.sort(key=score, reverse=True)
|
|
4116
|
-
write_memory(archive_path, archived[:ARCHIVE_CAP], F_GLOBAL_ARCHIVE, "all agents")
|
|
4117
|
-
rest = rest[:cap]
|
|
4118
|
-
write_memory(path, pinned + rest, F_GLOBAL, "all agents")
|
|
4119
|
-
ledger_append(
|
|
4120
|
-
root,
|
|
4121
|
-
"memory.global",
|
|
4122
|
-
"ceo",
|
|
4123
|
-
args.key or "global",
|
|
4124
|
-
f"{added} new / {merged} merged: {args.text[:80]}",
|
|
4125
|
-
)
|
|
4126
|
-
print(f"teamwork: global memory {added} new / {merged} merged ({path})")
|
|
4127
|
-
return 0
|
|
4128
4558
|
|
|
4559
|
+
def memory_claim_path(root: Path, role: str) -> Path:
|
|
4560
|
+
return root / DIR_RUNTIME / DIR_LOCKS / f"memory-{resolve_role(role)}.claim"
|
|
4129
4561
|
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
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'})"
|
|
4645
|
+
)
|
|
4646
|
+
|
|
4647
|
+
|
|
4648
|
+
def _write_memory_claim(root: Path, claim: dict) -> dict:
|
|
4649
|
+
import json
|
|
4650
|
+
|
|
4651
|
+
path = memory_claim_path(root, claim["role"])
|
|
4652
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
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
|
|
4880
|
+
|
|
4881
|
+
|
|
4882
|
+
def cmd_recall(args) -> int:
|
|
4883
|
+
root = project_root(args)
|
|
4884
|
+
if args.query:
|
|
4885
|
+
try:
|
|
4886
|
+
pattern = re.compile(args.query, re.IGNORECASE)
|
|
4887
|
+
except re.error as exc:
|
|
4888
|
+
die(
|
|
4889
|
+
f"invalid query pattern {args.query!r}: {exc}. "
|
|
4890
|
+
"Pass a plain text query or a valid regex."
|
|
4891
|
+
)
|
|
4892
|
+
else:
|
|
4893
|
+
pattern = None
|
|
4894
|
+
targets = [] # (owner_label, path, filename)
|
|
4895
|
+
if args.global_ or args.all:
|
|
4896
|
+
for f in sorted(global_dir(root).glob("*.md")):
|
|
4897
|
+
targets.append(("global", f, f.name))
|
|
4898
|
+
if args.all:
|
|
4899
|
+
for name in list_roles(root):
|
|
4900
|
+
for f in (role_dir(root, name) / F_LONG, role_dir(root, name) / F_SHORT):
|
|
4901
|
+
targets.append((name, f, f.name))
|
|
4902
|
+
elif args.agent:
|
|
4903
|
+
role = resolve_role(args.agent)
|
|
4904
|
+
for layer in (
|
|
4905
|
+
["short"]
|
|
4906
|
+
if args.layer == "short"
|
|
4907
|
+
else ["long"]
|
|
4908
|
+
if args.layer == "long"
|
|
4909
|
+
else ["short", "long"]
|
|
4910
|
+
):
|
|
4911
|
+
f = role_dir(root, role) / (F_SHORT if layer == "short" else F_LONG)
|
|
4912
|
+
targets.append((role, f, f.name))
|
|
4913
|
+
if not targets:
|
|
4914
|
+
die("nothing to search: pass --agent A, --global, or --all")
|
|
4163
4915
|
|
|
4164
4916
|
total = 0
|
|
4165
4917
|
for owner, path, filename in targets:
|
|
@@ -4206,7 +4958,9 @@ def _find_entry(entries: list, handle: str):
|
|
|
4206
4958
|
def cmd_touch(args) -> int:
|
|
4207
4959
|
root = project_root(args)
|
|
4208
4960
|
path, filename = _memory_path(root, args.agent, args.layer)
|
|
4209
|
-
|
|
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}"):
|
|
4210
4964
|
entries = parse_memory(path)
|
|
4211
4965
|
idx = _find_entry(entries, args.handle)
|
|
4212
4966
|
if idx is None:
|
|
@@ -4214,49 +4968,427 @@ def cmd_touch(args) -> int:
|
|
|
4214
4968
|
entries[idx]["uses"] += 1
|
|
4215
4969
|
entries[idx]["last_used"] = today_iso()
|
|
4216
4970
|
write_memory(path, entries, filename, args.agent)
|
|
4971
|
+
ledger_append(
|
|
4972
|
+
root,
|
|
4973
|
+
"memory.touch",
|
|
4974
|
+
actor,
|
|
4975
|
+
entries[idx]["key"] or args.handle,
|
|
4976
|
+
f"uses={entries[idx]['uses']}",
|
|
4977
|
+
)
|
|
4978
|
+
print(f"teamwork: touched {entries[idx]['key'] or args.handle} (uses={entries[idx]['uses']})")
|
|
4979
|
+
return 0
|
|
4980
|
+
|
|
4981
|
+
|
|
4982
|
+
def cmd_forget(args) -> int:
|
|
4983
|
+
root = project_root(args)
|
|
4984
|
+
path, filename = _memory_path(root, args.agent, args.layer)
|
|
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}"):
|
|
4988
|
+
entries = parse_memory(path)
|
|
4989
|
+
idx = _find_entry(entries, args.handle)
|
|
4990
|
+
if idx is None:
|
|
4991
|
+
die(f"no entry '{args.handle}' in {path}")
|
|
4992
|
+
removed = entries.pop(idx)
|
|
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)
|
|
5096
|
+
ledger_append(
|
|
5097
|
+
root,
|
|
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,
|
|
5105
|
+
)
|
|
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)
|
|
4217
5247
|
ledger_append(
|
|
4218
5248
|
root,
|
|
4219
|
-
"memory.
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
f"
|
|
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."
|
|
4223
5262
|
)
|
|
4224
|
-
print(f"teamwork: touched {entries[idx]['key'] or args.handle} (uses={entries[idx]['uses']})")
|
|
4225
5263
|
return 0
|
|
4226
5264
|
|
|
4227
5265
|
|
|
4228
|
-
def
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
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"])
|
|
4238
5304
|
ledger_append(
|
|
4239
5305
|
root,
|
|
4240
|
-
"memory.
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
f"
|
|
5306
|
+
"memory.release",
|
|
5307
|
+
actor,
|
|
5308
|
+
role,
|
|
5309
|
+
f"released the pen held by {holder}" + (f" (forced: {force})" if force else ""),
|
|
5310
|
+
"",
|
|
5311
|
+
role,
|
|
4244
5312
|
)
|
|
4245
|
-
print(f"teamwork:
|
|
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
|
+
)
|
|
4246
5371
|
return 0
|
|
4247
5372
|
|
|
4248
5373
|
|
|
4249
|
-
def consolidate_agent(
|
|
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:
|
|
4250
5382
|
role = resolve_role(agent)
|
|
4251
5383
|
agent = role
|
|
4252
|
-
d =
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
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"}
|
|
4260
5392
|
return _consolidate_locked(
|
|
4261
5393
|
root, role, agent, d, short_path, long_path, arch_path, dry_run, quiet
|
|
4262
5394
|
)
|
|
@@ -4376,8 +5508,9 @@ def cmd_consolidate(args) -> int:
|
|
|
4376
5508
|
names = list_roles(root) if (args.all or not args.agent) else [resolve_role(args.agent)]
|
|
4377
5509
|
if not names:
|
|
4378
5510
|
die("no agents in this workspace")
|
|
5511
|
+
actor = getattr(args, "by", "") or ""
|
|
4379
5512
|
for name in names:
|
|
4380
|
-
consolidate_agent(root, name, dry_run=args.dry_run)
|
|
5513
|
+
consolidate_agent(root, name, dry_run=args.dry_run, actor=actor)
|
|
4381
5514
|
return 0
|
|
4382
5515
|
|
|
4383
5516
|
|
|
@@ -4422,12 +5555,18 @@ def cmd_agents(args) -> int:
|
|
|
4422
5555
|
return 0
|
|
4423
5556
|
for role in roles:
|
|
4424
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
|
+
)
|
|
4425
5564
|
print(
|
|
4426
5565
|
f"{role:20s} instances={len(inst):2d} "
|
|
4427
5566
|
f"[{', '.join(inst) if inst else '-'}] "
|
|
4428
5567
|
f"mem={len(parse_memory(role_dir(root, role) / F_SHORT))}"
|
|
4429
5568
|
f"/{len(parse_memory(role_dir(root, role) / F_LONG))} "
|
|
4430
|
-
f"tasks={len(parse_tasks(task_file(root, role)))}"
|
|
5569
|
+
f"tasks={len(parse_tasks(task_file(root, role)))} {writer}"
|
|
4431
5570
|
)
|
|
4432
5571
|
return 0
|
|
4433
5572
|
|
|
@@ -4828,6 +5967,7 @@ def cmd_task_add(args) -> int:
|
|
|
4828
5967
|
args.body or "",
|
|
4829
5968
|
args.evidence,
|
|
4830
5969
|
"na" if args.no_review else "required",
|
|
5970
|
+
getattr(args, "review_profile", ""),
|
|
4831
5971
|
)
|
|
4832
5972
|
if args.claimed_by:
|
|
4833
5973
|
t["claimed_by"] = check_claimant(root, args.claimed_by, "claim", role)
|
|
@@ -5077,13 +6217,30 @@ def cmd_task_reclaim(args) -> int:
|
|
|
5077
6217
|
die(f"no task '{args.handle}' for {role}")
|
|
5078
6218
|
t = tasks[idx]
|
|
5079
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")
|
|
5080
6236
|
t["status"] = "todo"
|
|
5081
6237
|
t["claimed_by"] = ""
|
|
5082
6238
|
t["branch"] = t.get("branch") or ""
|
|
5083
6239
|
t["updated"] = today_iso()
|
|
5084
6240
|
prev_note = t.get("findings", "")
|
|
6241
|
+
forced_note = f" (forced: {args.force})" if args.force else ""
|
|
5085
6242
|
t["findings"] = (
|
|
5086
|
-
prev_note + f" | reclaimed from {previous} by {actor}: {args.reason}"
|
|
6243
|
+
prev_note + f" | reclaimed from {previous} by {actor}{forced_note}: {args.reason}"
|
|
5087
6244
|
).strip(" |")
|
|
5088
6245
|
write_tasks(path, tasks, role)
|
|
5089
6246
|
ledger_append(
|
|
@@ -5233,9 +6390,9 @@ def find_slot_provider(root: Path, agent: str) -> str:
|
|
|
5233
6390
|
base = root / DIR_RUNTIME / DIR_SLOTS
|
|
5234
6391
|
if not base.is_dir():
|
|
5235
6392
|
return ""
|
|
5236
|
-
wanted = safe_component(agent)
|
|
6393
|
+
wanted = {safe_component(agent).lower(), safe_component(pascal(agent)).lower()}
|
|
5237
6394
|
for prov in sorted(p.name for p in base.iterdir() if p.is_dir()):
|
|
5238
|
-
if wanted in used_slots(root, prov):
|
|
6395
|
+
if any(name.lower() in wanted for name in used_slots(root, prov)):
|
|
5239
6396
|
return prov
|
|
5240
6397
|
return ""
|
|
5241
6398
|
|
|
@@ -5398,7 +6555,157 @@ def claim_age_minutes(value: str) -> int | None:
|
|
|
5398
6555
|
return max(int((datetime.datetime.now() - taken).total_seconds() // 60), 0)
|
|
5399
6556
|
|
|
5400
6557
|
|
|
5401
|
-
|
|
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:
|
|
5402
6709
|
"""Return (lane, age_minutes, held) for a deliverable's review claim.
|
|
5403
6710
|
|
|
5404
6711
|
A claim is a lease, not a lock: an undated or unreadable stamp counts as
|
|
@@ -5410,21 +6717,22 @@ def review_claim_state(t: dict) -> tuple:
|
|
|
5410
6717
|
age = claim_age_minutes(t.get("review_claim_ts") or "")
|
|
5411
6718
|
if age is None:
|
|
5412
6719
|
return lane, None, False
|
|
5413
|
-
return lane, age, age <=
|
|
6720
|
+
return lane, age, age <= (REVIEW_LEASE_MINUTES if minutes is None else minutes)
|
|
5414
6721
|
|
|
5415
6722
|
|
|
5416
|
-
def review_is_held(t: dict) -> bool:
|
|
6723
|
+
def review_is_held(t: dict, minutes: int | None = None) -> bool:
|
|
5417
6724
|
"""True while a live claim pins a deliverable to one Reviewer lane."""
|
|
5418
|
-
return review_claim_state(t)[2]
|
|
6725
|
+
return review_claim_state(t, minutes)[2]
|
|
5419
6726
|
|
|
5420
6727
|
|
|
5421
|
-
def _claim_note(t: dict) -> str:
|
|
6728
|
+
def _claim_note(t: dict, minutes: int | None = None) -> str:
|
|
5422
6729
|
"""One line of claim state for the pulse and queue listings."""
|
|
5423
|
-
|
|
6730
|
+
limit = REVIEW_LEASE_MINUTES if minutes is None else minutes
|
|
6731
|
+
lane, age, held = review_claim_state(t, limit)
|
|
5424
6732
|
if not lane:
|
|
5425
6733
|
return "unclaimed"
|
|
5426
6734
|
if held:
|
|
5427
|
-
return f"held by {lane} ({age}m into the {
|
|
6735
|
+
return f"held by {lane} ({age}m into the {limit}m lease, {limit - age}m left)"
|
|
5428
6736
|
return f"claim by {lane} expired ({'undated' if age is None else f'{age}m'}) - take it over"
|
|
5429
6737
|
|
|
5430
6738
|
|
|
@@ -5444,8 +6752,9 @@ def busy_instances(root: Path) -> set:
|
|
|
5444
6752
|
continue
|
|
5445
6753
|
for agent in used_slots(root, prov):
|
|
5446
6754
|
busy.add(pascal(agent))
|
|
6755
|
+
lease = review_lease_minutes(root)
|
|
5447
6756
|
for _role, t in iter_tasks(root):
|
|
5448
|
-
if review_is_held(t):
|
|
6757
|
+
if review_is_held(t, lease):
|
|
5449
6758
|
busy.add(pascal(t["review_claim"]))
|
|
5450
6759
|
return busy
|
|
5451
6760
|
|
|
@@ -5478,8 +6787,22 @@ def awaiting_review(root: Path) -> list:
|
|
|
5478
6787
|
|
|
5479
6788
|
|
|
5480
6789
|
def open_review_items(root: Path) -> list:
|
|
5481
|
-
"""Deliverables awaiting review that no Reviewer lane currently holds.
|
|
5482
|
-
|
|
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
|
|
5483
6806
|
|
|
5484
6807
|
|
|
5485
6808
|
def review_lane_notes(root: Path) -> list:
|
|
@@ -5519,7 +6842,7 @@ def _pulse_review_claim(root: Path, owner: str, handle: str, lane: str) -> bool:
|
|
|
5519
6842
|
t.get("review", "required") == "na"
|
|
5520
6843
|
or t.get("review_status") == "passed"
|
|
5521
6844
|
or t["status"] not in ("review", "done")
|
|
5522
|
-
or review_is_held(t)
|
|
6845
|
+
or review_is_held(t, review_lease_minutes(root))
|
|
5523
6846
|
or pascal(t.get("claimed_by") or "") == pascal(lane)
|
|
5524
6847
|
):
|
|
5525
6848
|
return False
|
|
@@ -5531,18 +6854,19 @@ def _pulse_review_claim(root: Path, owner: str, handle: str, lane: str) -> bool:
|
|
|
5531
6854
|
"review.claim",
|
|
5532
6855
|
lane,
|
|
5533
6856
|
handle,
|
|
5534
|
-
f"pulse dispatch: {owner}/{handle} (lease {
|
|
6857
|
+
f"pulse dispatch: {owner}/{handle} (lease {review_lease_minutes(root)}m)",
|
|
5535
6858
|
"",
|
|
5536
|
-
|
|
6859
|
+
owner,
|
|
5537
6860
|
)
|
|
5538
6861
|
return True
|
|
5539
6862
|
|
|
5540
6863
|
|
|
5541
6864
|
def held_review_items(root: Path) -> list:
|
|
5542
6865
|
"""Every claim on the board, live or expired: (owner_role, task, lane, age)."""
|
|
6866
|
+
lease = review_lease_minutes(root)
|
|
5543
6867
|
out = []
|
|
5544
6868
|
for name, t in awaiting_review(root):
|
|
5545
|
-
lane, age, _held = review_claim_state(t)
|
|
6869
|
+
lane, age, _held = review_claim_state(t, lease)
|
|
5546
6870
|
if lane:
|
|
5547
6871
|
out.append((name, t, lane, age))
|
|
5548
6872
|
return out
|
|
@@ -5668,13 +6992,21 @@ def cmd_pulse(args: object) -> int:
|
|
|
5668
6992
|
if not ready_by_role:
|
|
5669
6993
|
print("- none")
|
|
5670
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
|
+
)
|
|
5671
7002
|
print()
|
|
5672
7003
|
|
|
5673
7004
|
if awaiting:
|
|
5674
7005
|
print(f"## deliverables awaiting review ({len(awaiting)})")
|
|
5675
7006
|
for name, t in awaiting:
|
|
5676
7007
|
print(
|
|
5677
|
-
f"- {name}/{t['id']} [{t['priority']}/{t['status']}] {t['title']}
|
|
7008
|
+
f"- {name}/{t['id']} [{t['priority']}/{t['status']}] {t['title']} "
|
|
7009
|
+
f"{_claim_note(t, review_lease_minutes(root))}"
|
|
5678
7010
|
)
|
|
5679
7011
|
print()
|
|
5680
7012
|
|
|
@@ -5684,7 +7016,9 @@ def cmd_pulse(args: object) -> int:
|
|
|
5684
7016
|
# parallel instead of one task per lane per wake.
|
|
5685
7017
|
# only a live claim blocks a lane; an expired one is up for takeover, which
|
|
5686
7018
|
# is exactly the work the next pulse should hand out
|
|
5687
|
-
claim_holders = {
|
|
7019
|
+
claim_holders = {
|
|
7020
|
+
pascal(lane) for _own, _t, lane, age in claims if age < review_lease_minutes(root)
|
|
7021
|
+
}
|
|
5688
7022
|
capacity: dict[str, int] = {}
|
|
5689
7023
|
for a in list_agents(root):
|
|
5690
7024
|
role_name = resolve_role(a)
|
|
@@ -5718,15 +7052,33 @@ def cmd_pulse(args: object) -> int:
|
|
|
5718
7052
|
capacity[inst] -= 1
|
|
5719
7053
|
|
|
5720
7054
|
limits = load_providers(root)
|
|
7055
|
+
slot_holders = {pascal(a) for a in list_agents(root) if find_slot_provider(root, a)}
|
|
5721
7056
|
free_slots = sum(
|
|
5722
7057
|
max(cap - len(used_slots(root, prov)), 0)
|
|
5723
7058
|
for prov, cap in limits.items()
|
|
5724
7059
|
if prov != "default"
|
|
5725
7060
|
)
|
|
5726
|
-
|
|
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
|
|
5727
7078
|
print(
|
|
5728
|
-
f"# capacity: {len(
|
|
5729
|
-
"
|
|
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"
|
|
5730
7082
|
)
|
|
5731
7083
|
print("## dispatch plan")
|
|
5732
7084
|
if not plan:
|
|
@@ -5734,6 +7086,13 @@ def cmd_pulse(args: object) -> int:
|
|
|
5734
7086
|
for role, inst, task, title, review_owner in plan:
|
|
5735
7087
|
tid = f"{review_owner}/{task['id']}" if review_owner else (task["id"] if task else "Review")
|
|
5736
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
|
+
)
|
|
5737
7096
|
print()
|
|
5738
7097
|
|
|
5739
7098
|
if args.commit and plan:
|
|
@@ -5853,42 +7212,518 @@ def cmd_parallel(args) -> int:
|
|
|
5853
7212
|
print(f"- TOTAL ready tasks: {ready_total}")
|
|
5854
7213
|
print()
|
|
5855
7214
|
|
|
5856
|
-
awaiting = 0
|
|
5857
|
-
for name in list_roles(root):
|
|
5858
|
-
for t in parse_tasks(task_file(root, name)):
|
|
5859
|
-
if (
|
|
5860
|
-
t.get("review", "required") != "na"
|
|
5861
|
-
and t.get("review_status") != "passed"
|
|
5862
|
-
and t["status"] in ("review", "done")
|
|
5863
|
-
):
|
|
5864
|
-
awaiting += 1
|
|
5865
|
-
print("## guidance")
|
|
5866
|
-
focused = (
|
|
5867
|
-
"ollama-cloud"
|
|
5868
|
-
if "ollama-cloud" in limits
|
|
5869
|
-
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 "")
|
|
5870
7690
|
)
|
|
5871
|
-
focused_free = max(limits.get(focused, 8) - len(used_slots(root, focused)), 0)
|
|
5872
|
-
bulk = "deepseek" if "deepseek" in limits else focused
|
|
5873
|
-
bulk_free = max(limits.get(bulk, 0) - len(used_slots(root, bulk)), 0)
|
|
5874
|
-
running = sum(len(used_slots(root, p)) for p in limits if p != "default")
|
|
5875
|
-
lanes = min(ready_total, focused_free + bulk_free)
|
|
5876
|
-
print(f"- focused reasoning lanes ({focused}): {focused_free} free")
|
|
5877
|
-
print(f"- bulk fanout lanes ({bulk}): {bulk_free} free")
|
|
5878
|
-
print(f"- start up to {lanes} new lane(s) now (ready tasks: {ready_total})")
|
|
5879
|
-
if running:
|
|
5880
|
-
print(
|
|
5881
|
-
f"- {running} lane(s) already hold a slot: hand the next task to a"
|
|
5882
|
-
" running lane before spawning another (keeps memory and context warm)"
|
|
5883
|
-
)
|
|
5884
|
-
if awaiting:
|
|
5885
|
-
print(f"- {awaiting} deliverable(s) await review; the Reviewer is a lane too")
|
|
5886
|
-
if ready_total > lanes:
|
|
5887
|
-
print("- more ready work than slots: queue it by priority, do not oversubscribe")
|
|
5888
7691
|
print(
|
|
5889
|
-
"-
|
|
5890
|
-
"
|
|
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"
|
|
5891
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
|
|
5892
7727
|
return 0
|
|
5893
7728
|
|
|
5894
7729
|
|
|
@@ -5928,10 +7763,19 @@ EXTERNAL_SCHEMES = ("http://", "https://", "mailto:", "doi:", "ftp://")
|
|
|
5928
7763
|
|
|
5929
7764
|
|
|
5930
7765
|
def iter_markdown(project: Path) -> list:
|
|
5931
|
-
"""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
|
+
"""
|
|
5932
7772
|
out = []
|
|
5933
7773
|
for p in project.rglob("*.md"):
|
|
5934
|
-
|
|
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):
|
|
5935
7779
|
continue
|
|
5936
7780
|
out.append(p)
|
|
5937
7781
|
return sorted(out)
|
|
@@ -6045,7 +7889,9 @@ def link_report(project: Path, root: Path) -> dict:
|
|
|
6045
7889
|
|
|
6046
7890
|
def cmd_links(args) -> int:
|
|
6047
7891
|
root = project_root(args)
|
|
6048
|
-
|
|
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)
|
|
6049
7895
|
rep = link_report(project, root)
|
|
6050
7896
|
issues = len(rep["broken"]) + len(rep["external_bad"]) + len(rep["bare"])
|
|
6051
7897
|
if not args.no_reciprocity:
|
|
@@ -6134,7 +7980,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6134
7980
|
names = list_roles(root)
|
|
6135
7981
|
tasks = all_tasks(root)
|
|
6136
7982
|
|
|
6137
|
-
# 1 - acceptance criteria -------------------------------------------------
|
|
6138
7983
|
s = _section("criteria", "acceptance criteria (machine records)")
|
|
6139
7984
|
criteria = parse_criteria(criteria_path(root))
|
|
6140
7985
|
if not criteria:
|
|
@@ -6171,7 +8016,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6171
8016
|
)
|
|
6172
8017
|
sections.append(s)
|
|
6173
8018
|
|
|
6174
|
-
# 2 - layered tests -------------------------------------------------------
|
|
6175
8019
|
s = _section("tests", "test layers (each owned and run by its own role)")
|
|
6176
8020
|
records = parse_tests(tests_path(root))
|
|
6177
8021
|
if not records:
|
|
@@ -6215,7 +8059,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6215
8059
|
)
|
|
6216
8060
|
sections.append(s)
|
|
6217
8061
|
|
|
6218
|
-
# 3 - work in flight ------------------------------------------------------
|
|
6219
8062
|
s = _section("work", "work in flight")
|
|
6220
8063
|
buckets = {k: [] for k in ("todo", "doing", "selfcheck", "blocked", "review")}
|
|
6221
8064
|
invalid = []
|
|
@@ -6240,7 +8083,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6240
8083
|
s["blockers"].append(f"{len(invalid)} task(s) have an unknown status")
|
|
6241
8084
|
sections.append(s)
|
|
6242
8085
|
|
|
6243
|
-
# 4 - awaiting review -----------------------------------------------------
|
|
6244
8086
|
s = _section("review", "awaiting independent review")
|
|
6245
8087
|
awaiting = [
|
|
6246
8088
|
(r, t)
|
|
@@ -6259,7 +8101,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6259
8101
|
s["lines"].append("none")
|
|
6260
8102
|
sections.append(s)
|
|
6261
8103
|
|
|
6262
|
-
# 5 - review churn (a failing loop must escalate, not spin) ---------------
|
|
6263
8104
|
s = _section("churn", "review churn")
|
|
6264
8105
|
churn = [
|
|
6265
8106
|
(r, t)
|
|
@@ -6285,7 +8126,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6285
8126
|
s["lines"].append("none")
|
|
6286
8127
|
sections.append(s)
|
|
6287
8128
|
|
|
6288
|
-
# 6 - branches -----------------------------------------------------------
|
|
6289
8129
|
s = _section("branches", "branches (merge and retire)")
|
|
6290
8130
|
project = project_of(root)
|
|
6291
8131
|
unmerged, leftover = [], []
|
|
@@ -6318,7 +8158,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6318
8158
|
s["lines"].append("none")
|
|
6319
8159
|
sections.append(s)
|
|
6320
8160
|
|
|
6321
|
-
# 7 - overrides, provenance, liveness ------------------------------------
|
|
6322
8161
|
s = _section("integrity", "gate overrides, provenance and liveness")
|
|
6323
8162
|
forced, unprovenanced, silent = [], [], []
|
|
6324
8163
|
for role, t in tasks:
|
|
@@ -6373,7 +8212,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6373
8212
|
s["lines"].append("none")
|
|
6374
8213
|
sections.append(s)
|
|
6375
8214
|
|
|
6376
|
-
# 8 - disputes -----------------------------------------------------------
|
|
6377
8215
|
s = _section("disputes", "open disputes")
|
|
6378
8216
|
open_disputes = [d for d in disputes if d["status"] != "resolved"]
|
|
6379
8217
|
for d in open_disputes:
|
|
@@ -6404,7 +8242,6 @@ def gate_report(root: Path, stale_days: int = 2, full: bool = False) -> dict:
|
|
|
6404
8242
|
s["lines"].append("none")
|
|
6405
8243
|
sections.append(s)
|
|
6406
8244
|
|
|
6407
|
-
# 9 - audit trail --------------------------------------------------------
|
|
6408
8245
|
s = _section("ledger", "audit trail")
|
|
6409
8246
|
if ledger_cache_behind(root):
|
|
6410
8247
|
s["lines"].append(
|
|
@@ -6539,17 +8376,27 @@ def archive_ensure_entry(root: Path, owner: str, t: dict, repo: Path | None = No
|
|
|
6539
8376
|
return f"# archive: wrote the accepted-results entry for {branch} on the base"
|
|
6540
8377
|
|
|
6541
8378
|
|
|
6542
|
-
#:
|
|
6543
|
-
#: gate's own rule table rather than re-declaring shapes: a
|
|
6544
|
-
#: drifted from the scanner twice (it covered 2 of the 5
|
|
6545
|
-
#: masked ``sk-learn-pipeline``, which the gate never
|
|
6546
|
-
#: the one place where a divergence between mask
|
|
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.
|
|
6547
8392
|
CREDENTIAL_RULES = (
|
|
6548
8393
|
"private-key",
|
|
6549
8394
|
"api-token",
|
|
6550
8395
|
"env-secret",
|
|
6551
8396
|
"json-secret",
|
|
6552
8397
|
"high-entropy-secret",
|
|
8398
|
+
"personal-email",
|
|
8399
|
+
"private-domain",
|
|
6553
8400
|
)
|
|
6554
8401
|
|
|
6555
8402
|
|
|
@@ -6560,8 +8407,16 @@ def _privacy_scan(): # noqa: ANN202 - module handle, cached below
|
|
|
6560
8407
|
directory can neither shadow the ``teamwork`` package nor leak into the
|
|
6561
8408
|
process. ``None`` means the bundle is incomplete (an install that dropped
|
|
6562
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.
|
|
6563
8418
|
"""
|
|
6564
|
-
global _PRIVACY_SCAN
|
|
8419
|
+
global _PRIVACY_SCAN, _PRIVACY_WARNED
|
|
6565
8420
|
if _PRIVACY_SCAN is not _UNLOADED:
|
|
6566
8421
|
return _PRIVACY_SCAN
|
|
6567
8422
|
path = _resources.asset("scripts") / "privacy_scan.py"
|
|
@@ -6573,13 +8428,26 @@ def _privacy_scan(): # noqa: ANN202 - module handle, cached below
|
|
|
6573
8428
|
module = importlib.util.module_from_spec(spec)
|
|
6574
8429
|
spec.loader.exec_module(module)
|
|
6575
8430
|
_PRIVACY_SCAN = module
|
|
6576
|
-
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.
|
|
6577
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
|
+
)
|
|
6578
8445
|
return _PRIVACY_SCAN
|
|
6579
8446
|
|
|
6580
8447
|
|
|
6581
8448
|
_UNLOADED = object()
|
|
6582
8449
|
_PRIVACY_SCAN = _UNLOADED
|
|
8450
|
+
_PRIVACY_WARNED = False
|
|
6583
8451
|
|
|
6584
8452
|
|
|
6585
8453
|
def mask_secret_shapes(text: str) -> str:
|
|
@@ -6587,14 +8455,29 @@ def mask_secret_shapes(text: str) -> str:
|
|
|
6587
8455
|
|
|
6588
8456
|
An archive entry quotes another agent's words verbatim, so a verdict that
|
|
6589
8457
|
proved a credential rule fires by quoting a fake token puts that token into
|
|
6590
|
-
history. The mask is
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
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.
|
|
6598
8481
|
"""
|
|
6599
8482
|
if not text:
|
|
6600
8483
|
return text
|
|
@@ -6608,7 +8491,9 @@ def mask_secret_shapes(text: str) -> str:
|
|
|
6608
8491
|
# placeholder, code-shape and allow filters, so a placeholder the gate accepts
|
|
6609
8492
|
# (``${API_KEY}``, ``replace_me``) was rewritten in the immutable record.
|
|
6610
8493
|
for finding in scanner.scan_lines(text, "", "archive", rules, ()):
|
|
6611
|
-
value
|
|
8494
|
+
# The raw value is a Finding attribute, never a report item, so the
|
|
8495
|
+
# scanner cannot publish it and this mask reads it explicitly.
|
|
8496
|
+
value = finding.raw or ""
|
|
6612
8497
|
if not value:
|
|
6613
8498
|
continue
|
|
6614
8499
|
rule = finding["rule"]
|
|
@@ -6831,16 +8716,6 @@ def commit_archive_merge_sha(
|
|
|
6831
8716
|
return f"{message}\n# committed the archive rewrite on {base} at {close_sha}"
|
|
6832
8717
|
|
|
6833
8718
|
|
|
6834
|
-
def find_review_role_unused(root: Path, handle: str):
|
|
6835
|
-
"""(role, task) for a task id / title across every role queue."""
|
|
6836
|
-
for role in list_roles(root):
|
|
6837
|
-
tasks = parse_tasks(task_file(root, role))
|
|
6838
|
-
idx = find_task(tasks, handle)
|
|
6839
|
-
if idx is not None:
|
|
6840
|
-
return role, tasks[idx], tasks, task_file(root, role), idx
|
|
6841
|
-
return None, None, None, None, None
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
8719
|
def _review_claim(root: Path, args) -> int:
|
|
6845
8720
|
"""Take a deliverable off the shared review queue, or hand it back.
|
|
6846
8721
|
|
|
@@ -6849,17 +8724,22 @@ def _review_claim(root: Path, args) -> int:
|
|
|
6849
8724
|
that the lanes collide on one deliverable and leave the rest unreviewed.
|
|
6850
8725
|
"""
|
|
6851
8726
|
claim, release = bool(args.claim), bool(args.release)
|
|
6852
|
-
|
|
6853
|
-
|
|
8727
|
+
renew = bool(getattr(args, "renew", False))
|
|
8728
|
+
if sum((claim, release, renew)) > 1:
|
|
8729
|
+
die("--claim, --release and --renew are mutually exclusive", 2)
|
|
6854
8730
|
if not (args.agent and args.handle):
|
|
6855
|
-
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)
|
|
6856
8732
|
if args.result:
|
|
6857
|
-
die(
|
|
8733
|
+
die(
|
|
8734
|
+
f"--claim/--release/--renew do not take --result {args.result}; the verdict is separate",
|
|
8735
|
+
2,
|
|
8736
|
+
)
|
|
6858
8737
|
role = resolve_role(args.agent)
|
|
6859
8738
|
reviewer, rrole = validate_actor(root, args.by or "reviewer", "reviewer", allow_role=False)
|
|
6860
8739
|
if rrole != "reviewer":
|
|
6861
8740
|
die(f"only the Reviewer role may hold a review (you passed {reviewer}).", 2)
|
|
6862
|
-
verb = "claim" if claim else "release"
|
|
8741
|
+
verb = "renew" if renew else ("claim" if claim else "release")
|
|
8742
|
+
lease = review_lease_minutes(root)
|
|
6863
8743
|
path = task_file(root, role)
|
|
6864
8744
|
with lock(root / DIR_RUNTIME / DIR_LOCKS / f"role-{role}.lock"):
|
|
6865
8745
|
tasks = parse_tasks(path)
|
|
@@ -6881,12 +8761,30 @@ def _review_claim(root: Path, args) -> int:
|
|
|
6881
8761
|
die(
|
|
6882
8762
|
f"{reviewer} produced {t['id']} and cannot review it; another lane must take it.", 2
|
|
6883
8763
|
)
|
|
6884
|
-
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
|
|
6885
8783
|
if claim:
|
|
6886
8784
|
if held and pascal(lane) != pascal(reviewer):
|
|
6887
8785
|
die(
|
|
6888
8786
|
f"{role}/{t['id']} is held by {lane} ({age}m into the "
|
|
6889
|
-
f"{
|
|
8787
|
+
f"{lease}m lease): let it finish, renew it, or wait for the lease "
|
|
6890
8788
|
"to expire and take it over",
|
|
6891
8789
|
2,
|
|
6892
8790
|
)
|
|
@@ -6903,14 +8801,14 @@ def _review_claim(root: Path, args) -> int:
|
|
|
6903
8801
|
"review.claim",
|
|
6904
8802
|
reviewer,
|
|
6905
8803
|
t["id"],
|
|
6906
|
-
f"{role}/{t['id']} claimed for review (lease {
|
|
8804
|
+
f"{role}/{t['id']} claimed for review (lease {lease}m){took_over}",
|
|
6907
8805
|
"",
|
|
6908
|
-
|
|
8806
|
+
role,
|
|
6909
8807
|
)
|
|
6910
8808
|
print(
|
|
6911
|
-
f"teamwork: {reviewer} holds {role}/{t['id']} for {
|
|
8809
|
+
f"teamwork: {reviewer} holds {role}/{t['id']} for {lease} min: "
|
|
6912
8810
|
f"review {role} {t['id']} --by {reviewer} --result pass --evidence "
|
|
6913
|
-
"'<cmd @rev -> result>' or hand it back with --release"
|
|
8811
|
+
"'<cmd @rev -> result>', renew with --renew, or hand it back with --release"
|
|
6914
8812
|
)
|
|
6915
8813
|
return 0
|
|
6916
8814
|
if not lane:
|
|
@@ -6939,7 +8837,7 @@ def _review_claim(root: Path, args) -> int:
|
|
|
6939
8837
|
t["id"],
|
|
6940
8838
|
f"{role}/{t['id']} released by {lane}{forced}",
|
|
6941
8839
|
"",
|
|
6942
|
-
|
|
8840
|
+
role,
|
|
6943
8841
|
)
|
|
6944
8842
|
print(f"teamwork: {role}/{t['id']} is back on the shared review queue")
|
|
6945
8843
|
return 0
|
|
@@ -6947,9 +8845,31 @@ def _review_claim(root: Path, args) -> int:
|
|
|
6947
8845
|
|
|
6948
8846
|
def cmd_review(args) -> int:
|
|
6949
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)
|
|
6950
8852
|
if args.queue:
|
|
6951
|
-
return _review_queue(
|
|
6952
|
-
|
|
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
|
+
):
|
|
6953
8873
|
return _review_claim(root, args)
|
|
6954
8874
|
if not (args.agent and args.handle):
|
|
6955
8875
|
die("usage: review <role> <T-id> --by <Reviewer> --result pass|fail [--evidence ...]")
|
|
@@ -6985,7 +8905,7 @@ def cmd_review(args) -> int:
|
|
|
6985
8905
|
f"{reviewer} produced {t['id']} and cannot review it; "
|
|
6986
8906
|
"an independent instance must review."
|
|
6987
8907
|
)
|
|
6988
|
-
lane, age, held = review_claim_state(t)
|
|
8908
|
+
lane, age, held = review_claim_state(t, review_lease_minutes(root))
|
|
6989
8909
|
if lane and pascal(lane) != pascal(reviewer):
|
|
6990
8910
|
print(
|
|
6991
8911
|
f"teamwork: NOTE {role}/{t['id']} is held by {lane} "
|
|
@@ -7179,7 +9099,8 @@ def cmd_review(args) -> int:
|
|
|
7179
9099
|
return 0
|
|
7180
9100
|
|
|
7181
9101
|
|
|
7182
|
-
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)
|
|
7183
9104
|
rows = []
|
|
7184
9105
|
for name in list_roles(root):
|
|
7185
9106
|
for t in parse_tasks(task_file(root, name)):
|
|
@@ -7187,17 +9108,25 @@ def _review_queue(root: Path, full: bool = False) -> int:
|
|
|
7187
9108
|
continue
|
|
7188
9109
|
if t.get("review_status") == "passed" or t["status"] == "dropped":
|
|
7189
9110
|
continue
|
|
7190
|
-
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))
|
|
7191
9112
|
print(f"# Reviewer queue ({root})")
|
|
7192
9113
|
if not rows:
|
|
7193
9114
|
print("- nothing awaiting review")
|
|
7194
9115
|
return 0
|
|
7195
|
-
rows.sort(key=lambda r: (r[0], r[1], r[2]["id"]))
|
|
7196
|
-
|
|
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
|
|
7197
9125
|
print(
|
|
7198
9126
|
f"- {name} {t['id']} [{t['priority']}/{t['status']}] {t['title']}"
|
|
7199
|
-
f"
|
|
7200
|
-
f"
|
|
9127
|
+
f" profile={profile} aged={age}d"
|
|
9128
|
+
f" review_status={t.get('review_status', 'pending')}"
|
|
9129
|
+
f" claim: {_claim_note(t, lease)}"
|
|
7201
9130
|
f" self_check={t.get('self_check', 'pending')}"
|
|
7202
9131
|
+ (f" branch={t['branch']}" if t.get("branch") else "")
|
|
7203
9132
|
+ (f" pr={t['pr']}" if t.get("pr") else "")
|
|
@@ -7208,10 +9137,20 @@ def _review_queue(root: Path, full: bool = False) -> int:
|
|
|
7208
9137
|
print(f" findings so far: {view_text(t['findings'], full)}")
|
|
7209
9138
|
print()
|
|
7210
9139
|
lanes = review_lane_notes(root)
|
|
7211
|
-
|
|
7212
|
-
|
|
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")
|
|
7213
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
|
+
)
|
|
7214
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")
|
|
7215
9154
|
print("# review <role> <T-id> --claim --by <Reviewer-instance> # pin it to your lane")
|
|
7216
9155
|
print(
|
|
7217
9156
|
"# review <role> <T-id> --by <Reviewer-instance> --result pass --evidence '<cmd @rev -> result>'"
|
|
@@ -7219,9 +9158,10 @@ def _review_queue(root: Path, full: bool = False) -> int:
|
|
|
7219
9158
|
print(
|
|
7220
9159
|
"# review <role> <T-id> --by <Reviewer-instance> --result fail --findings '<defect + evidence>'"
|
|
7221
9160
|
)
|
|
9161
|
+
print("# review <role> <T-id> --renew --by <Reviewer-instance> # extend a long review")
|
|
7222
9162
|
print(
|
|
7223
9163
|
f"# review <role> <T-id> --release --by <Reviewer-instance> # hand it back "
|
|
7224
|
-
f"(a claim lapses after {
|
|
9164
|
+
f"(a claim lapses after {lease}m)"
|
|
7225
9165
|
)
|
|
7226
9166
|
return 0
|
|
7227
9167
|
|
|
@@ -7695,6 +9635,16 @@ def _audit_section(root: Path, names: list, args) -> int:
|
|
|
7695
9635
|
return hits
|
|
7696
9636
|
|
|
7697
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
|
+
|
|
7698
9648
|
def _audit_submitted_for_review_without_a_self_chec(root: Path, names: list, args) -> int:
|
|
7699
9649
|
print("## submitted for review without a self-check (skipped their own test)")
|
|
7700
9650
|
hits = 0
|
|
@@ -7704,6 +9654,9 @@ def _audit_submitted_for_review_without_a_self_chec(root: Path, names: list, arg
|
|
|
7704
9654
|
t.get("review", "required") != "na"
|
|
7705
9655
|
and t.get("self_check") not in ("passed", "na")
|
|
7706
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()
|
|
7707
9660
|
):
|
|
7708
9661
|
print(
|
|
7709
9662
|
f"- {name} {t['id']} [{t['status']}] {t['title']} "
|
|
@@ -7720,7 +9673,12 @@ def _audit_done_without_evidence_fabricated_complet(root: Path, names: list, arg
|
|
|
7720
9673
|
hits = 0
|
|
7721
9674
|
for name in names:
|
|
7722
9675
|
for t in parse_tasks(task_file(root, name)):
|
|
7723
|
-
|
|
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):
|
|
7724
9682
|
print(f"- {name} {t['id']} {t['title']}")
|
|
7725
9683
|
hits += 1
|
|
7726
9684
|
if not hits:
|
|
@@ -7751,31 +9709,16 @@ def _audit_stale_in_progress(root: Path, names: list, args) -> int:
|
|
|
7751
9709
|
|
|
7752
9710
|
def _audit_possible_duplicate_tasks_across_agents(root: Path, names: list, args) -> int:
|
|
7753
9711
|
print("## possible duplicate tasks across agents")
|
|
7754
|
-
|
|
7755
|
-
for
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
hits = 0
|
|
7762
|
-
for i in range(len(items)):
|
|
7763
|
-
for j in range(i + 1, len(items)):
|
|
7764
|
-
n1, t1, w1 = items[i]
|
|
7765
|
-
n2, t2, w2 = items[j]
|
|
7766
|
-
if not w1 or not w2:
|
|
7767
|
-
continue
|
|
7768
|
-
jaccard = len(w1 & w2) / len(w1 | w2)
|
|
7769
|
-
if jaccard >= 0.6:
|
|
7770
|
-
print(
|
|
7771
|
-
f"- {int(jaccard * 100)}% similar: {n1}:{t1['id']} "
|
|
7772
|
-
f"{t1['title']!r} <-> {n2}:{t2['id']} {t2['title']!r}"
|
|
7773
|
-
)
|
|
7774
|
-
hits += 1
|
|
7775
|
-
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:
|
|
7776
9719
|
print("- none")
|
|
7777
9720
|
print()
|
|
7778
|
-
return
|
|
9721
|
+
return len(pairs)
|
|
7779
9722
|
|
|
7780
9723
|
|
|
7781
9724
|
def _audit_memory_caps(root: Path, names: list, args) -> int:
|
|
@@ -8083,7 +10026,7 @@ def _audit_audit_trail_append_only_hash_chained(root: Path, names: list, args) -
|
|
|
8083
10026
|
hits += 1
|
|
8084
10027
|
if records and not problems_ledger:
|
|
8085
10028
|
print(f"- {len(records)} records, chain intact, head {records[-1]['hash'][:16]}")
|
|
8086
|
-
manifest =
|
|
10029
|
+
manifest = work_docs_dir(root) / F_MANIFEST
|
|
8087
10030
|
if manifest.exists():
|
|
8088
10031
|
try:
|
|
8089
10032
|
import json
|
|
@@ -8208,26 +10151,46 @@ def _audit_git_archival_lfs_large_files(root: Path, names: list, args) -> int:
|
|
|
8208
10151
|
print("- .gitattributes has no Git LFS rules: run `teamwork.py repo`")
|
|
8209
10152
|
hits += 1
|
|
8210
10153
|
else:
|
|
8211
|
-
|
|
10154
|
+
all_files = []
|
|
8212
10155
|
for p_ in project.rglob("*"):
|
|
8213
10156
|
if not p_.is_file() or any(
|
|
8214
10157
|
part in {".git", ".pi"} | SKIP_DIRS for part in p_.parts
|
|
8215
10158
|
):
|
|
8216
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:
|
|
8217
10174
|
try:
|
|
8218
|
-
if
|
|
8219
|
-
big.append(
|
|
10175
|
+
if (project / rel).stat().st_size > LFS_MAX_BYTES:
|
|
10176
|
+
big.append(rel)
|
|
8220
10177
|
except OSError:
|
|
8221
10178
|
pass
|
|
8222
|
-
|
|
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)]
|
|
8223
10181
|
if untracked:
|
|
8224
10182
|
print(
|
|
8225
|
-
f"- {len(untracked)} large file(s) not LFS-tracked, e.g. "
|
|
10183
|
+
f"- {len(untracked)} large binary file(s) not LFS-tracked, e.g. "
|
|
8226
10184
|
+ ", ".join(untracked[:3])
|
|
8227
10185
|
)
|
|
8228
10186
|
hits += 1
|
|
8229
|
-
|
|
8230
|
-
|
|
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
|
+
)
|
|
8231
10194
|
else:
|
|
8232
10195
|
print("- no git repository yet: run `teamwork.py repo`")
|
|
8233
10196
|
hits += 1
|
|
@@ -8239,7 +10202,10 @@ def _audit_git_archival_lfs_large_files(root: Path, names: list, args) -> int:
|
|
|
8239
10202
|
def _audit_markdown_link_health_broken_malformed_ba(root: Path, names: list, args) -> int:
|
|
8240
10203
|
hits = 0
|
|
8241
10204
|
print("## markdown link health (broken / malformed / bare / missing back-links)")
|
|
8242
|
-
|
|
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)
|
|
8243
10209
|
link_issues = (
|
|
8244
10210
|
len(rep["broken"]) + len(rep["external_bad"]) + len(rep["bare"]) + len(rep["missing_back"])
|
|
8245
10211
|
)
|
|
@@ -8272,14 +10238,78 @@ def _print_dispute(d: dict) -> None:
|
|
|
8272
10238
|
print(f" ruling: signed=[{', '.join(d['signed'])}] {d['decision']}")
|
|
8273
10239
|
|
|
8274
10240
|
|
|
8275
|
-
def
|
|
8276
|
-
"""The
|
|
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
|
+
"""
|
|
8277
10253
|
if not dispute:
|
|
8278
10254
|
return set()
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
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))
|
|
8283
10313
|
|
|
8284
10314
|
|
|
8285
10315
|
def required_tribunal(root: Path, dispute: dict = None) -> list:
|
|
@@ -8287,19 +10317,40 @@ def required_tribunal(root: Path, dispute: dict = None) -> list:
|
|
|
8287
10317
|
|
|
8288
10318
|
The CEO always sits. CTO and CFO join when staffed. The Reviewer takes the
|
|
8289
10319
|
remaining neutral seat, because a slim roster staffs neither CTO nor CFO and a
|
|
8290
|
-
panel of one would let the CEO sign and resolve their own case. A role
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
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.
|
|
8294
10326
|
"""
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
|
|
8299
|
-
|
|
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")
|
|
8300
10335
|
return members
|
|
8301
10336
|
|
|
8302
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
|
+
|
|
8303
10354
|
def _dispute_tribunal_notice(root: Path, d: dict) -> None:
|
|
8304
10355
|
members = required_tribunal(root, d)
|
|
8305
10356
|
print("teamwork: this dispute needs the tribunal - escalate it now.")
|
|
@@ -8307,9 +10358,9 @@ def _dispute_tribunal_notice(root: Path, d: dict) -> None:
|
|
|
8307
10358
|
print(f" 1) python3 teamwork.py dispute escalate {d['id']} --by {d['raised_by']}")
|
|
8308
10359
|
print(f" 2) the tribunal ({', '.join(members)}) discusses and each signs:")
|
|
8309
10360
|
print(f" dispute sign {d['id']} --by {'|'.join(members)}")
|
|
8310
|
-
|
|
8311
|
-
if
|
|
8312
|
-
print(f" 3) dispute resolve {d['id']} --by {
|
|
10361
|
+
signer = _neutral_signer(root, d, members)
|
|
10362
|
+
if signer:
|
|
10363
|
+
print(f" 3) dispute resolve {d['id']} --by {signer} --decision '<binding ruling>'")
|
|
8313
10364
|
else:
|
|
8314
10365
|
print(" 3) nobody independent is staffed: add a reviewer, then resolve")
|
|
8315
10366
|
print(" 4) record the ruling in docs/Team/Decisions.md")
|
|
@@ -8422,6 +10473,16 @@ def cmd_dispute_sign(args) -> int:
|
|
|
8422
10473
|
members = required_tribunal(root, d)
|
|
8423
10474
|
if who not in members:
|
|
8424
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
|
+
)
|
|
8425
10486
|
if who not in d["signed"]:
|
|
8426
10487
|
d["signed"].append(who)
|
|
8427
10488
|
d["updated"] = today_iso()
|
|
@@ -8453,19 +10514,16 @@ def cmd_dispute_resolve(args) -> int:
|
|
|
8453
10514
|
panel = required_tribunal(root, d)
|
|
8454
10515
|
if arole not in panel:
|
|
8455
10516
|
die(f"only the tribunal may resolve: {', '.join(panel)}")
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
)
|
|
8467
|
-
if not (args.force or "").strip():
|
|
8468
|
-
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
|
+
)
|
|
8469
10527
|
if d["escalated"]:
|
|
8470
10528
|
members = required_tribunal(root, d)
|
|
8471
10529
|
missing = [r for r in members if r not in d["signed"]]
|
|
@@ -8528,11 +10586,6 @@ def cmd_dispute_list(args) -> int:
|
|
|
8528
10586
|
return 0
|
|
8529
10587
|
|
|
8530
10588
|
|
|
8531
|
-
def reporting_order() -> list[str]:
|
|
8532
|
-
"""Roles in reporting order, parents before children."""
|
|
8533
|
-
return [r for r in ROLE_REPORT_ORDER if r in ROLE_PARENT]
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
10589
|
def reporting_children(parent: str) -> list[str]:
|
|
8537
10590
|
"""Direct reports of ``parent``, in reporting order."""
|
|
8538
10591
|
return [r for r in ROLE_REPORT_ORDER if ROLE_PARENT.get(r) == parent]
|
|
@@ -8651,20 +10704,64 @@ def cmd_agents_md(args) -> int:
|
|
|
8651
10704
|
return 0
|
|
8652
10705
|
|
|
8653
10706
|
|
|
8654
|
-
def
|
|
8655
|
-
"""
|
|
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
|
+
"""
|
|
8656
10734
|
got = run_git(source, "fetch", "--prune", "--quiet", remote, branch)
|
|
8657
10735
|
if got.returncode != 0:
|
|
8658
10736
|
tail = (got.stderr or got.stdout).strip().splitlines()
|
|
8659
|
-
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
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
|
|
8664
10754
|
|
|
8665
10755
|
|
|
8666
10756
|
def _update_source(source: Path, remote: str, branch: str) -> tuple[bool, str]:
|
|
8667
|
-
"""Move ``source`` to ``
|
|
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
|
+
"""
|
|
8668
10765
|
dirty = run_git(source, "status", "--porcelain").stdout.strip()
|
|
8669
10766
|
if dirty:
|
|
8670
10767
|
# Never clobber uncommitted work, but do not refuse to mirror either: the
|
|
@@ -8673,14 +10770,38 @@ def _update_source(source: Path, remote: str, branch: str) -> tuple[bool, str]:
|
|
|
8673
10770
|
f"local changes ({dirty.splitlines()[0]}) - fast-forward skipped, "
|
|
8674
10771
|
"mirroring this checkout as it stands"
|
|
8675
10772
|
)
|
|
8676
|
-
ok, detail = _fetch_source(source, remote, branch)
|
|
10773
|
+
ok, detail, kind, commit = _fetch_source(source, remote, branch)
|
|
8677
10774
|
if not ok:
|
|
8678
10775
|
return False, detail
|
|
8679
10776
|
head = run_git(source, "rev-parse", "--short", "HEAD").stdout.strip()
|
|
8680
|
-
target =
|
|
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
|
+
)
|
|
8681
10802
|
if head == target:
|
|
8682
|
-
return True, f"already at {
|
|
8683
|
-
pulled = run_git(source, "merge", "--ff-only",
|
|
10803
|
+
return True, f"already at {label}"
|
|
10804
|
+
pulled = run_git(source, "merge", "--ff-only", commit)
|
|
8684
10805
|
if pulled.returncode != 0:
|
|
8685
10806
|
tail = (pulled.stderr or pulled.stdout).strip().splitlines()
|
|
8686
10807
|
return False, f"cannot fast-forward {head} -> {target}: {tail[-1] if tail else '?'}"
|
|
@@ -8959,8 +11080,10 @@ def save_model_config(root: Path, config: dict) -> None:
|
|
|
8959
11080
|
def resolved_model(root: Path, agent: str) -> dict:
|
|
8960
11081
|
"""The model the given instance should use: agent override, else role default."""
|
|
8961
11082
|
config = load_model_config(root)
|
|
8962
|
-
|
|
8963
|
-
|
|
11083
|
+
wanted = pascal(agent)
|
|
11084
|
+
for key, entry in config["agents"].items():
|
|
11085
|
+
if pascal(key) == wanted:
|
|
11086
|
+
return dict(entry, source="agent")
|
|
8964
11087
|
role = resolve_role(agent) if agent else ""
|
|
8965
11088
|
if role and role in config["roles"]:
|
|
8966
11089
|
return dict(config["roles"][role], source="role")
|
|
@@ -9056,14 +11179,37 @@ def resolve_model_plan(root: Path, env=None) -> dict:
|
|
|
9056
11179
|
env.get("PI_PROVIDER") or ""
|
|
9057
11180
|
).strip()
|
|
9058
11181
|
assignments, missing = {}, []
|
|
11182
|
+
alternate = (env.get("TEAMWORK_MODEL_ALT") or "").strip()
|
|
9059
11183
|
for role in sorted(list_roles(root)):
|
|
9060
11184
|
tier = LANE_TIER.get(role, "economy")
|
|
9061
11185
|
model = strong if tier == "strong" else (economy or strong)
|
|
9062
11186
|
if not model:
|
|
9063
11187
|
missing.append(role)
|
|
9064
11188
|
continue
|
|
9065
|
-
|
|
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
|
+
}
|
|
9066
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
|
+
)
|
|
9067
11213
|
if missing:
|
|
9068
11214
|
notes.append(
|
|
9069
11215
|
"no model found in the environment for: "
|
|
@@ -9105,7 +11251,8 @@ def cmd_model_auto(args) -> int:
|
|
|
9105
11251
|
die("nothing to apply: no model found in the environment")
|
|
9106
11252
|
config = load_model_config(root)
|
|
9107
11253
|
for role, entry in plan["assignments"].items():
|
|
9108
|
-
config["
|
|
11254
|
+
target = config["agents"] if entry.get("scope") == "agent" else config["roles"]
|
|
11255
|
+
target[role] = {"provider": entry["provider"], "model": entry["model"]}
|
|
9109
11256
|
save_model_config(root, config)
|
|
9110
11257
|
ledger_append(
|
|
9111
11258
|
root,
|
|
@@ -9232,11 +11379,12 @@ def _parsers_workspace(sub, common) -> None:
|
|
|
9232
11379
|
s = sub.add_parser(
|
|
9233
11380
|
"consolidate",
|
|
9234
11381
|
parents=[common],
|
|
9235
|
-
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)",
|
|
9236
11383
|
)
|
|
9237
11384
|
s.add_argument("--agent")
|
|
9238
11385
|
s.add_argument("--all", action="store_true")
|
|
9239
11386
|
s.add_argument("--dry-run", action="store_true")
|
|
11387
|
+
s.add_argument("--by", default="", help="which instance consolidates (default: the role)")
|
|
9240
11388
|
s.set_defaults(func=cmd_consolidate)
|
|
9241
11389
|
|
|
9242
11390
|
s = sub.add_parser("stats", parents=[common], help="memory sizes and cap violations")
|
|
@@ -9247,6 +11395,36 @@ def _parsers_workspace(sub, common) -> None:
|
|
|
9247
11395
|
s = sub.add_parser("agents", parents=[common], help="list agent directories")
|
|
9248
11396
|
s.set_defaults(func=cmd_agents)
|
|
9249
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
|
+
|
|
9250
11428
|
# --- tasks ----------------------------------------------------------- #
|
|
9251
11429
|
st = sub.add_parser("task", parents=[common], help="manage an agent's priority task list")
|
|
9252
11430
|
tsub = st.add_subparsers(dest="task_cmd", required=True)
|
|
@@ -9264,6 +11442,13 @@ def _parsers_workspace(sub, common) -> None:
|
|
|
9264
11442
|
s.add_argument("--deps", default="", help="comma list; cross-role as role:T-0001")
|
|
9265
11443
|
s.add_argument("--shared", default="", help="shared board / issue / PR reference")
|
|
9266
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
|
+
)
|
|
9267
11452
|
s.add_argument("--body", default="", help="acceptance criteria / notes")
|
|
9268
11453
|
s.add_argument(
|
|
9269
11454
|
"--bug",
|
|
@@ -9332,6 +11517,12 @@ def _parsers_workspace(sub, common) -> None:
|
|
|
9332
11517
|
s.add_argument("handle")
|
|
9333
11518
|
s.add_argument("--as", dest="by", required=True, help="instance taking it over")
|
|
9334
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
|
+
)
|
|
9335
11526
|
s.set_defaults(func=cmd_task_reclaim)
|
|
9336
11527
|
|
|
9337
11528
|
s = tsub.add_parser("rm", parents=[common], help="remove a task (own role only)")
|
|
@@ -9473,6 +11664,9 @@ def _parsers_memory(sub, common) -> None:
|
|
|
9473
11664
|
s.add_argument("--importance", type=int, default=3)
|
|
9474
11665
|
s.add_argument("--tags", default="")
|
|
9475
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")
|
|
9476
11670
|
s.set_defaults(func=cmd_global)
|
|
9477
11671
|
|
|
9478
11672
|
s = sub.add_parser("recall", parents=[common], help="search memory")
|
|
@@ -9487,14 +11681,77 @@ def _parsers_memory(sub, common) -> None:
|
|
|
9487
11681
|
s.add_argument("agent")
|
|
9488
11682
|
s.add_argument("handle", help="M-0003 or key or substring")
|
|
9489
11683
|
s.add_argument("--layer", choices=["short", "long"], default="short")
|
|
11684
|
+
s.add_argument("--by", default="", help="which instance touches it (default: the agent)")
|
|
9490
11685
|
s.set_defaults(func=cmd_touch)
|
|
9491
11686
|
|
|
9492
11687
|
s = sub.add_parser("forget", parents=[common], help="delete an entry")
|
|
9493
11688
|
s.add_argument("agent")
|
|
9494
11689
|
s.add_argument("handle")
|
|
9495
11690
|
s.add_argument("--layer", choices=["short", "long"], default="short")
|
|
11691
|
+
s.add_argument("--by", default="", help="which instance forgets it (default: the agent)")
|
|
9496
11692
|
s.set_defaults(func=cmd_forget)
|
|
9497
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
|
+
|
|
9498
11755
|
|
|
9499
11756
|
def _parsers_task(sub, common) -> None:
|
|
9500
11757
|
"""`task` command surface."""
|
|
@@ -9521,6 +11778,12 @@ def _parsers_quality(sub, common) -> None:
|
|
|
9521
11778
|
s.add_argument(
|
|
9522
11779
|
"--release", action="store_true", help="hand a claimed deliverable back to the queue"
|
|
9523
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
|
+
)
|
|
9524
11787
|
s.add_argument("--by", default="reviewer")
|
|
9525
11788
|
s.add_argument("--result", choices=["pass", "fail"])
|
|
9526
11789
|
s.add_argument("--evidence", default="", help="verification command @rev -> result")
|
|
@@ -9779,6 +12042,11 @@ def _parsers_integration(sub, common) -> None:
|
|
|
9779
12042
|
action="store_true",
|
|
9780
12043
|
help="also fail commits authored outside the team identity domain",
|
|
9781
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
|
+
)
|
|
9782
12050
|
s.set_defaults(func=cmd_identity)
|
|
9783
12051
|
|
|
9784
12052
|
s = sub.add_parser(
|
|
@@ -9813,11 +12081,17 @@ def _parsers_integration(sub, common) -> None:
|
|
|
9813
12081
|
s.add_argument(
|
|
9814
12082
|
"--lfs-threshold",
|
|
9815
12083
|
type=int,
|
|
9816
|
-
default=
|
|
12084
|
+
default=LFS_MAX_BYTES,
|
|
9817
12085
|
help="bytes above which a file must be LFS-tracked",
|
|
9818
12086
|
)
|
|
9819
12087
|
s.set_defaults(func=cmd_commit)
|
|
9820
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
|
+
|
|
9821
12095
|
|
|
9822
12096
|
def _parsers_governance(sub, common) -> None:
|
|
9823
12097
|
"""`governance` command surface."""
|
|
@@ -9912,6 +12186,20 @@ def _parsers_governance(sub, common) -> None:
|
|
|
9912
12186
|
|
|
9913
12187
|
def _parsers_audit(sub, common) -> None:
|
|
9914
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
|
+
|
|
9915
12203
|
s = sub.add_parser(
|
|
9916
12204
|
"links", parents=[common], help="check markdown cross-links, URLs and back-links"
|
|
9917
12205
|
)
|
|
@@ -10097,6 +12385,12 @@ def _parsers_meta(sub, common) -> None:
|
|
|
10097
12385
|
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
10098
12386
|
p.set_defaults(func=cmd_imaging)
|
|
10099
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
|
+
|
|
10100
12394
|
|
|
10101
12395
|
def main(argv=None) -> int:
|
|
10102
12396
|
args = build_parser().parse_args(argv)
|