prizmkit 1.1.149 → 1.1.150
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 +20 -17
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +77 -0
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +234 -39
- package/bundled/dev-pipeline/prizmkit_runtime/reset.py +57 -30
- package/bundled/dev-pipeline/prizmkit_runtime/reset_preserve.py +822 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +28 -11
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +71 -28
- package/bundled/dev-pipeline/prizmkit_runtime/task_checkout.py +18 -0
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +67 -13
- package/bundled/dev-pipeline/tests/test_reset_preserve.py +959 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +325 -5
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +21 -9
- package/bundled/skills/feature-pipeline-launcher/SKILL.md +21 -9
- package/bundled/skills/refactor-pipeline-launcher/SKILL.md +21 -9
- package/bundled/templates/project-memory-template.md +38 -0
- package/package.json +1 -1
package/bundled/VERSION.json
CHANGED
|
@@ -96,34 +96,36 @@ If `.prizmkit/plans/feature-list.json` path is omitted, defaults to `.prizmkit/p
|
|
|
96
96
|
|
|
97
97
|
---
|
|
98
98
|
|
|
99
|
-
### `cli.py reset feature` — Reset
|
|
99
|
+
### `cli.py reset feature` — Reset Feature Recovery State
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
Reset is state-only and never starts execution. `--run` is unsupported; invoke `feature run` separately after a successful reset.
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
-
python3 ./cli.py reset feature <feature-id> [--clean] [
|
|
104
|
+
python3 ./cli.py reset feature <feature-id> [--clean] [.prizmkit/plans/feature-list.json]
|
|
105
|
+
python3 ./cli.py reset feature --failed --preserve-runtime [.prizmkit/plans/feature-list.json]
|
|
105
106
|
```
|
|
106
107
|
|
|
107
108
|
| Flag | Description |
|
|
108
109
|
|------|-------------|
|
|
109
|
-
| `--clean` | Delete session history
|
|
110
|
-
| `--
|
|
110
|
+
| `--clean` | Delete the task branch, session history, and `.prizmkit/specs/{slug}/` artifacts |
|
|
111
|
+
| `--failed --preserve-runtime` | Restore every failed chain in the list while retaining checkout identity, branches/worktrees, sessions, checkpoints, artifacts, and WIP |
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
- `state/features/F-XXX/sessions/` — all session logs and prompts
|
|
114
|
-
- `.prizmkit/specs/{feature-slug}/` — spec.md, plan.md, context-snapshot.md
|
|
115
|
-
|
|
116
|
-
**What is always reset (with or without `--clean`):**
|
|
117
|
-
- `status.json` — status → pending, retry_count → 0
|
|
118
|
-
- `.prizmkit/plans/feature-list.json` — feature status → pending
|
|
113
|
+
`--preserve-runtime` requires `--failed` and cannot be combined with `--clean`, an item ID/range, or another reset filter. It restores every `failed` root plus causal `auto_skipped` descendants, clears code/infrastructure retry counters, and marks continuation pending without clearing existing history or failure metadata. Valid checkout/branch/worktree/checkpoint identity is reused unchanged; when the recorded branch is missing, reset creates a replacement from the recorded base, retains old artifacts for reference, and rewinds implementation-and-later checkpoint stages to `pending`.
|
|
119
114
|
|
|
120
115
|
**Examples:**
|
|
121
116
|
|
|
122
117
|
```bash
|
|
123
|
-
|
|
124
|
-
python3 ./.prizmkit/dev-pipeline/cli.py reset feature
|
|
125
|
-
python3 ./.prizmkit/dev-pipeline/cli.py
|
|
118
|
+
# Preserve all failed runtime chains, then execute separately
|
|
119
|
+
python3 ./.prizmkit/dev-pipeline/cli.py reset feature --failed --preserve-runtime
|
|
120
|
+
python3 ./.prizmkit/dev-pipeline/cli.py feature run .prizmkit/plans/feature-list.json
|
|
121
|
+
|
|
122
|
+
# Fresh single-feature reset, then execute separately
|
|
123
|
+
python3 ./.prizmkit/dev-pipeline/cli.py reset feature F-007
|
|
124
|
+
python3 ./.prizmkit/dev-pipeline/cli.py feature run F-007
|
|
125
|
+
|
|
126
|
+
# Destructive clean reset, then execute separately
|
|
126
127
|
python3 ./.prizmkit/dev-pipeline/cli.py reset feature F-007 --clean my-features.json
|
|
128
|
+
python3 ./.prizmkit/dev-pipeline/cli.py feature run F-007 my-features.json
|
|
127
129
|
```
|
|
128
130
|
|
|
129
131
|
---
|
|
@@ -598,8 +600,9 @@ Specify a model for individual features in `.prizmkit/plans/feature-list.json`:
|
|
|
598
600
|
# All features without a per-feature model use Sonnet
|
|
599
601
|
MODEL=claude-sonnet-4.6 python3 ./.prizmkit/dev-pipeline/cli.py feature run .prizmkit/plans/feature-list.json
|
|
600
602
|
|
|
601
|
-
#
|
|
602
|
-
|
|
603
|
+
# Destructively reset, then retry with Opus as a separate run
|
|
604
|
+
python3 ./.prizmkit/dev-pipeline/cli.py reset feature F-007 --clean
|
|
605
|
+
MODEL=claude-opus-4.6 python3 ./.prizmkit/dev-pipeline/cli.py feature run F-007
|
|
603
606
|
|
|
604
607
|
# Test which model the CLI is using
|
|
605
608
|
MODEL=claude-sonnet-4.6 python3 ./.prizmkit/dev-pipeline/cli.py feature test-cli
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import copy
|
|
5
6
|
import json
|
|
6
7
|
import re
|
|
7
8
|
from dataclasses import dataclass, field
|
|
@@ -443,6 +444,82 @@ def invalid_existing_checkpoint(path: str | Path | None) -> CheckpointState | No
|
|
|
443
444
|
return None
|
|
444
445
|
|
|
445
446
|
|
|
447
|
+
def rewind_checkpoint_for_implementation(
|
|
448
|
+
data: Mapping[str, Any],
|
|
449
|
+
*,
|
|
450
|
+
path: str | Path | None = None,
|
|
451
|
+
project_root: str | Path | None = None,
|
|
452
|
+
) -> dict[str, Any]:
|
|
453
|
+
"""Return a validated copy rewound from implementation through completion."""
|
|
454
|
+
state = validate_checkpoint_data(
|
|
455
|
+
data,
|
|
456
|
+
path=path,
|
|
457
|
+
exists=True,
|
|
458
|
+
project_root=project_root,
|
|
459
|
+
)
|
|
460
|
+
if not state.valid:
|
|
461
|
+
raise ValueError(f"Invalid checkpoint: {state.error_code}: {state.error_message}")
|
|
462
|
+
if not state.semantic.sequence_valid:
|
|
463
|
+
detail = state.semantic.error_message or "mandatory stage sequence is invalid"
|
|
464
|
+
raise ValueError(f"Invalid checkpoint: {detail}")
|
|
465
|
+
|
|
466
|
+
implementation_indexes = [
|
|
467
|
+
step.index for step in state.steps if step.skill == "prizmkit-implement"
|
|
468
|
+
]
|
|
469
|
+
if len(implementation_indexes) != 1:
|
|
470
|
+
raise ValueError("Invalid checkpoint: expected exactly one prizmkit-implement step")
|
|
471
|
+
|
|
472
|
+
rewound = copy.deepcopy(dict(data))
|
|
473
|
+
raw_steps = rewound.get("steps")
|
|
474
|
+
if not isinstance(raw_steps, list):
|
|
475
|
+
raise ValueError("Invalid checkpoint: steps must be an array")
|
|
476
|
+
implementation_index = implementation_indexes[0]
|
|
477
|
+
for raw_step in raw_steps[implementation_index:]:
|
|
478
|
+
if not isinstance(raw_step, dict):
|
|
479
|
+
raise ValueError("Invalid checkpoint: every step must be an object")
|
|
480
|
+
raw_step["status"] = "pending"
|
|
481
|
+
raw_step.pop("stage_result", None)
|
|
482
|
+
|
|
483
|
+
state_key = semantic_state_key(state.semantic.workflow_type)
|
|
484
|
+
raw_semantic = rewound.get(state_key)
|
|
485
|
+
if not isinstance(raw_semantic, dict):
|
|
486
|
+
raise ValueError(f"Invalid checkpoint: {state_key} must be an object")
|
|
487
|
+
raw_semantic.update(
|
|
488
|
+
{
|
|
489
|
+
"stage": "implement",
|
|
490
|
+
"current_stage": "prizmkit-implement",
|
|
491
|
+
"status": "pending",
|
|
492
|
+
"repair_scope": None,
|
|
493
|
+
"repair_round": 0,
|
|
494
|
+
"next_stage": "prizmkit-implement",
|
|
495
|
+
"resume_from": "prizmkit-implement",
|
|
496
|
+
"blocked_reason": None,
|
|
497
|
+
"terminal_status": None,
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
raw_semantic.pop("stage_result", None)
|
|
501
|
+
if isinstance(raw_semantic.get("completed_stages"), list):
|
|
502
|
+
raw_semantic["completed_stages"] = [
|
|
503
|
+
value
|
|
504
|
+
for value in raw_semantic["completed_stages"]
|
|
505
|
+
if value in {"plan", "prizmkit-plan"}
|
|
506
|
+
]
|
|
507
|
+
for key in ("complete", "completion_ready", "test_result_valid"):
|
|
508
|
+
if key in raw_semantic:
|
|
509
|
+
raw_semantic[key] = False
|
|
510
|
+
|
|
511
|
+
validated = validate_checkpoint_data(
|
|
512
|
+
rewound,
|
|
513
|
+
path=path,
|
|
514
|
+
exists=True,
|
|
515
|
+
project_root=project_root,
|
|
516
|
+
)
|
|
517
|
+
if not validated.valid or not validated.semantic.sequence_valid:
|
|
518
|
+
detail = validated.error_message or validated.semantic.error_message or "rewind validation failed"
|
|
519
|
+
raise ValueError(f"Invalid rewound checkpoint: {detail}")
|
|
520
|
+
return rewound
|
|
521
|
+
|
|
522
|
+
|
|
446
523
|
def semantic_state_key(workflow_type: str) -> str:
|
|
447
524
|
"""Return the family-owned L4 semantic metadata key."""
|
|
448
525
|
return SEMANTIC_STATE_KEYS.get(workflow_type, "l4_state")
|
|
@@ -23,8 +23,8 @@ PIPELINE_METADATA_EXCLUDES = (
|
|
|
23
23
|
|
|
24
24
|
HIDDEN_TOOL_WORKTREE_EXCLUDES = (
|
|
25
25
|
*PIPELINE_METADATA_EXCLUDES,
|
|
26
|
-
":(top,exclude)
|
|
27
|
-
":(top,exclude)
|
|
26
|
+
":(top,exclude)skills-lock.json",
|
|
27
|
+
":(top,exclude)AGENTS.md",
|
|
28
28
|
":(top,exclude).claude",
|
|
29
29
|
":(top,exclude,glob).claude/**",
|
|
30
30
|
":(top,exclude).codebuddy",
|
|
@@ -126,6 +126,14 @@ class WorktreeSupportAssetsResult:
|
|
|
126
126
|
reason: str = ""
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class RegisteredWorktree:
|
|
131
|
+
"""One path/branch pair reported by ``git worktree list --porcelain``."""
|
|
132
|
+
|
|
133
|
+
path: Path
|
|
134
|
+
branch: str = ""
|
|
135
|
+
|
|
136
|
+
|
|
129
137
|
def _safe_worktree_name(branch_name: str) -> str:
|
|
130
138
|
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", branch_name.strip())
|
|
131
139
|
safe = safe.strip(".-")
|
|
@@ -194,20 +202,59 @@ def worktree_remove_plan(runtime: WorktreeRuntimeContext, *, delete_branch: bool
|
|
|
194
202
|
)
|
|
195
203
|
|
|
196
204
|
|
|
197
|
-
def
|
|
198
|
-
"""Return
|
|
199
|
-
result = run_git_command(
|
|
205
|
+
def registered_worktrees(project_root: Path) -> tuple[RegisteredWorktree, ...]:
|
|
206
|
+
"""Return registered worktree identities without changing repository state."""
|
|
207
|
+
result = run_git_command(project_root, ("worktree", "list", "--porcelain"))
|
|
200
208
|
if result.return_code != 0:
|
|
201
|
-
return
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
return ()
|
|
210
|
+
registrations: list[RegisteredWorktree] = []
|
|
211
|
+
current_path: Path | None = None
|
|
212
|
+
current_branch = ""
|
|
213
|
+
for line in (*result.stdout.splitlines(), ""):
|
|
205
214
|
stripped = line.strip()
|
|
206
215
|
if stripped.startswith("worktree "):
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
if current_path is not None:
|
|
217
|
+
registrations.append(RegisteredWorktree(current_path.resolve(), current_branch))
|
|
218
|
+
current_path = Path(stripped.removeprefix("worktree ").strip())
|
|
219
|
+
current_branch = ""
|
|
220
|
+
elif stripped.startswith("branch "):
|
|
221
|
+
current_branch = stripped.removeprefix("branch ").strip().removeprefix("refs/heads/")
|
|
222
|
+
elif not stripped and current_path is not None:
|
|
223
|
+
registrations.append(RegisteredWorktree(current_path.resolve(), current_branch))
|
|
224
|
+
current_path = None
|
|
225
|
+
current_branch = ""
|
|
226
|
+
elif current_path is None and stripped:
|
|
227
|
+
legacy = re.match(r"^(.*?)\s+[0-9a-fA-F]+\s+\[([^]]+)]$", stripped)
|
|
228
|
+
if legacy:
|
|
229
|
+
registrations.append(
|
|
230
|
+
RegisteredWorktree(Path(legacy.group(1)).resolve(), legacy.group(2))
|
|
231
|
+
)
|
|
232
|
+
return tuple(registrations)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def worktree_registered(runtime: WorktreeRuntimeContext) -> bool:
|
|
236
|
+
"""Return whether Git metadata contains the resolved path and task branch."""
|
|
237
|
+
target = runtime.worktree_path.resolve()
|
|
238
|
+
branch = runtime.context.working_branch
|
|
239
|
+
return any(
|
|
240
|
+
registration.path == target and registration.branch == branch
|
|
241
|
+
for registration in registered_worktrees(runtime.context.project_root)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def local_branch_exists(project_root: Path, branch: str) -> bool:
|
|
246
|
+
"""Return whether an exact local branch ref exists."""
|
|
247
|
+
if not branch.strip():
|
|
248
|
+
return False
|
|
249
|
+
return run_git_command(
|
|
250
|
+
project_root,
|
|
251
|
+
("show-ref", "--verify", "--quiet", f"refs/heads/{branch}"),
|
|
252
|
+
).return_code == 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def create_branch_from_base(project_root: Path, branch: str, base_branch: str) -> GitCommandResult:
|
|
256
|
+
"""Create a local branch from a validated base without checking it out."""
|
|
257
|
+
return run_git_command(project_root, ("branch", branch, base_branch))
|
|
211
258
|
|
|
212
259
|
|
|
213
260
|
def mark_worktree_sentinel(runtime: WorktreeRuntimeContext) -> None:
|
|
@@ -555,13 +602,31 @@ def branch_merge_plan(context: BranchContext, *, auto_push: bool = False) -> Git
|
|
|
555
602
|
)
|
|
556
603
|
|
|
557
604
|
|
|
558
|
-
def
|
|
605
|
+
def _literal_pathspecs(paths: Sequence[str]) -> tuple[str, ...]:
|
|
606
|
+
return tuple(f":(top,literal){path}" for path in paths)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _wip_pathspecs(paths: Sequence[str] | None) -> tuple[str, ...]:
|
|
610
|
+
return _literal_pathspecs(paths) if paths is not None else (".",)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def branch_save_wip_plan(
|
|
614
|
+
context: BranchContext,
|
|
615
|
+
*,
|
|
616
|
+
commit_paths: Sequence[str] | None = None,
|
|
617
|
+
add_paths: Sequence[str] | None = None,
|
|
618
|
+
) -> GitCommandPlan:
|
|
559
619
|
"""Plan WIP preservation that fails when task state cannot be committed."""
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
620
|
+
commit_pathspecs = _wip_pathspecs(commit_paths)
|
|
621
|
+
add_pathspecs = _wip_pathspecs(add_paths if add_paths is not None else commit_paths)
|
|
622
|
+
commands = [
|
|
623
|
+
_git("rev-parse", "--abbrev-ref", "HEAD", description="verify current branch"),
|
|
624
|
+
_git("ls-files", "-u", description="require no unmerged paths before WIP commit"),
|
|
625
|
+
]
|
|
626
|
+
if add_pathspecs:
|
|
627
|
+
commands.append(_git("add", "-A", "--", *add_pathspecs, description="stage Git-visible work"))
|
|
628
|
+
commands.extend(
|
|
629
|
+
(
|
|
565
630
|
_git(
|
|
566
631
|
"commit",
|
|
567
632
|
"--no-verify",
|
|
@@ -571,26 +636,42 @@ def branch_save_wip_plan(context: BranchContext) -> GitCommandPlan:
|
|
|
571
636
|
"Pipeline was interrupted by signal. This commit preserves work-in-progress.",
|
|
572
637
|
"-m",
|
|
573
638
|
f"To resume: git checkout {context.working_branch}",
|
|
639
|
+
"--",
|
|
640
|
+
*commit_pathspecs,
|
|
574
641
|
description="create WIP commit",
|
|
575
642
|
),
|
|
576
643
|
_git(
|
|
577
644
|
"status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES,
|
|
578
645
|
description="require clean task checkout after WIP commit",
|
|
579
646
|
),
|
|
580
|
-
)
|
|
581
|
-
|
|
647
|
+
)
|
|
648
|
+
)
|
|
649
|
+
return GitCommandPlan(
|
|
650
|
+
name="branch_save_wip",
|
|
651
|
+
commands=tuple(commands),
|
|
652
|
+
notes=("Task state stays on its branch; ignored and runner-owned support paths are not explicit add operands.",),
|
|
582
653
|
)
|
|
583
654
|
|
|
584
655
|
|
|
585
|
-
def branch_ensure_return_plan(
|
|
656
|
+
def branch_ensure_return_plan(
|
|
657
|
+
context: BranchContext,
|
|
658
|
+
*,
|
|
659
|
+
commit_paths: Sequence[str] | None = None,
|
|
660
|
+
add_paths: Sequence[str] | None = None,
|
|
661
|
+
) -> GitCommandPlan:
|
|
586
662
|
"""Plan a fail-closed WIP commit followed by a clean base return."""
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
663
|
+
commit_pathspecs = _wip_pathspecs(commit_paths)
|
|
664
|
+
add_pathspecs = _wip_pathspecs(add_paths if add_paths is not None else commit_paths)
|
|
665
|
+
commands = [
|
|
666
|
+
_git("rebase", "--abort", allow_failure=True, description="abort in-progress rebase"),
|
|
667
|
+
_git("rev-parse", "--abbrev-ref", "HEAD", description="detect current branch"),
|
|
668
|
+
_git("ls-files", "-u", description="require no unmerged paths before WIP commit"),
|
|
669
|
+
_git("status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES, description="detect WIP before checkout"),
|
|
670
|
+
]
|
|
671
|
+
if add_pathspecs:
|
|
672
|
+
commands.append(_git("add", "-A", "--", *add_pathspecs, description="stage Git-visible WIP before checkout"))
|
|
673
|
+
commands.extend(
|
|
674
|
+
(
|
|
594
675
|
_git(
|
|
595
676
|
"commit",
|
|
596
677
|
"--no-verify",
|
|
@@ -600,7 +681,8 @@ def branch_ensure_return_plan(context: BranchContext) -> GitCommandPlan:
|
|
|
600
681
|
"Pipeline was interrupted by signal. This commit preserves work-in-progress.",
|
|
601
682
|
"-m",
|
|
602
683
|
f"To resume: git checkout {context.working_branch}",
|
|
603
|
-
|
|
684
|
+
"--",
|
|
685
|
+
*commit_pathspecs,
|
|
604
686
|
description="save WIP before return when on dev branch",
|
|
605
687
|
),
|
|
606
688
|
_git(
|
|
@@ -608,8 +690,30 @@ def branch_ensure_return_plan(context: BranchContext) -> GitCommandPlan:
|
|
|
608
690
|
description="require clean task checkout before return",
|
|
609
691
|
),
|
|
610
692
|
_git("checkout", context.base_branch, description="return to original branch"),
|
|
693
|
+
)
|
|
694
|
+
)
|
|
695
|
+
return GitCommandPlan(
|
|
696
|
+
name="branch_ensure_return",
|
|
697
|
+
commands=tuple(commands),
|
|
698
|
+
notes=("Return only after task state is committed and the checkout is clean; never merge or push.",),
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _clean_branch_return_plan(context: BranchContext) -> GitCommandPlan:
|
|
703
|
+
"""Plan a base return when the task checkout has no Git-visible WIP."""
|
|
704
|
+
return GitCommandPlan(
|
|
705
|
+
name="branch_ensure_return",
|
|
706
|
+
commands=(
|
|
707
|
+
_git("rebase", "--abort", allow_failure=True, description="abort in-progress rebase"),
|
|
708
|
+
_git("rev-parse", "--abbrev-ref", "HEAD", description="detect current branch"),
|
|
709
|
+
_git("ls-files", "-u", description="require no unmerged paths before base return"),
|
|
710
|
+
_git(
|
|
711
|
+
"status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES,
|
|
712
|
+
description="require clean task checkout before return",
|
|
713
|
+
),
|
|
714
|
+
_git("checkout", context.base_branch, description="return to original branch"),
|
|
611
715
|
),
|
|
612
|
-
notes=("
|
|
716
|
+
notes=("A clean task checkout needs no WIP commit before returning to base.",),
|
|
613
717
|
)
|
|
614
718
|
|
|
615
719
|
|
|
@@ -632,18 +736,86 @@ def current_branch(project_root: Path) -> str:
|
|
|
632
736
|
|
|
633
737
|
|
|
634
738
|
def git_status_safe(project_root: Path) -> str:
|
|
635
|
-
"""Return status while excluding
|
|
739
|
+
"""Return task-visible status while excluding runner-owned support paths."""
|
|
636
740
|
result = run_git_command(project_root, ("status", "--porcelain", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
|
|
637
741
|
return result.stdout if result.return_code == 0 else ""
|
|
638
742
|
|
|
639
743
|
|
|
744
|
+
def _status_record_paths(stdout: str) -> tuple[str, ...]:
|
|
745
|
+
records = stdout.split("\0")
|
|
746
|
+
changed: list[str] = []
|
|
747
|
+
index = 0
|
|
748
|
+
while index < len(records):
|
|
749
|
+
record = records[index]
|
|
750
|
+
index += 1
|
|
751
|
+
if not record or len(record) < 4 or record[2] != " ":
|
|
752
|
+
continue
|
|
753
|
+
status = record[:2]
|
|
754
|
+
changed.append(record[3:])
|
|
755
|
+
if "R" in status or "C" in status:
|
|
756
|
+
if index < len(records) and records[index]:
|
|
757
|
+
changed.append(records[index])
|
|
758
|
+
index += 1
|
|
759
|
+
return tuple(dict.fromkeys(path for path in changed if path))
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def visible_task_paths(project_root: Path) -> tuple[tuple[str, ...], GitCommandResult]:
|
|
763
|
+
result = run_git_command(
|
|
764
|
+
project_root,
|
|
765
|
+
("status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES),
|
|
766
|
+
)
|
|
767
|
+
return (_status_record_paths(result.stdout) if result.return_code == 0 else ()), result
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def addable_task_paths(project_root: Path, paths: Sequence[str]) -> tuple[tuple[str, ...], GitCommandResult]:
|
|
771
|
+
indexed = run_git_command(project_root, ("ls-files", "-z", "--", *_literal_pathspecs(paths)))
|
|
772
|
+
if indexed.return_code != 0:
|
|
773
|
+
return (), indexed
|
|
774
|
+
indexed_paths = set(indexed.stdout.split("\0"))
|
|
775
|
+
addable = tuple(
|
|
776
|
+
path
|
|
777
|
+
for path in paths
|
|
778
|
+
if path in indexed_paths or (project_root / path).exists() or (project_root / path).is_symlink()
|
|
779
|
+
)
|
|
780
|
+
return addable, indexed
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _status_failure_plan(name: str, result: GitCommandResult, description: str) -> GitPlanResult:
|
|
784
|
+
plan = GitCommandPlan(name=name, commands=(_git(*result.args, description=description),))
|
|
785
|
+
return GitPlanResult(plan=plan, results=(result,))
|
|
786
|
+
|
|
787
|
+
|
|
640
788
|
def branch_save_wip(project_root: Path, working_branch: str) -> GitPlanResult:
|
|
641
|
-
"""
|
|
789
|
+
"""Save task-visible WIP only when currently on the recorded working branch."""
|
|
642
790
|
context = BranchContext(base_branch="", working_branch=working_branch, project_root=project_root)
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
791
|
+
default_plan = branch_save_wip_plan(context)
|
|
792
|
+
active = current_branch(project_root)
|
|
793
|
+
if not working_branch or not active:
|
|
794
|
+
return GitPlanResult(plan=default_plan, results=())
|
|
795
|
+
if active != working_branch:
|
|
796
|
+
mismatch_plan = GitCommandPlan(
|
|
797
|
+
name=default_plan.name,
|
|
798
|
+
commands=(_git("rev-parse", "--abbrev-ref", "HEAD", description="verify current task branch"),),
|
|
799
|
+
)
|
|
800
|
+
failure = GitCommandResult(
|
|
801
|
+
args=mismatch_plan.commands[0].args,
|
|
802
|
+
return_code=1,
|
|
803
|
+
stdout=active,
|
|
804
|
+
stderr=f"Active branch {active} does not match task branch {working_branch}",
|
|
805
|
+
)
|
|
806
|
+
return GitPlanResult(plan=mismatch_plan, results=(failure,))
|
|
807
|
+
paths, status = visible_task_paths(project_root)
|
|
808
|
+
if status.return_code != 0:
|
|
809
|
+
return _status_failure_plan(default_plan.name, status, "inspect task-visible WIP")
|
|
810
|
+
if not paths:
|
|
811
|
+
return GitPlanResult(plan=default_plan, results=())
|
|
812
|
+
add_paths, indexed = addable_task_paths(project_root, paths)
|
|
813
|
+
if indexed.return_code != 0:
|
|
814
|
+
return _status_failure_plan(default_plan.name, indexed, "resolve task-visible WIP paths")
|
|
815
|
+
return run_git_plan(
|
|
816
|
+
project_root,
|
|
817
|
+
branch_save_wip_plan(context, commit_paths=paths, add_paths=add_paths),
|
|
818
|
+
)
|
|
647
819
|
|
|
648
820
|
|
|
649
821
|
def worktree_save_wip(runtime: WorktreeRuntimeContext) -> GitPlanResult:
|
|
@@ -652,7 +824,7 @@ def worktree_save_wip(runtime: WorktreeRuntimeContext) -> GitPlanResult:
|
|
|
652
824
|
|
|
653
825
|
|
|
654
826
|
def branch_ensure_return(project_root: Path, base_branch: str, working_branch: str = "") -> GitPlanResult:
|
|
655
|
-
"""Commit task WIP
|
|
827
|
+
"""Commit Git-visible task WIP when needed, then return the caller checkout to base."""
|
|
656
828
|
active = current_branch(project_root)
|
|
657
829
|
context = BranchContext(base_branch=base_branch, working_branch=working_branch or active, project_root=project_root)
|
|
658
830
|
if not active or active == base_branch:
|
|
@@ -670,7 +842,18 @@ def branch_ensure_return(project_root: Path, base_branch: str, working_branch: s
|
|
|
670
842
|
stderr=f"Active branch {active} does not match task branch {working_branch}",
|
|
671
843
|
)
|
|
672
844
|
return GitPlanResult(plan=mismatch_plan, results=(failure,))
|
|
673
|
-
|
|
845
|
+
paths, status = visible_task_paths(project_root)
|
|
846
|
+
if status.return_code != 0:
|
|
847
|
+
return _status_failure_plan("branch_ensure_return", status, "inspect task-visible WIP before base return")
|
|
848
|
+
if not paths:
|
|
849
|
+
return run_git_plan(project_root, _clean_branch_return_plan(context))
|
|
850
|
+
add_paths, indexed = addable_task_paths(project_root, paths)
|
|
851
|
+
if indexed.return_code != 0:
|
|
852
|
+
return _status_failure_plan("branch_ensure_return", indexed, "resolve task-visible WIP paths before base return")
|
|
853
|
+
return run_git_plan(
|
|
854
|
+
project_root,
|
|
855
|
+
branch_ensure_return_plan(context, commit_paths=paths, add_paths=add_paths),
|
|
856
|
+
)
|
|
674
857
|
|
|
675
858
|
|
|
676
859
|
def _git_config_has_value(project_root: Path, key: str) -> bool:
|
|
@@ -737,6 +920,18 @@ def run_git_plan(project_root: Path, plan: GitCommandPlan) -> GitPlanResult:
|
|
|
737
920
|
results: list[GitCommandResult] = []
|
|
738
921
|
for command in plan.commands:
|
|
739
922
|
result = run_git_command(project_root, command.args)
|
|
923
|
+
if (
|
|
924
|
+
command.args[:2] == ("ls-files", "-u")
|
|
925
|
+
and command.description.startswith("require no unmerged")
|
|
926
|
+
and result.return_code == 0
|
|
927
|
+
and result.stdout.strip()
|
|
928
|
+
):
|
|
929
|
+
result = GitCommandResult(
|
|
930
|
+
args=result.args,
|
|
931
|
+
return_code=1,
|
|
932
|
+
stdout=result.stdout,
|
|
933
|
+
stderr="Task checkout contains unresolved Git paths",
|
|
934
|
+
)
|
|
740
935
|
if (
|
|
741
936
|
command.args[:2] == ("status", "--porcelain")
|
|
742
937
|
and command.description.startswith("require clean")
|