@azure-id/orc 1.9.2 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README-id.md +21 -36
  3. package/README.md +25 -36
  4. package/bin/build-agents.js +117 -20
  5. package/bin/cli.js +725 -29
  6. package/bin/gotcha-import.js +1081 -0
  7. package/bin/gotcha.js +1286 -0
  8. package/bin/graph-query.js +1 -1
  9. package/bin/graph.js +717 -717
  10. package/bin/habit.js +1453 -0
  11. package/bin/mockrun-catalog.js +281 -276
  12. package/bin/run-undo.js +398 -0
  13. package/bin/trace-write.js +657 -0
  14. package/bin/verify-contracts.js +525 -79
  15. package/bin/verify-package.js +51 -4
  16. package/bin/webui/api.js +26 -0
  17. package/bin/webui/app.html +239 -232
  18. package/bin/webui/css/00-tokens.css +110 -92
  19. package/bin/webui/css/04-motion.css +87 -0
  20. package/bin/webui/css/06-responsive.css +203 -178
  21. package/bin/webui/css/panels/behaviour.css +205 -0
  22. package/bin/webui/fixtures/behaviour.js +532 -0
  23. package/bin/webui/fixtures/index.js +34 -0
  24. package/bin/webui/fixtures/knowledge.js +7 -1
  25. package/bin/webui/fixtures/stats.js +16 -0
  26. package/bin/webui/i18n/en/behaviour.json +143 -0
  27. package/bin/webui/i18n/en/nav.json +25 -24
  28. package/bin/webui/i18n/en/tour.json +37 -35
  29. package/bin/webui/i18n/id/behaviour.json +143 -0
  30. package/bin/webui/i18n/id/nav.json +25 -24
  31. package/bin/webui/i18n/id/tour.json +37 -35
  32. package/bin/webui/js/01-i18n.js +155 -154
  33. package/bin/webui/js/90-tour.js +498 -494
  34. package/bin/webui/js/91-shortcuts.js +126 -126
  35. package/bin/webui/js/99-boot.js +121 -118
  36. package/bin/webui/js/panels/behaviour.js +1022 -0
  37. package/mock-run/INDEX.md +109 -107
  38. package/mock-run/gotcha-import.md +118 -0
  39. package/mock-run/habits.md +129 -0
  40. package/mock-run/orc-quick.md +6 -1
  41. package/package.json +1 -1
  42. package/templates/agents/MODEL-MAPPING.md +7 -7
  43. package/templates/agents/orc-advisor-opus-5-xhigh.md +1 -7
  44. package/templates/agents/orc-analyze-mini-opus-5-med.md +1 -6
  45. package/templates/agents/orc-analyze-mini-sonnet-5-high.md +1 -4
  46. package/templates/agents/orc-claude-writer-opus-4-8-high.md +48 -53
  47. package/templates/agents/orc-claude-writer-opus-5-med.md +1 -8
  48. package/templates/agents/orc-context-combiner-opus-5-high.md +1 -11
  49. package/templates/agents/orc-executor-haiku-4-5.md +14 -6
  50. package/templates/agents/orc-executor-opus-4-7-high.md +14 -6
  51. package/templates/agents/orc-executor-opus-4-7-med.md +14 -6
  52. package/templates/agents/orc-executor-opus-4-8-high.md +14 -6
  53. package/templates/agents/orc-executor-opus-5-high.md +14 -6
  54. package/templates/agents/orc-executor-opus-5-low.md +14 -6
  55. package/templates/agents/orc-executor-opus-5-med.md +14 -6
  56. package/templates/agents/orc-executor-sonnet-4-6-high.md +14 -6
  57. package/templates/agents/orc-executor-sonnet-4-6-med.md +14 -6
  58. package/templates/agents/orc-executor-sonnet-5-high.md +14 -6
  59. package/templates/agents/orc-graph-noter-sonnet-4-6-med.md +1 -10
  60. package/templates/agents/orc-judge-opus-5-xhigh.md +5 -9
  61. package/templates/agents/orc-learn-writer-opus-5-low.md +1 -7
  62. package/templates/agents/orc-pattern-codifier-opus-5-med.md +1 -8
  63. package/templates/agents/orc-pattern-codifier-sonnet-5-high.md +58 -63
  64. package/templates/agents/orc-planner-mini-opus-5-med.md +1 -4
  65. package/templates/agents/orc-planner-mini-sonnet-5-high.md +1 -2
  66. package/templates/agents/orc-planner-opus-5-med.md +1 -4
  67. package/templates/agents/orc-recon-opus-5-low.md +1 -8
  68. package/templates/agents/orc-recon-sonnet-4-6-med.md +1 -8
  69. package/templates/agents/orc-retro-opus-5-med.md +6 -8
  70. package/templates/agents/orc-retro-sonnet-5-high.md +6 -7
  71. package/templates/agents/orc-reviewer-opus-5-med.md +52 -16
  72. package/templates/agents/orc-scout-opus-5-low.md +1 -6
  73. package/templates/agents/orc-scout-sonnet-4-6-high.md +35 -39
  74. package/templates/agents/orc-system-analyst-opus-5-high.md +1 -6
  75. package/templates/agents/orc-test-author-opus-5-med.md +4 -5
  76. package/templates/agents/orc-trace-writer-haiku-4-5.md +3 -7
  77. package/templates/agents/orc-verifier-opus-5-med.md +16 -8
  78. package/templates/agents/orc-wiki-scanner-opus-4-8-high.md +74 -79
  79. package/templates/agents/orc-wiki-scanner-opus-5-med.md +1 -8
  80. package/templates/agents/orc-wiki-scanner-sonnet-5-high.md +97 -106
  81. package/templates/commands/orc-analyze.md +13 -21
  82. package/templates/commands/orc-fast.md +10 -15
  83. package/templates/commands/orc-poly.md +12 -21
  84. package/templates/commands/orc-pr-driver.md +11 -30
  85. package/templates/commands/orc-pr-setup.md +10 -31
  86. package/templates/commands/orc-route.md +11 -41
  87. package/templates/commands/orc-test.md +5 -60
  88. package/templates/hooks/README.md +34 -0
  89. package/templates/hooks/orc-session-hook.js +264 -0
  90. package/templates/hooks/orc-statusline.js +3 -1
  91. package/templates/skills/_shared/README.md +9 -0
  92. package/templates/skills/_shared/code-graph.md +47 -55
  93. package/templates/skills/_shared/config-precedence.md +3 -1
  94. package/templates/skills/_shared/extra-dispatch.md +73 -88
  95. package/templates/skills/_shared/gotchas.md +228 -177
  96. package/templates/skills/_shared/habits.md +101 -0
  97. package/templates/skills/_shared/lane-contract.md +84 -0
  98. package/templates/skills/_shared/phases/README.md +142 -83
  99. package/templates/skills/_shared/phases/analyst-gates.md +10 -21
  100. package/templates/skills/_shared/phases/execution.md +8 -14
  101. package/templates/skills/_shared/phases/house-rules.md +27 -32
  102. package/templates/skills/_shared/phases/intake.md +127 -133
  103. package/templates/skills/_shared/phases/mock-example.md +46 -56
  104. package/templates/skills/_shared/phases/plan-handoff.md +91 -97
  105. package/templates/skills/_shared/phases/planning.md +7 -17
  106. package/templates/skills/_shared/phases/preflight.md +19 -42
  107. package/templates/skills/_shared/phases/review.md +23 -27
  108. package/templates/skills/_shared/phases/rules.md +18 -42
  109. package/templates/skills/_shared/phases/scoring.md +55 -65
  110. package/templates/skills/_shared/phases/security-checklist.md +46 -50
  111. package/templates/skills/_shared/phases/security.md +45 -55
  112. package/templates/skills/_shared/phases/ship.md +6 -15
  113. package/templates/skills/_shared/phases/stop-resume.md +2 -5
  114. package/templates/skills/_shared/phases/summary.md +73 -48
  115. package/templates/skills/_shared/phases/testgen.md +41 -51
  116. package/templates/skills/_shared/phases/trace-verbs.md +433 -0
  117. package/templates/skills/_shared/phases/trace.md +136 -367
  118. package/templates/skills/_shared/phases/verify.md +62 -70
  119. package/templates/skills/_shared/phases/wave-grouping.md +128 -133
  120. package/templates/skills/_shared/phases/wiki-consult.md +10 -6
  121. package/templates/skills/_shared/read-ladder.md +2 -55
  122. package/templates/skills/_shared/return-validation.md +17 -70
  123. package/templates/skills/_shared/review-slice.md +79 -0
  124. package/templates/skills/_shared/smoke-gate.md +46 -28
  125. package/templates/skills/context-combiner/SKILL.md +15 -44
  126. package/templates/skills/orc/SKILL.md +34 -62
  127. package/templates/skills/orc/references/pattern-gate.md +89 -89
  128. package/templates/skills/orc/references/phases/intake.md +41 -47
  129. package/templates/skills/orc/references/phases/integration.md +13 -19
  130. package/templates/skills/orc/references/preflight-report.md +8 -9
  131. package/templates/skills/orc/references/ultra-mode.md +8 -6
  132. package/templates/skills/orc/subskills/orc-execution/SKILL.md +27 -73
  133. package/templates/skills/orc/subskills/orc-execution/core.md +12 -99
  134. package/templates/skills/orc/subskills/orc-execution/subagent.md +14 -13
  135. package/templates/skills/orc/subskills/orc-review-verify/SKILL.md +11 -52
  136. package/templates/skills/orc/subskills/orc-review-verify/core.md +52 -135
  137. package/templates/skills/orc/subskills/orc-review-verify/subagent.md +7 -7
  138. package/templates/skills/orc/subskills/orc-testgen/SKILL.md +11 -22
  139. package/templates/skills/orc/subskills/orc-testgen/core.md +20 -59
  140. package/templates/skills/orc/subskills/orc-testgen/subagent.md +7 -7
  141. package/templates/skills/orc-advisor/SKILL.md +56 -60
  142. package/templates/skills/orc-analyze/SKILL.md +30 -66
  143. package/templates/skills/orc-analyze/schemas/report-audit.md +2 -1
  144. package/templates/skills/orc-analyze/schemas/report-prose.md +2 -1
  145. package/templates/skills/orc-analyze-mini/SKILL.md +35 -70
  146. package/templates/skills/orc-diy/README.md +31 -0
  147. package/templates/skills/orc-diy/SKILL.md +23 -74
  148. package/templates/skills/orc-diy/references/blocks/pattern.md +18 -18
  149. package/templates/skills/orc-fast/SKILL.md +44 -72
  150. package/templates/skills/orc-judge/SKILL.md +77 -82
  151. package/templates/skills/orc-mini/SKILL.md +61 -109
  152. package/templates/skills/orc-pattern/SKILL.md +27 -48
  153. package/templates/skills/orc-poly/SKILL.md +32 -61
  154. package/templates/skills/orc-pr-driver/SKILL.md +25 -51
  155. package/templates/skills/orc-pr-driver/references/green-gate.md +113 -105
  156. package/templates/skills/orc-pr-setup/SKILL.md +20 -45
  157. package/templates/skills/orc-quick/README.md +43 -2
  158. package/templates/skills/orc-quick/SKILL.md +76 -107
  159. package/templates/skills/orc-quick/references/dispatch-gate.md +16 -5
  160. package/templates/skills/orc-quick/references/gh-mode.md +48 -1
  161. package/templates/skills/orc-quick/references/look.md +3 -1
  162. package/templates/skills/orc-retro/SKILL.md +19 -18
  163. package/templates/skills/orc-retro/examples/retro-mock.md +1 -1
  164. package/templates/skills/orc-route/SKILL.md +25 -45
  165. package/templates/skills/orc-test/SKILL.md +16 -37
  166. package/templates/skills/orc-verify/SKILL.md +14 -32
  167. package/templates/skills/orc-wait/SKILL.md +156 -163
  168. package/templates/skills/orc-wiki/references/phases/phase-0.md +1 -6
  169. package/templates/skills/orc-wiki/references/phases/phase-1.md +1 -6
  170. package/templates/skills/orc-wiki/references/phases/phase-2.md +1 -6
  171. package/templates/skills/orc-wiki/references/phases/phase-3.md +1 -6
  172. package/templates/skills/orc-wiki/references/phases/phase-3c.md +1 -6
@@ -0,0 +1,398 @@
1
+ "use strict";
2
+ // ── `orc run snapshot` + `orc undo` (v2.0.0 W6b, 08-qol-spec Q2) ─────────────
3
+ //
4
+ // orc run snapshot --run <slug> [--json] at run start: the tree BEFORE the run
5
+ // orc undo --run <slug> [--files a,b] [--apply] [--json]
6
+ //
7
+ // The defect: quick printed `git checkout -- .` as the undo, which also throws
8
+ // away every UNRELATED edit the user had before the run. Claude Code's
9
+ // `/rewind` does not cover a subagent or a Bash edit, and almost every ORC edit
10
+ // is one. So there was no safe undo at all.
11
+ //
12
+ // THE SNAPSHOT. `git stash create` makes a commit of the working tree WITHOUT
13
+ // touching it (empty on a clean tree → HEAD). `git update-ref
14
+ // refs/orc/runs/<slug>/pre <sha>` keeps it alive; it never shows in
15
+ // `git stash list`. A stash commit holds TRACKED files only, so every untracked
16
+ // file is also written to the object store (`git hash-object -w`) and its blob
17
+ // is named in `.claude/orc/run/<slug>/snapshot.json` — user data, never in the
18
+ // install manifest. A second snapshot of the same slug (a resumed run) keeps
19
+ // the FIRST one: the pre-run tree is the only tree an undo may go back to.
20
+ //
21
+ // THE UNDO may only revert what the run changed, and must never destroy a user
22
+ // edit. So:
23
+ // - the file list is the run's OWN record (`--files`, else the checkpoint's
24
+ // `actual_files`), never `git status` — that list holds the user's edits too;
25
+ // - a file outside that list is never read, never written;
26
+ // - a run file the user edited AFTER the run's last own write (its newest
27
+ // run-folder file) is SKIPPED and named — the user decides, not ORC;
28
+ // - a file the pre-run tree does not hold is deleted only when the disk proves
29
+ // the run created it (birth time after the snapshot). Otherwise it is kept.
30
+ // PRINT-ONLY by default (08 Q2, S6: the exact commands are visible, nothing
31
+ // runs by itself). `--apply` runs the printed commands, with every rule above.
32
+ //
33
+ // Exit codes. snapshot: 0 taken (or kept) · 1 usage · 2 not a git repo / no
34
+ // commit yet (an answer: the run goes on without an undo). undo: 0 done ·
35
+ // 1 usage or a git error · 2 no snapshot for this slug · 3 no record of the
36
+ // run's files (pass --files) · 4 done, but a file was kept for the user.
37
+
38
+ const fs = require("fs");
39
+ const path = require("path");
40
+ const { spawnSync } = require("child_process");
41
+
42
+ const SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,80}$/;
43
+ const REF_ROOT = "refs/orc/runs";
44
+ const SNAP_FILE = "snapshot.json";
45
+ const UNTRACKED_MAX = 5000; // files copied per snapshot
46
+ const UNTRACKED_BYTES = 20 * 1024 * 1024; // a bigger file is named, not copied
47
+ const AGE_DAYS = 30; // a snapshot ref older than this is removed at the next snapshot
48
+ const SLACK_MS = 2000; // mtime resolution on the slowest file systems
49
+
50
+ function git(cwd, args, opts) {
51
+ const r = spawnSync("git", args, Object.assign({ cwd, encoding: "utf8", windowsHide: true, maxBuffer: 256 * 1024 * 1024 }, opts || {}));
52
+ return { ok: r.status === 0, code: r.status, out: r.stdout == null ? "" : r.stdout, err: String(r.stderr || "").trim() };
53
+ }
54
+ const posix = (p) => String(p || "").replace(/\\/g, "/").replace(/^\.\//, "");
55
+ const refOf = (slug) => `${REF_ROOT}/${slug}/pre`;
56
+
57
+ function valueOf(args, name) {
58
+ const i = args.indexOf(name);
59
+ if (i === -1) return undefined;
60
+ const v = args[i + 1];
61
+ return v !== undefined && !v.startsWith("--") ? v : "";
62
+ }
63
+
64
+ function readSnap(runDir, slug) {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(path.join(runDir, slug, SNAP_FILE), "utf8"));
67
+ } catch (_) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ // → { ok, top } | { ok:false, reason }
73
+ function gitTop(cwd) {
74
+ const r = git(cwd, ["rev-parse", "--show-toplevel"]);
75
+ if (!r.ok) return { ok: false, reason: "not-a-git-repo" };
76
+ return { ok: true, top: path.resolve(r.out.trim()) };
77
+ }
78
+
79
+ // Remove the refs whose snapshot is older than AGE_DAYS. Only a ref whose
80
+ // snapshot.json says so: a ref with no record is left for `orc doctor`.
81
+ function pruneOld(top, runDir, now, keep) {
82
+ const pruned = [];
83
+ const r = git(top, ["for-each-ref", "--format=%(refname)", REF_ROOT]);
84
+ if (!r.ok) return pruned;
85
+ for (const ref of r.out.split("\n").map((l) => l.trim()).filter(Boolean)) {
86
+ const m = /^refs\/orc\/runs\/(.+)\/pre$/.exec(ref);
87
+ if (!m || m[1] === keep) continue;
88
+ const snap = readSnap(runDir, m[1]);
89
+ if (!snap || !(snap.taken_ms > 0)) continue;
90
+ if (now - snap.taken_ms <= AGE_DAYS * 86400000) continue;
91
+ if (git(top, ["update-ref", "-d", ref]).ok) pruned.push(m[1]);
92
+ }
93
+ return pruned;
94
+ }
95
+
96
+ // → { code, payload }
97
+ function snapshot(cwd, runDir, slug, now) {
98
+ now = now || Date.now();
99
+ if (!slug || !SLUG_RE.test(slug)) return { code: 1, payload: { ok: false, reason: "usage", message: "Usage: orc run snapshot --run <slug> [--json] (slug: [a-z0-9._-])" } };
100
+ const t = gitTop(cwd);
101
+ if (!t.ok) return { code: 2, payload: { ok: false, reason: t.reason, slug, message: "not a git repository — no snapshot, so this run has no `orc undo`" } };
102
+ const top = t.top;
103
+ const ref = refOf(slug);
104
+ const have = git(top, ["rev-parse", "--verify", "-q", ref + "^{commit}"]);
105
+ if (have.ok) {
106
+ const snap = readSnap(runDir, slug);
107
+ return {
108
+ code: 0,
109
+ payload: { ok: true, slug, ref, pre: have.out.trim(), existing: true, taken_at: snap ? snap.taken_at : null, snapshot_file: path.join(runDir, slug, SNAP_FILE), pruned: [], message: "a snapshot for this run already exists — kept (the pre-run tree never moves)" },
110
+ };
111
+ }
112
+ const head = git(top, ["rev-parse", "--verify", "-q", "HEAD"]);
113
+ if (!head.ok) return { code: 2, payload: { ok: false, reason: "no-commit", slug, message: "the repository has no commit yet — no snapshot, so this run has no `orc undo`" } };
114
+ const stash = git(top, ["stash", "create"]);
115
+ if (!stash.ok) return { code: 1, payload: { ok: false, reason: "git-error", slug, message: `git stash create failed: ${stash.err}` } };
116
+ const pre = stash.out.trim() || head.out.trim();
117
+ const clean = !stash.out.trim();
118
+ const upd = git(top, ["update-ref", ref, pre]);
119
+ if (!upd.ok) return { code: 1, payload: { ok: false, reason: "git-error", slug, message: `git update-ref failed: ${upd.err}` } };
120
+
121
+ // The untracked files: a stash commit does not hold them.
122
+ const list = git(top, ["ls-files", "--others", "--exclude-standard", "-z"]);
123
+ const paths = list.ok ? list.out.split("\0").filter(Boolean) : [];
124
+ const untracked = [];
125
+ const not_copied = [];
126
+ const toHash = [];
127
+ for (const p of paths) {
128
+ if (toHash.length >= UNTRACKED_MAX || /[\r\n]/.test(p)) {
129
+ not_copied.push({ path: posix(p), why: toHash.length >= UNTRACKED_MAX ? "over the file cap" : "a newline in the name" });
130
+ continue;
131
+ }
132
+ let size = 0;
133
+ try {
134
+ size = fs.statSync(path.join(top, p)).size;
135
+ } catch (_) {
136
+ continue;
137
+ }
138
+ if (size > UNTRACKED_BYTES) not_copied.push({ path: posix(p), why: "over 20 MB" });
139
+ else toHash.push(p);
140
+ }
141
+ if (toHash.length) {
142
+ const h = git(top, ["hash-object", "-w", "--stdin-paths"], { input: toHash.join("\n") + "\n" });
143
+ const blobs = h.ok ? h.out.split("\n").map((l) => l.trim()).filter(Boolean) : [];
144
+ toHash.forEach((p, i) => (blobs[i] ? untracked.push({ path: posix(p), blob: blobs[i] }) : not_copied.push({ path: posix(p), why: "hash-object failed" })));
145
+ }
146
+ const dirty = clean ? [] : git(top, ["diff", "--name-only", "-z", head.out.trim(), pre]).out.split("\0").filter(Boolean).map(posix);
147
+ const snap = {
148
+ version: 1,
149
+ slug,
150
+ ref,
151
+ pre,
152
+ head: head.out.trim(),
153
+ clean,
154
+ taken_at: new Date(now).toISOString(),
155
+ taken_ms: now,
156
+ dirty,
157
+ untracked,
158
+ not_copied,
159
+ };
160
+ const dir = path.join(runDir, slug);
161
+ fs.mkdirSync(dir, { recursive: true });
162
+ fs.writeFileSync(path.join(dir, SNAP_FILE), JSON.stringify(snap, null, 2) + "\n");
163
+ const pruned = pruneOld(top, runDir, now, slug);
164
+ return {
165
+ code: 0,
166
+ payload: { ok: true, slug, ref, pre, head: snap.head, clean, existing: false, taken_at: snap.taken_at, dirty, untracked: untracked.length, not_copied, snapshot_file: path.join(dir, SNAP_FILE), pruned },
167
+ };
168
+ }
169
+
170
+ // The run's own record of the files it changed.
171
+ function runFiles(runDir, slug, flagFiles) {
172
+ if (typeof flagFiles === "string" && flagFiles.trim())
173
+ return { source: "--files", files: flagFiles.split(/[,\s]+/).map(posix).filter(Boolean) };
174
+ let ck = null;
175
+ try {
176
+ ck = JSON.parse(fs.readFileSync(path.join(runDir, slug, "checkpoint.json"), "utf8"));
177
+ } catch (_) {}
178
+ if (!ck) return null;
179
+ const set = new Set();
180
+ for (const f of Array.isArray(ck.actual_files) ? ck.actual_files : []) set.add(posix(f));
181
+ for (const t of Array.isArray(ck.tasks) ? ck.tasks : []) for (const f of Array.isArray(t && t.actual_files) ? t.actual_files : []) set.add(posix(f));
182
+ if (!set.size) return null;
183
+ return { source: "checkpoint.json", files: [...set] };
184
+ }
185
+
186
+ // The run's last own write: the newest file in its run folder (the checkpoint,
187
+ // the trace, a lane's own record), the snapshot itself excluded.
188
+ function lastRunWrite(runDir, slug, top) {
189
+ let best = null;
190
+ const dir = path.join(runDir, slug);
191
+ let names = [];
192
+ try {
193
+ names = fs.readdirSync(dir);
194
+ } catch (_) {}
195
+ for (const n of names) {
196
+ if (n === SNAP_FILE) continue;
197
+ try {
198
+ const st = fs.statSync(path.join(dir, n));
199
+ if (st.isFile() && (best == null || st.mtimeMs > best)) best = st.mtimeMs;
200
+ } catch (_) {}
201
+ }
202
+ try {
203
+ const ck = JSON.parse(fs.readFileSync(path.join(dir, "checkpoint.json"), "utf8"));
204
+ if (ck && ck.trace_path) {
205
+ const tp = path.isAbsolute(ck.trace_path) ? ck.trace_path : path.join(top, ck.trace_path);
206
+ const m = fs.statSync(tp).mtimeMs;
207
+ if (best == null || m > best) best = m;
208
+ }
209
+ } catch (_) {}
210
+ return best;
211
+ }
212
+
213
+ function blobOf(top, spec) {
214
+ const r = git(top, ["rev-parse", "--verify", "-q", spec]);
215
+ return r.ok ? r.out.trim() : null;
216
+ }
217
+
218
+ // → { code, payload }
219
+ function undo(cwd, runDir, slug, opts) {
220
+ opts = opts || {};
221
+ if (!slug || !SLUG_RE.test(slug)) return { code: 1, payload: { ok: false, reason: "usage", message: "Usage: orc undo --run <slug> [--files a,b] [--apply] [--json] (prints; --apply runs it)" } };
222
+ const t = gitTop(cwd);
223
+ if (!t.ok) return { code: 2, payload: { ok: false, reason: t.reason, slug, message: "not a git repository — there is no snapshot to go back to" } };
224
+ const top = t.top;
225
+ const ref = refOf(slug);
226
+ const snap = readSnap(runDir, slug);
227
+ const pre = blobOf(top, ref + "^{commit}") || (snap && snap.pre && blobOf(top, snap.pre + "^{commit}"));
228
+ if (!pre)
229
+ return { code: 2, payload: { ok: false, reason: "no-snapshot", slug, message: `no snapshot for run ${slug} — the run never called \`orc run snapshot\`, so ORC cannot tell your edits from its own. Nothing was changed.` } };
230
+ const rec = runFiles(runDir, slug, opts.files);
231
+ if (!rec)
232
+ return { code: 3, payload: { ok: false, reason: "no-record", slug, ref, pre, message: `no record of the files run ${slug} changed — pass --files <the run's actual_files>. ORC never guesses from git status: that list holds your edits too.` } };
233
+ // The pre-run INDEX: a stash commit's second parent; a clean tree's is HEAD.
234
+ const preIndex = blobOf(top, pre + "^2^{commit}") || pre;
235
+ const mark = lastRunWrite(runDir, slug, top);
236
+ const projectRoot = path.resolve(opts.projectRoot || top);
237
+ const untracked = new Map(((snap && snap.untracked) || []).map((u) => [u.path, u.blob]));
238
+ const notCopied = new Set(((snap && snap.not_copied) || []).map((u) => u.path));
239
+ const takenMs = snap && snap.taken_ms > 0 ? snap.taken_ms : null;
240
+
241
+ const rows = [];
242
+ for (const raw of [...new Set(rec.files)].sort()) {
243
+ const abs = path.resolve(projectRoot, raw);
244
+ const rel = posix(path.relative(top, abs));
245
+ const row = { path: rel, action: null, reason: null, commands: [] };
246
+ rows.push(row);
247
+ if (!rel || rel.startsWith("../") || path.isAbsolute(rel) || rel.startsWith(".git/")) {
248
+ Object.assign(row, { action: "skipped", reason: "outside the repository" });
249
+ continue;
250
+ }
251
+ let st = null;
252
+ try {
253
+ st = fs.statSync(abs);
254
+ } catch (_) {}
255
+ if (st && !st.isFile()) {
256
+ Object.assign(row, { action: "skipped", reason: "not a regular file" });
257
+ continue;
258
+ }
259
+ if (st && mark != null && st.mtimeMs > mark + SLACK_MS) {
260
+ Object.assign(row, { action: "skipped", reason: "edited after the run's last write — yours now; restore it by hand if you want", hint: `git restore --source=${ref} -- ${rel}` });
261
+ continue;
262
+ }
263
+ const preBlob = blobOf(top, `${pre}:${rel}`);
264
+ const cur = st ? git(top, ["hash-object", "--", rel]).out.trim() : null;
265
+ if (preBlob) {
266
+ if (cur === preBlob) row.action = "unchanged";
267
+ else {
268
+ row.action = "restore";
269
+ row.commands.push(`git restore --source=${ref} --worktree -- ${rel}`);
270
+ }
271
+ } else if (untracked.has(rel)) {
272
+ const blob = untracked.get(rel);
273
+ if (cur === blob) row.action = "unchanged";
274
+ else {
275
+ row.action = "restore-untracked";
276
+ row.blob = blob;
277
+ row.commands.push(`git cat-file blob ${blob} > ${rel}`);
278
+ }
279
+ } else if (notCopied.has(rel)) {
280
+ Object.assign(row, { action: "skipped", reason: "it existed before the run but the snapshot could not copy it" });
281
+ continue;
282
+ } else if (!st) {
283
+ row.action = "unchanged";
284
+ } else {
285
+ // Not in the pre-run tree and not an untracked file then: the run created
286
+ // it, or it is an IGNORED file the snapshot never saw. Delete only on proof.
287
+ const born = st.birthtimeMs > 0 ? st.birthtimeMs : null;
288
+ if (takenMs != null && born != null && born >= takenMs - SLACK_MS) {
289
+ row.action = "delete";
290
+ row.commands.push(`git rm --cached --quiet --ignore-unmatch -- ${rel}`, `rm -- ${rel}`);
291
+ } else {
292
+ Object.assign(row, { action: "skipped", reason: born != null ? "it existed before the run (an ignored file the snapshot does not hold)" : "cannot prove the run created it" });
293
+ continue;
294
+ }
295
+ }
296
+ // The index: back to the pre-run index entry, only when it differs.
297
+ if (row.action !== "delete") {
298
+ const idxNow = git(top, ["ls-files", "-s", "--", rel]).out.trim().split(/\s+/)[1] || null;
299
+ const idxPre = blobOf(top, `${preIndex}:${rel}`);
300
+ if (idxNow !== idxPre) {
301
+ if (idxPre) row.commands.push(`git restore --source=${preIndex === pre ? ref : ref + "^2"} --staged -- ${rel}`);
302
+ else if (idxNow) row.commands.push(`git rm --cached --quiet -- ${rel}`);
303
+ if (row.action === "unchanged") row.action = "restore-index";
304
+ }
305
+ }
306
+ }
307
+
308
+ const failed = [];
309
+ if (opts.apply) {
310
+ for (const row of rows) {
311
+ if (!row.commands.length) continue;
312
+ const abs = path.join(top, row.path);
313
+ try {
314
+ for (const c of row.commands) {
315
+ if (c.startsWith("rm -- ")) fs.unlinkSync(abs);
316
+ else if (c.startsWith("git cat-file blob ")) {
317
+ const b = spawnSync("git", ["cat-file", "blob", row.blob], { cwd: top, windowsHide: true, maxBuffer: 256 * 1024 * 1024 });
318
+ if (b.status !== 0) throw new Error(String(b.stderr || "cat-file failed").trim());
319
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
320
+ fs.writeFileSync(abs, b.stdout);
321
+ } else {
322
+ const argv = c.replace(/^git /, "").split(" -- ")[0].split(" ").concat(["--", row.path]);
323
+ const r = git(top, argv);
324
+ if (!r.ok) throw new Error(r.err || c);
325
+ }
326
+ }
327
+ } catch (e) {
328
+ row.error = String((e && e.message) || e);
329
+ failed.push(row.path);
330
+ }
331
+ }
332
+ }
333
+ const pick = (a) => rows.filter((r) => r.action === a).map((r) => r.path);
334
+ const skipped = rows.filter((r) => r.action === "skipped").map((r) => ({ path: r.path, reason: r.reason, hint: r.hint || null }));
335
+ const payload = {
336
+ ok: !failed.length,
337
+ slug,
338
+ ref,
339
+ pre,
340
+ applied: !!opts.apply,
341
+ files_source: rec.source,
342
+ last_run_write: mark == null ? null : new Date(mark).toISOString(),
343
+ files: rows,
344
+ restored: rows.filter((r) => /^restore/.test(r.action)).map((r) => r.path),
345
+ deleted: pick("delete"),
346
+ unchanged: pick("unchanged"),
347
+ skipped,
348
+ failed,
349
+ commands: rows.reduce((a, r) => a.concat(r.commands), []),
350
+ };
351
+ return { code: failed.length ? 1 : skipped.length ? 4 : 0, payload };
352
+ }
353
+
354
+ function renderSnapshot(p) {
355
+ if (!p.ok) return console.error(p.message);
356
+ if (p.existing) return console.log(`snapshot: kept ${p.ref} (${p.pre.slice(0, 10)}) — ${p.message}`);
357
+ console.log(`snapshot: ${p.ref} → ${p.pre.slice(0, 10)}${p.clean ? " (clean tree = HEAD)" : ` (${p.dirty.length} uncommitted file(s) held)`} · ${p.untracked} untracked file(s) copied`);
358
+ for (const n of p.not_copied) console.log(` not copied: ${n.path} (${n.why})`);
359
+ if (p.pruned.length) console.log(` removed ${p.pruned.length} snapshot ref(s) older than ${AGE_DAYS} days: ${p.pruned.join(", ")}`);
360
+ console.log(` undo later: orc undo --run ${p.slug}`);
361
+ }
362
+
363
+ function renderUndo(p) {
364
+ if (!p.files) return console.error(p.message);
365
+ console.log(`undo ${p.slug}${p.applied ? "" : " — PRINT ONLY, nothing changed (--apply runs these commands)"} · files from ${p.files_source}`);
366
+ for (const r of p.files) {
367
+ const tag = r.action === "skipped" ? `KEPT — ${r.reason}` : r.action;
368
+ console.log(` ${r.path} ${tag}${r.error ? ` (FAILED: ${r.error})` : ""}`);
369
+ for (const c of r.commands) console.log(` ${c}`);
370
+ if (r.hint) console.log(` by hand: ${r.hint}`);
371
+ }
372
+ console.log(`${p.applied ? "" : "with --apply: "}${p.restored.length} restored · ${p.deleted.length} deleted · ${p.unchanged.length} unchanged · ${p.skipped.length} kept for you${p.failed.length ? ` · ${p.failed.length} FAILED` : ""}. Files outside this run were not touched.`);
373
+ }
374
+
375
+ // deps: { args, wantsJson, emitJson, resolveClaudeDir, resolveRunDir, ensureRunDir, repoRootOf }
376
+ function snapshotCmd(deps) {
377
+ const claudeDir = deps.resolveClaudeDir();
378
+ const root = deps.repoRootOf(claudeDir);
379
+ const r = snapshot(fs.existsSync(root) ? root : process.cwd(), deps.ensureRunDir(claudeDir), valueOf(deps.args, "--run"));
380
+ if (deps.wantsJson()) deps.emitJson(r.payload, r.code);
381
+ renderSnapshot(r.payload);
382
+ process.exit(r.code);
383
+ }
384
+
385
+ function undoCmd(deps) {
386
+ const claudeDir = deps.resolveClaudeDir();
387
+ const root = deps.repoRootOf(claudeDir);
388
+ const r = undo(fs.existsSync(root) ? root : process.cwd(), deps.resolveRunDir(claudeDir), valueOf(deps.args, "--run"), {
389
+ files: valueOf(deps.args, "--files"),
390
+ apply: deps.args.includes("--apply"),
391
+ projectRoot: root,
392
+ });
393
+ if (deps.wantsJson()) deps.emitJson(r.payload, r.code);
394
+ renderUndo(r.payload);
395
+ process.exit(r.code);
396
+ }
397
+
398
+ module.exports = { snapshot, undo, runFiles, snapshotCmd, undoCmd, refOf, AGE_DAYS };