cowork-flow 1.9.2 → 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 (61) hide show
  1. package/CHANGELOG.md +23 -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/task_lifecycle_commands.py +0 -1
  24. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +0 -7
  25. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +6 -4
  26. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +8 -6
  27. package/template/.cowork-flow/scripts/adapters/host/edit_scope.py +113 -0
  28. package/template/.cowork-flow/scripts/adapters/host/opencode_policy.py +12 -0
  29. package/template/.cowork-flow/scripts/adapters/host/qoder_policy.py +7 -5
  30. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +0 -91
  31. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +15 -77
  32. package/template/.cowork-flow/scripts/run.py +0 -2
  33. package/template/.cowork-flow/scripts/services/context_paths.py +2 -2
  34. package/template/.cowork-flow/scripts/services/fact_view.py +49 -21
  35. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +1 -216
  36. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +0 -5
  37. package/template/.cowork-flow/scripts/services/task_gate_profiles.py +1 -1
  38. package/template/.cowork-flow/scripts/services/task_lifecycle.py +14 -31
  39. package/template/.cowork-flow/spec/backend/error-handling.md +0 -14
  40. package/template/.cowork-flow/spec/contracts/context-injection.md +49 -22
  41. package/template/.cowork-flow/spec/contracts/decision-anchor.md +3 -3
  42. package/template/.cowork-flow/spec/contracts/error-codes.md +1 -3
  43. package/template/.cowork-flow/spec/contracts/evidence-completion.md +6 -8
  44. package/template/.cowork-flow/spec/contracts/index.md +0 -1
  45. package/template/.cowork-flow/spec/contracts/plan-binding.md +3 -2
  46. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +6 -21
  47. package/template/.cowork-flow/spec/contracts/task-adopt.md +1 -1
  48. package/template/.cowork-flow/spec/runtime/contract-registry.json +0 -13
  49. package/template/.cowork-flow/spec/runtime/host-assets.json +10 -7
  50. package/template/.cowork-flow/spec/runtime/scope-rules.json +2 -2
  51. package/template/.opencode/cowork-flow/plugin-core.js +42 -87
  52. package/template/.opencode/plugins/cowork-flow.js +7 -9
  53. package/template/AGENTS.md +0 -1
  54. package/template/skills/agent-dispatch/SKILL.md +0 -15
  55. package/template/skills/runtime-health/scripts/doctor.py +0 -49
  56. package/template/skills/spec-sync/SKILL.md +0 -1
  57. package/template/skills/task-planning/SKILL.md +1 -1
  58. package/template/skills/task-review/SKILL.md +3 -3
  59. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +0 -178
  60. package/template/.cowork-flow/scripts/services/spec_check.py +0 -573
  61. package/template/.cowork-flow/spec/contracts/spec-checks.md +0 -281
@@ -1,573 +0,0 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """Spec check executor: user-declared checks in spec frontmatter.
4
-
5
- Mechanism consumes declarations, never spec prose. Each spec file may carry a
6
- `checks:` list in its YAML frontmatter; this module parses that minimal subset,
7
- matches declarations against changed files (directory-prefix or extension
8
- forms only), and executes the commands with a three-state outcome:
9
-
10
- - ``pass`` exit code 0
11
- - ``violation`` exit code != 0
12
- - ``unchecked`` command missing / interpreter missing / timeout — never
13
- reported as pass (lifecycle gates treat it as its own blocking state)
14
-
15
- Editor-phase runs are clamped to a short timeout and only touch declarations
16
- whose ``files`` match the edited path; slow or unfiltered commands belong to
17
- the lifecycle phase. Output passed back to agents is normalized to one line;
18
- full command output is only shown on an explicit ``--file`` query.
19
-
20
- Every report also carries a one-word ``summary.classification`` (see
21
- ``classify_spec_check_result``), so "nothing declared", "declared but nothing
22
- ran in this phase", "parse failure", "executor failure" and "unchecked" are
23
- distinct queryable facts instead of one grey not-passing state. Reports
24
- written before this field existed read back as ``legacy`` through
25
- ``report_classification`` rather than being guessed at.
26
- """
27
-
28
- from __future__ import annotations
29
-
30
- import json
31
- import os
32
- import shlex
33
- import shutil
34
- import subprocess
35
- from dataclasses import dataclass, field
36
- from pathlib import Path
37
- from typing import Iterable
38
-
39
- # os.name keeps this layer free of a sys import.
40
- IS_WINDOWS = os.name == "nt"
41
-
42
- # Edit phase gets 2.5s (hook budget ~5s incl. startup);
43
- # slow checks belong to the lifecycle phase.
44
- EDIT_PHASE_TIMEOUT = 2.5
45
- DEFAULT_TIMEOUT = 30.0
46
- MAX_TIMEOUT = 120.0
47
- MAX_FIRST_LINE_CHARS = 200
48
-
49
- # Machine-owned subtrees; user checks never scan these.
50
- EXCLUDED_SPEC_SUBDIRS = ("contracts", "runtime", "schemas")
51
-
52
- SCHEMA_VERSION = 1
53
-
54
- # One word per distinct outcome, most severe first. `empty` is the legal
55
- # "declarations exist but none ran in this phase" case (an edit-only
56
- # declaration under a lifecycle run); `legacy` is the read-back value for a
57
- # report written before this field existed, never a produced outcome.
58
- CLASSIFICATIONS = (
59
- "parse-error",
60
- "executor-failure",
61
- "zero-declaration",
62
- "violation",
63
- "unchecked",
64
- "pass",
65
- "empty",
66
- )
67
- LEGACY_CLASSIFICATION = "legacy"
68
-
69
-
70
- class SpecCheckError(Exception):
71
- """Raised for structural failures that callers must surface."""
72
-
73
-
74
- @dataclass(frozen=True)
75
- class CheckDecl:
76
- """One user-declared check from a spec frontmatter block."""
77
-
78
- cmd: str
79
- files: tuple[str, ...] = ()
80
- timeout: float = DEFAULT_TIMEOUT
81
- when: str = "both"
82
- cmd_win: str | None = None
83
-
84
-
85
- @dataclass(frozen=True)
86
- class SpecDeclarations:
87
- spec: str
88
- decls: tuple[CheckDecl, ...] = ()
89
- errors: tuple[str, ...] = ()
90
-
91
-
92
- @dataclass
93
- class CheckResult:
94
- spec: str
95
- cmd: str
96
- status: str # pass | violation | unchecked
97
- exit_code: int | None = None
98
- first_violation_line: str = ""
99
- reason: str = ""
100
- output: list[str] = field(default_factory=list)
101
-
102
- def to_json(self) -> dict:
103
- payload = {
104
- "spec": self.spec,
105
- "cmd": self.cmd,
106
- "status": self.status,
107
- }
108
- if self.exit_code is not None:
109
- payload["exitCode"] = self.exit_code
110
- if self.first_violation_line:
111
- payload["firstViolationLine"] = self.first_violation_line
112
- if self.reason:
113
- payload["reason"] = self.reason
114
- return payload
115
-
116
-
117
- def _strip_quotes(value: str) -> str:
118
- value = value.strip()
119
- # Unwrap only true single-pair quoting ("src/").
120
- # "python" -c "..." quotes twice; pass that value intact.
121
- for quote in ("\"", "'"):
122
- if (
123
- len(value) >= 2
124
- and value[0] == quote
125
- and value[-1] == quote
126
- and value.count(quote) == 2
127
- ):
128
- return value[1:-1].strip()
129
- return value
130
-
131
-
132
- def parse_frontmatter_checks(text: str) -> tuple[list[CheckDecl], list[str]]:
133
- """Parse the minimal frontmatter subset this contract supports.
134
-
135
- Returns (declarations, errors). A missing frontmatter block or a block
136
- without ``checks:`` is not an error; malformed content inside a declared
137
- block is, so doctor can point at the offending spec.
138
- """
139
- lines = text.splitlines()
140
- if not lines or lines[0].strip() != "---":
141
- return [], []
142
- end = None
143
- for index in range(1, len(lines)):
144
- if lines[index].strip() == "---":
145
- end = index
146
- break
147
- if end is None:
148
- return [], ["frontmatter block not closed"]
149
-
150
- decls: list[CheckDecl] = []
151
- errors: list[str] = []
152
- in_checks = False
153
- current: dict[str, str] | None = None
154
-
155
- for raw in lines[1:end]:
156
- stripped = raw.strip()
157
- if not stripped:
158
- continue
159
- if not in_checks:
160
- if stripped == "checks:":
161
- in_checks = True
162
- elif stripped.endswith(":") and not stripped.startswith("#"):
163
- # Another frontmatter key; ignore its body silently.
164
- in_checks = False
165
- continue
166
- if stripped.startswith("- "):
167
- if current is not None:
168
- parsed = _decl_from_pairs(current, errors)
169
- if parsed is not None:
170
- decls.append(parsed)
171
- current = {}
172
- stripped = stripped[2:].strip()
173
- if not stripped:
174
- continue
175
- if current is None:
176
- errors.append(f"unexpected line outside a check item: {stripped!r}")
177
- in_checks = False
178
- continue
179
- if ":" not in stripped:
180
- errors.append(f"malformed check line: {stripped!r}")
181
- current = None
182
- in_checks = False
183
- continue
184
- key, _, value = stripped.partition(":")
185
- key = key.strip()
186
- if not key:
187
- errors.append(f"malformed check line: {stripped!r}")
188
- continue
189
- current[key] = _strip_quotes(value)
190
- parsed = _decl_from_pairs(current, errors)
191
- if parsed is not None:
192
- decls.append(parsed)
193
- return decls, errors
194
-
195
-
196
- def _decl_from_pairs(pairs: dict[str, str], errors: list[str]) -> CheckDecl | None:
197
- cmd = pairs.pop("cmd", "")
198
- if not cmd:
199
- errors.append("check item without cmd")
200
- return None
201
- when = pairs.pop("when", "both") or "both"
202
- if when not in {"edit", "lifecycle", "both"}:
203
- errors.append(f"unknown when value: {when!r}")
204
- when = "both"
205
- timeout_raw = pairs.pop("timeout", "")
206
- try:
207
- timeout = float(timeout_raw) if timeout_raw else DEFAULT_TIMEOUT
208
- except ValueError:
209
- errors.append(f"non-numeric timeout: {timeout_raw!r}")
210
- timeout = DEFAULT_TIMEOUT
211
- timeout = min(max(timeout, 0.5), MAX_TIMEOUT)
212
- files_raw = pairs.pop("files", "")
213
- files, file_errors = _parse_files(files_raw)
214
- errors.extend(file_errors)
215
- if file_errors:
216
- # Broken files filter: drop the declaration (never widen to
217
- # "matches everything"); doctor surfaces it.
218
- return None
219
- cmd_win = pairs.pop("cmd.win", None)
220
- unknown = sorted(pairs)
221
- if unknown:
222
- errors.append(f"unknown check keys ignored: {', '.join(unknown)}")
223
- return CheckDecl(
224
- cmd=cmd,
225
- files=files,
226
- timeout=timeout,
227
- when=when,
228
- cmd_win=cmd_win,
229
- )
230
-
231
-
232
- def _parse_files(raw: str) -> tuple[tuple[str, ...], list[str]]:
233
- if not raw:
234
- return (), []
235
- errors: list[str] = []
236
- matched: list[str] = []
237
- for part in raw.split(","):
238
- token = _strip_quotes(part).replace("\\", "/")
239
- while token.startswith("./"):
240
- token = token[2:]
241
- if not token:
242
- continue
243
- if token.endswith("/**"):
244
- token = token[:-2]
245
- if token.startswith("*."):
246
- if "*" in token[2:] or "?" in token:
247
- errors.append(f"unsupported files form (use dir/ or *.ext): {token!r}")
248
- continue
249
- matched.append(token)
250
- continue
251
- if "*" in token or "?" in token or "[" in token:
252
- errors.append(f"unsupported files form (use dir/ or *.ext): {token!r}")
253
- continue
254
- matched.append(token if token.endswith("/") else f"{token}/")
255
- return tuple(matched), errors
256
-
257
-
258
- def path_matches_decl_files(relative_path: str, decl: CheckDecl) -> bool:
259
- if not decl.files:
260
- return True
261
- normalized = relative_path.replace("\\", "/")
262
- for token in decl.files:
263
- if token.startswith("*."):
264
- if normalized.endswith(token[1:]):
265
- return True
266
- elif normalized.startswith(token):
267
- return True
268
- return False
269
-
270
-
271
- def collect_spec_declarations(repo_root: Path) -> list[SpecDeclarations]:
272
- spec_dir = repo_root / ".cowork-flow" / "spec"
273
- if not spec_dir.is_dir():
274
- return []
275
- collected: list[SpecDeclarations] = []
276
- for path in sorted(spec_dir.rglob("*.md")):
277
- relative = path.relative_to(spec_dir).as_posix()
278
- parts = Path(relative).parts
279
- if parts and parts[0] in EXCLUDED_SPEC_SUBDIRS:
280
- continue
281
- try:
282
- text = path.read_text(encoding="utf-8")
283
- except OSError:
284
- collected.append(
285
- SpecDeclarations(relative, (), (f"unreadable spec file: {relative}",))
286
- )
287
- continue
288
- decls, errors = parse_frontmatter_checks(text)
289
- collected.append(SpecDeclarations(relative, tuple(decls), tuple(errors)))
290
- return collected
291
-
292
-
293
- def _decl_for_platform(decl: CheckDecl) -> str:
294
- if IS_WINDOWS and decl.cmd_win:
295
- return decl.cmd_win
296
- return decl.cmd
297
-
298
-
299
- def _first_violation_line(output: Iterable[str]) -> str:
300
- for line in output:
301
- line = line.strip()
302
- if line:
303
- return line[:MAX_FIRST_LINE_CHARS]
304
- return ""
305
-
306
-
307
- def _run_command(decl: CheckDecl, repo_root: Path) -> tuple[str, int | None, str, list[str]]:
308
- """Execute one declaration. Returns (status, exit_code, reason, output)."""
309
- command = _decl_for_platform(decl)
310
- tokens: list[str] = []
311
- if IS_WINDOWS:
312
- # Windows: .cmd shims need shell=True (a direct spawn fails
313
- # with EINVAL); an argv list double-escapes the quotes.
314
- # A missing command stays "unchecked" instead of a
315
- # cmd.exe rc=1 masquerading as a violation.
316
- try:
317
- tokens = shlex.split(command, posix=False)
318
- except ValueError:
319
- tokens = []
320
- first_token = tokens[0].strip('"') if tokens else ""
321
- if not first_token or shutil.which(first_token) is None:
322
- return "unchecked", None, f"command not found: {first_token}", []
323
- argv: str | list[str] = command
324
- popen_kwargs: dict = {"shell": True}
325
- else:
326
- try:
327
- argv = shlex.split(command)
328
- except ValueError as error:
329
- return "unchecked", None, f"unparsable command: {error}", []
330
- if not argv:
331
- return "unchecked", None, "empty command", []
332
- resolved = shutil.which(argv[0])
333
- if resolved is None:
334
- return "unchecked", None, f"command not found: {argv[0]}", []
335
- popen_kwargs = {}
336
- try:
337
- completed = subprocess.run(
338
- argv,
339
- cwd=str(repo_root),
340
- capture_output=True,
341
- # UTF-8 with replacement: command output is noise and must
342
- # never crash the reader or read as a check outcome.
343
- encoding="utf-8",
344
- errors="replace",
345
- timeout=decl.timeout,
346
- check=False,
347
- **popen_kwargs,
348
- )
349
- except subprocess.TimeoutExpired:
350
- return "unchecked", None, f"timed out after {decl.timeout:g}s", []
351
- except OSError as error:
352
- # Degraded environments (PATH stripped) are unchecked,
353
- # never a passing check.
354
- return "unchecked", None, f"failed to launch: {error}", []
355
-
356
- merged = list(completed.stdout.splitlines()) + list(completed.stderr.splitlines())
357
- if completed.returncode == 0:
358
- return "pass", 0, "", []
359
- return "violation", completed.returncode, "", merged
360
-
361
-
362
- def run_checks(
363
- repo_root: Path,
364
- *,
365
- phase: str,
366
- changed_files: Iterable[str] | None = None,
367
- file_filter: str | None = None,
368
- ) -> dict:
369
- """Run declared checks for one phase and return a JSON-ready report.
370
-
371
- phase: "edit" runs only edit-eligible declarations whose ``files`` match
372
- ``changed_files`` (2.5s clamp); "lifecycle" runs every declaration honoring
373
- each ``files`` scope. Parse errors are reported but never block as
374
- violations — they surface through doctor.
375
- """
376
- if phase not in {"edit", "lifecycle"}:
377
- raise SpecCheckError(f"unknown phase: {phase!r}")
378
- changed = [c.replace("\\", "/") for c in (changed_files or [])]
379
- if file_filter:
380
- changed = [file_filter.replace("\\", "/")]
381
-
382
- collected = collect_spec_declarations(repo_root)
383
- # Repository-wide declaration count, phase-independent: "no checks
384
- # declared" is a property of the repo, not of this phase's result list
385
- # (a lifecycle run skips every edit-only declaration and would otherwise
386
- # read as "nothing declared").
387
- declarations_total = sum(len(spec_decls.decls) for spec_decls in collected)
388
-
389
- results: list[CheckResult] = []
390
- parse_errors: list[dict[str, str]] = []
391
- for spec_decls in collected:
392
- for message in spec_decls.errors:
393
- parse_errors.append({"spec": spec_decls.spec, "error": message})
394
- for decl in spec_decls.decls:
395
- if phase == "edit":
396
- if decl.when == "lifecycle":
397
- continue
398
- if not changed:
399
- continue
400
- if not any(
401
- path_matches_decl_files(path, decl) for path in changed
402
- ):
403
- continue
404
- effective = CheckDecl(
405
- cmd=decl.cmd,
406
- files=decl.files,
407
- timeout=min(decl.timeout, EDIT_PHASE_TIMEOUT),
408
- when=decl.when,
409
- cmd_win=decl.cmd_win,
410
- )
411
- else:
412
- if decl.when == "edit":
413
- continue
414
- effective = decl
415
- status, exit_code, reason, output = _run_command(effective, repo_root)
416
- result = CheckResult(
417
- spec=spec_decls.spec,
418
- cmd=_decl_for_platform(effective),
419
- status=status,
420
- exit_code=exit_code,
421
- first_violation_line=_first_violation_line(output) if output else "",
422
- reason=reason,
423
- )
424
- if output:
425
- result.output = output[:20]
426
- results.append(result)
427
-
428
- summary = {
429
- "pass": sum(1 for r in results if r.status == "pass"),
430
- "violation": sum(1 for r in results if r.status == "violation"),
431
- "unchecked": sum(1 for r in results if r.status == "unchecked"),
432
- "declarations": declarations_total,
433
- }
434
- summary["classification"] = classify_spec_check_result(summary, parse_errors)
435
- return {
436
- "schemaVersion": SCHEMA_VERSION,
437
- "phase": phase,
438
- "changedFiles": changed,
439
- "declarations": declarations_total,
440
- "specFiles": len(collected),
441
- "results": [r.to_json() for r in results],
442
- "parseErrors": parse_errors,
443
- "summary": summary,
444
- }
445
-
446
-
447
- def classify_spec_check_result(summary: dict, parse_errors: list) -> str:
448
- """The report's one-word outcome, ordered by severity.
449
-
450
- Parse failures and a failed executor outrank the counters: both mean the
451
- checks did not produce a usable verdict, so neither may be read as a
452
- benign "nothing to check" state.
453
- """
454
- if parse_errors:
455
- return "parse-error"
456
- summary = summary or {}
457
- if int(summary.get("executorError") or 0):
458
- return "executor-failure"
459
- if int(summary.get("declarations") or 0) == 0:
460
- return "zero-declaration"
461
- if int(summary.get("violation") or 0):
462
- return "violation"
463
- if int(summary.get("unchecked") or 0):
464
- return "unchecked"
465
- if int(summary.get("pass") or 0):
466
- return "pass"
467
- return "empty"
468
-
469
-
470
- def report_classification(report: dict | None) -> str:
471
- """Read a report's classification.
472
-
473
- A report written before the field existed (or an unrecognized value) reads
474
- back as ``legacy``: absence is its own fact, and re-deriving a verdict
475
- from the counters would silently upgrade an old report into a guess.
476
- """
477
- summary = (report or {}).get("summary") or {}
478
- value = summary.get("classification")
479
- return value if value in CLASSIFICATIONS else LEGACY_CLASSIFICATION
480
-
481
-
482
- def normalized_one_line(report: dict) -> str:
483
- """Single-line summary for hook injection; never multi-line output."""
484
- results = report.get("results") or []
485
- for item in results:
486
- if item.get("status") == "violation":
487
- spec = item.get("spec", "?")
488
- line = item.get("firstViolationLine") or item.get("reason") or ""
489
- return f"spec-check[{spec}] violation: {line}"
490
- for item in results:
491
- if item.get("status") == "unchecked":
492
- return f"spec-check[{item.get('spec', '?')}] unchecked: {item.get('reason', '')}"
493
- return ""
494
-
495
-
496
- # Per-file throttle for editor-phase runs: an edit storm
497
- # must not multiply command cost. State is a file (each
498
- # hook call is a new process). The slot is written after a
499
- # completed run, never on a crash.
500
- EDIT_THROTTLE_INTERVAL_SECONDS = 10.0
501
- EDIT_THROTTLE_FILE = ".cowork-flow" / Path(".runtime") / "spec-edit-throttle.json"
502
-
503
-
504
- def _read_throttle(root: Path) -> dict:
505
- try:
506
- data = json.loads((root / EDIT_THROTTLE_FILE).read_text(encoding="utf-8"))
507
- except (OSError, json.JSONDecodeError, ValueError):
508
- return {}
509
- return data if isinstance(data, dict) else {}
510
-
511
-
512
- def _write_throttle(root: Path, file_path: str, timestamp: float) -> None:
513
- path = root / EDIT_THROTTLE_FILE
514
- try:
515
- path.parent.mkdir(parents=True, exist_ok=True)
516
- path.write_text(
517
- json.dumps({"file": file_path, "ts": timestamp}, ensure_ascii=False)
518
- + "\n",
519
- encoding="utf-8",
520
- )
521
- except OSError:
522
- # Best-effort: a failed write means the next edit runs.
523
- pass
524
-
525
-
526
- def run_edit_checks(
527
- repo_root: Path,
528
- file_path: str,
529
- *,
530
- throttled: bool = True,
531
- now: float | None = None,
532
- ) -> str:
533
- """Editor-phase single-line result for one changed file.
534
-
535
- Empty string means "nothing to report": no declaration matched, the file
536
- was throttled, or checks passed. Violations and unchecked degradations
537
- return the normalized one-line summary. Never raises for expected
538
- operating conditions — the editor path must not break edits.
539
- """
540
- import time
541
-
542
- normalized = file_path.replace("\\", "/").strip()
543
- if not normalized:
544
- return ""
545
- # Hosts pass absolute paths; filters are repo-relative.
546
- try:
547
- normalized = (
548
- Path(normalized).resolve().relative_to(repo_root.resolve()).as_posix()
549
- )
550
- except (ValueError, OSError):
551
- pass
552
- if throttled:
553
- state = _read_throttle(repo_root)
554
- current = time.time() if now is None else now
555
- if (
556
- state.get("file") == normalized
557
- and isinstance(state.get("ts"), (int, float))
558
- and current - state["ts"] < EDIT_THROTTLE_INTERVAL_SECONDS
559
- ):
560
- return ""
561
- try:
562
- report = run_checks(repo_root, phase="edit", changed_files=[normalized])
563
- except Exception:
564
- # Executor crash, not a check failure: leave the throttle
565
- # untouched so a retry inside the interval still runs.
566
- return ""
567
- if throttled:
568
- _write_throttle(repo_root, normalized, time.time() if now is None else now)
569
- return normalized_one_line(report)
570
-
571
-
572
- def summary_has(report: dict, key: str) -> bool:
573
- return bool((report.get("summary") or {}).get(key))