@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
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
"""The harness surface: its registry, its always-on budget and its lint.
|
|
2
|
+
|
|
3
|
+
Harness^2 names the *harness* around a frozen model as eight file-level
|
|
4
|
+
components, each with an activation class that says what it costs on every run.
|
|
5
|
+
This module does the same for this repository: the registry maps C1..C8 onto real
|
|
6
|
+
paths, and every byte count is measured on disk at run time, so a row cannot
|
|
7
|
+
drift from the tree it claims to describe.
|
|
8
|
+
|
|
9
|
+
``harness list`` is deliberately a table, not a diagram: one row per component -
|
|
10
|
+
never one per file, so the table can be read against the source taxonomy row by
|
|
11
|
+
row - with an activation class, a measured cost and the role that pays it. C5
|
|
12
|
+
(subagents) is the one component that is not a file - a lane packet is assembled
|
|
13
|
+
per task - so its class, byte and role cells are ``-`` and its path cell says so.
|
|
14
|
+
|
|
15
|
+
The path column is the specification the bytes were measured through, not a
|
|
16
|
+
short name for the component: ``AGENTS.md`` would not let a reader recompute the
|
|
17
|
+
managed block's size from a file that also holds text outside the block, and
|
|
18
|
+
``scripts/*.py`` alone would not explain a count that also sums
|
|
19
|
+
``src/teamwork/*.py``. Every cell is therefore the frozen spec of Architecture
|
|
20
|
+
12.2, so ``wc -c`` over what the row names reproduces the number printed beside
|
|
21
|
+
it.
|
|
22
|
+
|
|
23
|
+
``harness lint`` is the whole-proposal invalidator. A skill that no always-on
|
|
24
|
+
file names is silently dead, a ``teamwork.py <word>`` the CLI does not implement
|
|
25
|
+
is a broken instruction, and a referenced path that was never shipped sends the
|
|
26
|
+
reader to nowhere. Each of those is a fact about the tree, so each is checked
|
|
27
|
+
mechanically instead of being trusted to review.
|
|
28
|
+
|
|
29
|
+
The always-on budget is the cost doctrine made measurable: SKILL.md, the managed
|
|
30
|
+
teamwork block of AGENTS.md, the role charter and the role's memory files are in
|
|
31
|
+
context on every run, so they are the expensive option and the only one capped.
|
|
32
|
+
``AlwaysOnBudgetKb`` in the team ``Settings.json`` sets the cap; missing,
|
|
33
|
+
unreadable or non-numeric settings fall back to
|
|
34
|
+
:data:`teamwork.config.DEFAULT_ALWAYS_ON_BUDGET_KB`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import json
|
|
41
|
+
import re
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import NamedTuple
|
|
44
|
+
|
|
45
|
+
from .config import (
|
|
46
|
+
DEFAULT_ALWAYS_ON_BUDGET_KB,
|
|
47
|
+
DIR_AGENTS,
|
|
48
|
+
DIR_ROLES,
|
|
49
|
+
F_GLOBAL,
|
|
50
|
+
F_LONG,
|
|
51
|
+
F_ROLE,
|
|
52
|
+
F_SETTINGS,
|
|
53
|
+
F_SHORT,
|
|
54
|
+
)
|
|
55
|
+
from .gitutil import HooksDirUnresolved, run_git
|
|
56
|
+
from .gitutil import hooks_dir as resolved_hooks_dir
|
|
57
|
+
from .io import die
|
|
58
|
+
from .layout import (
|
|
59
|
+
global_dir,
|
|
60
|
+
known_instances,
|
|
61
|
+
list_roles,
|
|
62
|
+
project_of,
|
|
63
|
+
project_path,
|
|
64
|
+
project_root,
|
|
65
|
+
role_dir,
|
|
66
|
+
skills_path,
|
|
67
|
+
)
|
|
68
|
+
from .names import resolve_role
|
|
69
|
+
from .records import parse_skills
|
|
70
|
+
|
|
71
|
+
#: The three activation classes (12.3), cheapest to reason about first: always-on
|
|
72
|
+
#: text is in context on every call, on-demand text is opened when a skill or a
|
|
73
|
+
#: reference is needed, and in-loop code runs inside the tool loop.
|
|
74
|
+
ALWAYS_ON = "always-on"
|
|
75
|
+
ON_DEMAND = "on-demand"
|
|
76
|
+
IN_LOOP = "in-loop"
|
|
77
|
+
CLASSES = (ALWAYS_ON, ON_DEMAND, IN_LOOP)
|
|
78
|
+
|
|
79
|
+
NO_VALUE = "-"
|
|
80
|
+
|
|
81
|
+
#: C5 is an inference context, not an artifact: it has no file, so its class, byte
|
|
82
|
+
#: and role cells are ``-`` (12.2: ``-`` is the value for the component with no
|
|
83
|
+
#: file) and its path cell says what it is instead of naming a path.
|
|
84
|
+
LANE_PACKET = "n/a - lane packet, no file"
|
|
85
|
+
|
|
86
|
+
#: The closed set of values the class column may print: the three activation
|
|
87
|
+
#: classes plus `-`, the one component that is an inference context, not a file.
|
|
88
|
+
CLASS_CELLS = CLASSES + (NO_VALUE,)
|
|
89
|
+
|
|
90
|
+
#: The path column, verbatim from the frozen registry table of Architecture 12.2.
|
|
91
|
+
#: Each string is the spec the row's bytes are measured through, so the number can
|
|
92
|
+
#: be recomputed from the cell alone: the managed block (not the file that holds
|
|
93
|
+
#: it), the union of both globs (not one member of it), both hook stages
|
|
94
|
+
#: through the resolver (not one file under a path that does not exist in a
|
|
95
|
+
#: worktree). A prettier spelling would describe the component and hide the
|
|
96
|
+
#: measurement, which is the defect this column exists to prevent.
|
|
97
|
+
SPEC_SYSTEM_PROMPT = "SKILL.md"
|
|
98
|
+
SPEC_GUARDRAILS = "managed block in AGENTS.md"
|
|
99
|
+
SPEC_MEMORY = "Roles/<Role>/*.md + Global/GlobalMemory.md"
|
|
100
|
+
SPEC_TOOL_GUIDANCE = "references/*.md"
|
|
101
|
+
SPEC_SKILLS = ".agents/skills/*/SKILL.md"
|
|
102
|
+
SPEC_SCRIPTS = "scripts/*.py + src/teamwork/*.py"
|
|
103
|
+
SPEC_HOOKS = "hook_dir(root)/{pre-commit,pre-push}"
|
|
104
|
+
|
|
105
|
+
#: The two stages the `hooks` command group writes; C8 measures both.
|
|
106
|
+
HOOK_STAGES = ("pre-commit", "pre-push")
|
|
107
|
+
|
|
108
|
+
RULE_IDENTITY = "IDENTITY"
|
|
109
|
+
RULE_TRIGGER = "TRIGGER"
|
|
110
|
+
RULE_SUBCOMMAND = "SUBCOMMAND"
|
|
111
|
+
RULE_HELPER = "HELPER"
|
|
112
|
+
RULE_EVIDENCE = "EVIDENCE"
|
|
113
|
+
RULES = (RULE_IDENTITY, RULE_TRIGGER, RULE_SUBCOMMAND, RULE_HELPER, RULE_EVIDENCE)
|
|
114
|
+
|
|
115
|
+
SKILLS_DIRNAME = Path(".agents") / "skills"
|
|
116
|
+
|
|
117
|
+
#: The four verdicts an evidence row may carry. ``NO_ENTRY`` - no row at all - is a
|
|
118
|
+
#: valid answer for a draft skill and only becomes a failure once it is shared.
|
|
119
|
+
VERDICT_RE = re.compile(
|
|
120
|
+
r"verdict:\s*(?:helped|hurt|no\s+observed\s+effect|confounded)\b",
|
|
121
|
+
re.IGNORECASE,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
_COMMAND_RE = re.compile(r"teamwork\.py\s+([a-z][a-z0-9_-]*)")
|
|
125
|
+
|
|
126
|
+
#: A token that looks like a repository-relative path: at least two segments and a
|
|
127
|
+
#: file extension. The lookbehind keeps URL host names (``https://host/a/b.md``)
|
|
128
|
+
#: and shell-variable roots (``$SKILL/scripts/x.py``) out: their existence cannot
|
|
129
|
+
#: be decided from the repository, so the match may not start mid-token.
|
|
130
|
+
_PATH_RE = re.compile(
|
|
131
|
+
r"(?<![\w/.$-])([A-Za-z0-9_][A-Za-z0-9_./-]*/[A-Za-z0-9_./-]*\.[A-Za-z][A-Za-z0-9]{0,7})"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
#: Characters that make a path token a template, a glob or a shell variable rather
|
|
135
|
+
#: than a file the skill promises to ship.
|
|
136
|
+
_PLACEHOLDER_CHARS = ("<", ">", "{", "}", "$", "*", "?", "~")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Surface(NamedTuple):
|
|
140
|
+
"""One registry row. ``size`` is ``None`` when the surface is not a file."""
|
|
141
|
+
|
|
142
|
+
component: str
|
|
143
|
+
klass: str
|
|
144
|
+
path: str
|
|
145
|
+
role: str
|
|
146
|
+
size: int | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Finding(NamedTuple):
|
|
150
|
+
"""One lint failure, rendered as ``- PROBLEM <rule> <path>: <detail>``."""
|
|
151
|
+
|
|
152
|
+
rule: str
|
|
153
|
+
path: str
|
|
154
|
+
detail: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
#: What one rule said about one file, for ``lint --verbose``. ``ok``/``fail`` are
|
|
158
|
+
#: verdicts; ``corpus`` marks a file read as evidence for a rule that reasons over
|
|
159
|
+
#: several files at once (TRIGGER reads every always-on surface, EVIDENCE reads the
|
|
160
|
+
#: team registry); ``n/a`` marks a rule that does not apply to that file; ``skipped``
|
|
161
|
+
#: marks a rule that could not run at all.
|
|
162
|
+
OK = "ok"
|
|
163
|
+
FAIL = "fail"
|
|
164
|
+
CORPUS = "corpus"
|
|
165
|
+
NOT_APPLICABLE = "n/a"
|
|
166
|
+
SKIPPED = "skipped"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class CheckedFile(NamedTuple):
|
|
170
|
+
"""One file ``lint`` opened, with the rules evaluated against it and the verdicts."""
|
|
171
|
+
|
|
172
|
+
path: str
|
|
173
|
+
outcomes: tuple
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class LintStats(NamedTuple):
|
|
177
|
+
"""What the last lint actually looked at, for ``--verbose`` and for tests."""
|
|
178
|
+
|
|
179
|
+
repo: str
|
|
180
|
+
skills: int
|
|
181
|
+
always_on: list[str]
|
|
182
|
+
commands: int
|
|
183
|
+
shared_checked: int
|
|
184
|
+
registry: str
|
|
185
|
+
checked: list[CheckedFile]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class BudgetRow(NamedTuple):
|
|
189
|
+
"""One instance's always-on cost against the cap."""
|
|
190
|
+
|
|
191
|
+
instance: str
|
|
192
|
+
always_on: int
|
|
193
|
+
budget: int
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def over(self) -> bool:
|
|
197
|
+
return self.always_on > self.budget
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------- #
|
|
201
|
+
# Measuring the tree
|
|
202
|
+
# --------------------------------------------------------------------------- #
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def repo_root(args) -> Path:
|
|
206
|
+
"""The work tree the command acts on: the git toplevel, else the project dir."""
|
|
207
|
+
here = project_path(args)
|
|
208
|
+
result = run_git(here, "rev-parse", "--show-toplevel")
|
|
209
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
210
|
+
return Path(result.stdout.strip()).resolve()
|
|
211
|
+
return here
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def file_size(path: Path) -> int:
|
|
215
|
+
try:
|
|
216
|
+
return path.stat().st_size
|
|
217
|
+
except OSError:
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def glob_size(base: Path, pattern: str) -> int:
|
|
222
|
+
"""Bytes of every file ``base/pattern`` matches right now; no match is 0."""
|
|
223
|
+
try:
|
|
224
|
+
matches = sorted(base.glob(pattern))
|
|
225
|
+
except OSError: # pragma: no cover - only an unreadable directory gets here
|
|
226
|
+
return 0
|
|
227
|
+
return sum(file_size(path) for path in matches if path.is_file())
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def managed_block_size(path: Path) -> int:
|
|
231
|
+
"""Bytes of the CEO-owned teamwork block inside AGENTS.md, markers included.
|
|
232
|
+
|
|
233
|
+
The markers are the engine's own pair (`agents-md` writes them): a second copy
|
|
234
|
+
of a marker literal is a second source of truth.
|
|
235
|
+
"""
|
|
236
|
+
from . import engine # local: the command layer registers this module
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
text = path.read_text(encoding="utf-8")
|
|
240
|
+
except OSError:
|
|
241
|
+
return 0
|
|
242
|
+
start = text.find(engine.BEGIN)
|
|
243
|
+
end = text.find(engine.END)
|
|
244
|
+
if start < 0 or end < start:
|
|
245
|
+
return 0
|
|
246
|
+
return len(text[start : end + len(engine.END)].encode("utf-8"))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def hooks_dir(repo: Path) -> Path | None:
|
|
250
|
+
"""``<git-dir>/hooks`` resolved by git, or ``None`` when git cannot answer.
|
|
251
|
+
|
|
252
|
+
This is the tolerant face of the one contract in :func:`teamwork.gitutil.hooks_dir`
|
|
253
|
+
- same ``rev-parse``, same resolution through the common git directory, so a
|
|
254
|
+
worktree row and the installer row name the same directory. A report must not
|
|
255
|
+
abort because a directory is missing, so a failure here is a reported absence;
|
|
256
|
+
the installer, which writes into the directory, lets it die instead.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
return resolved_hooks_dir(repo)
|
|
260
|
+
except HooksDirUnresolved:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def display(path: Path, base: Path) -> str:
|
|
265
|
+
"""A repo-relative path when it is inside the base, else the absolute one."""
|
|
266
|
+
try:
|
|
267
|
+
return path.resolve().relative_to(base.resolve()).as_posix()
|
|
268
|
+
except ValueError:
|
|
269
|
+
return path.resolve().as_posix()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def collect(repo: Path, root: Path) -> list[Surface]:
|
|
273
|
+
"""The eight components, in C1..C8 order, measured on disk now.
|
|
274
|
+
|
|
275
|
+
One row per Harness^2 component, never one per file: an inventory that splits a
|
|
276
|
+
component across rows cannot be checked against the taxonomy it claims to
|
|
277
|
+
describe. Each row's ``path`` is the frozen spec (Architecture 12.2) and
|
|
278
|
+
``size`` is what that spec selects at run time: C2 is the managed block inside
|
|
279
|
+
AGENTS.md, C3 is every role's markdown plus the global memory, C7 is the
|
|
280
|
+
scripts plus the engine package, C8 is both hook stages found through the git
|
|
281
|
+
hook directory. Measuring something other than what the cell names would make
|
|
282
|
+
the printed number unreproducible from the row, which is what the registry is
|
|
283
|
+
for.
|
|
284
|
+
"""
|
|
285
|
+
hooks = hooks_dir(repo)
|
|
286
|
+
return [
|
|
287
|
+
Surface("C1", ALWAYS_ON, SPEC_SYSTEM_PROMPT, "ceo", file_size(repo / "SKILL.md")),
|
|
288
|
+
Surface(
|
|
289
|
+
"C2",
|
|
290
|
+
ALWAYS_ON,
|
|
291
|
+
SPEC_GUARDRAILS,
|
|
292
|
+
"ceo",
|
|
293
|
+
managed_block_size(repo / "AGENTS.md"),
|
|
294
|
+
),
|
|
295
|
+
Surface("C3", ALWAYS_ON, SPEC_MEMORY, NO_VALUE, memory_bytes(root)),
|
|
296
|
+
Surface("C4", ON_DEMAND, SPEC_TOOL_GUIDANCE, "ceo", glob_size(repo, "references/*.md")),
|
|
297
|
+
Surface("C5", NO_VALUE, LANE_PACKET, NO_VALUE, None),
|
|
298
|
+
Surface(
|
|
299
|
+
"C6",
|
|
300
|
+
ON_DEMAND,
|
|
301
|
+
SPEC_SKILLS,
|
|
302
|
+
"ceo",
|
|
303
|
+
glob_size(repo, ".agents/skills/*/SKILL.md"),
|
|
304
|
+
),
|
|
305
|
+
Surface(
|
|
306
|
+
"C7",
|
|
307
|
+
IN_LOOP,
|
|
308
|
+
SPEC_SCRIPTS,
|
|
309
|
+
"developer",
|
|
310
|
+
glob_size(repo, "scripts/*.py") + glob_size(repo, "src/teamwork/*.py"),
|
|
311
|
+
),
|
|
312
|
+
Surface(
|
|
313
|
+
"C8",
|
|
314
|
+
IN_LOOP,
|
|
315
|
+
SPEC_HOOKS,
|
|
316
|
+
NO_VALUE,
|
|
317
|
+
sum(file_size(hooks / stage) for stage in HOOK_STAGES) if hooks is not None else 0,
|
|
318
|
+
),
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def memory_bytes(root: Path) -> int:
|
|
323
|
+
"""C3: every role's markdown plus the shared global memory of the team."""
|
|
324
|
+
total = file_size(global_dir(root) / F_GLOBAL)
|
|
325
|
+
for role in list_roles(root):
|
|
326
|
+
total += glob_size(role_dir(root, role), "*.md")
|
|
327
|
+
return total
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
#: A row's third cell is a spec, and the frozen specs contain single spaces
|
|
331
|
+
#: (``managed block in AGENTS.md``), so the cells are separated by two or more
|
|
332
|
+
#: spaces and split on that: ``split()`` would tear a spec in half.
|
|
333
|
+
_CELL_SEPARATOR_RE = re.compile(r"\s{2,}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def render_surfaces(rows: list[Surface]) -> str:
|
|
337
|
+
"""Exactly eight rows, four columns, no header: the frozen registry format."""
|
|
338
|
+
path_width = max([len(row.path) for row in rows] + [len("path")])
|
|
339
|
+
class_width = max(len(row.klass) for row in rows)
|
|
340
|
+
lines = []
|
|
341
|
+
for row in rows:
|
|
342
|
+
size = NO_VALUE if row.size is None else str(row.size)
|
|
343
|
+
lines.append(f"{row.klass:<{class_width}} {size:>9} {row.path:<{path_width}} {row.role}")
|
|
344
|
+
return "\n".join(lines)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def parse_surface_row(line: str) -> tuple:
|
|
348
|
+
"""``(class, bytes, path, role)`` from one printed row - the inverse of render.
|
|
349
|
+
|
|
350
|
+
The bytes cell stays a string (``-`` for C5): a caller that wants a number has
|
|
351
|
+
to decide what ``-`` means, and pretending it is ``0`` would turn "not
|
|
352
|
+
measured" into a measurement.
|
|
353
|
+
"""
|
|
354
|
+
cells = _CELL_SEPARATOR_RE.split(line.strip())
|
|
355
|
+
if len(cells) != 4:
|
|
356
|
+
raise ValueError(f"not a four-column registry row: {line!r} -> {cells!r}")
|
|
357
|
+
klass, size, path, role = cells
|
|
358
|
+
return klass, size, path, role
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def class_totals(rows: list[Surface]) -> dict:
|
|
362
|
+
"""Bytes per activation class, summed over the eight rows.
|
|
363
|
+
|
|
364
|
+
The registry prints its eight rows and nothing else, so the cost roll-up a
|
|
365
|
+
reader wants ("what does always-on cost me") is computed here rather than
|
|
366
|
+
appended to the table the frozen format fixes.
|
|
367
|
+
"""
|
|
368
|
+
return {klass: sum(row.size or 0 for row in rows if row.klass == klass) for klass in CLASSES}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# --------------------------------------------------------------------------- #
|
|
372
|
+
# lint: the whole-proposal invalidators
|
|
373
|
+
# --------------------------------------------------------------------------- #
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def parse_frontmatter(text: str) -> dict:
|
|
377
|
+
"""The scalar fields of a leading ``---`` block; a repeated key wins last."""
|
|
378
|
+
lines = text.splitlines()
|
|
379
|
+
if not lines or lines[0].strip() != "---":
|
|
380
|
+
return {}
|
|
381
|
+
out: dict = {}
|
|
382
|
+
for raw in lines[1:]:
|
|
383
|
+
if raw.strip() == "---":
|
|
384
|
+
break
|
|
385
|
+
match = re.match(r"^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$", raw)
|
|
386
|
+
if match is None:
|
|
387
|
+
continue
|
|
388
|
+
value = match.group(2).strip()
|
|
389
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
390
|
+
value = value[1:-1].strip()
|
|
391
|
+
out[match.group(1).lower()] = value
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def skill_files(repo: Path) -> list[Path]:
|
|
396
|
+
"""``.agents/skills/<dir>/SKILL.md`` - one skill per directory, none nested."""
|
|
397
|
+
try:
|
|
398
|
+
found = sorted((repo / SKILLS_DIRNAME).glob("*/SKILL.md"))
|
|
399
|
+
except OSError: # pragma: no cover
|
|
400
|
+
return []
|
|
401
|
+
return [path for path in found if path.is_file()]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def always_on_surfaces(repo: Path) -> list[Path]:
|
|
405
|
+
"""The files a skill name must appear in for the skill to be reachable."""
|
|
406
|
+
names = [repo / "AGENTS.md", repo / "SKILL.md"]
|
|
407
|
+
roles = repo / "roles"
|
|
408
|
+
if roles.is_dir():
|
|
409
|
+
names.extend(sorted(roles.glob("*.md")))
|
|
410
|
+
return [path for path in names if path.is_file()]
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def cli_subcommands() -> set:
|
|
414
|
+
"""Every ``teamwork.py <word>`` the parser actually accepts.
|
|
415
|
+
|
|
416
|
+
Read off the parser, never copied: the scripts *are* the engine, so a
|
|
417
|
+
hardcoded list would drift the first time a command is renamed.
|
|
418
|
+
"""
|
|
419
|
+
from . import engine # local: the command layer is what registers this module
|
|
420
|
+
|
|
421
|
+
parser = engine.build_parser()
|
|
422
|
+
names: set = set()
|
|
423
|
+
for action in parser._actions:
|
|
424
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
425
|
+
names.update(action.choices)
|
|
426
|
+
return names
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def helper_paths(text: str) -> list[str]:
|
|
430
|
+
"""Repository-relative paths a skill file claims to ship, placeholders dropped."""
|
|
431
|
+
out = set()
|
|
432
|
+
for raw in _PATH_RE.findall(text):
|
|
433
|
+
token = raw.strip("\"'`()[].,;:")
|
|
434
|
+
if not token or any(char in token for char in _PLACEHOLDER_CHARS):
|
|
435
|
+
continue
|
|
436
|
+
if token.startswith(("/", "http", "www")) or "://" in token:
|
|
437
|
+
continue
|
|
438
|
+
if ".." in token.split("/"):
|
|
439
|
+
continue
|
|
440
|
+
out.add(token)
|
|
441
|
+
return sorted(out)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def lint(repo: Path, root: Path) -> tuple[list[Finding], LintStats]:
|
|
445
|
+
"""Run every rule over the tree; return the findings and what was checked."""
|
|
446
|
+
findings: list[Finding] = []
|
|
447
|
+
surfaces = always_on_surfaces(repo)
|
|
448
|
+
corpus = "\n".join(path.read_text(encoding="utf-8") for path in surfaces)
|
|
449
|
+
commands = cli_subcommands()
|
|
450
|
+
files = skill_files(repo)
|
|
451
|
+
registry = skills_path(root)
|
|
452
|
+
has_registry = registry.is_file()
|
|
453
|
+
|
|
454
|
+
# Files read for a rule that reasons across files rather than per file: the
|
|
455
|
+
# always-on corpus TRIGGER searches, and the team registry EVIDENCE reads.
|
|
456
|
+
checked: list[CheckedFile] = [
|
|
457
|
+
CheckedFile(display(path, repo), ((RULE_TRIGGER, CORPUS),)) for path in surfaces
|
|
458
|
+
]
|
|
459
|
+
|
|
460
|
+
evidence: list[Finding] = []
|
|
461
|
+
evaluated: list[str] = []
|
|
462
|
+
shared_checked = 0
|
|
463
|
+
if has_registry:
|
|
464
|
+
evidence, evaluated, shared_checked = evidence_findings(repo, registry)
|
|
465
|
+
|
|
466
|
+
for path in files:
|
|
467
|
+
rel = display(path, repo)
|
|
468
|
+
text = path.read_text(encoding="utf-8")
|
|
469
|
+
fields = parse_frontmatter(text)
|
|
470
|
+
name = (fields.get("name") or "").strip()
|
|
471
|
+
directory = path.parent.name
|
|
472
|
+
mine: list[Finding] = []
|
|
473
|
+
|
|
474
|
+
if not name:
|
|
475
|
+
mine.append(Finding(RULE_IDENTITY, rel, "frontmatter has no `name:` line"))
|
|
476
|
+
elif name != directory:
|
|
477
|
+
mine.append(
|
|
478
|
+
Finding(
|
|
479
|
+
RULE_IDENTITY,
|
|
480
|
+
rel,
|
|
481
|
+
f"frontmatter name '{name}' does not match the directory '{directory}'",
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
if not (fields.get("description") or "").strip():
|
|
485
|
+
mine.append(
|
|
486
|
+
Finding(RULE_IDENTITY, rel, "frontmatter `description:` is missing or empty")
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if directory and directory not in corpus:
|
|
490
|
+
mine.append(
|
|
491
|
+
Finding(
|
|
492
|
+
RULE_TRIGGER,
|
|
493
|
+
rel,
|
|
494
|
+
f"'{directory}' appears in no always-on surface "
|
|
495
|
+
"(AGENTS.md, SKILL.md, roles/*.md), so the skill is silently dead",
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
for word in sorted(set(_COMMAND_RE.findall(text))):
|
|
500
|
+
if word not in commands:
|
|
501
|
+
mine.append(
|
|
502
|
+
Finding(RULE_SUBCOMMAND, rel, f"`teamwork.py {word}` is not a CLI subcommand")
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
for token in helper_paths(text):
|
|
506
|
+
if not (repo / token).exists():
|
|
507
|
+
mine.append(
|
|
508
|
+
Finding(RULE_HELPER, rel, f"references '{token}', which does not exist")
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
findings.extend(mine)
|
|
512
|
+
checked.append(CheckedFile(rel, _outcomes(mine, rel, evidence, evaluated, has_registry)))
|
|
513
|
+
|
|
514
|
+
findings.extend(evidence)
|
|
515
|
+
if has_registry:
|
|
516
|
+
checked.append(CheckedFile(display(registry, project_of(root)), ((RULE_EVIDENCE, CORPUS),)))
|
|
517
|
+
|
|
518
|
+
findings = ordered(findings)
|
|
519
|
+
stats = LintStats(
|
|
520
|
+
repo=str(repo),
|
|
521
|
+
skills=len(files),
|
|
522
|
+
always_on=[display(path, repo) for path in surfaces],
|
|
523
|
+
commands=len(commands),
|
|
524
|
+
shared_checked=shared_checked,
|
|
525
|
+
registry=display(registry, project_of(root)) if has_registry else "",
|
|
526
|
+
checked=checked,
|
|
527
|
+
)
|
|
528
|
+
return findings, stats
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _outcomes(
|
|
532
|
+
mine: list[Finding], rel: str, evidence: list[Finding], evaluated: list[str], has_registry: bool
|
|
533
|
+
) -> tuple:
|
|
534
|
+
"""The verdict of each rule on one skill file, in frozen rule order."""
|
|
535
|
+
failed = {finding.rule for finding in mine}
|
|
536
|
+
out = [(rule, FAIL if rule in failed else OK) for rule in RULES[:4]]
|
|
537
|
+
if not has_registry:
|
|
538
|
+
out.append((RULE_EVIDENCE, SKIPPED))
|
|
539
|
+
elif rel not in evaluated:
|
|
540
|
+
out.append((RULE_EVIDENCE, NOT_APPLICABLE))
|
|
541
|
+
elif any(finding.path == rel for finding in evidence):
|
|
542
|
+
out.append((RULE_EVIDENCE, FAIL))
|
|
543
|
+
else:
|
|
544
|
+
out.append((RULE_EVIDENCE, OK))
|
|
545
|
+
return tuple(out)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def render_checked(checked: list[CheckedFile]) -> list[str]:
|
|
549
|
+
"""One ``# checked: <file> -> <RULE verdict, ...>`` line per file, rule order."""
|
|
550
|
+
return [
|
|
551
|
+
f"# checked: {item.path} -> "
|
|
552
|
+
+ ", ".join(f"{rule} {outcome}" for rule, outcome in item.outcomes)
|
|
553
|
+
for item in checked
|
|
554
|
+
]
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def ordered(findings: list[Finding]) -> list[Finding]:
|
|
558
|
+
"""Rule order first (IDENTITY..EVIDENCE), path order inside a rule."""
|
|
559
|
+
rank = {rule: index for index, rule in enumerate(RULES)}
|
|
560
|
+
return sorted(findings, key=lambda finding: (rank.get(finding.rule, len(rank)), finding.path))
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def evidence_findings(repo: Path, registry: Path) -> tuple[list[Finding], list[str], int]:
|
|
564
|
+
"""EVIDENCE: a shared skill must carry an evidence row; a draft skill need not.
|
|
565
|
+
|
|
566
|
+
Returns the findings, the paths the rule evaluated (shared entries, so
|
|
567
|
+
``--verbose`` can say which file was checked for what), and how many of them
|
|
568
|
+
were readable skill files.
|
|
569
|
+
"""
|
|
570
|
+
findings: list[Finding] = []
|
|
571
|
+
evaluated: list[str] = []
|
|
572
|
+
checked = 0
|
|
573
|
+
for entry in parse_skills(registry):
|
|
574
|
+
if (entry.get("status") or "").strip().lower() != "shared":
|
|
575
|
+
continue
|
|
576
|
+
rel = (entry.get("path") or "").strip()
|
|
577
|
+
evaluated.append(rel or entry.get("name") or "?")
|
|
578
|
+
skill = repo / rel if rel else None
|
|
579
|
+
if skill is None or not skill.is_file():
|
|
580
|
+
findings.append(
|
|
581
|
+
Finding(
|
|
582
|
+
RULE_HELPER,
|
|
583
|
+
rel or entry.get("name") or "?",
|
|
584
|
+
"registered in Skills.md as shared, but the file does not exist",
|
|
585
|
+
)
|
|
586
|
+
)
|
|
587
|
+
continue
|
|
588
|
+
checked += 1
|
|
589
|
+
if not VERDICT_RE.search(skill.read_text(encoding="utf-8")):
|
|
590
|
+
findings.append(
|
|
591
|
+
Finding(
|
|
592
|
+
RULE_EVIDENCE,
|
|
593
|
+
rel,
|
|
594
|
+
"shared skill has no evidence row "
|
|
595
|
+
"(`verdict: helped|hurt|no observed effect|confounded`)",
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
return findings, evaluated, checked
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# --------------------------------------------------------------------------- #
|
|
602
|
+
# budget: the always-on cap, per instance
|
|
603
|
+
# --------------------------------------------------------------------------- #
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def settings_budget_kb(root: Path) -> int | None:
|
|
607
|
+
"""``AlwaysOnBudgetKb`` from the team settings, or ``None`` when unusable."""
|
|
608
|
+
try:
|
|
609
|
+
data = json.loads((root / F_SETTINGS).read_text(encoding="utf-8"))
|
|
610
|
+
except (OSError, ValueError):
|
|
611
|
+
return None
|
|
612
|
+
if not isinstance(data, dict):
|
|
613
|
+
return None
|
|
614
|
+
value = data.get("AlwaysOnBudgetKb")
|
|
615
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
616
|
+
return None
|
|
617
|
+
return value
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def charter_path(repo: Path, root: Path, role: str) -> Path:
|
|
621
|
+
"""The charter an instance actually reads: the team copy, else the bundle copy."""
|
|
622
|
+
team_copy = role_dir(root, role) / F_ROLE
|
|
623
|
+
return team_copy if team_copy.is_file() else repo / "roles" / f"{role}.md"
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def instance_always_on(repo: Path, root: Path, role: str) -> int:
|
|
627
|
+
"""What is in context on every run for one instance, in bytes."""
|
|
628
|
+
return (
|
|
629
|
+
managed_block_size(repo / "AGENTS.md")
|
|
630
|
+
+ file_size(repo / "SKILL.md")
|
|
631
|
+
+ file_size(charter_path(repo, root, role))
|
|
632
|
+
+ file_size(role_dir(root, role) / F_SHORT)
|
|
633
|
+
+ file_size(role_dir(root, role) / F_LONG)
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def budget_rows(repo: Path, root: Path, limit_kb: int | None) -> tuple[list[BudgetRow], int, str]:
|
|
638
|
+
"""One row per agent instance, the cap in bytes, and where the cap came from."""
|
|
639
|
+
configured = settings_budget_kb(root)
|
|
640
|
+
if limit_kb is not None:
|
|
641
|
+
kb, source = limit_kb, "--limit"
|
|
642
|
+
elif configured is not None:
|
|
643
|
+
kb, source = configured, "Settings.json AlwaysOnBudgetKb"
|
|
644
|
+
else:
|
|
645
|
+
kb, source = DEFAULT_ALWAYS_ON_BUDGET_KB, "default"
|
|
646
|
+
cap = max(kb, 0) * 1024
|
|
647
|
+
rows = [
|
|
648
|
+
BudgetRow(instance, instance_always_on(repo, root, resolve_role(instance)), cap)
|
|
649
|
+
for instance in known_instances(root)
|
|
650
|
+
]
|
|
651
|
+
return rows, cap, source
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def render_budget(rows: list[BudgetRow]) -> str:
|
|
655
|
+
"""``<instance> <always_on> <budget> <ok|OVER>`` per row, then the total line."""
|
|
656
|
+
lines = [
|
|
657
|
+
f"{row.instance} {row.always_on} {row.budget} {'OVER' if row.over else 'ok'}"
|
|
658
|
+
for row in rows
|
|
659
|
+
]
|
|
660
|
+
state = "OVER" if any(row.over for row in rows) else "ok"
|
|
661
|
+
lines.append(
|
|
662
|
+
f"total {sum(row.always_on for row in rows)} {sum(row.budget for row in rows)} {state}"
|
|
663
|
+
)
|
|
664
|
+
return "\n".join(lines)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
# --------------------------------------------------------------------------- #
|
|
668
|
+
# Commands
|
|
669
|
+
# --------------------------------------------------------------------------- #
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def cmd_list(args) -> int:
|
|
673
|
+
repo = repo_root(args)
|
|
674
|
+
root = project_root(args)
|
|
675
|
+
print(render_surfaces(collect(repo, root)))
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def cmd_lint(args) -> int:
|
|
680
|
+
repo = repo_root(args)
|
|
681
|
+
root = project_root(args)
|
|
682
|
+
findings, stats = lint(repo, root)
|
|
683
|
+
if args.verbose:
|
|
684
|
+
print(f"# repo: {stats.repo}")
|
|
685
|
+
print(f"# skill files: {stats.skills}")
|
|
686
|
+
print(f"# cli subcommands: {stats.commands}")
|
|
687
|
+
print(f"# always-on surfaces: {', '.join(stats.always_on) or '(none)'}")
|
|
688
|
+
if stats.registry:
|
|
689
|
+
print(f"# EVIDENCE: {stats.shared_checked} shared skill(s) checked")
|
|
690
|
+
else:
|
|
691
|
+
print("# EVIDENCE: skipped (no team workspace / no Skills.md)")
|
|
692
|
+
# 12.4: --verbose also lists every file checked, so a clean run is
|
|
693
|
+
# auditable file by file instead of being a count the reader must trust.
|
|
694
|
+
for line in render_checked(stats.checked):
|
|
695
|
+
print(line)
|
|
696
|
+
for finding in findings:
|
|
697
|
+
print(f"- PROBLEM {finding.rule} {finding.path}: {finding.detail}")
|
|
698
|
+
print(f"# problems found: {len(findings)}")
|
|
699
|
+
return 1 if findings else 0
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def cmd_budget(args) -> int:
|
|
703
|
+
repo = repo_root(args)
|
|
704
|
+
root = project_root(args)
|
|
705
|
+
if not (root / DIR_ROLES).is_dir() and not (root / DIR_AGENTS).is_dir():
|
|
706
|
+
die(
|
|
707
|
+
f"no team workspace at {root}: `harness budget` measures the always-on "
|
|
708
|
+
"context every instance loads, so it needs `teamwork.py init` first."
|
|
709
|
+
)
|
|
710
|
+
rows, cap, source = budget_rows(repo, root, args.limit)
|
|
711
|
+
print(f"# always-on budget for {root}")
|
|
712
|
+
print(f"# cap: {cap} bytes per instance ({source})")
|
|
713
|
+
if not rows:
|
|
714
|
+
print("# no agent instances in this team yet")
|
|
715
|
+
print(render_budget(rows))
|
|
716
|
+
return 1 if any(row.over for row in rows) else 0
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def add_parsers(sub, common) -> None:
|
|
720
|
+
"""Register the ``harness`` command group on the shared argparse tree."""
|
|
721
|
+
surface = sub.add_parser(
|
|
722
|
+
"harness",
|
|
723
|
+
parents=[common],
|
|
724
|
+
help="name, measure and lint the harness surface (Harness^2 C1..C8)",
|
|
725
|
+
)
|
|
726
|
+
hsub = surface.add_subparsers(dest="harness_cmd", required=True)
|
|
727
|
+
|
|
728
|
+
parser = hsub.add_parser(
|
|
729
|
+
"list", parents=[common], help="every surface with its class, bytes and role"
|
|
730
|
+
)
|
|
731
|
+
parser.set_defaults(func=cmd_list)
|
|
732
|
+
|
|
733
|
+
parser = hsub.add_parser(
|
|
734
|
+
"lint",
|
|
735
|
+
parents=[common],
|
|
736
|
+
help="whole-proposal checks: identity, trigger, subcommand, helper, evidence",
|
|
737
|
+
)
|
|
738
|
+
parser.add_argument(
|
|
739
|
+
"--verbose", action="store_true", help="print what was checked and what was skipped"
|
|
740
|
+
)
|
|
741
|
+
parser.set_defaults(func=cmd_lint)
|
|
742
|
+
|
|
743
|
+
parser = hsub.add_parser(
|
|
744
|
+
"budget", parents=[common], help="always-on bytes per instance against AlwaysOnBudgetKb"
|
|
745
|
+
)
|
|
746
|
+
parser.add_argument(
|
|
747
|
+
"--limit",
|
|
748
|
+
type=int,
|
|
749
|
+
default=None,
|
|
750
|
+
metavar="KB",
|
|
751
|
+
help="override AlwaysOnBudgetKb for this run only",
|
|
752
|
+
)
|
|
753
|
+
parser.set_defaults(func=cmd_budget)
|