prizmkit 1.1.152 → 1.1.154

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 (44) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/README.md +100 -87
  3. package/bundled/dev-pipeline/assets/skill-subagent-integration.md +1 -1
  4. package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +14 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +192 -110
  6. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +146 -111
  7. package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +3 -5
  8. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/paths.py +0 -3
  10. package/bundled/dev-pipeline/prizmkit_runtime/reset.py +122 -15
  11. package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +1 -1
  12. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +1 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +19 -52
  14. package/bundled/dev-pipeline/prizmkit_runtime/status.py +106 -1
  15. package/bundled/dev-pipeline/scripts/init-bugfix-pipeline.py +2 -2
  16. package/bundled/dev-pipeline/scripts/parse-stream-progress.py +10 -12
  17. package/bundled/dev-pipeline/scripts/update-bug-status.py +196 -67
  18. package/bundled/dev-pipeline/scripts/update-checkpoint.py +16 -3
  19. package/bundled/dev-pipeline/scripts/update-feature-status.py +45 -117
  20. package/bundled/dev-pipeline/scripts/update-refactor-status.py +45 -119
  21. package/bundled/dev-pipeline/scripts/utils.py +119 -0
  22. package/bundled/dev-pipeline/templates/bug-fix-list-schema.json +3 -2
  23. package/bundled/dev-pipeline/tests/test_auto_skip.py +111 -32
  24. package/bundled/dev-pipeline/tests/test_checkpoint_state.py +148 -12
  25. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +0 -19
  26. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +113 -188
  27. package/bundled/dev-pipeline/tests/test_recovery_workflow.py +211 -0
  28. package/bundled/dev-pipeline/tests/test_reset_modes.py +935 -0
  29. package/bundled/dev-pipeline/tests/test_reset_preserve.py +5 -5
  30. package/bundled/dev-pipeline/tests/test_unified_cli.py +544 -181
  31. package/bundled/skills/_metadata.json +1 -1
  32. package/bundled/skills/bug-planner/references/schema-validation.md +1 -1
  33. package/bundled/skills/bug-planner/scripts/validate-bug-list.py +1 -1
  34. package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -13
  35. package/bundled/skills/feature-pipeline-launcher/SKILL.md +22 -14
  36. package/bundled/skills/recovery-workflow/SKILL.md +7 -5
  37. package/bundled/skills/recovery-workflow/evals/evals.json +2 -2
  38. package/bundled/skills/recovery-workflow/references/detection.md +3 -3
  39. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +1 -1
  40. package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -13
  41. package/bundled/templates/project-memory-template.md +19 -11
  42. package/package.json +1 -1
  43. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +0 -228
  44. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +0 -767
@@ -38,8 +38,11 @@ class ResetOptions:
38
38
  item_id: str = ""
39
39
  item_range: tuple[str, str] | None = None
40
40
  filter_mode: str = ""
41
+ state_only: bool = False
42
+ fresh_checkout: bool = False
41
43
  clean: bool = False
42
44
  preserve_runtime: bool = False
45
+ all_items: bool = False
43
46
  help_requested: bool = False
44
47
  unknown_args: tuple[str, ...] = ()
45
48
 
@@ -112,7 +115,7 @@ def run_reset_command(kind: str, legacy_args: tuple[str, ...], paths) -> Command
112
115
  list_path = _resolve_list_path(options.list_path, paths.project_root)
113
116
  if not list_path.is_file():
114
117
  return CommandResult(1, f"Python {kind} reset", f"{kind} list not found: {list_path}")
115
- if not options.preserve_runtime and not (family.state_dir / "pipeline.json").is_file():
118
+ if (options.fresh_checkout or options.clean) and not (family.state_dir / "pipeline.json").is_file():
116
119
  return CommandResult(1, f"Python {kind} reset", f"No pipeline state found. Run the {kind} pipeline first to initialize.")
117
120
 
118
121
  options = ResetOptions(
@@ -120,8 +123,11 @@ def run_reset_command(kind: str, legacy_args: tuple[str, ...], paths) -> Command
120
123
  item_id=options.item_id,
121
124
  item_range=options.item_range,
122
125
  filter_mode=options.filter_mode,
126
+ state_only=options.state_only,
127
+ fresh_checkout=options.fresh_checkout,
123
128
  clean=options.clean,
124
129
  preserve_runtime=options.preserve_runtime,
130
+ all_items=options.all_items,
125
131
  help_requested=options.help_requested,
126
132
  unknown_args=options.unknown_args,
127
133
  )
@@ -140,6 +146,16 @@ def run_reset_command(kind: str, legacy_args: tuple[str, ...], paths) -> Command
140
146
  text,
141
147
  stdout=text,
142
148
  )
149
+ if options.state_only:
150
+ state_result = _run_state_only_updater(family, options, paths.project_root)
151
+ text = state_result.stdout.strip() or state_result.stderr.strip() or "State-only reset failed"
152
+ ok = state_result.returncode == 0 and _json_without_error(state_result.stdout)
153
+ return CommandResult(
154
+ 0 if ok else 1,
155
+ f"Python {kind} reset --state-only",
156
+ text,
157
+ stdout=text,
158
+ )
143
159
 
144
160
  try:
145
161
  items = _resolve_items(family, options)
@@ -174,8 +190,11 @@ def _parse_reset_args(family: RunnerFamily, legacy_args: Sequence[str]) -> Reset
174
190
  item_id = ""
175
191
  item_range: tuple[str, str] | None = None
176
192
  filter_mode = ""
193
+ state_only = False
194
+ fresh_checkout = False
177
195
  clean = False
178
196
  preserve_runtime = False
197
+ all_items = False
179
198
  help_requested = False
180
199
  unknown: list[str] = []
181
200
  positional: list[str] = []
@@ -183,16 +202,22 @@ def _parse_reset_args(family: RunnerFamily, legacy_args: Sequence[str]) -> Reset
183
202
  index = 0
184
203
  while index < len(args):
185
204
  arg = args[index]
186
- if arg in {"--help", "-h", "help"}:
205
+ if arg in {"--help", "-h"}:
187
206
  help_requested = True
207
+ elif arg == "--state-only":
208
+ state_only = True
209
+ elif arg == "--fresh-checkout":
210
+ fresh_checkout = True
188
211
  elif arg == "--clean":
189
212
  clean = True
190
213
  elif arg == "--preserve-runtime":
191
214
  preserve_runtime = True
215
+ elif arg == "--all":
216
+ all_items = True
192
217
  elif arg == "--run":
193
218
  raise ValueError(
194
219
  "Reset no longer accepts --run. Run reset first, then execute "
195
- f"`{family.kind} run` as a separate command."
220
+ f"`run {family.kind}` as a separate command."
196
221
  )
197
222
  elif arg in FILTER_FLAGS:
198
223
  next_filter = FILTER_FLAGS[arg]
@@ -208,9 +233,15 @@ def _parse_reset_args(family: RunnerFamily, legacy_args: Sequence[str]) -> Reset
208
233
 
209
234
  for value in positional:
210
235
  if _looks_like_range(value, family.id_prefix):
211
- item_range = _normalize_range(value, family.id_prefix)
212
- elif _looks_like_item_id(value, family.id_prefix) and not item_id:
213
- item_id = _normalize_item_id(value, family.id_prefix)
236
+ if item_range is None and not item_id:
237
+ item_range = _normalize_range(value, family.id_prefix)
238
+ else:
239
+ unknown.append(value)
240
+ elif _looks_like_item_id(value, family.id_prefix):
241
+ if not item_id and item_range is None:
242
+ item_id = _normalize_item_id(value, family.id_prefix)
243
+ else:
244
+ unknown.append(value)
214
245
  elif list_path == family.plan_path:
215
246
  list_path = Path(value).expanduser()
216
247
  else:
@@ -221,8 +252,11 @@ def _parse_reset_args(family: RunnerFamily, legacy_args: Sequence[str]) -> Reset
221
252
  item_id=item_id,
222
253
  item_range=item_range,
223
254
  filter_mode=filter_mode,
255
+ state_only=state_only,
256
+ fresh_checkout=fresh_checkout,
224
257
  clean=clean,
225
258
  preserve_runtime=preserve_runtime,
259
+ all_items=all_items,
226
260
  help_requested=help_requested,
227
261
  unknown_args=tuple(unknown),
228
262
  )
@@ -231,14 +265,51 @@ def _parse_reset_args(family: RunnerFamily, legacy_args: Sequence[str]) -> Reset
231
265
  def _reset_option_error(family: RunnerFamily, options: ResetOptions) -> str:
232
266
  if options.unknown_args:
233
267
  return "Unsupported reset arguments: " + " ".join(options.unknown_args)
234
- if not options.preserve_runtime:
268
+
269
+ selected_modes = [
270
+ name
271
+ for name, enabled in (
272
+ ("--state-only", options.state_only),
273
+ ("--fresh-checkout", options.fresh_checkout),
274
+ ("--clean", options.clean),
275
+ ("--preserve-runtime", options.preserve_runtime),
276
+ )
277
+ if enabled
278
+ ]
279
+ if len(selected_modes) != 1:
280
+ return (
281
+ "Exactly one reset mode is required: --state-only, --fresh-checkout, "
282
+ "--clean, or --preserve-runtime.\n" + _help_text(family)
283
+ )
284
+
285
+ selectors = sum(
286
+ bool(value)
287
+ for value in (
288
+ options.item_id,
289
+ options.item_range,
290
+ options.filter_mode,
291
+ options.all_items,
292
+ )
293
+ )
294
+ if options.state_only:
295
+ if options.item_range or options.filter_mode:
296
+ return "--state-only accepts only one item ID or explicit --all; ranges and status filters are unsupported."
297
+ if selectors != 1 or not (options.item_id or options.all_items):
298
+ return "--state-only requires exactly one item ID or explicit --all."
299
+ return ""
300
+
301
+ if options.fresh_checkout or options.clean:
302
+ if options.all_items:
303
+ return "--all is supported only with --state-only."
304
+ if selectors != 1:
305
+ mode = "--clean" if options.clean else "--fresh-checkout"
306
+ return f"{mode} requires exactly one item ID, range, or supported status filter."
235
307
  return ""
308
+
236
309
  if options.filter_mode != "failed":
237
310
  return "--preserve-runtime requires --failed and cannot be used with another reset filter."
238
- if options.clean:
239
- return "--preserve-runtime and --clean are mutually exclusive."
240
- if options.item_id or options.item_range:
241
- return "--preserve-runtime is a whole-list failed-chain reset and cannot target an ID or range."
311
+ if options.item_id or options.item_range or options.all_items:
312
+ return "--preserve-runtime is a whole-list failed-chain reset and cannot target an ID, range, or --all."
242
313
  return ""
243
314
 
244
315
 
@@ -282,6 +353,8 @@ def _resolve_items(family: RunnerFamily, options: ResetOptions) -> tuple[ResetIt
282
353
  for item_id in ids:
283
354
  item = by_id.get(item_id)
284
355
  if not isinstance(item, dict):
356
+ if options.item_id:
357
+ raise ValueError(f"{FAMILY_LABELS[family.kind].title()} '{item_id}' not found in {options.list_path}")
285
358
  continue
286
359
  title = str(item.get(TITLE_FIELD_BY_KIND[family.kind]) or "")
287
360
  resolved.append(ResetItem(item_id=item_id, title=title, slug=_slug_for_item(family, item_id, title), status=str(item.get("status") or "unknown")))
@@ -339,6 +412,8 @@ def _reset_one(family: RunnerFamily, item: ResetItem, options: ResetOptions, pro
339
412
  except RuntimeError as exc:
340
413
  lines.append(f"ERROR: Reset {item.item_id} checkout identity failed: {exc}")
341
414
  return False
415
+ if options.fresh_checkout:
416
+ lines.append(f"Fresh-checkout boundary published for {item.item_id}")
342
417
  lines.append(f"{item.item_id} {action} complete: status -> pending, retry count -> 0")
343
418
  return True
344
419
  error = _error_text(result.stdout) or result.stderr.strip() or "unknown"
@@ -346,6 +421,33 @@ def _reset_one(family: RunnerFamily, item: ResetItem, options: ResetOptions, pro
346
421
  return False
347
422
 
348
423
 
424
+ def _run_state_only_updater(
425
+ family: RunnerFamily,
426
+ options: ResetOptions,
427
+ project_root: Path,
428
+ ) -> subprocess.CompletedProcess[str]:
429
+ command = [
430
+ sys.executable,
431
+ str(family.updater_script),
432
+ family.list_arg,
433
+ str(options.list_path),
434
+ "--state-dir",
435
+ str(family.state_dir),
436
+ "--action",
437
+ "reset_state",
438
+ ]
439
+ if options.item_id:
440
+ command.extend([family.item_id_arg, options.item_id])
441
+ return subprocess.run(
442
+ command,
443
+ cwd=str(project_root),
444
+ stdout=subprocess.PIPE,
445
+ stderr=subprocess.PIPE,
446
+ text=True,
447
+ check=False,
448
+ )
449
+
450
+
349
451
  def _run_updater(family: RunnerFamily, action: str, options: ResetOptions, item: ResetItem, project_root: Path) -> subprocess.CompletedProcess[str]:
350
452
  command = [
351
453
  sys.executable,
@@ -455,9 +557,14 @@ def _numeric_suffix(value: str, prefix: str) -> int:
455
557
  def _help_text(family: RunnerFamily) -> str:
456
558
  label = FAMILY_LABELS[family.kind]
457
559
  return (
458
- f"Usage: prizmkit-runtime reset {family.kind} <{label}-id|range> [--clean] "
459
- "[--auto-skipped|--failed|--stalled] [list-path]\n"
560
+ f"Usage: prizmkit-runtime reset {family.kind} <{label}-id> --state-only [list-path]\n"
561
+ f" prizmkit-runtime reset {family.kind} --state-only --all [list-path]\n"
562
+ f" prizmkit-runtime reset {family.kind} <{label}-id|range> --fresh-checkout [list-path]\n"
563
+ f" prizmkit-runtime reset {family.kind} <--auto-skipped|--failed|--stalled> --fresh-checkout [list-path]\n"
564
+ f" prizmkit-runtime reset {family.kind} <{label}-id|range> --clean [list-path]\n"
565
+ f" prizmkit-runtime reset {family.kind} <--auto-skipped|--failed|--stalled> --clean [list-path]\n"
460
566
  f" prizmkit-runtime reset {family.kind} --failed --preserve-runtime [list-path]\n"
461
- "Reset changes state only and never accepts --run; execute the matching family run command separately.\n"
462
- "Standard/clean reset abandons the active checkout boundary. Preserve-runtime reset keeps task runtime and work."
567
+ "Exactly one reset mode is required. State-only preserves checkout, Git, checkpoints, and artifacts; "
568
+ "fresh-checkout publishes a new checkout boundary; clean is destructive; preserve-runtime is whole-list failed recovery.\n"
569
+ "Reset never accepts --run; execute the matching run <family> command separately."
463
570
  )
@@ -126,7 +126,7 @@ class PreserveRuntimeResetResult:
126
126
  "Existing sessions, checkpoints, artifacts, branches, worktrees, and task work were not deleted.",
127
127
  "Reset did not start a runner. Run separately:",
128
128
  "python3 ./.prizmkit/dev-pipeline/cli.py "
129
- f"{family.kind} run {shlex.quote(str(list_path))}",
129
+ f"run {family.kind} {shlex.quote(str(list_path))}",
130
130
  )
131
131
  )
132
132
  return tuple(lines)
@@ -285,7 +285,7 @@ def parse_invocation(
285
285
  index = 0
286
286
  while index < len(args):
287
287
  arg = args[index]
288
- if arg in {"--help", "-h", "help"}:
288
+ if arg in {"--help", "-h"}:
289
289
  help_requested = True
290
290
  elif arg == "--dry-run":
291
291
  dry_run = True
@@ -1,4 +1,4 @@
1
- """Python foreground runner dispatch for feature, bugfix, refactor, and recovery."""
1
+ """Python foreground runner dispatch for feature, bugfix, and refactor pipelines."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -43,8 +43,7 @@ from .runner_bookkeeping import (
43
43
  from .runner_classification import classify_session, has_branch_completion_evidence, has_completed_artifact_path
44
44
  from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
45
45
  from .runner_prompts import PromptGenerationResult, generate_prompt
46
- from .runner_recovery import run_recovery, run_recovery_detect
47
- from .runner_status import get_next, run_status_action, start_item, status_text, update_item
46
+ from .runner_status import get_next, start_item, status_text, update_item
48
47
  from .sessions import AISessionConfig, AISessionLauncher, detect_stream_json_support
49
48
  from .task_checkout import (
50
49
  CHECKOUT_MODE_BRANCH,
@@ -95,21 +94,6 @@ def run_pipeline_command(kind: str, action: str, legacy_args: tuple[str, ...], p
95
94
  if action == "status":
96
95
  status = status_text(family, invocation, paths.project_root)
97
96
  return CommandResult(status.return_code, f"Python {kind} status", status.stdout.strip() or status.stderr.strip())
98
- if action == "unskip":
99
- status = run_status_action(
100
- family,
101
- "unskip",
102
- invocation,
103
- item_id=invocation.item_id,
104
- project_root=paths.project_root,
105
- )
106
- return CommandResult(
107
- status.return_code,
108
- f"Python {kind} unskip",
109
- status.stdout.strip() or status.stderr.strip(),
110
- )
111
- if action == "test-cli":
112
- return _test_cli(kind, paths)
113
97
  if action != "run":
114
98
  return CommandResult(1, "Unsupported Python runner action", f"Unsupported action: {kind} {action}")
115
99
  if not invocation.list_path.is_file():
@@ -128,17 +112,6 @@ def run_pipeline_command(kind: str, action: str, legacy_args: tuple[str, ...], p
128
112
  )
129
113
 
130
114
 
131
- def run_recovery_command(action: str, legacy_args: tuple[str, ...], paths) -> CommandResult:
132
- """Dispatch a Python recovery command."""
133
- if action == "detect":
134
- code, stdout, stderr, _data = run_recovery_detect(paths)
135
- return CommandResult(code, "Python recovery detect", stdout.strip() or stderr.strip())
136
- if action == "run":
137
- code, stdout, stderr = run_recovery(paths, legacy_args)
138
- return CommandResult(code, "Python recovery run", stdout.strip() or stderr.strip())
139
- return CommandResult(1, "Unsupported Python recovery action", f"Unsupported action: {action}")
140
-
141
-
142
115
  def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) -> "PipelineRunResult":
143
116
  from .runner_models import PipelineRunResult
144
117
 
@@ -182,6 +155,14 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
182
155
  item_id = str(next_result.data.get(f"{family.kind}_id") or next_result.data.get("feature_id") or next_result.data.get("bug_id") or next_result.data.get("refactor_id") or "")
183
156
  if not item_id:
184
157
  return PipelineRunResult(False, processed, "missing_item_id", details=(marker[:500],))
158
+ if item_id in item_statuses:
159
+ return PipelineRunResult(
160
+ False,
161
+ processed,
162
+ "reselected_item",
163
+ item_statuses[item_id],
164
+ _render_item_statuses(item_statuses),
165
+ )
185
166
  try:
186
167
  final_status = _process_item(family, invocation, item_id, paths, initial_metadata=next_result.data)
187
168
  except KeyboardInterrupt:
@@ -197,6 +178,14 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
197
178
  )
198
179
  processed += 1
199
180
  item_statuses[item_id] = final_status
181
+ if final_status == "skipped":
182
+ return PipelineRunResult(
183
+ False,
184
+ processed,
185
+ "item_skipped",
186
+ final_status,
187
+ _render_item_statuses(item_statuses),
188
+ )
200
189
  if final_status in _completion_compatible_statuses(family):
201
190
  if final_status == "completed":
202
191
  _emit_info(f"Pausing 5s before next {family.kind}...")
@@ -210,7 +199,7 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
210
199
  return PipelineRunResult(False, processed, "item_failed", final_status, details)
211
200
 
212
201
 
213
- _COMPLETION_COMPATIBLE_STATUSES = {"completed", "skipped", "auto_skipped"}
202
+ _COMPLETION_COMPATIBLE_STATUSES = {"completed", "auto_skipped"}
214
203
 
215
204
 
216
205
  def _completion_compatible_statuses(family: RunnerFamily) -> set[str]:
@@ -1165,28 +1154,6 @@ def _record_setup_failure(
1165
1154
  branch_ensure_return(project_root, base_branch, branch_name)
1166
1155
  return new_status
1167
1156
 
1168
- def _test_cli(kind: str, paths) -> CommandResult:
1169
- """Report the AI CLI resolution used by Python runner commands."""
1170
- config = load_runtime_config(paths)
1171
- cli = config.ai_client.command or "(not found)"
1172
- platform = config.ai_client.platform or "unresolved"
1173
- details = [
1174
- f"detected_cli: {cli}",
1175
- f"platform: {platform}",
1176
- f"source: {config.ai_client.source}",
1177
- f"runtime_mode: {config.runtime_mode}",
1178
- ]
1179
- if config.model:
1180
- details.append(f"model: {config.model}")
1181
- details.extend(f"note: {note}" for note in config.ai_client.notes)
1182
- return CommandResult(
1183
- 0 if config.ai_client.command else 1,
1184
- f"Python {kind} AI CLI test",
1185
- "AI CLI resolution for the canonical Python runtime.",
1186
- tuple(details),
1187
- )
1188
-
1189
-
1190
1157
  def _use_worktree_for_family(env: RunnerEnvironment, family: RunnerFamily) -> bool:
1191
1158
  return env.use_worktree and family.kind in {"feature", "bugfix"}
1192
1159
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Mapping
5
+ import json
6
+ from collections.abc import Callable, Mapping
6
7
  from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
9
 
@@ -69,3 +70,107 @@ def default_status_targets(paths: RuntimePaths) -> dict[str, StatusTarget]:
69
70
  "bugfix": StatusTarget("bugfix", paths.bugfix_plan, paths.bugfix_state_dir),
70
71
  "refactor": StatusTarget("refactor", paths.refactor_plan, paths.refactor_state_dir),
71
72
  }
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class AggregateStatusResult:
77
+ """Deterministic read-only status output across every pipeline family."""
78
+
79
+ exit_code: int
80
+ output: str
81
+
82
+
83
+ _FAMILY_PLAN_KEYS = {
84
+ "feature": "features",
85
+ "bugfix": "bugs",
86
+ "refactor": "refactors",
87
+ }
88
+
89
+
90
+ def _load_json_object(path: Path, label: str) -> dict[str, object]:
91
+ try:
92
+ data = json.loads(path.read_text(encoding="utf-8"))
93
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
94
+ raise ValueError(f"malformed {label}: {exc}") from exc
95
+ if not isinstance(data, dict):
96
+ raise ValueError(f"malformed {label}: expected a JSON object")
97
+ return data
98
+
99
+
100
+ def _validate_status_inputs(target: StatusTarget) -> None:
101
+ """Validate only state consumed by status, without creating or repairing it."""
102
+ plan = _load_json_object(target.plan_path, "plan list")
103
+ items = plan.get(_FAMILY_PLAN_KEYS[target.kind])
104
+ if not isinstance(items, list):
105
+ raise ValueError(
106
+ f"malformed plan list: {_FAMILY_PLAN_KEYS[target.kind]} must be an array"
107
+ )
108
+
109
+ pipeline_state = target.state_dir / "pipeline.json"
110
+ if pipeline_state.exists():
111
+ if not pipeline_state.is_file():
112
+ raise ValueError("malformed runtime state: pipeline.json is not a file")
113
+ _load_json_object(pipeline_state, "runtime state pipeline.json")
114
+
115
+ for item in items:
116
+ if not isinstance(item, dict):
117
+ raise ValueError("malformed plan list: every task must be a JSON object")
118
+ item_id = item.get("id")
119
+ if not isinstance(item_id, str) or not item_id.strip():
120
+ raise ValueError("malformed plan list: every task requires a non-empty id")
121
+ status_path = target.state_dir / item_id / "status.json"
122
+ if status_path.exists():
123
+ if not status_path.is_file():
124
+ raise ValueError(
125
+ f"malformed runtime state: {item_id}/status.json is not a file"
126
+ )
127
+ _load_json_object(status_path, f"runtime state {item_id}/status.json")
128
+
129
+
130
+ def _render_family_result(result: object) -> tuple[int, str]:
131
+ exit_code = getattr(result, "exit_code", None)
132
+ render = getattr(result, "render", None)
133
+ if not isinstance(exit_code, int) or not callable(render):
134
+ raise ValueError("unparseable configured family result")
135
+ try:
136
+ rendered = render()
137
+ except Exception as exc: # isolate one broken family result
138
+ raise ValueError(f"unparseable configured family result: {exc}") from exc
139
+ if not isinstance(rendered, str) or not rendered.strip():
140
+ raise ValueError("unparseable configured family result: empty output")
141
+ return exit_code, rendered.strip()
142
+
143
+
144
+ def aggregate_family_status(
145
+ paths: RuntimePaths,
146
+ status_handler: Callable[[str], object],
147
+ ) -> AggregateStatusResult:
148
+ """Aggregate canonical family status handlers without mutating runtime state."""
149
+ targets = default_status_targets(paths)
150
+ sections: list[str] = []
151
+ failed = False
152
+
153
+ for family in ("feature", "bugfix", "refactor"):
154
+ target = targets[family]
155
+ label = family.upper()
156
+ body: str
157
+ if not target.plan_path.exists():
158
+ body = "not configured: plan list not found"
159
+ elif not target.plan_path.is_file():
160
+ failed = True
161
+ body = "error: malformed plan list: path is not a file"
162
+ else:
163
+ try:
164
+ _validate_status_inputs(target)
165
+ family_exit, rendered = _render_family_result(status_handler(family))
166
+ if family_exit != 0:
167
+ failed = True
168
+ body = f"error: {rendered}"
169
+ else:
170
+ body = rendered
171
+ except Exception as exc: # render remaining family sections truthfully
172
+ failed = True
173
+ body = f"error: {exc}"
174
+ sections.append(f"{label}\n{'=' * len(label)}\n{body}")
175
+
176
+ return AggregateStatusResult(1 if failed else 0, "\n\n".join(sections))
@@ -36,9 +36,9 @@ VALID_SEVERITIES = ["critical", "high", "medium", "low"]
36
36
  VALID_VERIFICATION_TYPES = ["automated", "manual", "hybrid"]
37
37
  VALID_STATUSES = [
38
38
  "pending", "in_progress", "completed", "failed",
39
- "skipped", "needs_info",
39
+ "skipped", "needs_info", "auto_skipped",
40
40
  ]
41
- TERMINAL_STATUSES = {"completed", "failed", "skipped", "needs_info"}
41
+ TERMINAL_STATUSES = {"completed", "failed", "skipped", "needs_info", "auto_skipped"}
42
42
  VALID_ERROR_SOURCE_TYPES = [
43
43
  "stack_trace", "user_report", "failed_test", "log_pattern", "monitoring_alert",
44
44
  ]
@@ -820,24 +820,22 @@ class ProgressTracker:
820
820
  self.message_count += 1
821
821
  elif event_type in {"message_update", "message_end"}:
822
822
  assistant_event = event.get("assistantMessageEvent")
823
+ message = event.get("message")
823
824
  text = ""
824
825
  if isinstance(assistant_event, dict):
825
826
  text = str(assistant_event.get("delta") or assistant_event.get("text") or "")
826
- if not text:
827
- message = event.get("message")
828
- if isinstance(message, dict):
829
- content = message.get("content")
830
- if isinstance(content, str):
831
- text = content
832
- elif isinstance(content, list):
833
- text = "\n".join(
834
- str(item.get("text") or "")
835
- for item in content if isinstance(item, dict)
836
- )
827
+ if not text and isinstance(message, dict):
828
+ content = message.get("content")
829
+ if isinstance(content, str):
830
+ text = content
831
+ elif isinstance(content, list):
832
+ text = "\n".join(
833
+ str(item.get("text") or "")
834
+ for item in content if isinstance(item, dict)
835
+ )
837
836
  if text.strip():
838
837
  self.last_text_snippet = text.strip()[-120:]
839
838
  self._detect_phase(text)
840
- self._detect_terminal_error(text, require_error_context=True)
841
839
  if (
842
840
  event_type == "message_end"
843
841
  and isinstance(message, dict)