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