cowork-flow 1.9.1 → 2.0.0
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 +31 -1
- package/docs/architecture.md +1 -34
- package/docs/direction.md +1 -1
- package/docs/hosts.md +13 -9
- package/package.json +1 -1
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/dsh/plugins/workflow-state.js +31 -16
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/opencode/cowork-flow/plugin-core.js +42 -87
- package/presets/opencode/plugins/cowork-flow.js +7 -9
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/qoder/hooks/hooks.json +1 -1
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/src/commands/host.js +23 -2
- package/src/commands/registry.js +4 -2
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -1
- package/template/.cowork-flow/adapters/qoder/adapter.yaml +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +111 -7
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +0 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
- package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
- package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
- package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
- package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
- package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
- package/template/.cowork-flow/scripts/run.py +0 -2
- package/template/.cowork-flow/scripts/runtime/session_state.py +19 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
- package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
- package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +140 -0
- package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
- package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
- package/template/.cowork-flow/spec/contracts/error-codes.md +4 -5
- package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
- package/template/.cowork-flow/spec/contracts/index.md +1 -2
- package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
- package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +31 -22
- package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
- package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
- package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
- package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
- package/template/.opencode/cowork-flow/plugin-core.js +42 -87
- package/template/.opencode/plugins/cowork-flow.js +7 -9
- package/template/AGENTS.md +0 -1
- package/template/skills/agent-dispatch/SKILL.md +2 -15
- package/template/skills/cowork-flow/SKILL.md +9 -0
- package/template/skills/runtime-health/scripts/doctor.py +57 -49
- package/template/skills/spec-sync/SKILL.md +0 -1
- package/template/skills/task-planning/SKILL.md +1 -1
- package/template/skills/task-review/SKILL.md +3 -3
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
- package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
- package/template/.cowork-flow/spec/contracts/spec-checks.md +0 -281
|
@@ -32,7 +32,6 @@ COMMAND_SCRIPTS = {
|
|
|
32
32
|
"state": "adapters/cli/state.py",
|
|
33
33
|
"mcp-state": "adapters/mcp/state_server.py",
|
|
34
34
|
"subagent": "adapters/cli/subagent.py",
|
|
35
|
-
"spec-check": "adapters/cli/spec_check.py",
|
|
36
35
|
}
|
|
37
36
|
RESERVED_COMMAND_NAMES = (*COMMAND_SCRIPTS, "python", "help", "-h", "--help")
|
|
38
37
|
|
|
@@ -51,7 +50,6 @@ Common commands:
|
|
|
51
50
|
resume
|
|
52
51
|
task
|
|
53
52
|
state
|
|
54
|
-
spec-check
|
|
55
53
|
get-context
|
|
56
54
|
get-developer
|
|
57
55
|
init-developer
|
|
@@ -207,6 +207,25 @@ def _read_json(path: Path) -> dict:
|
|
|
207
207
|
return data if isinstance(data, dict) else {}
|
|
208
208
|
|
|
209
209
|
|
|
210
|
+
def iter_subagent_contexts(repo_root: Path) -> list[dict]:
|
|
211
|
+
"""Every readable subagent runtime context, ordered by context id.
|
|
212
|
+
|
|
213
|
+
The id falls back to the file name when the record omits it, so a
|
|
214
|
+
record still lists under the id its callers already know.
|
|
215
|
+
"""
|
|
216
|
+
directory = subagent_contexts_dir(repo_root)
|
|
217
|
+
if not directory.is_dir():
|
|
218
|
+
return []
|
|
219
|
+
contexts: list[dict] = []
|
|
220
|
+
for path in sorted(directory.glob("*.json")):
|
|
221
|
+
data = _read_json(path)
|
|
222
|
+
if not data:
|
|
223
|
+
continue
|
|
224
|
+
data.setdefault("runtime_context_id", path.stem)
|
|
225
|
+
contexts.append(data)
|
|
226
|
+
return contexts
|
|
227
|
+
|
|
228
|
+
|
|
210
229
|
def _write_json(
|
|
211
230
|
path: Path,
|
|
212
231
|
data: dict,
|
|
@@ -24,10 +24,10 @@ DEFAULT_SCOPE_RULES: dict = {
|
|
|
24
24
|
"trailingSlashRejectedTypes": ["planned-file", "deleted-file"],
|
|
25
25
|
},
|
|
26
26
|
"stageContract": {
|
|
27
|
-
"budget":
|
|
27
|
+
"budget": 3400,
|
|
28
28
|
"scopeLimit": 8,
|
|
29
29
|
"specLimit": 4,
|
|
30
|
-
"verifyLimit":
|
|
30
|
+
"verifyLimit": 10,
|
|
31
31
|
},
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -19,7 +19,7 @@ from typing import Any
|
|
|
19
19
|
|
|
20
20
|
GOAL_TRUNCATE = 300
|
|
21
21
|
VERIFY_COMMAND_TRUNCATE = 120
|
|
22
|
-
VERIFY_COMMAND_MAX =
|
|
22
|
+
VERIFY_COMMAND_MAX = 10
|
|
23
23
|
SCOPE_BOUNDARY_TRUNCATE = 160
|
|
24
24
|
ANCHOR_SECTIONS = ("目标", "验收标准", "被拒方案", "验证命令", "范围边界")
|
|
25
25
|
ACCEPTANCE_RE = re.compile(
|
|
@@ -225,12 +225,12 @@ def spec_entries_for_task(
|
|
|
225
225
|
|
|
226
226
|
STAGE_CONTRACT_SCOPE_LIMIT = 8
|
|
227
227
|
STAGE_CONTRACT_SPECS_LIMIT = 4
|
|
228
|
-
STAGE_CONTRACT_VERIFY_LIMIT =
|
|
229
|
-
STAGE_CONTRACT_BUDGET =
|
|
228
|
+
STAGE_CONTRACT_VERIFY_LIMIT = 10
|
|
229
|
+
STAGE_CONTRACT_BUDGET = 3400
|
|
230
230
|
GATES_TEXT = (
|
|
231
231
|
"Gates: edits outside Scope are review blockers; CLAUDE.md and workflow "
|
|
232
232
|
"files are protected; spec/ edits may be allowed by review policy; "
|
|
233
|
-
"scope is agent-mutable (
|
|
233
|
+
"scope is agent-mutable (declare more in implement.jsonl)"
|
|
234
234
|
)
|
|
235
235
|
# Delegated subtasks render the parent scope as a read-only
|
|
236
236
|
# reference: a child must not believe it can self-declare
|
|
@@ -257,11 +257,14 @@ def _fit_stage_contract(
|
|
|
257
257
|
budget: int = STAGE_CONTRACT_BUDGET,
|
|
258
258
|
) -> list[str]:
|
|
259
259
|
"""Degrade an over-budget block without ever emitting a malformed one:
|
|
260
|
-
the closing tag and the guard rows (Scope/Gates) always
|
|
261
|
-
1) drop removable rows (Specs/Verify) from the
|
|
262
|
-
entries one by one (min 1); 3)
|
|
263
|
-
|
|
264
|
-
|
|
260
|
+
the opening tag, the closing tag and the guard rows (Scope/Gates) always
|
|
261
|
+
survive intact. Order: 1) drop removable rows (Specs/Verify) from the
|
|
262
|
+
tail; 2) shrink Scope entries one by one (min 1); 3) `_cut_scope_rows`
|
|
263
|
+
shortens the Scope rows' own text, and nothing else -- it returns None
|
|
264
|
+
when even that cannot keep the block usable, and the block is then
|
|
265
|
+
returned as is, over budget (guards and parseability outrank it).
|
|
266
|
+
Single renderer: the host shims transport this output unchanged, so the
|
|
267
|
+
row-role rules and the drop order live here only."""
|
|
265
268
|
if len("\n".join(lines)) <= budget:
|
|
266
269
|
return lines
|
|
267
270
|
removable = [
|
|
@@ -289,18 +292,43 @@ def _fit_stage_contract(
|
|
|
289
292
|
lines = candidate
|
|
290
293
|
if len("\n".join(lines)) <= budget:
|
|
291
294
|
return lines
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
295
|
+
cut = _cut_scope_rows(lines, budget)
|
|
296
|
+
return lines if cut is None else cut
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _cut_scope_rows(lines: list[str], budget: int) -> list[str] | None:
|
|
300
|
+
"""Last resort: shorten the text of every Scope row so the block fits.
|
|
301
|
+
|
|
302
|
+
Returns None -- the caller then returns the block whole and lets it
|
|
303
|
+
exceed the budget -- in three cases: there is no Scope row to cut at all
|
|
304
|
+
(a shape the renderer never emits), the fixed rows plus a minimal
|
|
305
|
+
`Scope: X` row do not fit, or the cut would shave a `Scope: ` label off a
|
|
306
|
+
row (`rstrip` landing inside the entry's leading whitespace). A body-wide
|
|
307
|
+
cut is never allowed: it would truncate the tag every consumer matches
|
|
308
|
+
on, and a label-less row counts as a dropped guard row. The floor is not
|
|
309
|
+
redundant with the label check -- a negative `room` slices from the row's
|
|
310
|
+
tail, so without it a long row would go through chopped and still over
|
|
311
|
+
budget. Guard rows and parseability outrank the budget.
|
|
312
|
+
"""
|
|
313
|
+
fixed = [line for line in lines if not line.startswith("Scope:")]
|
|
314
|
+
scope_count = len(lines) - len(fixed)
|
|
315
|
+
if scope_count == 0:
|
|
316
|
+
return None
|
|
317
|
+
# Every Scope row is shortened to the same width, so the room left after
|
|
318
|
+
# the fixed rows and the separators is shared between them.
|
|
319
|
+
room = (budget - len("\n".join(fixed)) - scope_count) // scope_count
|
|
320
|
+
if room < len("Scope: ") + 1:
|
|
321
|
+
return None
|
|
322
|
+
cut = [
|
|
323
|
+
line[:room].rstrip() if line.startswith("Scope:") else line
|
|
324
|
+
for line in lines
|
|
325
|
+
]
|
|
326
|
+
if any(
|
|
327
|
+
original.startswith("Scope:") and not shortened.startswith("Scope: ")
|
|
328
|
+
for original, shortened in zip(lines, cut)
|
|
329
|
+
):
|
|
330
|
+
return None
|
|
331
|
+
return cut
|
|
304
332
|
|
|
305
333
|
|
|
306
334
|
def _stage_contract_lines(
|
|
@@ -46,13 +46,8 @@ PROTECTED_WORKFLOW_PATTERNS = (
|
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
# A recorded exemption names what it waived: the flag that allowed it, the
|
|
49
|
-
# actor who applied it, and the concrete items.
|
|
50
|
-
# pathological repo cannot turn one completion into pages of output, and a
|
|
51
|
-
# single command is clipped so one long declaration cannot do the same.
|
|
52
|
-
SPEC_EXEMPTION_FLAG = "--allow-unchecked"
|
|
49
|
+
# actor who applied it, and the concrete items.
|
|
53
50
|
EVIDENCE_EXEMPTION_FLAG = "--allow-missing-evidence"
|
|
54
|
-
SPEC_EXEMPTION_ITEM_LIMIT = 10
|
|
55
|
-
SPEC_EXEMPTION_COMMAND_LIMIT = 80
|
|
56
51
|
|
|
57
52
|
|
|
58
53
|
@dataclass(frozen=True)
|
|
@@ -71,9 +66,7 @@ class LifecycleCheckResult:
|
|
|
71
66
|
stage: str
|
|
72
67
|
blockers: tuple[str, ...] = ()
|
|
73
68
|
issues: tuple[LifecycleCheckIssue, ...] = ()
|
|
74
|
-
spec_check: dict | None = None
|
|
75
69
|
evidence_exemption: dict | None = None
|
|
76
|
-
spec_exemption: dict | None = None
|
|
77
70
|
|
|
78
71
|
def __post_init__(self) -> None:
|
|
79
72
|
if self.issues and not self.blockers:
|
|
@@ -124,20 +117,9 @@ class LifecycleCheckRunner:
|
|
|
124
117
|
task_dir: Path,
|
|
125
118
|
*,
|
|
126
119
|
allow_spec_file_modifications: bool | None = None,
|
|
127
|
-
allow_unchecked_specs: bool | None = None,
|
|
128
120
|
allow_missing_evidence: bool | None = None,
|
|
129
121
|
execution_policy: LifecycleExecutionPolicy | None = None,
|
|
130
122
|
) -> LifecycleCheckResult:
|
|
131
|
-
spec_report = _spec_check_report(self.repo_root)
|
|
132
|
-
allow_unchecked = (
|
|
133
|
-
bool(allow_unchecked_specs)
|
|
134
|
-
if allow_unchecked_specs is not None
|
|
135
|
-
else _policy_allows_unchecked(execution_policy)
|
|
136
|
-
)
|
|
137
|
-
spec_issues = _spec_check_completion_issues(
|
|
138
|
-
spec_report,
|
|
139
|
-
allow_unchecked_specs=allow_unchecked,
|
|
140
|
-
)
|
|
141
123
|
ac_issues, evidence_exemption = _ac_evidence_completion_facts(
|
|
142
124
|
task_dir,
|
|
143
125
|
allow_missing_evidence=(
|
|
@@ -161,15 +143,9 @@ class LifecycleCheckRunner:
|
|
|
161
143
|
self.repo_root,
|
|
162
144
|
task_dir,
|
|
163
145
|
))
|
|
164
|
-
+ list(spec_issues)
|
|
165
146
|
+ list(ac_issues)
|
|
166
147
|
),
|
|
167
|
-
spec_check=spec_report,
|
|
168
148
|
evidence_exemption=evidence_exemption,
|
|
169
|
-
spec_exemption=_spec_check_exemption(
|
|
170
|
-
spec_report,
|
|
171
|
-
allow_unchecked_specs=allow_unchecked,
|
|
172
|
-
),
|
|
173
149
|
)
|
|
174
150
|
|
|
175
151
|
|
|
@@ -182,12 +158,6 @@ def _policy_allows_spec_changes(
|
|
|
182
158
|
return bool(allow_spec_file_modifications)
|
|
183
159
|
|
|
184
160
|
|
|
185
|
-
def _policy_allows_unchecked(
|
|
186
|
-
execution_policy: LifecycleExecutionPolicy | None,
|
|
187
|
-
) -> bool:
|
|
188
|
-
return bool(execution_policy is not None and execution_policy.allow_unchecked_specs)
|
|
189
|
-
|
|
190
|
-
|
|
191
161
|
def _policy_allows_missing_evidence(
|
|
192
162
|
execution_policy: LifecycleExecutionPolicy | None,
|
|
193
163
|
) -> bool:
|
|
@@ -299,83 +269,6 @@ def _ac_evidence_completion_facts(
|
|
|
299
269
|
return issues, exemption
|
|
300
270
|
|
|
301
271
|
|
|
302
|
-
def _spec_check_report(repo_root: Path) -> dict | None:
|
|
303
|
-
"""Run the lifecycle-phase spec checks. A structural failure degrades to
|
|
304
|
-
an unchecked report — never to a silently passing gate."""
|
|
305
|
-
try:
|
|
306
|
-
from services.spec_check import run_checks
|
|
307
|
-
except Exception: # pragma: no cover - import surface must stay lazy
|
|
308
|
-
return {"schemaVersion": 1, "phase": "lifecycle", "results": [],
|
|
309
|
-
"parseErrors": [], "summary": {"pass": 0, "violation": 0,
|
|
310
|
-
"unchecked": 1, "executorError": 1,
|
|
311
|
-
"classification": "executor-failure"}}
|
|
312
|
-
try:
|
|
313
|
-
return run_checks(repo_root, phase="lifecycle")
|
|
314
|
-
except Exception as error: # fail closed: unchecked, not pass
|
|
315
|
-
return {"schemaVersion": 1, "phase": "lifecycle", "results": [],
|
|
316
|
-
"parseErrors": [], "summary": {"pass": 0, "violation": 0,
|
|
317
|
-
"unchecked": 1, "executorError": 1, "error": str(error),
|
|
318
|
-
"classification": "executor-failure"}}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def _exemption_command(cmd: object) -> str:
|
|
322
|
-
"""One declared command, clipped so a long line cannot swamp the output.
|
|
323
|
-
|
|
324
|
-
The clip never splits the first token: that token names the program, which
|
|
325
|
-
is the part that says what could not be verified.
|
|
326
|
-
"""
|
|
327
|
-
text = " ".join(str(cmd or "").split())
|
|
328
|
-
if len(text) <= SPEC_EXEMPTION_COMMAND_LIMIT:
|
|
329
|
-
return text
|
|
330
|
-
head = text.split(" ", 1)[0]
|
|
331
|
-
return text[: max(SPEC_EXEMPTION_COMMAND_LIMIT, len(head))] + "…"
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _unchecked_exemption_items(spec_report: dict) -> tuple[list[dict], bool]:
|
|
335
|
-
"""The unchecked declarations, capped, plus whether anything was dropped."""
|
|
336
|
-
items = [
|
|
337
|
-
{
|
|
338
|
-
"spec": item.get("spec") or "?",
|
|
339
|
-
"cmd": _exemption_command(item.get("cmd")),
|
|
340
|
-
}
|
|
341
|
-
for item in spec_report.get("results") or []
|
|
342
|
-
if isinstance(item, dict) and item.get("status") == "unchecked"
|
|
343
|
-
]
|
|
344
|
-
return items[:SPEC_EXEMPTION_ITEM_LIMIT], len(items) > SPEC_EXEMPTION_ITEM_LIMIT
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def _spec_check_exemption(
|
|
348
|
-
spec_report: dict | None,
|
|
349
|
-
*,
|
|
350
|
-
allow_unchecked_specs: bool,
|
|
351
|
-
) -> dict | None:
|
|
352
|
-
"""The exemption this completion applied, or None.
|
|
353
|
-
|
|
354
|
-
Single source for both the persisted telemetry and the delivery output:
|
|
355
|
-
a waived gate that is recorded but never shown reads as a clean pass, and
|
|
356
|
-
one that is shown as a bare count reads as a clean pass to whoever has to
|
|
357
|
-
check it afterwards. An executor failure is never exemptible, so it never
|
|
358
|
-
yields one.
|
|
359
|
-
"""
|
|
360
|
-
if not spec_report or not allow_unchecked_specs:
|
|
361
|
-
return None
|
|
362
|
-
summary = spec_report.get("summary") or {}
|
|
363
|
-
if int(summary.get("executorError") or 0):
|
|
364
|
-
return None
|
|
365
|
-
unchecked = int(summary.get("unchecked") or 0)
|
|
366
|
-
if not unchecked:
|
|
367
|
-
return None
|
|
368
|
-
items, truncated = _unchecked_exemption_items(spec_report)
|
|
369
|
-
exemption = {
|
|
370
|
-
"unchecked": unchecked,
|
|
371
|
-
"uncheckedItems": items,
|
|
372
|
-
"flag": SPEC_EXEMPTION_FLAG,
|
|
373
|
-
}
|
|
374
|
-
if truncated:
|
|
375
|
-
exemption["truncated"] = True
|
|
376
|
-
return exemption
|
|
377
|
-
|
|
378
|
-
|
|
379
272
|
def _actor_text(actor: object) -> str:
|
|
380
273
|
"""The recorded actor, or `null` — never a stand-in identity."""
|
|
381
274
|
return "null" if actor is None else str(actor)
|
|
@@ -390,23 +283,6 @@ def exemption_report_lines(meta: dict | None) -> list[str]:
|
|
|
390
283
|
"""
|
|
391
284
|
meta = meta if isinstance(meta, dict) else {}
|
|
392
285
|
lines: list[str] = []
|
|
393
|
-
spec_exempt = meta.get("specCheckExempt")
|
|
394
|
-
if isinstance(spec_exempt, dict):
|
|
395
|
-
lines.append(
|
|
396
|
-
f"Exempted: {spec_exempt.get('unchecked', 0)} unchecked "
|
|
397
|
-
f"spec-check command(s) via {spec_exempt.get('flag')} "
|
|
398
|
-
"(recorded in task.json meta.specCheckExempt, "
|
|
399
|
-
f"by {_actor_text(spec_exempt.get('by'))})"
|
|
400
|
-
)
|
|
401
|
-
items = spec_exempt.get("uncheckedItems") or []
|
|
402
|
-
for item in items:
|
|
403
|
-
if isinstance(item, dict):
|
|
404
|
-
lines.append(f" - {item.get('spec')}: {item.get('cmd')}")
|
|
405
|
-
if spec_exempt.get("truncated"):
|
|
406
|
-
lines.append(
|
|
407
|
-
f" - (more than {len(items)} unchecked commands; the list is "
|
|
408
|
-
"truncated — see meta.specCheckExempt.uncheckedItems)"
|
|
409
|
-
)
|
|
410
286
|
evidence_exempt = meta.get("evidenceExempt")
|
|
411
287
|
if isinstance(evidence_exempt, dict):
|
|
412
288
|
missing = evidence_exempt.get("missing") or []
|
|
@@ -436,103 +312,12 @@ def exemption_meta_payloads(
|
|
|
436
312
|
such as the creator or assignee.
|
|
437
313
|
"""
|
|
438
314
|
payloads: dict[str, dict] = {}
|
|
439
|
-
spec_exemption = getattr(check_result, "spec_exemption", None)
|
|
440
|
-
if spec_exemption:
|
|
441
|
-
payloads["specCheckExempt"] = {"by": executor, **spec_exemption}
|
|
442
315
|
evidence_exemption = getattr(check_result, "evidence_exemption", None)
|
|
443
316
|
if evidence_exemption:
|
|
444
317
|
payloads["evidenceExempt"] = {"by": executor, **evidence_exemption}
|
|
445
318
|
return payloads
|
|
446
319
|
|
|
447
320
|
|
|
448
|
-
def _spec_check_parse_error_issue(parse_errors: list) -> LifecycleCheckIssue | None:
|
|
449
|
-
"""The blocking fact for declarations the parser could not read."""
|
|
450
|
-
if not parse_errors:
|
|
451
|
-
return None
|
|
452
|
-
details = []
|
|
453
|
-
for item in parse_errors:
|
|
454
|
-
if isinstance(item, dict):
|
|
455
|
-
spec = item.get("spec") or "?"
|
|
456
|
-
error = item.get("error") or "unknown parse error"
|
|
457
|
-
details.append(f"{spec}: {error}")
|
|
458
|
-
suffix = "; ".join(details[:3])
|
|
459
|
-
return LifecycleCheckIssue(
|
|
460
|
-
code="SPEC-CHECK-PARSE-ERROR",
|
|
461
|
-
message=(
|
|
462
|
-
f"spec checks report {len(parse_errors)} parse error(s)"
|
|
463
|
-
+ (f": {suffix}" if suffix else "")
|
|
464
|
-
),
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def _spec_check_executor_issue(summary: dict) -> LifecycleCheckIssue | None:
|
|
469
|
-
"""The blocking fact for a spec-check executor that never ran.
|
|
470
|
-
|
|
471
|
-
Not waivable: --allow-unchecked exempts a command that could not run, not
|
|
472
|
-
an executor that is broken. The `unchecked: 1` in a degraded report is a
|
|
473
|
-
placeholder for "no verdict", so it never becomes a finding of its own.
|
|
474
|
-
"""
|
|
475
|
-
if not int(summary.get("executorError") or 0):
|
|
476
|
-
return None
|
|
477
|
-
return LifecycleCheckIssue(
|
|
478
|
-
code="SPEC-CHECK-EXECUTOR-ERROR",
|
|
479
|
-
message=(
|
|
480
|
-
"spec-check executor failed; the declared checks did not run, so "
|
|
481
|
-
"this is not an exemption-eligible unchecked result — fix the "
|
|
482
|
-
"spec-check executor and rerun"
|
|
483
|
-
),
|
|
484
|
-
)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
def _spec_check_completion_issues(
|
|
488
|
-
spec_report: dict | None,
|
|
489
|
-
*,
|
|
490
|
-
allow_unchecked_specs: bool,
|
|
491
|
-
) -> list[LifecycleCheckIssue]:
|
|
492
|
-
if not spec_report:
|
|
493
|
-
return []
|
|
494
|
-
summary = spec_report.get("summary") or {}
|
|
495
|
-
issues: list[LifecycleCheckIssue] = []
|
|
496
|
-
parse_error_issue = _spec_check_parse_error_issue(
|
|
497
|
-
spec_report.get("parseErrors") or []
|
|
498
|
-
)
|
|
499
|
-
if parse_error_issue is not None:
|
|
500
|
-
issues.append(parse_error_issue)
|
|
501
|
-
executor_issue = _spec_check_executor_issue(summary)
|
|
502
|
-
if executor_issue is not None:
|
|
503
|
-
issues.append(executor_issue)
|
|
504
|
-
violation = int(summary.get("violation") or 0)
|
|
505
|
-
unchecked = int(summary.get("unchecked") or 0)
|
|
506
|
-
if violation:
|
|
507
|
-
first_lines = []
|
|
508
|
-
for item in spec_report.get("results") or []:
|
|
509
|
-
if item.get("status") == "violation":
|
|
510
|
-
detail = item.get("firstViolationLine") or item.get("reason") or ""
|
|
511
|
-
first_lines.append(f"{item.get('spec')}: {detail}")
|
|
512
|
-
issues.append(
|
|
513
|
-
LifecycleCheckIssue(
|
|
514
|
-
code="SPEC-CHECK-VIOLATION",
|
|
515
|
-
message=(
|
|
516
|
-
"spec checks report "
|
|
517
|
-
f"{violation} violation(s): {'; '.join(first_lines[:3])}"
|
|
518
|
-
),
|
|
519
|
-
)
|
|
520
|
-
)
|
|
521
|
-
if unchecked and executor_issue is None and not allow_unchecked_specs:
|
|
522
|
-
issues.append(
|
|
523
|
-
LifecycleCheckIssue(
|
|
524
|
-
code="SPEC-CHECK-UNCHECKED",
|
|
525
|
-
message=(
|
|
526
|
-
f"spec checks report {unchecked} unchecked command(s) "
|
|
527
|
-
"(missing/timeout); rerun spec-check, or pass "
|
|
528
|
-
"--allow-unchecked to complete with the exemption recorded"
|
|
529
|
-
),
|
|
530
|
-
)
|
|
531
|
-
)
|
|
532
|
-
return issues
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
321
|
def task_changed_paths(repo_root: Path, task_dir: Path) -> tuple[list[str], bool]:
|
|
537
322
|
"""Review change set for one task: baseline..HEAD merged with the
|
|
538
323
|
working tree, plus whether the snapshot degraded to status-only."""
|
|
@@ -35,7 +35,6 @@ class LifecycleExecutionPolicy:
|
|
|
35
35
|
|
|
36
36
|
execution_scope: str
|
|
37
37
|
allow_spec_file_modifications: bool
|
|
38
|
-
allow_unchecked_specs: bool = False
|
|
39
38
|
allow_missing_evidence: bool = False
|
|
40
39
|
|
|
41
40
|
|
|
@@ -106,20 +105,17 @@ def resolve_execution_policy(
|
|
|
106
105
|
execution_context: ExecutionContext | None = None,
|
|
107
106
|
*,
|
|
108
107
|
allow_spec_file_modifications: bool | None = None,
|
|
109
|
-
allow_unchecked_specs: bool | None = None,
|
|
110
108
|
allow_missing_evidence: bool | None = None,
|
|
111
109
|
) -> LifecycleExecutionPolicy:
|
|
112
110
|
"""Resolve lifecycle scope capability from execution facts."""
|
|
113
111
|
if execution_context is None:
|
|
114
112
|
if (
|
|
115
113
|
allow_spec_file_modifications is not None
|
|
116
|
-
or allow_unchecked_specs is not None
|
|
117
114
|
or allow_missing_evidence is not None
|
|
118
115
|
):
|
|
119
116
|
return LifecycleExecutionPolicy(
|
|
120
117
|
execution_scope="explicit",
|
|
121
118
|
allow_spec_file_modifications=bool(allow_spec_file_modifications),
|
|
122
|
-
allow_unchecked_specs=bool(allow_unchecked_specs),
|
|
123
119
|
allow_missing_evidence=bool(allow_missing_evidence),
|
|
124
120
|
)
|
|
125
121
|
execution_context = ExecutionContext()
|
|
@@ -133,7 +129,6 @@ def resolve_execution_policy(
|
|
|
133
129
|
return LifecycleExecutionPolicy(
|
|
134
130
|
execution_scope=execution_context.mode,
|
|
135
131
|
allow_spec_file_modifications=allowed,
|
|
136
|
-
allow_unchecked_specs=bool(allow_unchecked_specs),
|
|
137
132
|
allow_missing_evidence=bool(allow_missing_evidence),
|
|
138
133
|
)
|
|
139
134
|
|
|
@@ -64,7 +64,7 @@ def is_light_task(task_data: dict) -> bool:
|
|
|
64
64
|
|
|
65
65
|
A light task still runs under the strict profile: it skips the review
|
|
66
66
|
receipt, the acceptance-evidence and the dev_type gates, and nothing
|
|
67
|
-
else. The
|
|
67
|
+
else. The file-scope and acceptance-row gates stay in force,
|
|
68
68
|
so this is a narrower reading list rather than an exemption channel.
|
|
69
69
|
"""
|
|
70
70
|
if resolve_gate_profile(task_data) != GATE_PROFILE_STRICT:
|
|
@@ -234,7 +234,6 @@ class TaskLifecycleService:
|
|
|
234
234
|
*,
|
|
235
235
|
completed_at: str | None = None,
|
|
236
236
|
allow_spec_file_modifications: bool | None = None,
|
|
237
|
-
allow_unchecked_specs: bool = False,
|
|
238
237
|
allow_missing_evidence: bool = False,
|
|
239
238
|
execution_context: object | None = None,
|
|
240
239
|
) -> LifecycleResult:
|
|
@@ -243,7 +242,6 @@ class TaskLifecycleService:
|
|
|
243
242
|
task,
|
|
244
243
|
completed_at=completed_at,
|
|
245
244
|
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
246
|
-
allow_unchecked_specs=allow_unchecked_specs,
|
|
247
245
|
allow_missing_evidence=allow_missing_evidence,
|
|
248
246
|
execution_context=execution_context,
|
|
249
247
|
)
|
|
@@ -255,7 +253,6 @@ class TaskLifecycleService:
|
|
|
255
253
|
*,
|
|
256
254
|
preflight: Preflight | None = None,
|
|
257
255
|
allow_spec_file_modifications: bool | None = None,
|
|
258
|
-
allow_unchecked_specs: bool = False,
|
|
259
256
|
allow_missing_evidence: bool = False,
|
|
260
257
|
completed_at: str | None = None,
|
|
261
258
|
execution_context: object | None = None,
|
|
@@ -268,7 +265,6 @@ class TaskLifecycleService:
|
|
|
268
265
|
self.repo_root,
|
|
269
266
|
execution_context,
|
|
270
267
|
allow_spec_file_modifications=allow_spec_file_modifications,
|
|
271
|
-
allow_unchecked_specs=allow_unchecked_specs,
|
|
272
268
|
allow_missing_evidence=allow_missing_evidence,
|
|
273
269
|
)
|
|
274
270
|
prepared = self._prepare_transition(
|
|
@@ -667,7 +663,6 @@ class TaskLifecycleService:
|
|
|
667
663
|
allow_spec_file_modifications=(
|
|
668
664
|
execution_policy.allow_spec_file_modifications
|
|
669
665
|
),
|
|
670
|
-
allow_unchecked_specs=execution_policy.allow_unchecked_specs,
|
|
671
666
|
allow_missing_evidence=execution_policy.allow_missing_evidence,
|
|
672
667
|
execution_policy=execution_policy,
|
|
673
668
|
)
|
|
@@ -749,32 +744,20 @@ class TaskLifecycleService:
|
|
|
749
744
|
persisted["meta"] = meta
|
|
750
745
|
if stage.name == COMPLETE_STAGE.name:
|
|
751
746
|
meta = dict(persisted.get("meta") or {})
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
if "evidenceExempt" in payloads:
|
|
767
|
-
meta["evidenceExempt"] = {
|
|
768
|
-
"at": datetime.now(timezone.utc).strftime(
|
|
769
|
-
"%Y-%m-%dT%H:%M:%SZ"
|
|
770
|
-
),
|
|
771
|
-
**payloads["evidenceExempt"],
|
|
772
|
-
}
|
|
773
|
-
if "specCheckExempt" in payloads:
|
|
774
|
-
meta["specCheckExempt"] = {
|
|
775
|
-
"at": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
|
776
|
-
**payloads["specCheckExempt"],
|
|
777
|
-
}
|
|
747
|
+
# Resolved here rather than reusing resolved_executor: the
|
|
748
|
+
# complete stage does not activate a session, so that one is
|
|
749
|
+
# always None and the actor would never be recorded.
|
|
750
|
+
payloads = exemption_meta_payloads(
|
|
751
|
+
check_result,
|
|
752
|
+
executor=self._resolve_executor(executor),
|
|
753
|
+
)
|
|
754
|
+
if "evidenceExempt" in payloads:
|
|
755
|
+
meta["evidenceExempt"] = {
|
|
756
|
+
"at": datetime.now(timezone.utc).strftime(
|
|
757
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
758
|
+
),
|
|
759
|
+
**payloads["evidenceExempt"],
|
|
760
|
+
}
|
|
778
761
|
persisted["meta"] = meta
|
|
779
762
|
return persisted
|
|
780
763
|
|