prizmkit 1.1.124 → 1.1.126
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/adapters/claude/command-adapter.js +4 -6
- package/bundled/dev-pipeline/prizmkit_runtime/reset.py +49 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +75 -14
- package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +37 -0
- package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +267 -0
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +4 -4
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +5 -16
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -14
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +8 -4
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +9 -8
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +12 -15
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +11 -16
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +9 -8
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +8 -4
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +1 -1
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +1 -1
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +206 -3
- package/bundled/dev-pipeline/tests/test_runtime_helper.py +36 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +21 -12
- package/bundled/skills/_metadata.json +4 -4
- package/bundled/skills/prizmkit-code-review/SKILL.md +130 -103
- package/bundled/skills/prizmkit-code-review/assets/reviewer-role.json +3 -5
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +89 -38
- package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +44 -62
- package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +249 -143
- package/package.json +1 -1
- package/bundled/skills/prizmkit-code-review/references/reviewer-execution-protocol.md +0 -179
- package/bundled/skills/prizmkit-code-review/references/reviewer-workspace-protocol.md +0 -118
- package/bundled/skills/prizmkit-code-review/scripts/check_loop.py +0 -380
- package/bundled/skills/prizmkit-code-review/scripts/workspace_snapshot.py +0 -222
package/bundled/VERSION.json
CHANGED
|
@@ -46,10 +46,8 @@ function buildReviewerRoleDefinition(roleManifest) {
|
|
|
46
46
|
throw new Error(`Reviewer role must make ${capability} unavailable`);
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
if (roleManifest.execution_mode !== 'foreground-
|
|
50
|
-
|
|
51
|
-
|| roleManifest.max_delegation_depth !== 0) {
|
|
52
|
-
throw new Error('Reviewer role violates the single-authority execution topology');
|
|
49
|
+
if (roleManifest.execution_mode !== 'foreground-independent-review') {
|
|
50
|
+
throw new Error('Reviewer role must use foreground independent review');
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
const tools = (roleManifest.observational_capabilities || []).map(capability => {
|
|
@@ -64,14 +62,14 @@ name: ${roleManifest.role_name}
|
|
|
64
62
|
description: ${roleManifest.description}
|
|
65
63
|
tools: ${tools.join(', ')}
|
|
66
64
|
permissionMode: dontAsk
|
|
67
|
-
maxTurns:
|
|
65
|
+
maxTurns: 80
|
|
68
66
|
---
|
|
69
67
|
|
|
70
68
|
You are a capability-restricted read-only Reviewer execution role.
|
|
71
69
|
|
|
72
70
|
Read and follow the complete prompt supplied by the Main Agent. Perform the complete review yourself. Your tool allowlist intentionally excludes workspace mutation, shell execution, skill invocation, downstream execution creation, delegation-equivalent behavior, and orchestration re-entry.
|
|
73
71
|
|
|
74
|
-
If the supplied review cannot be completed with the available
|
|
72
|
+
If the supplied review cannot be completed with the available read-only capabilities and current context, return \`BLOCKED\` with the missing capability or context. Do not delegate, request a helper, mutate the workspace, or claim \`PASS\` from incomplete work.
|
|
75
73
|
`;
|
|
76
74
|
}
|
|
77
75
|
|
|
@@ -11,8 +11,23 @@ from dataclasses import dataclass
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from .commands import CommandResult # type: ignore # runtime boundary import
|
|
14
|
-
from .gitops import
|
|
14
|
+
from .gitops import (
|
|
15
|
+
BranchContext,
|
|
16
|
+
WorktreePolicy,
|
|
17
|
+
default_branch,
|
|
18
|
+
guarded_worktree_remove,
|
|
19
|
+
list_reset_branches,
|
|
20
|
+
reset_cleanup_branch,
|
|
21
|
+
worktree_runtime_context,
|
|
22
|
+
)
|
|
15
23
|
from .runner_models import RunnerFamily, family_for
|
|
24
|
+
from .task_checkout import (
|
|
25
|
+
CHECKOUT_MODE_WORKTREE,
|
|
26
|
+
TaskCheckout,
|
|
27
|
+
load_task_checkout,
|
|
28
|
+
mark_task_checkout_reset_locked,
|
|
29
|
+
task_checkout_guard,
|
|
30
|
+
)
|
|
16
31
|
|
|
17
32
|
|
|
18
33
|
@dataclass(frozen=True)
|
|
@@ -128,7 +143,12 @@ def run_reset_command(kind: str, legacy_args: tuple[str, ...], paths) -> Command
|
|
|
128
143
|
failed = 0
|
|
129
144
|
return_branch = default_branch(paths.project_root)
|
|
130
145
|
for item in items:
|
|
131
|
-
|
|
146
|
+
try:
|
|
147
|
+
with task_checkout_guard(family.state_dir, item.item_id):
|
|
148
|
+
ok = _reset_one(family, item, options, paths.project_root, return_branch, lines)
|
|
149
|
+
except RuntimeError as exc:
|
|
150
|
+
lines.append(f"ERROR: Reset {item.item_id} could not acquire checkout lifecycle: {exc}")
|
|
151
|
+
ok = False
|
|
132
152
|
if ok:
|
|
133
153
|
success += 1
|
|
134
154
|
else:
|
|
@@ -254,7 +274,25 @@ def _reset_one(family: RunnerFamily, item: ResetItem, options: ResetOptions, pro
|
|
|
254
274
|
else:
|
|
255
275
|
lines.append(f"Current status: {item.status} (no runtime state file)")
|
|
256
276
|
|
|
257
|
-
|
|
277
|
+
checkout = load_task_checkout(family.state_dir, family.task_type, item.item_id)
|
|
278
|
+
branches = list(list_reset_branches(project_root, BRANCH_PREFIX_BY_KIND[family.kind], item.item_id))
|
|
279
|
+
if checkout is not None and (checkout.is_active or checkout.is_completed):
|
|
280
|
+
if checkout.active_dev_branch not in branches:
|
|
281
|
+
branches.insert(0, checkout.active_dev_branch)
|
|
282
|
+
if checkout.checkout_mode == CHECKOUT_MODE_WORKTREE:
|
|
283
|
+
runtime = worktree_runtime_context(
|
|
284
|
+
BranchContext(checkout.base_branch, checkout.active_dev_branch, project_root),
|
|
285
|
+
WorktreePolicy(True, True, True, Path(checkout.worktree_path).parent),
|
|
286
|
+
)
|
|
287
|
+
if runtime.worktree_path != Path(checkout.worktree_path).resolve():
|
|
288
|
+
lines.append(f"ERROR: Reset {item.item_id} worktree identity resolves to an unexpected path")
|
|
289
|
+
return False
|
|
290
|
+
removal = guarded_worktree_remove(runtime, delete_branch=False)
|
|
291
|
+
if not removal.ok:
|
|
292
|
+
lines.append(f"ERROR: Reset {item.item_id} could not remove linked worktree: {removal.reason}")
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
for branch in branches:
|
|
258
296
|
cleanup = reset_cleanup_branch(project_root, branch, return_branch=return_branch, delete=options.clean)
|
|
259
297
|
lines.append(f"Discarded uncommitted changes on dev branch: {cleanup.branch}")
|
|
260
298
|
if cleanup.deleted:
|
|
@@ -262,11 +300,19 @@ def _reset_one(family: RunnerFamily, item: ResetItem, options: ResetOptions, pro
|
|
|
262
300
|
elif not options.clean:
|
|
263
301
|
lines.append(f"Dev branch preserved for debugging: {cleanup.branch}")
|
|
264
302
|
lines.extend(f"WARN: {warning}" for warning in cleanup.warnings)
|
|
303
|
+
if cleanup.warnings:
|
|
304
|
+
lines.append(f"ERROR: Reset {item.item_id} branch cleanup failed")
|
|
305
|
+
return False
|
|
265
306
|
|
|
266
307
|
action = "clean" if options.clean else "reset"
|
|
267
308
|
lines.append(f"{'Cleaning' if options.clean else 'Resetting'} {item.item_id} status...")
|
|
268
309
|
result = _run_updater(family, action, options, item, project_root)
|
|
269
310
|
if result.returncode == 0 and _json_without_error(result.stdout):
|
|
311
|
+
try:
|
|
312
|
+
mark_task_checkout_reset_locked(family.state_dir, family.task_type, item.item_id)
|
|
313
|
+
except RuntimeError as exc:
|
|
314
|
+
lines.append(f"ERROR: Reset {item.item_id} checkout identity failed: {exc}")
|
|
315
|
+
return False
|
|
270
316
|
lines.append(f"{item.item_id} {action} complete: status -> pending, retry count -> 0")
|
|
271
317
|
return True
|
|
272
318
|
error = _error_text(result.stdout) or result.stderr.strip() or "unknown"
|
|
@@ -40,6 +40,15 @@ from .runner_prompts import PromptGenerationResult, generate_prompt
|
|
|
40
40
|
from .runner_recovery import run_recovery, run_recovery_detect
|
|
41
41
|
from .runner_status import get_next, run_status_action, start_item, status_text, update_item
|
|
42
42
|
from .sessions import AISessionConfig, AISessionLauncher, detect_stream_json_support
|
|
43
|
+
from .task_checkout import (
|
|
44
|
+
CHECKOUT_MODE_BRANCH,
|
|
45
|
+
CHECKOUT_MODE_WORKTREE,
|
|
46
|
+
TaskCheckout,
|
|
47
|
+
complete_task_checkout_locked,
|
|
48
|
+
load_task_checkout,
|
|
49
|
+
reserve_task_checkout_locked,
|
|
50
|
+
task_checkout_guard,
|
|
51
|
+
)
|
|
43
52
|
|
|
44
53
|
|
|
45
54
|
def run_pipeline_command(kind: str, action: str, legacy_args: tuple[str, ...], paths) -> CommandResult:
|
|
@@ -247,8 +256,22 @@ def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) ->
|
|
|
247
256
|
return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
|
|
248
257
|
|
|
249
258
|
|
|
250
|
-
def _with_recovery_metadata(
|
|
259
|
+
def _with_recovery_metadata(
|
|
260
|
+
family: RunnerFamily,
|
|
261
|
+
item_id: str,
|
|
262
|
+
metadata: dict[str, object],
|
|
263
|
+
checkout: TaskCheckout | None = None,
|
|
264
|
+
) -> dict[str, object]:
|
|
251
265
|
enriched = dict(metadata)
|
|
266
|
+
checkout = checkout or load_task_checkout(family.state_dir, family.task_type, item_id)
|
|
267
|
+
if checkout is not None:
|
|
268
|
+
if checkout.is_active:
|
|
269
|
+
enriched["active_dev_branch"] = checkout.active_dev_branch
|
|
270
|
+
enriched["base_branch"] = checkout.base_branch
|
|
271
|
+
return enriched
|
|
272
|
+
for key in ("active_dev_branch", "base_branch"):
|
|
273
|
+
enriched.pop(key, None)
|
|
274
|
+
return enriched
|
|
252
275
|
if enriched.get("active_dev_branch") and enriched.get("base_branch"):
|
|
253
276
|
return enriched
|
|
254
277
|
recovered = _latest_session_status(family.state_dir, item_id)
|
|
@@ -431,6 +454,21 @@ def _process_item(
|
|
|
431
454
|
paths,
|
|
432
455
|
*,
|
|
433
456
|
initial_metadata: dict[str, object],
|
|
457
|
+
) -> str:
|
|
458
|
+
try:
|
|
459
|
+
with task_checkout_guard(family.state_dir, item_id):
|
|
460
|
+
return _process_item_locked(family, invocation, item_id, paths, initial_metadata=initial_metadata)
|
|
461
|
+
except KeyboardInterrupt:
|
|
462
|
+
raise
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _process_item_locked(
|
|
466
|
+
family: RunnerFamily,
|
|
467
|
+
invocation: RunnerInvocation,
|
|
468
|
+
item_id: str,
|
|
469
|
+
paths,
|
|
470
|
+
*,
|
|
471
|
+
initial_metadata: dict[str, object],
|
|
434
472
|
) -> str:
|
|
435
473
|
env = RunnerEnvironment.from_env()
|
|
436
474
|
config = load_runtime_config(paths)
|
|
@@ -438,12 +476,20 @@ def _process_item(
|
|
|
438
476
|
raise RuntimeError("No supported AI CLI command was found")
|
|
439
477
|
|
|
440
478
|
active_branch = current_branch(paths.project_root)
|
|
441
|
-
|
|
479
|
+
checkout = load_task_checkout(family.state_dir, family.task_type, item_id)
|
|
480
|
+
if checkout is not None and checkout.is_completed:
|
|
481
|
+
raise RuntimeError(f"Task {item_id} is completed; run explicit reset or clean before rerunning")
|
|
482
|
+
metadata = _with_recovery_metadata(family, item_id, initial_metadata, checkout)
|
|
442
483
|
base_branch = _resolve_base_branch(paths.project_root, metadata, active_branch, family, item_id)
|
|
443
484
|
branch_name = _resolve_working_branch(family, item_id, metadata, env, active_branch)
|
|
444
485
|
branch_context = BranchContext(base_branch, branch_name, paths.project_root)
|
|
445
486
|
worktree_runtime = None
|
|
446
|
-
|
|
487
|
+
if checkout is not None and checkout.is_active:
|
|
488
|
+
use_worktree = checkout.uses_worktree
|
|
489
|
+
worktree_path = Path(checkout.worktree_path) if checkout.worktree_path else None
|
|
490
|
+
else:
|
|
491
|
+
use_worktree = _use_worktree_for_family(env, family)
|
|
492
|
+
worktree_path = None
|
|
447
493
|
execution_root = paths.project_root
|
|
448
494
|
|
|
449
495
|
try:
|
|
@@ -468,9 +514,23 @@ def _process_item(
|
|
|
468
514
|
use_worktree=True,
|
|
469
515
|
cleanup_on_success=True,
|
|
470
516
|
preserve_on_failure=True,
|
|
471
|
-
worktree_root=paths.state_dir / "worktrees",
|
|
517
|
+
worktree_root=(worktree_path.parent if worktree_path is not None else paths.state_dir / "worktrees"),
|
|
472
518
|
),
|
|
473
519
|
)
|
|
520
|
+
if worktree_path is not None and worktree_runtime.worktree_path != worktree_path.resolve():
|
|
521
|
+
raise RuntimeError(f"Task {item_id} worktree identity resolves to an unexpected path")
|
|
522
|
+
checkout_candidate = TaskCheckout.active(
|
|
523
|
+
family.task_type,
|
|
524
|
+
item_id,
|
|
525
|
+
branch_name,
|
|
526
|
+
base_branch,
|
|
527
|
+
checkout_mode=CHECKOUT_MODE_WORKTREE if use_worktree else CHECKOUT_MODE_BRANCH,
|
|
528
|
+
worktree_path=str(worktree_runtime.worktree_path) if worktree_runtime is not None else "",
|
|
529
|
+
)
|
|
530
|
+
checkout = reserve_task_checkout_locked(family.state_dir, checkout_candidate)
|
|
531
|
+
_emit_info(f"Checkout: reserved task identity at {family.state_dir / item_id / 'checkout.json'}")
|
|
532
|
+
|
|
533
|
+
if use_worktree:
|
|
474
534
|
setup = ensure_linked_worktree(worktree_runtime)
|
|
475
535
|
if not setup.ok:
|
|
476
536
|
return "infra_error"
|
|
@@ -670,9 +730,7 @@ def _process_item(
|
|
|
670
730
|
if final_session_status != "success":
|
|
671
731
|
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
672
732
|
_save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
|
|
673
|
-
if
|
|
674
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
675
|
-
else:
|
|
733
|
+
if not use_worktree:
|
|
676
734
|
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
677
735
|
return new_status
|
|
678
736
|
|
|
@@ -697,7 +755,6 @@ def _process_item(
|
|
|
697
755
|
_emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
|
|
698
756
|
if use_worktree and worktree_runtime is not None:
|
|
699
757
|
worktree_save_wip(worktree_runtime)
|
|
700
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
701
758
|
else:
|
|
702
759
|
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
703
760
|
raise
|
|
@@ -789,6 +846,10 @@ def _complete_success_merge(
|
|
|
789
846
|
merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
|
|
790
847
|
_emit_git_plan_output(merge)
|
|
791
848
|
if merge.ok:
|
|
849
|
+
checkout = load_task_checkout(family.state_dir, family.task_type, item_id)
|
|
850
|
+
if checkout is None:
|
|
851
|
+
raise RuntimeError(f"Task {item_id} checkout identity disappeared before completion")
|
|
852
|
+
complete_task_checkout_locked(family.state_dir, checkout)
|
|
792
853
|
guarded_worktree_remove(worktree_runtime, delete_branch=True)
|
|
793
854
|
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
794
855
|
return new_status
|
|
@@ -797,6 +858,10 @@ def _complete_success_merge(
|
|
|
797
858
|
merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
|
|
798
859
|
_emit_git_plan_output(merge)
|
|
799
860
|
if merge.ok:
|
|
861
|
+
checkout = load_task_checkout(family.state_dir, family.task_type, item_id)
|
|
862
|
+
if checkout is None:
|
|
863
|
+
raise RuntimeError(f"Task {item_id} checkout identity disappeared before completion")
|
|
864
|
+
complete_task_checkout_locked(family.state_dir, checkout)
|
|
800
865
|
_emit_success(f"Merged {branch_name} into {base_branch}")
|
|
801
866
|
return new_status
|
|
802
867
|
|
|
@@ -829,9 +894,7 @@ def _complete_success_merge(
|
|
|
829
894
|
merged_status = _status_from_update(update)
|
|
830
895
|
commit_final_bookkeeping(status_root, status_family, item_id, merged_status)
|
|
831
896
|
_save_failure_wip(execution_root, family, item_id, merged_status, worktree_runtime)
|
|
832
|
-
if worktree_runtime is
|
|
833
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
834
|
-
else:
|
|
897
|
+
if worktree_runtime is None:
|
|
835
898
|
branch_ensure_return(paths.project_root, base_branch, branch_name)
|
|
836
899
|
return merged_status
|
|
837
900
|
|
|
@@ -876,9 +939,7 @@ def _record_setup_failure(
|
|
|
876
939
|
if update.ok:
|
|
877
940
|
commit_final_bookkeeping(status_root, status_family, item_id, new_status)
|
|
878
941
|
_save_failure_wip(status_root, family, item_id, new_status, worktree_runtime)
|
|
879
|
-
if worktree_runtime is
|
|
880
|
-
guarded_worktree_remove(worktree_runtime, delete_branch=False)
|
|
881
|
-
elif current_branch(project_root) == branch_name:
|
|
942
|
+
if worktree_runtime is None and current_branch(project_root) == branch_name:
|
|
882
943
|
branch_ensure_return(project_root, base_branch, branch_name)
|
|
883
944
|
return new_status
|
|
884
945
|
|
|
@@ -233,6 +233,39 @@ def _text_contains(args: argparse.Namespace) -> HelperResult:
|
|
|
233
233
|
return HelperResult(args.found_marker if found else args.missing_marker, found, {"path": str(target), "pattern": args.pattern})
|
|
234
234
|
|
|
235
235
|
|
|
236
|
+
def _text_final_verdict(args: argparse.Namespace) -> HelperResult:
|
|
237
|
+
target = _path(args.path)
|
|
238
|
+
if not target.is_file():
|
|
239
|
+
return HelperResult("REVIEW_INCOMPLETE", False, {"path": str(target), "reason": "file_missing"}, 1)
|
|
240
|
+
try:
|
|
241
|
+
lines = target.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
return HelperResult("REVIEW_INCOMPLETE", False, {"path": str(target), "reason": type(exc).__name__}, 1)
|
|
244
|
+
|
|
245
|
+
final_indexes = [index for index, line in enumerate(lines) if line.strip() == "## Final Result"]
|
|
246
|
+
if not final_indexes:
|
|
247
|
+
return HelperResult("REVIEW_INCOMPLETE", False, {"path": str(target), "reason": "final_result_missing"}, 1)
|
|
248
|
+
|
|
249
|
+
verdict = ""
|
|
250
|
+
for line in lines[final_indexes[-1] + 1:]:
|
|
251
|
+
stripped = line.strip()
|
|
252
|
+
if stripped.startswith("## "):
|
|
253
|
+
break
|
|
254
|
+
if stripped.startswith("- Verdict:"):
|
|
255
|
+
verdict = stripped[len("- Verdict:"):].strip()
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
markers = {
|
|
259
|
+
"PASS": "REVIEW_PASS",
|
|
260
|
+
"NEEDS_FIXES": "REVIEW_NEEDS_FIXES",
|
|
261
|
+
"BLOCKED": "REVIEW_BLOCKED",
|
|
262
|
+
}
|
|
263
|
+
marker = markers.get(verdict)
|
|
264
|
+
if not marker:
|
|
265
|
+
return HelperResult("REVIEW_INVALID", False, {"path": str(target), "verdict": verdict or None}, 1)
|
|
266
|
+
return HelperResult(marker, True, {"path": str(target), "verdict": verdict})
|
|
267
|
+
|
|
268
|
+
|
|
236
269
|
def _platform_detect(args: argparse.Namespace) -> HelperResult:
|
|
237
270
|
paths = resolve_runtime_paths(project_root=args.project_root, pipeline_root=args.pipeline_root)
|
|
238
271
|
resolution = resolve_platform(paths.project_root)
|
|
@@ -411,6 +444,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
411
444
|
contains.add_argument("--missing-marker", default="GATE:MISSING")
|
|
412
445
|
_add_json(contains)
|
|
413
446
|
contains.set_defaults(func=_text_contains)
|
|
447
|
+
final_verdict = text_sub.add_parser("final-verdict", help="Read the verdict from the last review Final Result section")
|
|
448
|
+
final_verdict.add_argument("path")
|
|
449
|
+
_add_json(final_verdict)
|
|
450
|
+
final_verdict.set_defaults(func=_text_final_verdict)
|
|
414
451
|
|
|
415
452
|
platform = subparsers.add_parser("platform", help="AI platform and skill detection")
|
|
416
453
|
platform_sub = platform.add_subparsers(dest="action", required=True)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Durable task checkout identity for pipeline branch and worktree reuse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CHECKOUT_STATE_ACTIVE = "active"
|
|
17
|
+
CHECKOUT_STATE_COMPLETED = "completed"
|
|
18
|
+
CHECKOUT_STATE_RESET = "reset"
|
|
19
|
+
CHECKOUT_MODE_BRANCH = "branch"
|
|
20
|
+
CHECKOUT_MODE_WORKTREE = "worktree"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskCheckoutError(RuntimeError):
|
|
24
|
+
"""Raised when durable checkout identity cannot be coordinated safely."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TaskCheckout:
|
|
29
|
+
"""Checkout identity that remains authoritative until explicit reset or clean."""
|
|
30
|
+
|
|
31
|
+
task_type: str
|
|
32
|
+
task_id: str
|
|
33
|
+
state: str
|
|
34
|
+
active_dev_branch: str = ""
|
|
35
|
+
base_branch: str = ""
|
|
36
|
+
checkout_mode: str = CHECKOUT_MODE_BRANCH
|
|
37
|
+
worktree_path: str = ""
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def active(
|
|
41
|
+
cls,
|
|
42
|
+
task_type: str,
|
|
43
|
+
task_id: str,
|
|
44
|
+
active_dev_branch: str,
|
|
45
|
+
base_branch: str,
|
|
46
|
+
*,
|
|
47
|
+
checkout_mode: str = CHECKOUT_MODE_BRANCH,
|
|
48
|
+
worktree_path: str = "",
|
|
49
|
+
) -> "TaskCheckout":
|
|
50
|
+
branch = active_dev_branch.strip()
|
|
51
|
+
base = base_branch.strip()
|
|
52
|
+
mode = checkout_mode.strip()
|
|
53
|
+
path = worktree_path.strip()
|
|
54
|
+
if not branch or not base:
|
|
55
|
+
raise TaskCheckoutError("Active task checkout requires development and base branches")
|
|
56
|
+
if mode not in {CHECKOUT_MODE_BRANCH, CHECKOUT_MODE_WORKTREE}:
|
|
57
|
+
raise TaskCheckoutError(f"Unsupported task checkout mode: {mode or '(empty)'}")
|
|
58
|
+
if mode == CHECKOUT_MODE_WORKTREE and not path:
|
|
59
|
+
raise TaskCheckoutError("Worktree task checkout requires a canonical worktree path")
|
|
60
|
+
if mode == CHECKOUT_MODE_BRANCH:
|
|
61
|
+
path = ""
|
|
62
|
+
return cls(task_type, task_id, CHECKOUT_STATE_ACTIVE, branch, base, mode, path)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def reset(cls, task_type: str, task_id: str) -> "TaskCheckout":
|
|
66
|
+
return cls(task_type=task_type, task_id=task_id, state=CHECKOUT_STATE_RESET)
|
|
67
|
+
|
|
68
|
+
def completed(self) -> "TaskCheckout":
|
|
69
|
+
if not self.is_active:
|
|
70
|
+
raise TaskCheckoutError("Only an active task checkout can be completed")
|
|
71
|
+
return TaskCheckout(
|
|
72
|
+
task_type=self.task_type,
|
|
73
|
+
task_id=self.task_id,
|
|
74
|
+
state=CHECKOUT_STATE_COMPLETED,
|
|
75
|
+
active_dev_branch=self.active_dev_branch,
|
|
76
|
+
base_branch=self.base_branch,
|
|
77
|
+
checkout_mode=self.checkout_mode,
|
|
78
|
+
worktree_path=self.worktree_path,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_active(self) -> bool:
|
|
83
|
+
return self.state == CHECKOUT_STATE_ACTIVE
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_completed(self) -> bool:
|
|
87
|
+
return self.state == CHECKOUT_STATE_COMPLETED
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_reset(self) -> bool:
|
|
91
|
+
return self.state == CHECKOUT_STATE_RESET
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def uses_worktree(self) -> bool:
|
|
95
|
+
return self.checkout_mode == CHECKOUT_MODE_WORKTREE
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def task_checkout_path(state_dir: Path | str, task_id: str) -> Path:
|
|
99
|
+
"""Return the canonical checkout identity path for one task."""
|
|
100
|
+
return Path(state_dir) / task_id / "checkout.json"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def task_checkout_lock_path(state_dir: Path | str, task_id: str) -> Path:
|
|
104
|
+
"""Return the exclusive lifecycle lock directory for one task."""
|
|
105
|
+
return Path(state_dir) / task_id / ".checkout.lock"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_task_checkout(state_dir: Path | str, task_type: str, task_id: str) -> TaskCheckout | None:
|
|
109
|
+
"""Load and validate checkout identity, returning None only when absent."""
|
|
110
|
+
path = task_checkout_path(state_dir, task_id)
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
115
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
116
|
+
raise TaskCheckoutError(f"Cannot read task checkout identity {path}: {exc}") from exc
|
|
117
|
+
if not isinstance(data, dict):
|
|
118
|
+
raise TaskCheckoutError(f"Invalid task checkout identity {path}: expected a JSON object")
|
|
119
|
+
|
|
120
|
+
checkout = TaskCheckout(
|
|
121
|
+
task_type=str(data.get("task_type") or ""),
|
|
122
|
+
task_id=str(data.get("task_id") or ""),
|
|
123
|
+
state=str(data.get("state") or ""),
|
|
124
|
+
active_dev_branch=str(data.get("active_dev_branch") or ""),
|
|
125
|
+
base_branch=str(data.get("base_branch") or ""),
|
|
126
|
+
checkout_mode=str(data.get("checkout_mode") or CHECKOUT_MODE_BRANCH),
|
|
127
|
+
worktree_path=str(data.get("worktree_path") or ""),
|
|
128
|
+
)
|
|
129
|
+
if checkout.task_type != task_type or checkout.task_id != task_id:
|
|
130
|
+
raise TaskCheckoutError(f"Task checkout identity mismatch in {path}")
|
|
131
|
+
if checkout.state not in {CHECKOUT_STATE_ACTIVE, CHECKOUT_STATE_COMPLETED, CHECKOUT_STATE_RESET}:
|
|
132
|
+
raise TaskCheckoutError(f"Invalid task checkout state in {path}: {checkout.state or '(empty)'}")
|
|
133
|
+
if checkout.checkout_mode not in {CHECKOUT_MODE_BRANCH, CHECKOUT_MODE_WORKTREE}:
|
|
134
|
+
raise TaskCheckoutError(f"Invalid task checkout mode in {path}: {checkout.checkout_mode or '(empty)'}")
|
|
135
|
+
if checkout.is_active or checkout.is_completed:
|
|
136
|
+
if not checkout.active_dev_branch.strip() or not checkout.base_branch.strip():
|
|
137
|
+
raise TaskCheckoutError(f"Incomplete task checkout identity in {path}")
|
|
138
|
+
if checkout.uses_worktree and not checkout.worktree_path.strip():
|
|
139
|
+
raise TaskCheckoutError(f"Missing worktree path in task checkout identity {path}")
|
|
140
|
+
return checkout
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _process_is_running(pid: int) -> bool:
|
|
144
|
+
if pid <= 0:
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
os.kill(pid, 0)
|
|
148
|
+
except ProcessLookupError:
|
|
149
|
+
return False
|
|
150
|
+
except PermissionError:
|
|
151
|
+
return True
|
|
152
|
+
except OSError:
|
|
153
|
+
return False
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _lock_owner_pid(lock_path: Path) -> int:
|
|
158
|
+
for _attempt in range(5):
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads((lock_path / "owner.json").read_text(encoding="utf-8"))
|
|
161
|
+
return int(data.get("pid") or 0) if isinstance(data, dict) else 0
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
time.sleep(0.01)
|
|
164
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
165
|
+
return 0
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _remove_stale_lock(lock_path: Path) -> None:
|
|
170
|
+
stale_path = lock_path.with_name(f"{lock_path.name}.stale-{os.getpid()}-{os.urandom(6).hex()}")
|
|
171
|
+
try:
|
|
172
|
+
os.rename(lock_path, stale_path)
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
return
|
|
175
|
+
except OSError as exc:
|
|
176
|
+
raise TaskCheckoutError(f"Cannot claim stale task checkout lock {lock_path}: {exc}") from exc
|
|
177
|
+
shutil.rmtree(stale_path, ignore_errors=True)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@contextmanager
|
|
181
|
+
def task_checkout_guard(state_dir: Path | str, task_id: str) -> Iterator[None]:
|
|
182
|
+
"""Exclusively guard one task lifecycle, reclaiming locks from dead processes."""
|
|
183
|
+
lock_path = task_checkout_lock_path(state_dir, task_id)
|
|
184
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
acquired = False
|
|
186
|
+
for _attempt in range(3):
|
|
187
|
+
try:
|
|
188
|
+
lock_path.mkdir()
|
|
189
|
+
(lock_path / "owner.json").write_text(json.dumps({"pid": os.getpid()}) + "\n", encoding="utf-8")
|
|
190
|
+
acquired = True
|
|
191
|
+
break
|
|
192
|
+
except FileExistsError:
|
|
193
|
+
owner_pid = _lock_owner_pid(lock_path)
|
|
194
|
+
if _process_is_running(owner_pid):
|
|
195
|
+
raise TaskCheckoutError(f"Task {task_id} is already running or being reset (PID {owner_pid})")
|
|
196
|
+
_remove_stale_lock(lock_path)
|
|
197
|
+
except OSError as exc:
|
|
198
|
+
raise TaskCheckoutError(f"Cannot acquire task checkout lock {lock_path}: {exc}") from exc
|
|
199
|
+
if not acquired:
|
|
200
|
+
raise TaskCheckoutError(f"Cannot acquire task checkout lock {lock_path}")
|
|
201
|
+
try:
|
|
202
|
+
yield
|
|
203
|
+
finally:
|
|
204
|
+
shutil.rmtree(lock_path, ignore_errors=True)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def persist_task_checkout(state_dir: Path | str, checkout: TaskCheckout) -> Path:
|
|
208
|
+
"""Atomically persist checkout identity while the caller holds its task guard."""
|
|
209
|
+
path = task_checkout_path(state_dir, checkout.task_id)
|
|
210
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
descriptor = -1
|
|
212
|
+
temporary = ""
|
|
213
|
+
try:
|
|
214
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".checkout-", suffix=".json", dir=path.parent)
|
|
215
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
216
|
+
descriptor = -1
|
|
217
|
+
json.dump(asdict(checkout), handle, indent=2, ensure_ascii=False)
|
|
218
|
+
handle.write("\n")
|
|
219
|
+
handle.flush()
|
|
220
|
+
os.fsync(handle.fileno())
|
|
221
|
+
os.replace(temporary, path)
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
if descriptor >= 0:
|
|
224
|
+
os.close(descriptor)
|
|
225
|
+
if temporary:
|
|
226
|
+
try:
|
|
227
|
+
os.unlink(temporary)
|
|
228
|
+
except OSError:
|
|
229
|
+
pass
|
|
230
|
+
raise TaskCheckoutError(f"Cannot persist task checkout identity {path}: {exc}") from exc
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def reserve_task_checkout_locked(state_dir: Path | str, candidate: TaskCheckout) -> TaskCheckout:
|
|
235
|
+
"""Reserve once under a task guard; matching reservations are idempotent."""
|
|
236
|
+
if not candidate.is_active:
|
|
237
|
+
raise TaskCheckoutError("Only an active checkout can be reserved")
|
|
238
|
+
existing = load_task_checkout(state_dir, candidate.task_type, candidate.task_id)
|
|
239
|
+
if existing is not None and existing.is_completed:
|
|
240
|
+
raise TaskCheckoutError(f"Task {candidate.task_id} is completed; run explicit reset or clean before rerunning")
|
|
241
|
+
if existing is not None and existing.is_active:
|
|
242
|
+
if existing != candidate:
|
|
243
|
+
raise TaskCheckoutError(
|
|
244
|
+
f"Task {candidate.task_id} already owns checkout {existing.active_dev_branch}; refusing {candidate.active_dev_branch}"
|
|
245
|
+
)
|
|
246
|
+
return existing
|
|
247
|
+
persist_task_checkout(state_dir, candidate)
|
|
248
|
+
return candidate
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def reserve_task_checkout(state_dir: Path | str, candidate: TaskCheckout) -> TaskCheckout:
|
|
252
|
+
"""Reserve a checkout with first-writer-wins synchronization."""
|
|
253
|
+
with task_checkout_guard(state_dir, candidate.task_id):
|
|
254
|
+
return reserve_task_checkout_locked(state_dir, candidate)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def complete_task_checkout_locked(state_dir: Path | str, checkout: TaskCheckout) -> Path:
|
|
258
|
+
"""Mark an active checkout terminal after its successful merge."""
|
|
259
|
+
current = load_task_checkout(state_dir, checkout.task_type, checkout.task_id)
|
|
260
|
+
if current != checkout or not checkout.is_active:
|
|
261
|
+
raise TaskCheckoutError(f"Task {checkout.task_id} checkout changed before completion")
|
|
262
|
+
return persist_task_checkout(state_dir, checkout.completed())
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def mark_task_checkout_reset_locked(state_dir: Path | str, task_type: str, task_id: str) -> Path:
|
|
266
|
+
"""Publish an explicit reset boundary while the caller holds the task guard."""
|
|
267
|
+
return persist_task_checkout(state_dir, TaskCheckout.reset(task_type, task_id))
|
|
@@ -96,10 +96,10 @@ Implement the minimal fix (red → green):
|
|
|
96
96
|
"Review",
|
|
97
97
|
"""\
|
|
98
98
|
Verify fix quality:
|
|
99
|
-
1.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
1. Run `/prizmkit-code-review` with the current bugfix artifact directory.
|
|
100
|
+
2. The Main Agent self-reviews for up to ten rounds; semantic risk limits independent Reviewers to low=0, medium=1, high=2.
|
|
101
|
+
3. Reviewer 2 requires an accepted high finding from Reviewer 1 plus repair, tests, and Main-Agent reconvergence; Reviewer 3 is forbidden.
|
|
102
|
+
4. Continue only after the report's last Final Result is valid; preserve append-only progress for recovery.""",
|
|
103
103
|
),
|
|
104
104
|
6: (
|
|
105
105
|
"User Verification",
|
|
@@ -30,7 +30,7 @@ Infer what needs to be done from the feature context above and follow the standa
|
|
|
30
30
|
|
|
31
31
|
3. **Implement**: Run `/prizmkit-implement` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/` to execute the plan using TDD (write tests first, then implement).
|
|
32
32
|
|
|
33
|
-
4. **Review**: Run `/prizmkit-code-review` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}
|
|
33
|
+
4. **Review**: Run `/prizmkit-code-review` with `artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/`. The Main Agent self-reviews for up to ten rounds; semantic risk then limits independent Reviewers to `low=0`, `medium=1`, `high=2`, with Reviewer 2 only after an accepted high finding is repaired and reconverged. Reviewer 3 is forbidden. Require the last `## Final Result` verdict before continuing.
|
|
34
34
|
|
|
35
35
|
5. **Test**: Run `/prizmkit-test scope=this-change artifact_dir=.prizmkit/specs/{{FEATURE_SLUG}}/` to generate and verify tests only for this feature's changed scope after review. Do not use bugfix/refactor artifact directories for this gate.
|
|
36
36
|
|