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.
Files changed (65) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/docs/architecture.md +1 -34
  3. package/docs/direction.md +1 -1
  4. package/docs/hosts.md +13 -9
  5. package/package.json +1 -1
  6. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  7. package/presets/codex/.codex-plugin/plugin.json +1 -1
  8. package/presets/dsh/plugins/workflow-state.js +31 -16
  9. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  10. package/presets/opencode/cowork-flow/plugin-core.js +42 -87
  11. package/presets/opencode/plugins/cowork-flow.js +7 -9
  12. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  13. package/presets/qoder/hooks/hooks.json +1 -1
  14. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  15. package/src/commands/host.js +23 -2
  16. package/src/commands/registry.js +4 -2
  17. package/template/.cowork-flow/.version +1 -1
  18. package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -1
  19. package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -1
  20. package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -1
  21. package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -1
  22. package/template/.cowork-flow/adapters/qoder/adapter.yaml +1 -1
  23. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +111 -7
  24. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +0 -1
  25. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
  26. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
  27. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
  28. package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
  29. package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
  30. package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
  31. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
  32. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
  33. package/template/.cowork-flow/scripts/run.py +0 -2
  34. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -0
  35. package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
  36. package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
  37. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
  38. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
  39. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
  40. package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
  41. package/template/.cowork-flow/scripts/services/workflow_runtime.py +140 -0
  42. package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
  43. package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
  44. package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
  45. package/template/.cowork-flow/spec/contracts/error-codes.md +4 -5
  46. package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
  47. package/template/.cowork-flow/spec/contracts/index.md +1 -2
  48. package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
  49. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +31 -22
  50. package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
  51. package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
  52. package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
  53. package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
  54. package/template/.opencode/cowork-flow/plugin-core.js +42 -87
  55. package/template/.opencode/plugins/cowork-flow.js +7 -9
  56. package/template/AGENTS.md +0 -1
  57. package/template/skills/agent-dispatch/SKILL.md +2 -15
  58. package/template/skills/cowork-flow/SKILL.md +9 -0
  59. package/template/skills/runtime-health/scripts/doctor.py +57 -49
  60. package/template/skills/spec-sync/SKILL.md +0 -1
  61. package/template/skills/task-planning/SKILL.md +1 -1
  62. package/template/skills/task-review/SKILL.md +3 -3
  63. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
  64. package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
  65. 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": 1200,
27
+ "budget": 3400,
28
28
  "scopeLimit": 8,
29
29
  "specLimit": 4,
30
- "verifyLimit": 3,
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 = 5
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 = 3
229
- STAGE_CONTRACT_BUDGET = 1200
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 (self-declared via task context add)"
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 survive. Order:
261
- 1) drop removable rows (Specs/Verify) from the tail; 2) shrink Scope
262
- entries one by one (min 1); 3) last-resort codepoint cut that keeps the
263
- closing tag. Single renderer: the host shims transport this output
264
- unchanged, so the row-role rules and the drop order live here only."""
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
- closing = lines[-1]
293
- # The final join inserts one newline between the cut body
294
- # and the closing tag; reserve it so the block stays
295
- # within budget byte-for-byte.
296
- room = budget - len(closing) - 1
297
- body = "\n".join(lines[:-1])
298
- if len(body) <= room:
299
- return lines
300
- head = body[:room].rstrip()
301
- if not head:
302
- return lines
303
- return [head, closing]
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. The item list is capped so a
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 spec-check, file-scope and acceptance-row gates stay in force,
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
- spec_report = getattr(check_result, "spec_check", None)
753
- if isinstance(spec_report, dict):
754
- # Overwrite-on-each-completion telemetry: retry loops must
755
- # not accumulate, the latest gate result wins.
756
- meta["specCheckSummary"] = dict(
757
- spec_report.get("summary") or {}
758
- )
759
- # Resolved here rather than reusing resolved_executor: the
760
- # complete stage does not activate a session, so that one is
761
- # always None and the actor would never be recorded.
762
- payloads = exemption_meta_payloads(
763
- check_result,
764
- executor=self._resolve_executor(executor),
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