@dieulc/workflow 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/checkpoint.ts +302 -0
- package/index.ts +5219 -0
- package/package.json +56 -0
- package/roles.ts +325 -0
- package/utils.ts +1203 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dieuluucanh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @dieulc/workflow
|
|
2
|
+
|
|
3
|
+
Plan↔Build mode, questionnaire, subagent explore, todos and shadow-checkpoint rewind for Pi.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Plan / Build duality** — `Plan` tool gating (`edit`/`write` blocked in plan), `Build` restores full tools; tab + `CtrlAlt+p` shortcuts, `--workflow-plan` flag
|
|
8
|
+
- **Questionnaire** — 1–4 questions with headers/options, dedup freeform aliases, inline `↳ Your answer:` editor
|
|
9
|
+
- **Explore (subagents)** — parallel `pi --mode json` subagents (`max 8`, concurrency 4), `session_shutdown` kill, `activeSubagents`
|
|
10
|
+
- **Todos** — plan-faithful todo list (global step refs + statuses + phase groups) that persists across Plan/Build/Default modes. `workflow_todo` (`list/update/done/pending/add/toggle/sync/clear`) with industry-style wholesale `update` (send the COMPLETE list; omitted items are removed) plus idempotent `done`/`pending`, lenient `[DONE:<ref>]` markers, throttled Claude-Code-style reminders, a tool-result footer, and a stale ⚠ notice — but **no forced turns and no heuristic auto-completion** (reference: Claude Code / OpenCode / Codex practice).
|
|
11
|
+
- **Rewind** — shadow bare-repo checkpoints per cwd (`agent/checkpoints/checkpoint-<slug>.git`), `isDirty` + `createSafetySnapshot` + `restoreCode`
|
|
12
|
+
- **Plannotator bridge** — `plannotator:request/review-result/plan-approved` events, inline markdown handoff (`ScrollView` → inline scrollback), `getUtcDatePrefix` / `normalizePlanPath` for `.pi/plans/<UTC-date>-<slug>.md`
|
|
13
|
+
- **Model roles** — Planner/Explorer/Builder roles with one model + thinking level each (`roles.ts`, `~/.pi/agent/roles.json` v2). `/plan` switches to the planner model, `/build` to the builder model, `explore` subagents use the explorer model. `/role` opens a tabbed picker (scoped models first, `Ctrl+O` for all).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@dieulc/workflow # latest
|
|
19
|
+
pi install npm:@dieulc/workflow@0.1.0 # pinned
|
|
20
|
+
pi install -l npm:@dieulc/workflow # project-local (.pi/settings.json)
|
|
21
|
+
pi -e npm:@dieulc/workflow # try without installing
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Manage it with `pi list`, `pi update --extensions`, `pi remove npm:@dieulc/workflow`. Requires Pi on Node ≥ 22.19; the Pi core packages (`@earendil-works/*`, `typebox`) are provided by Pi at runtime and declared as peer dependencies.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
| Command | Description |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `/plan` / `/workflow` | Enter plan mode (tool gating + switches session model to planner model `high` thinking) |
|
|
31
|
+
| `/build` | Enter build mode (full tools + switches session model to builder model `medium` thinking) |
|
|
32
|
+
| `/rewind` | Pick checkpoint to restore |
|
|
33
|
+
| `/todos` | Show todo list |
|
|
34
|
+
| `/btw` | Queue note for next turn |
|
|
35
|
+
| `/role` | Config screen: one model + thinking per role (planner/explorer/builder tabs, custom roles included). CLI: `list`, `set <role> <provider/model> [thinking]`, `use <role\|auto>`, `add/remove/reset` |
|
|
36
|
+
|
|
37
|
+
## Settings
|
|
38
|
+
|
|
39
|
+
Role config lives in `~/.pi/agent/roles.json` v2 (user) with optional per-project override at `<cwd>/.pi/roles.json` (per-role replace). Each role holds exactly one `{provider, id, thinking}`. v1 `modelPool` files are discarded and reseeded on load. Seeded with defaults on first run. No extra `settings.json` keys — respects `theme`, `modelRegistry`, and `plannotator` phases from `agent/plannotator.json`.
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
See `index.ts`, `checkpoint.ts` (shadow bare-repo), `utils.ts` (safe-command gating, todo/plan extraction), `roles.ts` (role registry, one model per role). Hot-reload via `/reload` when placed in `~/.pi/agent/extensions/workflow/` or loaded via `pi.extensions` manifest.
|
|
44
|
+
|
|
45
|
+
### Todo protocol (industry-aligned)
|
|
46
|
+
|
|
47
|
+
- **Canonical refs**: every todo row is numbered **globally** `1..N`; those numbers are the only refs the model ever needs. Refs are canonical positions — they are recomputed as `1..N` after every mutation (plan merge, `update`, session restore), so the pinned widget, handoff messages, tool output and `workflow_todo` refs always show the same numbers (no 11–20 drift). The plan's per-phase labels (e.g. `Phase B: 1..3`) are preserved only as display metadata (`(plan label B2)`). Integer refs resolve by canonical `ref` → positional `step` → unique `label`; the resolver also accepts `Group/Label`, `Label@Group`, `#N` and unique text (≥8 chars).
|
|
48
|
+
- **States**: `pending` → `in_progress` → `completed` (+ `cancelled`). Exactly one `in_progress` at a time (the harness normalizes extras). `completed` is kept as a derived legacy field for old sessions/external readers.
|
|
49
|
+
- **Update**: `workflow_todo {action:"update", todos:[{ref?, text?, status?}]}` replaces the whole list — TodoWrite/todowrite/update_plan semantics. A `ref` is resolved by canonical ref, then normalized text, then leniently by plan label / group-qualified ref / unique text; a ref that had to be resolved leniently (or matched nothing and was added) is reported under `Warnings:` in the tool result. `done`/`pending` are idempotent single-item sets; `toggle` is a convenience flip; `add` appends an ad-hoc item; `sync` re-extracts the plan file, matches steps by exact text then by plan label (so model-rewritten wording reunites instead of duplicating), **preserves agent-added items** (reported as `kept N agent-added`) and never un-completes (status rank merge); `clear` empties.
|
|
50
|
+
- **Reminder cadence** (Claude Code-style throttle): a hidden `[TODO LIST]` context block is injected only when `turnsSinceLastTodoWrite ≥ TURNS_SINCE_WRITE` **and** `turnsSinceLastReminder ≥ TURNS_BETWEEN_REMINDERS` (`=3`). Unresolved `[DONE:…]` refs from the previous turn bypass the throttle once so the model learns the right numbers. A one-line `[TODO …]` footer is appended to the first successful `edit`/`write`/`bash` result of each turn.
|
|
51
|
+
- **Stale notice (no forced turns)**: if a run mutated files but recorded no todo progress, the user gets one `⚠` status marker + a `ctx.ui.notify` (30 s dedupe); plan-file `- [x]` ticks are reconciled at `agent_end` in all modes, skipped when the plan content is unchanged (path+hash guard). No auto-completion — the list is never marked done based on intent. Legacy lists that already contain duplicate rows are not auto-merged; run `/todos clear` (or approve a new plan) to reset.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cd agent/extensions/workflow
|
|
57
|
+
npm install
|
|
58
|
+
npm run typecheck # tsc --noEmit
|
|
59
|
+
npm test # node --test utils.todo.test.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Plain TypeScript, no build step — Pi loads `index.ts` directly (via jiti). From the repo root, `npm run verify` checks this package's manifest, tarball contents, tests and load path before a release. Live reload: edit the `.ts` file and run `/reload` in Pi.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT — see [LICENSE](./LICENSE).
|
package/checkpoint.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow bare-repo checkpoint manager for workflow rewind — file snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Each cwd gets a per-worktree bare repo under <agentDir>/checkpoints/checkpoint-<slug>.git
|
|
5
|
+
* Snapshots are created before LLM edits (turn_start) and keyed by session leaf entryId.
|
|
6
|
+
* Uses pi.exec (direct spawn, bypasses workflow/utils.ts isSafeCommand gating on bash tool).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const MAX_CHECKPOINTS_PER_SESSION = 100;
|
|
15
|
+
|
|
16
|
+
function slugifyCwd(cwd: string): string {
|
|
17
|
+
const normalized = cwd.replace(/\\/g, "/").toLowerCase();
|
|
18
|
+
return (
|
|
19
|
+
normalized
|
|
20
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
21
|
+
.replace(/^-+|-+$/g, "")
|
|
22
|
+
.slice(0, 80) || "root"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function shadowDirFor(cwd: string): string {
|
|
27
|
+
const slug = slugifyCwd(cwd);
|
|
28
|
+
return path.join(getAgentDir(), "checkpoints", `checkpoint-${slug}.git`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function shadowWorkTreeFile(shadowDir: string): string {
|
|
32
|
+
return path.join(shadowDir, "pi-work-tree");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function gitShadow(
|
|
36
|
+
pi: ExtensionAPI,
|
|
37
|
+
cwd: string,
|
|
38
|
+
args: string[],
|
|
39
|
+
opts?: { signal?: AbortSignal },
|
|
40
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
41
|
+
const shadowDir = shadowDirFor(cwd);
|
|
42
|
+
const fullArgs = [
|
|
43
|
+
"--git-dir",
|
|
44
|
+
shadowDir,
|
|
45
|
+
"--work-tree",
|
|
46
|
+
cwd,
|
|
47
|
+
"-c",
|
|
48
|
+
"core.autocrlf=false",
|
|
49
|
+
...args,
|
|
50
|
+
];
|
|
51
|
+
try {
|
|
52
|
+
const res: any = await (pi as any).exec(
|
|
53
|
+
"git",
|
|
54
|
+
fullArgs,
|
|
55
|
+
opts?.signal ? { signal: opts.signal } : undefined,
|
|
56
|
+
);
|
|
57
|
+
// pi.exec returns {stdout, stderr, exitCode?} or {code, stdout, stderr}
|
|
58
|
+
return {
|
|
59
|
+
stdout: String(res?.stdout ?? res?.output ?? ""),
|
|
60
|
+
stderr: String(res?.stderr ?? res?.error ?? ""),
|
|
61
|
+
exitCode:
|
|
62
|
+
typeof res?.exitCode === "number"
|
|
63
|
+
? res.exitCode
|
|
64
|
+
: typeof res?.code === "number"
|
|
65
|
+
? res.code
|
|
66
|
+
: 0,
|
|
67
|
+
};
|
|
68
|
+
} catch (e: any) {
|
|
69
|
+
return {
|
|
70
|
+
stdout: String(e?.stdout ?? ""),
|
|
71
|
+
stderr: String(e?.stderr ?? e?.message ?? ""),
|
|
72
|
+
exitCode: 1,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isGitRepoSync(cwd: string): boolean {
|
|
78
|
+
let cur = path.resolve(cwd);
|
|
79
|
+
while (true) {
|
|
80
|
+
if (fs.existsSync(path.join(cur, ".git"))) return true;
|
|
81
|
+
const parent = path.dirname(cur);
|
|
82
|
+
if (parent === cur) break;
|
|
83
|
+
cur = parent;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function isGitRepo(
|
|
89
|
+
pi: ExtensionAPI,
|
|
90
|
+
cwd: string,
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
// Prefer quick fs check first, fallback to git rev-parse if fs misses (worktree etc.)
|
|
93
|
+
if (isGitRepoSync(cwd)) return true;
|
|
94
|
+
try {
|
|
95
|
+
const r: any = await (pi as any).exec("git", [
|
|
96
|
+
"rev-parse",
|
|
97
|
+
"--is-inside-work-tree",
|
|
98
|
+
]);
|
|
99
|
+
const out = String(r?.stdout ?? "").trim();
|
|
100
|
+
return out === "true";
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function ensureShadow(
|
|
107
|
+
pi: ExtensionAPI,
|
|
108
|
+
cwd: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const dir = shadowDirFor(cwd);
|
|
111
|
+
try {
|
|
112
|
+
if (!fs.existsSync(dir)) {
|
|
113
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
114
|
+
await (pi as any).exec("git", ["init", "--bare", dir]);
|
|
115
|
+
} else if (!fs.existsSync(path.join(dir, "HEAD"))) {
|
|
116
|
+
await (pi as any).exec("git", ["init", "--bare", dir]);
|
|
117
|
+
}
|
|
118
|
+
// Ensure pi-work-tree sidecar reflects current cwd (handles cwd moves)
|
|
119
|
+
try {
|
|
120
|
+
const wtFile = shadowWorkTreeFile(dir);
|
|
121
|
+
const prev = fs.existsSync(wtFile)
|
|
122
|
+
? fs.readFileSync(wtFile, "utf8").trim()
|
|
123
|
+
: "";
|
|
124
|
+
if (prev !== path.resolve(cwd)) {
|
|
125
|
+
fs.writeFileSync(wtFile, path.resolve(cwd), "utf8");
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
128
|
+
// Mirror local excludes so checkout respects local .gitignore additions
|
|
129
|
+
try {
|
|
130
|
+
const localExclude = path.join(cwd, ".git", "info", "exclude");
|
|
131
|
+
const shadowExclude = path.join(dir, "info", "exclude");
|
|
132
|
+
if (fs.existsSync(localExclude)) {
|
|
133
|
+
try {
|
|
134
|
+
fs.mkdirSync(path.dirname(shadowExclude), { recursive: true });
|
|
135
|
+
} catch {}
|
|
136
|
+
const content = fs.readFileSync(localExclude, "utf8");
|
|
137
|
+
fs.writeFileSync(shadowExclude, content, "utf8");
|
|
138
|
+
}
|
|
139
|
+
} catch {}
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function getHeadRef(
|
|
144
|
+
pi: ExtensionAPI,
|
|
145
|
+
cwd: string,
|
|
146
|
+
): Promise<string | null> {
|
|
147
|
+
const r = await gitShadow(pi, cwd, ["rev-parse", "HEAD"]);
|
|
148
|
+
const sha = r.stdout.trim();
|
|
149
|
+
if (r.exitCode !== 0 || !sha || sha.includes("unknown revision")) return null;
|
|
150
|
+
if (!/^[0-9a-f]{4,40}$/i.test(sha)) return null;
|
|
151
|
+
return sha;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function capCheckpoints(pi: ExtensionAPI, cwd: string): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
const r = await gitShadow(pi, cwd, ["rev-list", "--count", "HEAD"]);
|
|
157
|
+
const count = parseInt(r.stdout.trim(), 10);
|
|
158
|
+
if (!Number.isFinite(count) || count <= MAX_CHECKPOINTS_PER_SESSION) return;
|
|
159
|
+
// Bounded history: keep only the newest MAX commits by resetting to a shallower depth.
|
|
160
|
+
// For bare repo with linear history we retain all objects but GC will prune unreachable.
|
|
161
|
+
// Simple gc to keep disk bounded — no destructive branch delete for v1.
|
|
162
|
+
await gitShadow(pi, cwd, ["gc", "--prune=now", "--quiet"]);
|
|
163
|
+
} catch (e: unknown) {
|
|
164
|
+
void e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function createCheckpoint(
|
|
169
|
+
pi: ExtensionAPI,
|
|
170
|
+
cwd: string,
|
|
171
|
+
entryId: string,
|
|
172
|
+
promptFirstLine: string,
|
|
173
|
+
): Promise<{ ref: string | null }> {
|
|
174
|
+
if (!entryId) return { ref: null };
|
|
175
|
+
const repo = await isGitRepo(pi, cwd);
|
|
176
|
+
if (!repo) return { ref: null };
|
|
177
|
+
try {
|
|
178
|
+
await ensureShadow(pi, cwd);
|
|
179
|
+
} catch {
|
|
180
|
+
return { ref: null };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
// git add -A for tracked files only (respects .gitignore), exclude internal dirs
|
|
184
|
+
// Use pathspec excludes to avoid snapshotting agent internals if they live inside cwd.
|
|
185
|
+
await gitShadow(pi, cwd, [
|
|
186
|
+
"add",
|
|
187
|
+
"-A",
|
|
188
|
+
"--",
|
|
189
|
+
".",
|
|
190
|
+
":(exclude).git",
|
|
191
|
+
":(exclude)agent/checkpoints",
|
|
192
|
+
":(exclude)agent/sessions",
|
|
193
|
+
":(exclude).pi/plans",
|
|
194
|
+
]);
|
|
195
|
+
// Commit even if empty so each user prompt gets a ref (allow-empty)
|
|
196
|
+
const msg = `checkpoint: ${promptFirstLine.slice(0, 80).replace(/"/g, "'")} entryId=${entryId}`;
|
|
197
|
+
const commit = await gitShadow(pi, cwd, [
|
|
198
|
+
"-c",
|
|
199
|
+
"commit.gpgsign=false",
|
|
200
|
+
"-c",
|
|
201
|
+
"core.hooksPath=/dev/null",
|
|
202
|
+
"commit",
|
|
203
|
+
"--allow-empty",
|
|
204
|
+
"-m",
|
|
205
|
+
msg,
|
|
206
|
+
"--author",
|
|
207
|
+
"checkpoint@pi-code <checkpoint@pi-code>",
|
|
208
|
+
]);
|
|
209
|
+
// If nothing to commit and allow-empty still creates, commit succeeds. If no HEAD yet, first commit also succeeds.
|
|
210
|
+
void commit;
|
|
211
|
+
const ref = await getHeadRef(pi, cwd);
|
|
212
|
+
if (ref) {
|
|
213
|
+
await capCheckpoints(pi, cwd);
|
|
214
|
+
return { ref };
|
|
215
|
+
}
|
|
216
|
+
return { ref: null };
|
|
217
|
+
} catch {
|
|
218
|
+
return { ref: null };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function isDirty(pi: ExtensionAPI, cwd: string): Promise<boolean> {
|
|
223
|
+
const r = await gitShadow(pi, cwd, ["status", "--porcelain"]);
|
|
224
|
+
return r.stdout.trim().length > 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function createSafetySnapshot(
|
|
228
|
+
pi: ExtensionAPI,
|
|
229
|
+
cwd: string,
|
|
230
|
+
): Promise<{ ref: string | null }> {
|
|
231
|
+
return createCheckpoint(
|
|
232
|
+
pi,
|
|
233
|
+
cwd,
|
|
234
|
+
`safety-${Date.now()}`,
|
|
235
|
+
"pre-restore safety",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function restoreCode(
|
|
240
|
+
pi: ExtensionAPI,
|
|
241
|
+
cwd: string,
|
|
242
|
+
ref: string,
|
|
243
|
+
opts?: { signal?: AbortSignal },
|
|
244
|
+
): Promise<{ restored: boolean; skipped: number }> {
|
|
245
|
+
const repo = await isGitRepo(pi, cwd);
|
|
246
|
+
if (!repo) return { restored: false, skipped: 0 };
|
|
247
|
+
// Nested repo guard: if cwd contains a .git but shadow pi-work-tree diverges, abort would cross boundary
|
|
248
|
+
try {
|
|
249
|
+
const sd = shadowDirFor(cwd);
|
|
250
|
+
const wtFile = shadowWorkTreeFile(sd);
|
|
251
|
+
if (fs.existsSync(wtFile)) {
|
|
252
|
+
const recorded = fs.readFileSync(wtFile, "utf8").trim();
|
|
253
|
+
if (recorded && path.resolve(recorded) !== path.resolve(cwd)) {
|
|
254
|
+
// cwd moved since snapshot — still allow restore to recorded worktree is safe because --work-tree is explicit
|
|
255
|
+
// Just warn via return, not hard abort; caller decides.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Abort if nested .git under CWD would be traversed incorrectly — checkout excludes .git so safe, but flag nested case
|
|
259
|
+
// We do not abort; we just exclude .git from checkout path.
|
|
260
|
+
} catch {}
|
|
261
|
+
try {
|
|
262
|
+
// Checkout tracked files to cwd
|
|
263
|
+
const r = await gitShadow(
|
|
264
|
+
pi,
|
|
265
|
+
cwd,
|
|
266
|
+
["checkout", "-f", ref, "--", "."],
|
|
267
|
+
opts,
|
|
268
|
+
);
|
|
269
|
+
if (r.exitCode !== 0) {
|
|
270
|
+
// Fallback to restore if checkout fails (e.g., ref is empty commit)
|
|
271
|
+
const r2 = await gitShadow(
|
|
272
|
+
pi,
|
|
273
|
+
cwd,
|
|
274
|
+
["restore", "--source", ref, "."],
|
|
275
|
+
opts,
|
|
276
|
+
);
|
|
277
|
+
if (r2.exitCode !== 0) return { restored: false, skipped: 0 };
|
|
278
|
+
}
|
|
279
|
+
return { restored: true, skipped: 0 };
|
|
280
|
+
} catch {
|
|
281
|
+
return { restored: false, skipped: 0 };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function findPersistedRef(
|
|
286
|
+
branch: any[],
|
|
287
|
+
targetId: string,
|
|
288
|
+
): string | null {
|
|
289
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
290
|
+
const e: any = branch[i];
|
|
291
|
+
if (
|
|
292
|
+
e?.type === "custom" &&
|
|
293
|
+
e?.customType === "workflow-checkpoint" &&
|
|
294
|
+
e?.data?.entryId === targetId &&
|
|
295
|
+
typeof e?.data?.ref === "string" &&
|
|
296
|
+
e.data.ref.trim()
|
|
297
|
+
) {
|
|
298
|
+
return e.data.ref.trim();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|