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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/README.md +100 -87
- package/bundled/dev-pipeline/assets/skill-subagent-integration.md +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +14 -0
- package/bundled/dev-pipeline/prizmkit_runtime/cli.py +192 -110
- package/bundled/dev-pipeline/prizmkit_runtime/commands.py +146 -111
- package/bundled/dev-pipeline/prizmkit_runtime/daemon.py +3 -5
- package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
- package/bundled/dev-pipeline/prizmkit_runtime/paths.py +0 -3
- package/bundled/dev-pipeline/prizmkit_runtime/reset.py +122 -15
- package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +19 -52
- package/bundled/dev-pipeline/prizmkit_runtime/status.py +106 -1
- package/bundled/dev-pipeline/scripts/init-bugfix-pipeline.py +2 -2
- package/bundled/dev-pipeline/scripts/parse-stream-progress.py +10 -12
- package/bundled/dev-pipeline/scripts/update-bug-status.py +196 -67
- package/bundled/dev-pipeline/scripts/update-checkpoint.py +16 -3
- package/bundled/dev-pipeline/scripts/update-feature-status.py +45 -117
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +45 -119
- package/bundled/dev-pipeline/scripts/utils.py +119 -0
- package/bundled/dev-pipeline/templates/bug-fix-list-schema.json +3 -2
- package/bundled/dev-pipeline/tests/test_auto_skip.py +111 -32
- package/bundled/dev-pipeline/tests/test_checkpoint_state.py +148 -12
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +0 -19
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +113 -188
- package/bundled/dev-pipeline/tests/test_recovery_workflow.py +211 -0
- package/bundled/dev-pipeline/tests/test_reset_modes.py +935 -0
- package/bundled/dev-pipeline/tests/test_reset_preserve.py +5 -5
- package/bundled/dev-pipeline/tests/test_unified_cli.py +544 -181
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/bug-planner/references/schema-validation.md +1 -1
- package/bundled/skills/bug-planner/scripts/validate-bug-list.py +1 -1
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -13
- package/bundled/skills/feature-pipeline-launcher/SKILL.md +22 -14
- package/bundled/skills/recovery-workflow/SKILL.md +7 -5
- package/bundled/skills/recovery-workflow/evals/evals.json +2 -2
- package/bundled/skills/recovery-workflow/references/detection.md +3 -3
- package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +1 -1
- package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -13
- package/bundled/templates/project-memory-template.md +19 -11
- package/package.json +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +0 -228
- 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
|
|
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"
|
|
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}
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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.
|
|
239
|
-
return "--preserve-runtime and
|
|
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
|
|
459
|
-
"
|
|
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
|
-
"
|
|
462
|
-
"
|
|
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}
|
|
129
|
+
f"run {family.kind} {shlex.quote(str(list_path))}",
|
|
130
130
|
)
|
|
131
131
|
)
|
|
132
132
|
return tuple(lines)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Python foreground runner dispatch for feature, bugfix, refactor
|
|
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 .
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
if isinstance(
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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)
|