obsidian-lean-coder 0.0.0-stage → 0.1.1

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 (58) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -2
  4. package/agents/olc-build.md +47 -0
  5. package/agents/olc-plan.md +44 -0
  6. package/dist/approval/index.d.ts +53 -0
  7. package/dist/approval/index.js +123 -0
  8. package/dist/build/index.d.ts +52 -0
  9. package/dist/build/index.js +146 -0
  10. package/dist/config/index.d.ts +56 -0
  11. package/dist/config/index.js +136 -0
  12. package/dist/core/project-identity.d.ts +53 -0
  13. package/dist/core/project-identity.js +141 -0
  14. package/dist/git/checkpoint.d.ts +63 -0
  15. package/dist/git/checkpoint.js +123 -0
  16. package/dist/git/inspection.d.ts +35 -0
  17. package/dist/git/inspection.js +78 -0
  18. package/dist/git/policy.d.ts +65 -0
  19. package/dist/git/policy.js +88 -0
  20. package/dist/git/restore.d.ts +63 -0
  21. package/dist/git/restore.js +143 -0
  22. package/dist/index.d.ts +14 -0
  23. package/dist/index.js +14 -0
  24. package/dist/init/index.d.ts +45 -0
  25. package/dist/init/index.js +178 -0
  26. package/dist/log/index.d.ts +68 -0
  27. package/dist/log/index.js +224 -0
  28. package/dist/memory/index.d.ts +89 -0
  29. package/dist/memory/index.js +166 -0
  30. package/dist/planning/index.d.ts +57 -0
  31. package/dist/planning/index.js +180 -0
  32. package/dist/plugin.d.ts +16 -0
  33. package/dist/plugin.js +46 -0
  34. package/dist/resume/index.d.ts +60 -0
  35. package/dist/resume/index.js +121 -0
  36. package/dist/session/index.d.ts +54 -0
  37. package/dist/session/index.js +108 -0
  38. package/dist/tasks/index.d.ts +59 -0
  39. package/dist/tasks/index.js +182 -0
  40. package/dist/taste/index.d.ts +79 -0
  41. package/dist/taste/index.js +162 -0
  42. package/dist/validation/index.d.ts +58 -0
  43. package/dist/validation/index.js +72 -0
  44. package/dist/vault/index.d.ts +50 -0
  45. package/dist/vault/index.js +153 -0
  46. package/dist/verify/index.d.ts +78 -0
  47. package/dist/verify/index.js +137 -0
  48. package/package.json +36 -4
  49. package/skills/olc-explore/SKILL.md +35 -0
  50. package/templates/.gitkeep +0 -0
  51. package/templates/activity-log.md +10 -0
  52. package/templates/checkpoint.md +24 -0
  53. package/templates/decision.md +19 -0
  54. package/templates/explore-report.md +23 -0
  55. package/templates/intent.md +23 -0
  56. package/templates/plan.md +27 -0
  57. package/templates/session-summary.md +26 -0
  58. package/templates/taste-rule.md +14 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Safe Git checkpoint commits (OLC-051, enforces the OLC-050 decision).
3
+ *
4
+ * Flow: validate identity → normalize owned paths → refuse when an owned
5
+ * path is already staged → byte-compare every owned file against the
6
+ * caller-supplied write-time snapshot (any difference = someone else
7
+ * touched it → abort, zero git mutation) → `git add -- <owned paths>` →
8
+ * re-verify staged bytes against the snapshots (race guard; reset ours on
9
+ * mismatch) → `git commit -- <owned paths>` (pathspec, so foreign staged
10
+ * changes on other paths survive untouched) → append the checkpoint record
11
+ * (with commit SHA) to `tasks/<taskId>/checkpoint.md` in the vault.
12
+ *
13
+ * Banned from this flow, always: `git add .` / `git add -A`, plain
14
+ * `git commit` without a pathspec, and `git add -p`.
15
+ *
16
+ * MVP limits: owned paths must currently exist as files (file deletions by
17
+ * OLC are not checkpointable yet); snapshots are UTF-8 strings.
18
+ */
19
+ import { readFileSync } from "node:fs";
20
+ import { relative, resolve, sep } from "node:path";
21
+ import { validateIdentity } from "../core/project-identity.js";
22
+ import { assertContextCurrent } from "../validation/index.js";
23
+ import { parseFrontmatter, readVaultDoc, renderTemplate, resolveVaultDoc, writeVaultDoc, } from "../vault/index.js";
24
+ import { git, gitRaw, inspectTree } from "./inspection.js";
25
+ export class CheckpointError extends Error {
26
+ code;
27
+ constructor(code, message) {
28
+ super(message);
29
+ this.name = "CheckpointError";
30
+ this.code = code;
31
+ }
32
+ }
33
+ function normalizeOwned(repoRoot, ownedPaths) {
34
+ return ownedPaths.map((p) => {
35
+ const abs = resolve(repoRoot, p);
36
+ const rel = relative(repoRoot, abs);
37
+ if (rel === "" || rel === "." || rel.startsWith(`..${sep}`) || rel === "..") {
38
+ throw new CheckpointError("PATH_OUTSIDE_REPO", `Refused checkpoint path ${JSON.stringify(p)}: it is not inside the repository ${repoRoot}. ` +
39
+ "Checkpoint only stages repo-relative owned paths.");
40
+ }
41
+ return rel.split(sep).join("/");
42
+ });
43
+ }
44
+ /**
45
+ * Append a checkpoint-template entry to `tasks/<taskId>/checkpoint.md`.
46
+ * First call writes the full template; later calls append the body after `---`.
47
+ * Returns the vault-absolute record path. Exported for reuse by `restore.ts`.
48
+ */
49
+ export function appendCheckpointRecord(vaultRoot, projectId, taskId, vars) {
50
+ const entry = renderTemplate("checkpoint", vars);
51
+ const recordPath = resolveVaultDoc(vaultRoot, projectId, "tasks", taskId, "checkpoint.md");
52
+ let record;
53
+ try {
54
+ const existing = readVaultDoc(recordPath);
55
+ record = `${existing.replace(/\s+$/, "")}\n\n---\n\n${parseFrontmatter(entry).body}`;
56
+ }
57
+ catch {
58
+ record = entry;
59
+ }
60
+ writeVaultDoc(recordPath, record, { overwrite: true });
61
+ return recordPath;
62
+ }
63
+ export function createCheckpoint(input) {
64
+ const identity = input.session
65
+ ? assertContextCurrent({ projectRoot: input.projectRoot, config: input.config, session: input.session })
66
+ : validateIdentity(input.projectRoot, input.config);
67
+ const { repoRoot, branch, vaultRoot, projectId } = identity;
68
+ const owned = normalizeOwned(repoRoot, input.ownedPaths);
69
+ const before = inspectTree(repoRoot);
70
+ const prestaged = before.staged.filter((p) => owned.includes(p));
71
+ if (prestaged.length > 0) {
72
+ throw new CheckpointError("OWNED_PATH_PRESTAGED", `Refusing checkpoint: owned path(s) already staged by someone else: ${prestaged.join(", ")}. ` +
73
+ "Unstage them or confirm who staged what before retrying — OLC stages only what it verified itself.");
74
+ }
75
+ for (const rel of owned) {
76
+ const abs = resolve(repoRoot, rel);
77
+ const expected = input.snapshots[rel];
78
+ let actual;
79
+ try {
80
+ actual = readFileSync(abs, "utf8");
81
+ }
82
+ catch {
83
+ throw new CheckpointError("MISSING_OWNED_FILE", `Refusing checkpoint: owned file ${rel} cannot be read (deleted or moved since OLC wrote it?). ` +
84
+ "Confirm the current tree state before retrying.");
85
+ }
86
+ if (expected === undefined || actual !== expected) {
87
+ throw new CheckpointError("FOREIGN_EDIT", `Refusing checkpoint: owned file ${rel} no longer matches what OLC wrote ` +
88
+ "(edited by someone/something else, or no snapshot was recorded). " +
89
+ "Ask the user how to proceed — OLC never commits mixed ownership.");
90
+ }
91
+ }
92
+ const pending = git(repoRoot, ["status", "--porcelain=v1", "-uall", "--", ...owned]);
93
+ if (pending === "") {
94
+ return { status: "clean", branch, head: before.head };
95
+ }
96
+ git(repoRoot, ["add", "--", ...owned]);
97
+ const cached = git(repoRoot, ["diff", "--cached", "--name-only", "--", ...owned]);
98
+ const cachedSet = new Set(cached === "" ? [] : cached.split("\n"));
99
+ for (const rel of owned) {
100
+ if (!cachedSet.has(rel))
101
+ continue;
102
+ const stagedBytes = gitRaw(repoRoot, ["show", `:${rel}`]).toString("utf8");
103
+ if (stagedBytes !== input.snapshots[rel]) {
104
+ git(repoRoot, ["reset", "-q", "--", ...owned]);
105
+ throw new CheckpointError("FOREIGN_EDIT", `Refusing checkpoint: staged content of ${rel} changed between verification and staging (race detected). ` +
106
+ "Our staged copy was reset; worktree untouched. Ask the user how to proceed.");
107
+ }
108
+ }
109
+ const committedPaths = [...cachedSet].filter((p) => owned.includes(p)).sort();
110
+ const date = input.date ?? new Date().toISOString();
111
+ const message = `olc checkpoint: ${input.taskId}\n\n${input.summary}`;
112
+ git(repoRoot, ["commit", "-m", message, "--", ...owned]);
113
+ const sha = git(repoRoot, ["rev-parse", "HEAD"]);
114
+ const recordPath = appendCheckpointRecord(vaultRoot, projectId, input.taskId, {
115
+ task_id: input.taskId,
116
+ project_id: projectId,
117
+ commit_sha: sha,
118
+ date,
119
+ summary: input.summary,
120
+ verification: input.verification ?? "(no verification recorded)",
121
+ });
122
+ return { status: "committed", branch, sha, committedPaths, recordPath };
123
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Git tree inspection for OLC checkpoints (OLC-051).
3
+ *
4
+ * Read-only helpers: parse `git status --porcelain` without touching the
5
+ * index or worktree. The mutating checkpoint flow lives in `checkpoint.ts`
6
+ * and must follow the OLC-050 decision (explicit paths only, abort on
7
+ * ambiguity — never `git add .`, plain `git commit`, or `git add -p`).
8
+ */
9
+ export declare class GitError extends Error {
10
+ readonly code: "GIT_FAILED";
11
+ constructor(message: string);
12
+ }
13
+ /** Run git and return stdout (trailing whitespace only trimmed). */
14
+ export declare function git(repoRoot: string, args: string[]): string;
15
+ /** Run git and return raw stdout bytes (for byte-exact content comparison). */
16
+ export declare function gitRaw(repoRoot: string, args: string[]): Buffer;
17
+ export interface PorcelainSets {
18
+ /** Paths with staged (index) changes. */
19
+ staged: string[];
20
+ /** Paths with unstaged (worktree) changes. */
21
+ unstaged: string[];
22
+ /** Untracked paths. */
23
+ untracked: string[];
24
+ }
25
+ /**
26
+ * Parse `git status --porcelain=v1` output (pure, no I/O).
27
+ * Rename entries (`R old -> new`) resolve to the new path.
28
+ */
29
+ export declare function parsePorcelain(output: string): PorcelainSets;
30
+ export interface TreeState extends PorcelainSets {
31
+ branch: string;
32
+ head: string;
33
+ }
34
+ /** Read-only snapshot of branch, HEAD, and porcelain status. */
35
+ export declare function inspectTree(repoRoot: string): TreeState;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Git tree inspection for OLC checkpoints (OLC-051).
3
+ *
4
+ * Read-only helpers: parse `git status --porcelain` without touching the
5
+ * index or worktree. The mutating checkpoint flow lives in `checkpoint.ts`
6
+ * and must follow the OLC-050 decision (explicit paths only, abort on
7
+ * ambiguity — never `git add .`, plain `git commit`, or `git add -p`).
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ import { resolveBranch } from "../core/project-identity.js";
11
+ export class GitError extends Error {
12
+ code;
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "GitError";
16
+ this.code = "GIT_FAILED";
17
+ }
18
+ }
19
+ /** Run git and return stdout (trailing whitespace only trimmed). */
20
+ export function git(repoRoot, args) {
21
+ try {
22
+ return execFileSync("git", args, {
23
+ cwd: repoRoot,
24
+ encoding: "utf8",
25
+ stdio: ["ignore", "pipe", "pipe"],
26
+ }).replace(/\s+$/, "");
27
+ }
28
+ catch (error) {
29
+ const detail = error.stderr ?? error.message;
30
+ throw new GitError(`git ${args.join(" ")} failed in ${repoRoot}: ${String(detail).trim()}. Is git installed and the repository intact?`);
31
+ }
32
+ }
33
+ /** Run git and return raw stdout bytes (for byte-exact content comparison). */
34
+ export function gitRaw(repoRoot, args) {
35
+ try {
36
+ return execFileSync("git", args, { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] });
37
+ }
38
+ catch (error) {
39
+ const detail = error.stderr ?? error.message;
40
+ throw new GitError(`git ${args.join(" ")} failed in ${repoRoot}: ${String(detail).trim()}. Is git installed and the repository intact?`);
41
+ }
42
+ }
43
+ /**
44
+ * Parse `git status --porcelain=v1` output (pure, no I/O).
45
+ * Rename entries (`R old -> new`) resolve to the new path.
46
+ */
47
+ export function parsePorcelain(output) {
48
+ const staged = [];
49
+ const unstaged = [];
50
+ const untracked = [];
51
+ for (const rawLine of output.split("\n")) {
52
+ const line = rawLine.replace(/\s+$/, "");
53
+ if (line === "")
54
+ continue;
55
+ const x = line[0];
56
+ const y = line[1];
57
+ let path = line.slice(3);
58
+ const arrow = path.indexOf(" -> ");
59
+ if (arrow !== -1)
60
+ path = path.slice(arrow + 4);
61
+ if (x === "?" && y === "?") {
62
+ untracked.push(path);
63
+ continue;
64
+ }
65
+ if (x !== " " && x !== "?" && x !== "!")
66
+ staged.push(path);
67
+ if (y !== " " && y !== "?" && y !== "!")
68
+ unstaged.push(path);
69
+ }
70
+ return { staged, unstaged, untracked };
71
+ }
72
+ /** Read-only snapshot of branch, HEAD, and porcelain status. */
73
+ export function inspectTree(repoRoot) {
74
+ const branch = resolveBranch(repoRoot);
75
+ const head = git(repoRoot, ["rev-parse", "HEAD"]);
76
+ const { staged, unstaged, untracked } = parsePorcelain(git(repoRoot, ["status", "--porcelain=v1", "-uall"]));
77
+ return { branch, head, staged, unstaged, untracked };
78
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pre-switch and task-final checkpoint policy (OLC-053).
3
+ *
4
+ * Thin policy over OLC-051 `createCheckpoint`:
5
+ * - `milestone` / `explicit` / `pre-switch`: delegate directly. Unchanged
6
+ * owned files return `clean` (no commit, no record), so repeated triggers
7
+ * never stack empty commits.
8
+ * - `task-final`: the final checkpoint of a verified task. Created only when
9
+ * safe: verification fully passed AND the task registry already shows
10
+ * `review` (i.e. OLC-041 `completeTaskWithVerification` ran). Anything
11
+ * else returns `not-ready` / `verify-failed` with zero git mutation.
12
+ *
13
+ * A commit is never treated as task completion: every result carries
14
+ * `needsUserConfirmation`, and the registry status is left untouched
15
+ * (closing the task stays a user-confirmed OLC-033 transition).
16
+ *
17
+ * Checkpoint safety aborts (`CheckpointError`: foreign edit, pre-staged
18
+ * owned paths, paths outside the repo) propagate to the caller unchanged.
19
+ */
20
+ import type { ResolvedConfig } from "../config/index.js";
21
+ import { type VerifyOutcome } from "../verify/index.js";
22
+ import { type CheckpointResult } from "./checkpoint.js";
23
+ export type CheckpointTrigger = "milestone" | "explicit" | "pre-switch" | "task-final";
24
+ export interface CheckpointPolicyInput {
25
+ /** Any directory inside the project (identity resolution starts here). */
26
+ projectRoot: string;
27
+ config: ResolvedConfig;
28
+ taskId: string;
29
+ trigger: CheckpointTrigger;
30
+ /** Repo-relative paths OLC owns for this checkpoint (files must exist). */
31
+ ownedPaths: string[];
32
+ /** Write-time content snapshot per repo-relative owned path. */
33
+ snapshots: Record<string, string>;
34
+ /** Custom summary; defaults to a trigger-specific message. */
35
+ message?: string;
36
+ /**
37
+ * Required for `task-final`: the verification outcome that must have
38
+ * fully passed. Omitted or failing outcome → no checkpoint is created.
39
+ */
40
+ verifyOutcome?: VerifyOutcome;
41
+ /** Defaults to current time (pass explicit dates in tests). */
42
+ date?: string;
43
+ }
44
+ export type PolicyResult = (Extract<CheckpointResult, {
45
+ status: "committed";
46
+ }> & {
47
+ needsUserConfirmation: boolean;
48
+ }) | (Extract<CheckpointResult, {
49
+ status: "clean";
50
+ }> & {
51
+ needsUserConfirmation: boolean;
52
+ }) | {
53
+ status: "not-ready";
54
+ reason: string;
55
+ } | {
56
+ status: "verify-failed";
57
+ report: string;
58
+ escalated: string[];
59
+ };
60
+ /**
61
+ * Apply the checkpoint policy for one trigger. Returns the checkpoint
62
+ * outcome, or a no-mutation `not-ready` / `verify-failed` refusal.
63
+ * Throws `CheckpointError` / `IdentityError` exactly as `createCheckpoint`.
64
+ */
65
+ export declare function maybeCheckpointPolicy(input: CheckpointPolicyInput): PolicyResult;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Pre-switch and task-final checkpoint policy (OLC-053).
3
+ *
4
+ * Thin policy over OLC-051 `createCheckpoint`:
5
+ * - `milestone` / `explicit` / `pre-switch`: delegate directly. Unchanged
6
+ * owned files return `clean` (no commit, no record), so repeated triggers
7
+ * never stack empty commits.
8
+ * - `task-final`: the final checkpoint of a verified task. Created only when
9
+ * safe: verification fully passed AND the task registry already shows
10
+ * `review` (i.e. OLC-041 `completeTaskWithVerification` ran). Anything
11
+ * else returns `not-ready` / `verify-failed` with zero git mutation.
12
+ *
13
+ * A commit is never treated as task completion: every result carries
14
+ * `needsUserConfirmation`, and the registry status is left untouched
15
+ * (closing the task stays a user-confirmed OLC-033 transition).
16
+ *
17
+ * Checkpoint safety aborts (`CheckpointError`: foreign edit, pre-staged
18
+ * owned paths, paths outside the repo) propagate to the caller unchanged.
19
+ */
20
+ import { getTask } from "../tasks/index.js";
21
+ import { formatVerifyReport } from "../verify/index.js";
22
+ import { createCheckpoint, } from "./checkpoint.js";
23
+ const DEFAULT_SUMMARY = {
24
+ milestone: "milestone checkpoint",
25
+ explicit: "explicit checkpoint requested",
26
+ "pre-switch": "checkpoint before task switch",
27
+ "task-final": "final checkpoint of verified task",
28
+ };
29
+ function oneLineVerifySummary(outcome) {
30
+ const total = outcome.results.length;
31
+ return `verification: all ${total} check(s) passed`;
32
+ }
33
+ /**
34
+ * Apply the checkpoint policy for one trigger. Returns the checkpoint
35
+ * outcome, or a no-mutation `not-ready` / `verify-failed` refusal.
36
+ * Throws `CheckpointError` / `IdentityError` exactly as `createCheckpoint`.
37
+ */
38
+ export function maybeCheckpointPolicy(input) {
39
+ const { projectRoot, config, taskId, trigger, ownedPaths, snapshots, message, verifyOutcome, date, } = input;
40
+ const summary = message ?? DEFAULT_SUMMARY[trigger];
41
+ if (trigger === "task-final") {
42
+ if (!verifyOutcome) {
43
+ return {
44
+ status: "not-ready",
45
+ reason: "task-final checkpoint needs a verification outcome: pass verifyOutcome from verifyChecks " +
46
+ "after the final checks pass. No checkpoint created.",
47
+ };
48
+ }
49
+ if (!verifyOutcome.ok) {
50
+ return {
51
+ status: "verify-failed",
52
+ report: formatVerifyReport(verifyOutcome),
53
+ escalated: verifyOutcome.escalated,
54
+ };
55
+ }
56
+ const task = getTask(config.vaultPath, config.projectId, taskId);
57
+ if (!task || task.status !== "review") {
58
+ return {
59
+ status: "not-ready",
60
+ reason: `task-final checkpoint needs task ${taskId} in "review" status ` +
61
+ `(found: ${task ? `"${task.status}"` : "unregistered"}). ` +
62
+ "Run completeTaskWithVerification first; the task still needs user confirmation to close. " +
63
+ "No checkpoint created.",
64
+ };
65
+ }
66
+ const result = createCheckpoint({
67
+ projectRoot,
68
+ config,
69
+ taskId,
70
+ ownedPaths,
71
+ snapshots,
72
+ summary,
73
+ verification: oneLineVerifySummary(verifyOutcome),
74
+ date,
75
+ });
76
+ return { ...result, needsUserConfirmation: true };
77
+ }
78
+ const result = createCheckpoint({
79
+ projectRoot,
80
+ config,
81
+ taskId,
82
+ ownedPaths,
83
+ snapshots,
84
+ summary,
85
+ date,
86
+ });
87
+ return { ...result, needsUserConfirmation: false };
88
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Safe restore-to-checkpoint (OLC-052).
3
+ *
4
+ * Restoring NEVER moves the branch backward or deletes history: the selected
5
+ * checkpoint's file contents are applied as a NEW commit on top of HEAD, with
6
+ * a traceable message. Later commits stay reachable by construction.
7
+ *
8
+ * Flow: validate identity → resolve + verify the checkpoint SHA (must be a
9
+ * commit AND an ancestor of HEAD) → read each intended path's bytes at that
10
+ * commit (a path absent there aborts — MVP never deletes/creates files on
11
+ * restore) → inspect the current tree and abort on ANY risk to an intended
12
+ * path (staged, unstaged, or untracked-same-name content would be lost) →
13
+ * write checkpoint bytes → `git add -- <paths>` → re-verify staged bytes
14
+ * (race guard; reset ours on mismatch) → `git commit -m <msg> -- <paths>`
15
+ * (pathspec, so foreign staged changes elsewhere survive) → append the
16
+ * restore record (with BOTH shas) to `tasks/<taskId>/checkpoint.md`.
17
+ *
18
+ * There is no force flag: on any risk the function aborts listing the exact
19
+ * paths, and the caller (build agent) surfaces that list to the user as the
20
+ * confirmation step. Banned here as in checkpoint.ts: `git add .` / `-A`,
21
+ * plain `git commit` without a pathspec, `git add -p`, `git reset --hard`,
22
+ * `git checkout <sha> -- .`, and any form of `git reset` to an older commit.
23
+ */
24
+ import type { ResolvedConfig } from "../config/index.js";
25
+ import { type SessionContext } from "../validation/index.js";
26
+ export type RestoreErrorCode = "UNKNOWN_CHECKPOINT" | "NOT_ANCESTOR" | "PATH_OUTSIDE_REPO" | "PATH_NOT_IN_CHECKPOINT" | "RESTORE_PATH_PRESTAGED" | "UNSTAGED_RISK" | "UNTRACKED_RISK" | "STAGE_MISMATCH";
27
+ export declare class RestoreError extends Error {
28
+ readonly code: RestoreErrorCode;
29
+ constructor(code: RestoreErrorCode, message: string);
30
+ }
31
+ export interface RestoreInput {
32
+ /** Any directory inside the project (identity resolution starts here). */
33
+ projectRoot: string;
34
+ config: ResolvedConfig;
35
+ taskId: string;
36
+ /** Full or abbreviated SHA of the checkpoint commit to restore. */
37
+ checkpointSha: string;
38
+ /** Repo-relative paths to restore (must all exist at the checkpoint). */
39
+ restorePaths: string[];
40
+ /** Why this restore happens (commit message + vault record). */
41
+ reason: string;
42
+ /** Defaults to current time (pass explicit dates in tests). */
43
+ date?: string;
44
+ /**
45
+ * Session context from `startSession`. When present the restore is also
46
+ * revalidated against it (vault unmounted / re-registered / branch
47
+ * switched between operations blocks with zero writes).
48
+ */
49
+ session?: SessionContext;
50
+ }
51
+ export interface RestoreResult {
52
+ /** "already-matches" when the tree already holds the checkpoint contents (no commit, no record). */
53
+ status: "restored" | "already-matches";
54
+ branch: string;
55
+ /** The verified checkpoint commit that was restored from. */
56
+ restoredFrom: string;
57
+ /** The NEW commit applying the restored contents (absent when already-matches). */
58
+ sha?: string;
59
+ restoredPaths: string[];
60
+ /** Vault record path (absent when already-matches — nothing to cross-reference). */
61
+ recordPath?: string;
62
+ }
63
+ export declare function restoreCheckpoint(input: RestoreInput): RestoreResult;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Safe restore-to-checkpoint (OLC-052).
3
+ *
4
+ * Restoring NEVER moves the branch backward or deletes history: the selected
5
+ * checkpoint's file contents are applied as a NEW commit on top of HEAD, with
6
+ * a traceable message. Later commits stay reachable by construction.
7
+ *
8
+ * Flow: validate identity → resolve + verify the checkpoint SHA (must be a
9
+ * commit AND an ancestor of HEAD) → read each intended path's bytes at that
10
+ * commit (a path absent there aborts — MVP never deletes/creates files on
11
+ * restore) → inspect the current tree and abort on ANY risk to an intended
12
+ * path (staged, unstaged, or untracked-same-name content would be lost) →
13
+ * write checkpoint bytes → `git add -- <paths>` → re-verify staged bytes
14
+ * (race guard; reset ours on mismatch) → `git commit -m <msg> -- <paths>`
15
+ * (pathspec, so foreign staged changes elsewhere survive) → append the
16
+ * restore record (with BOTH shas) to `tasks/<taskId>/checkpoint.md`.
17
+ *
18
+ * There is no force flag: on any risk the function aborts listing the exact
19
+ * paths, and the caller (build agent) surfaces that list to the user as the
20
+ * confirmation step. Banned here as in checkpoint.ts: `git add .` / `-A`,
21
+ * plain `git commit` without a pathspec, `git add -p`, `git reset --hard`,
22
+ * `git checkout <sha> -- .`, and any form of `git reset` to an older commit.
23
+ */
24
+ import { spawnSync } from "node:child_process";
25
+ import { writeFileSync } from "node:fs";
26
+ import { relative, resolve, sep } from "node:path";
27
+ import { validateIdentity } from "../core/project-identity.js";
28
+ import { assertContextCurrent } from "../validation/index.js";
29
+ import { appendCheckpointRecord } from "./checkpoint.js";
30
+ import { GitError, git, gitRaw, inspectTree } from "./inspection.js";
31
+ export class RestoreError extends Error {
32
+ code;
33
+ constructor(code, message) {
34
+ super(message);
35
+ this.name = "RestoreError";
36
+ this.code = code;
37
+ }
38
+ }
39
+ function normalizeRestorePaths(repoRoot, restorePaths) {
40
+ return restorePaths.map((p) => {
41
+ const abs = resolve(repoRoot, p);
42
+ const rel = relative(repoRoot, abs);
43
+ if (rel === "" || rel === "." || rel.startsWith(`..${sep}`) || rel === "..") {
44
+ throw new RestoreError("PATH_OUTSIDE_REPO", `Refused restore path ${JSON.stringify(p)}: it is not inside the repository ${repoRoot}. ` +
45
+ "Restore only touches repo-relative paths.");
46
+ }
47
+ return rel.split(sep).join("/");
48
+ });
49
+ }
50
+ function isAncestor(repoRoot, sha) {
51
+ const result = spawnSync("git", ["merge-base", "--is-ancestor", sha, "HEAD"], {
52
+ cwd: repoRoot,
53
+ stdio: "ignore",
54
+ });
55
+ if (result.status === 0)
56
+ return true;
57
+ if (result.status === 1)
58
+ return false;
59
+ throw new GitError(`git merge-base --is-ancestor failed in ${repoRoot} (status ${result.status}). Is git installed and the repository intact?`);
60
+ }
61
+ export function restoreCheckpoint(input) {
62
+ const identity = input.session
63
+ ? assertContextCurrent({ projectRoot: input.projectRoot, config: input.config, session: input.session })
64
+ : validateIdentity(input.projectRoot, input.config);
65
+ const { repoRoot, branch, vaultRoot, projectId } = identity;
66
+ const paths = normalizeRestorePaths(repoRoot, input.restorePaths);
67
+ let full;
68
+ try {
69
+ full = git(repoRoot, ["rev-parse", "--verify", `${input.checkpointSha}^{commit}`]);
70
+ }
71
+ catch {
72
+ throw new RestoreError("UNKNOWN_CHECKPOINT", `Refusing restore: ${JSON.stringify(input.checkpointSha)} does not resolve to a commit in ${repoRoot}. ` +
73
+ "Run `git log --oneline` to find the checkpoint SHA from its record first.");
74
+ }
75
+ if (!isAncestor(repoRoot, full)) {
76
+ throw new RestoreError("NOT_ANCESTOR", `Refusing restore: commit ${full} is not an ancestor of HEAD. ` +
77
+ "OLC only restores checkpoints on the current branch history — later commits must stay reachable. " +
78
+ "Ask the user how to proceed.");
79
+ }
80
+ const wanted = new Map();
81
+ for (const rel of paths) {
82
+ try {
83
+ wanted.set(rel, gitRaw(repoRoot, ["show", `${full}:${rel}`]));
84
+ }
85
+ catch {
86
+ throw new RestoreError("PATH_NOT_IN_CHECKPOINT", `Refusing restore: path ${rel} does not exist at checkpoint ${full.slice(0, 12)}. ` +
87
+ "MVP restore never deletes or creates files — remove or create the file manually, checkpoint that, and retry.");
88
+ }
89
+ }
90
+ const tree = inspectTree(repoRoot);
91
+ const prestaged = tree.staged.filter((p) => paths.includes(p));
92
+ if (prestaged.length > 0) {
93
+ throw new RestoreError("RESTORE_PATH_PRESTAGED", `Refusing restore: path(s) already staged: ${prestaged.join(", ")}. ` +
94
+ "Unstaging is itself a destructive choice — confirm who staged what before retrying.");
95
+ }
96
+ const atRisk = tree.unstaged.filter((p) => paths.includes(p));
97
+ if (atRisk.length > 0) {
98
+ throw new RestoreError("UNSTAGED_RISK", `Refusing restore: uncommitted worktree changes would be lost at: ${atRisk.join(", ")}. ` +
99
+ "Commit or checkpoint them first, or confirm with the user that they may be discarded.");
100
+ }
101
+ const shadowed = tree.untracked.filter((p) => paths.includes(p));
102
+ if (shadowed.length > 0) {
103
+ throw new RestoreError("UNTRACKED_RISK", `Refusing restore: untracked file(s) would be overwritten at: ${shadowed.join(", ")}. ` +
104
+ "Move them aside or confirm with the user before retrying.");
105
+ }
106
+ for (const [rel, bytes] of wanted) {
107
+ writeFileSync(resolve(repoRoot, rel), bytes);
108
+ }
109
+ git(repoRoot, ["add", "--", ...paths]);
110
+ const stagedList = git(repoRoot, ["diff", "--cached", "--name-only", "--", ...paths]);
111
+ if (stagedList === "") {
112
+ return { status: "already-matches", branch, restoredFrom: full, restoredPaths: [...paths].sort() };
113
+ }
114
+ for (const [rel, bytes] of wanted) {
115
+ const stagedBytes = gitRaw(repoRoot, ["show", `:${rel}`]);
116
+ if (!stagedBytes.equals(bytes)) {
117
+ git(repoRoot, ["reset", "-q", "--", ...paths]);
118
+ throw new RestoreError("STAGE_MISMATCH", `Refusing restore: staged content of ${rel} changed between write and staging (race detected). ` +
119
+ "Our staged copy was reset; ask the user how to proceed.");
120
+ }
121
+ }
122
+ const short = full.slice(0, 12);
123
+ const date = input.date ?? new Date().toISOString();
124
+ const message = `olc restore: ${input.taskId} (${[...paths].sort().join(", ")}) to ${short}\n\n${input.reason}`;
125
+ git(repoRoot, ["commit", "-m", message, "--", ...paths]);
126
+ const sha = git(repoRoot, ["rev-parse", "HEAD"]);
127
+ const recordPath = appendCheckpointRecord(vaultRoot, projectId, input.taskId, {
128
+ task_id: input.taskId,
129
+ project_id: projectId,
130
+ commit_sha: sha,
131
+ date,
132
+ summary: `Restore ${[...paths].sort().join(", ")} to checkpoint ${short}: ${input.reason}`,
133
+ verification: `(restore of ${short}; later history preserved)`,
134
+ });
135
+ return {
136
+ status: "restored",
137
+ branch,
138
+ restoredFrom: full,
139
+ sha,
140
+ restoredPaths: [...paths].sort(),
141
+ recordPath,
142
+ };
143
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OLC package entrypoint. Thin on purpose — see docs/PRD.md §8 for planned
3
+ * module layout (config, core, git, logging, tools, validation).
4
+ *
5
+ * The plugin default export is re-exported here (not only from
6
+ * `./plugin.js`) because OpenCode's npm loader resolves the package root,
7
+ * i.e. `dist/index.js` — without a `default` export carrying `{ id, setup }`
8
+ * the loader ignores the package entirely (found by OLC-071 smoke test:
9
+ * zero load attempts for 0.1.0). `OLC_VERSION` is only consumed inside
10
+ * `plugin.ts` tool `execute` (deferred to call time), so this re-export
11
+ * cycle is safe under ESM live bindings.
12
+ */
13
+ export declare const OLC_VERSION = "0.1.1";
14
+ export { default } from "./plugin.js";
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * OLC package entrypoint. Thin on purpose — see docs/PRD.md §8 for planned
3
+ * module layout (config, core, git, logging, tools, validation).
4
+ *
5
+ * The plugin default export is re-exported here (not only from
6
+ * `./plugin.js`) because OpenCode's npm loader resolves the package root,
7
+ * i.e. `dist/index.js` — without a `default` export carrying `{ id, setup }`
8
+ * the loader ignores the package entirely (found by OLC-071 smoke test:
9
+ * zero load attempts for 0.1.0). `OLC_VERSION` is only consumed inside
10
+ * `plugin.ts` tool `execute` (deferred to call time), so this re-export
11
+ * cycle is safe under ESM live bindings.
12
+ */
13
+ export const OLC_VERSION = "0.1.1";
14
+ export { default } from "./plugin.js";