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,166 @@
1
+ /**
2
+ * Progressive memory retrieval (OLC-062).
3
+ *
4
+ * Read the small things first (`index.md`, `current-state.md`), then pull
5
+ * only the memory/decision docs, task docs, and log events relevant to the
6
+ * caller's query. Never scans the whole vault: every read is resolved inside
7
+ * the validated project's scope, so unrelated project folders are untouched
8
+ * by construction. Writes (`recordMemory`) go through the same identity gate
9
+ * and land only inside the validated project scope.
10
+ *
11
+ * Reuses: identity gate (OLC-021), scope-enforced vault paths + atomic
12
+ * writes (OLC-023), newest-first log readers (OLC-060).
13
+ */
14
+ import { readdirSync } from "node:fs";
15
+ import { validateIdentity } from "../core/project-identity.js";
16
+ import { readLastEvents } from "../log/index.js";
17
+ import { readVaultDoc, resolveVaultDoc, writeVaultDoc, } from "../vault/index.js";
18
+ export class MemoryError extends Error {
19
+ code;
20
+ constructor(code, message) {
21
+ super(message);
22
+ this.name = "MemoryError";
23
+ this.code = code;
24
+ }
25
+ }
26
+ /** Directories searched for relevant docs — always inside one project scope. */
27
+ const SEARCH_DIRS = ["memory", "decisions"];
28
+ /** Tokens this short or this common never match (noise guard for scoring). */
29
+ const STOPWORDS = new Set("a,an,the,and,or,for,with,that,this,from,are,was,were,has,have,had,will,would,should,could,about,into,over,after,before,between,under,what,when,where,which,while,also,just,then,than,them,they,their,to,of,in,on,at,is,it,as,be,by,we,us,do,if,so,no,my,me,our,your,his,her,its,not,but,all,any,can,how,why,who".split(","));
30
+ /** Max matches returned and max excerpt chars per match (output-token caps). */
31
+ const DEFAULT_MATCH_LIMIT = 8;
32
+ const EXCERPT_CHARS = 1200;
33
+ function tokenize(query) {
34
+ return [
35
+ ...new Set(query
36
+ .toLowerCase()
37
+ .split(/[^a-z0-9]+/)
38
+ .filter((t) => t.length >= 2 && !STOPWORDS.has(t))),
39
+ ];
40
+ }
41
+ function checkName(name) {
42
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
43
+ throw new MemoryError("INVALID_NAME", `Invalid memory name ${JSON.stringify(name)}: use letters, digits, dash or underscore, starting with a letter or digit. No paths, no extensions (".md" is added).`);
44
+ }
45
+ }
46
+ function readOrNull(path) {
47
+ try {
48
+ return readVaultDoc(path);
49
+ }
50
+ catch (error) {
51
+ if (error.code === "ENOENT")
52
+ return null;
53
+ throw error;
54
+ }
55
+ }
56
+ function listMdFiles(dir) {
57
+ try {
58
+ return readdirSync(dir, { withFileTypes: true })
59
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
60
+ .map((e) => e.name)
61
+ .sort();
62
+ }
63
+ catch (error) {
64
+ if (error.code === "ENOENT")
65
+ return [];
66
+ throw error;
67
+ }
68
+ }
69
+ function scoreDoc(fileName, content, tokens) {
70
+ const lowerName = fileName.toLowerCase();
71
+ const lowerBody = content.toLowerCase();
72
+ let score = 0;
73
+ for (const token of tokens) {
74
+ if (lowerName.includes(token))
75
+ score += 2;
76
+ // Count occurrences, capped so one long file can't dominate.
77
+ let count = 0;
78
+ let from = 0;
79
+ for (;;) {
80
+ const hit = lowerBody.indexOf(token, from);
81
+ if (hit === -1 || count >= 5)
82
+ break;
83
+ count++;
84
+ from = hit + token.length;
85
+ }
86
+ score += count;
87
+ }
88
+ return score;
89
+ }
90
+ function matchesQuery(event, tokens) {
91
+ const haystack = `${event.kind} ${event.taskId ?? ""} ${event.summary} ${event.details ?? ""}`.toLowerCase();
92
+ return tokens.some((t) => haystack.includes(t));
93
+ }
94
+ /**
95
+ * Recall relevant memory. Validates identity first (wrong project or
96
+ * unavailable vault throws before any read), then reads index + state,
97
+ * the requested task's docs, query-matching scope docs, and recent logs.
98
+ * Read-only: never writes, never leaves the validated project scope.
99
+ */
100
+ export function recallMemory(input) {
101
+ const identity = validateIdentity(input.projectRoot, input.config);
102
+ const { vaultRoot, projectId, branch } = identity;
103
+ const tokens = tokenize(input.query ?? "");
104
+ const index = readOrNull(resolveVaultDoc(vaultRoot, projectId, "index.md"));
105
+ const currentState = readOrNull(resolveVaultDoc(vaultRoot, projectId, "current-state.md"));
106
+ let taskDocs = null;
107
+ if (input.taskId !== undefined) {
108
+ taskDocs = {
109
+ taskId: input.taskId,
110
+ intent: readOrNull(resolveVaultDoc(vaultRoot, projectId, "tasks", input.taskId, "intent.md")),
111
+ plan: readOrNull(resolveVaultDoc(vaultRoot, projectId, "tasks", input.taskId, "plan.md")),
112
+ };
113
+ }
114
+ const matches = [];
115
+ if (tokens.length > 0) {
116
+ for (const dir of SEARCH_DIRS) {
117
+ const absDir = resolveVaultDoc(vaultRoot, projectId, dir);
118
+ for (const file of listMdFiles(absDir)) {
119
+ const content = readOrNull(resolveVaultDoc(vaultRoot, projectId, dir, file)) ?? "";
120
+ const score = scoreDoc(file, content, tokens);
121
+ if (score <= 0)
122
+ continue;
123
+ const truncated = content.length > EXCERPT_CHARS;
124
+ matches.push({
125
+ dir,
126
+ file,
127
+ score,
128
+ excerpt: truncated
129
+ ? content.slice(0, EXCERPT_CHARS) + "\n…(truncated)"
130
+ : content,
131
+ truncated,
132
+ });
133
+ }
134
+ }
135
+ matches.sort((a, b) => b.score - a.score || (a.file < b.file ? -1 : 1));
136
+ }
137
+ let logEvents = [];
138
+ if (input.includeLogs ?? true) {
139
+ const events = readLastEvents(vaultRoot, projectId, input.logLimit ?? 10);
140
+ logEvents = tokens.length > 0 ? events.filter((e) => matchesQuery(e, tokens)) : events;
141
+ }
142
+ return {
143
+ projectId,
144
+ branch,
145
+ index,
146
+ currentState,
147
+ taskDocs,
148
+ matches: matches.slice(0, input.limit ?? DEFAULT_MATCH_LIMIT),
149
+ logEvents,
150
+ };
151
+ }
152
+ /**
153
+ * Record one memory note inside the validated project scope.
154
+ * Identity failures throw before any write; existing notes are never
155
+ * silently replaced (`overwrite: true` required).
156
+ * Returns the vault-relative path of the note.
157
+ */
158
+ export function recordMemory(input) {
159
+ checkName(input.name);
160
+ if (typeof input.content !== "string" || input.content.trim() === "") {
161
+ throw new MemoryError("INVALID_INPUT", "Memory content must be a non-empty string.");
162
+ }
163
+ const identity = validateIdentity(input.projectRoot, input.config);
164
+ writeVaultDoc(resolveVaultDoc(identity.vaultRoot, identity.projectId, "memory", `${input.name}.md`), input.content.endsWith("\n") ? input.content : input.content + "\n", { overwrite: input.overwrite ?? false });
165
+ return `memory/${input.name}.md`;
166
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * intent.md / plan.md workflow (OLC-032).
3
+ *
4
+ * - `draftTaskDocs`: render both templates (status `draft`) and create them
5
+ * under `tasks/<taskId>/`. Refuses when docs already exist.
6
+ * - `approveTaskDocs`: flip both statuses to `approved` (explicit overwrite).
7
+ * - `reviseTaskDocs`: append the reason to `history.md`, write updated docs,
8
+ * reset status to `draft` (re-approval required).
9
+ * - `rejectTaskDocs`: mark both `rejected` and record the reason in history.
10
+ * - `assertApproved`: gate for build-side use — throws unless BOTH docs exist
11
+ * and are `approved`, naming the offending document.
12
+ */
13
+ export type PlanningStatus = "draft" | "approved" | "rejected";
14
+ export declare class PlanningError extends Error {
15
+ readonly code: "NOT_APPROVED" | "MISSING_DOC" | "INVALID_STATUS";
16
+ constructor(code: "NOT_APPROVED" | "MISSING_DOC" | "INVALID_STATUS", message: string);
17
+ }
18
+ export interface IntentVars {
19
+ what: string;
20
+ why: string;
21
+ nonGoals: string;
22
+ openQuestions: string;
23
+ }
24
+ export interface PlanVars {
25
+ approach: string;
26
+ steps: string;
27
+ acceptanceCriteria: string;
28
+ tests: string;
29
+ risks: string;
30
+ }
31
+ /** Create both docs with status `draft`. Throws (no overwrite) if either exists. */
32
+ export declare function draftTaskDocs(vaultRoot: string, projectId: string, taskId: string, intent: IntentVars, plan: PlanVars): {
33
+ intentPath: string;
34
+ planPath: string;
35
+ };
36
+ /** Current statuses of both docs (`missing` when the file does not exist). */
37
+ export declare function getDocStatuses(vaultRoot: string, projectId: string, taskId: string): {
38
+ intent: PlanningStatus | "missing";
39
+ plan: PlanningStatus | "missing";
40
+ };
41
+ /** Approve both docs. Both must currently exist (draft or rejected). */
42
+ export declare function approveTaskDocs(vaultRoot: string, projectId: string, taskId: string, date: string): void;
43
+ /**
44
+ * Revise: record the reason in history, optionally replace doc bodies,
45
+ * reset both statuses to `draft` so re-approval is required.
46
+ */
47
+ export declare function reviseTaskDocs(vaultRoot: string, projectId: string, taskId: string, reason: string, date: string, updated?: {
48
+ intent?: IntentVars;
49
+ plan?: PlanVars;
50
+ }): void;
51
+ /** Reject both docs and record the reason. A rejected plan blocks builds. */
52
+ export declare function rejectTaskDocs(vaultRoot: string, projectId: string, taskId: string, reason: string, date: string): void;
53
+ /**
54
+ * Build gate: throws `PlanningError(NOT_APPROVED)` unless both docs exist
55
+ * and carry status `approved`. Call before any implementation work.
56
+ */
57
+ export declare function assertApproved(vaultRoot: string, projectId: string, taskId: string): void;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * intent.md / plan.md workflow (OLC-032).
3
+ *
4
+ * - `draftTaskDocs`: render both templates (status `draft`) and create them
5
+ * under `tasks/<taskId>/`. Refuses when docs already exist.
6
+ * - `approveTaskDocs`: flip both statuses to `approved` (explicit overwrite).
7
+ * - `reviseTaskDocs`: append the reason to `history.md`, write updated docs,
8
+ * reset status to `draft` (re-approval required).
9
+ * - `rejectTaskDocs`: mark both `rejected` and record the reason in history.
10
+ * - `assertApproved`: gate for build-side use — throws unless BOTH docs exist
11
+ * and are `approved`, naming the offending document.
12
+ */
13
+ import { VaultError, parseFrontmatter, readVaultDoc, renderTemplate, resolveVaultDoc, withFrontmatter, writeVaultDoc, } from "../vault/index.js";
14
+ export class PlanningError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = "PlanningError";
19
+ this.code = code;
20
+ }
21
+ }
22
+ function docPaths(vaultRoot, projectId, taskId) {
23
+ return {
24
+ intent: resolveVaultDoc(vaultRoot, projectId, "tasks", taskId, "intent.md"),
25
+ plan: resolveVaultDoc(vaultRoot, projectId, "tasks", taskId, "plan.md"),
26
+ history: resolveVaultDoc(vaultRoot, projectId, "tasks", taskId, "history.md"),
27
+ };
28
+ }
29
+ function templateVars(projectId, taskId, status, extra) {
30
+ return { project_id: projectId, task_id: taskId, status, ...extra };
31
+ }
32
+ /** Create both docs with status `draft`. Throws (no overwrite) if either exists. */
33
+ export function draftTaskDocs(vaultRoot, projectId, taskId, intent, plan) {
34
+ const paths = docPaths(vaultRoot, projectId, taskId);
35
+ const intentText = renderTemplate("intent", templateVars(projectId, taskId, "draft", {
36
+ what: intent.what,
37
+ why: intent.why,
38
+ non_goals: intent.nonGoals,
39
+ open_questions: intent.openQuestions,
40
+ }));
41
+ const planText = renderTemplate("plan", templateVars(projectId, taskId, "draft", {
42
+ approach: plan.approach,
43
+ steps: plan.steps,
44
+ acceptance_criteria: plan.acceptanceCriteria,
45
+ tests: plan.tests,
46
+ risks: plan.risks,
47
+ }));
48
+ // Write plan first so a failure leaves either both-missing or plan-only;
49
+ // either way no partial intent exists without its plan. Both use
50
+ // create-only semantics (existing docs are never silently replaced).
51
+ writeVaultDoc(paths.plan, planText);
52
+ try {
53
+ writeVaultDoc(paths.intent, intentText);
54
+ }
55
+ catch (error) {
56
+ if (error instanceof VaultError && error.code === "ALREADY_EXISTS") {
57
+ throw new PlanningError("NOT_APPROVED", `Cannot draft task ${taskId}: intent.md already exists and plan.md was just created. ` +
58
+ "Remove the stray plan.md or revise the existing docs instead — nothing was overwritten.");
59
+ }
60
+ throw error;
61
+ }
62
+ return { intentPath: paths.intent, planPath: paths.plan };
63
+ }
64
+ function readStatus(path, label) {
65
+ let text;
66
+ try {
67
+ text = readVaultDoc(path);
68
+ }
69
+ catch {
70
+ throw new PlanningError("MISSING_DOC", `Planning document missing: ${label} at ${path}. Draft it before approving.`);
71
+ }
72
+ const status = parseFrontmatter(text).data["status"];
73
+ if (status !== "draft" && status !== "approved" && status !== "rejected") {
74
+ throw new PlanningError("INVALID_STATUS", `Planning document ${label} has unknown status ${JSON.stringify(status)}. Expected draft, approved, or rejected.`);
75
+ }
76
+ return status;
77
+ }
78
+ /** Current statuses of both docs (`missing` when the file does not exist). */
79
+ export function getDocStatuses(vaultRoot, projectId, taskId) {
80
+ const paths = docPaths(vaultRoot, projectId, taskId);
81
+ const read = (path, label) => {
82
+ try {
83
+ return readStatus(path, label);
84
+ }
85
+ catch (error) {
86
+ if (error instanceof PlanningError && error.code === "MISSING_DOC")
87
+ return "missing";
88
+ throw error;
89
+ }
90
+ };
91
+ return { intent: read(paths.intent, "intent.md"), plan: read(paths.plan, "plan.md") };
92
+ }
93
+ function setStatus(vaultRoot, projectId, taskId, status) {
94
+ const paths = docPaths(vaultRoot, projectId, taskId);
95
+ for (const [path, label] of [
96
+ [paths.intent, "intent.md"],
97
+ [paths.plan, "plan.md"],
98
+ ]) {
99
+ const parsed = parseFrontmatter(readVaultDoc(path));
100
+ writeVaultDoc(path, withFrontmatter({ ...parsed.data, status }, parsed.body), {
101
+ overwrite: true,
102
+ });
103
+ }
104
+ }
105
+ function appendHistory(vaultRoot, projectId, taskId, kind, reason, date) {
106
+ const { history } = docPaths(vaultRoot, projectId, taskId);
107
+ let existing = "";
108
+ try {
109
+ existing = readVaultDoc(history);
110
+ }
111
+ catch {
112
+ existing = "";
113
+ }
114
+ const entry = `## ${date} — ${kind}\n\n${reason}\n`;
115
+ writeVaultDoc(history, existing === "" ? `# History — ${taskId}\n\n${entry}` : `${existing}\n${entry}`, {
116
+ overwrite: true,
117
+ });
118
+ }
119
+ /** Approve both docs. Both must currently exist (draft or rejected). */
120
+ export function approveTaskDocs(vaultRoot, projectId, taskId, date) {
121
+ const paths = docPaths(vaultRoot, projectId, taskId);
122
+ readStatus(paths.intent, "intent.md");
123
+ readStatus(paths.plan, "plan.md");
124
+ setStatus(vaultRoot, projectId, taskId, "approved");
125
+ appendHistory(vaultRoot, projectId, taskId, "approved", "Approved by user.", date);
126
+ }
127
+ /**
128
+ * Revise: record the reason in history, optionally replace doc bodies,
129
+ * reset both statuses to `draft` so re-approval is required.
130
+ */
131
+ export function reviseTaskDocs(vaultRoot, projectId, taskId, reason, date, updated) {
132
+ appendHistory(vaultRoot, projectId, taskId, "revision", reason, date);
133
+ if (updated?.intent) {
134
+ const paths = docPaths(vaultRoot, projectId, taskId);
135
+ writeVaultDoc(paths.intent, renderTemplate("intent", templateVars(projectId, taskId, "draft", {
136
+ what: updated.intent.what,
137
+ why: updated.intent.why,
138
+ non_goals: updated.intent.nonGoals,
139
+ open_questions: updated.intent.openQuestions,
140
+ })), { overwrite: true });
141
+ }
142
+ if (updated?.plan) {
143
+ const paths = docPaths(vaultRoot, projectId, taskId);
144
+ writeVaultDoc(paths.plan, renderTemplate("plan", templateVars(projectId, taskId, "draft", {
145
+ approach: updated.plan.approach,
146
+ steps: updated.plan.steps,
147
+ acceptance_criteria: updated.plan.acceptanceCriteria,
148
+ tests: updated.plan.tests,
149
+ risks: updated.plan.risks,
150
+ })), { overwrite: true });
151
+ }
152
+ setStatus(vaultRoot, projectId, taskId, "draft");
153
+ }
154
+ /** Reject both docs and record the reason. A rejected plan blocks builds. */
155
+ export function rejectTaskDocs(vaultRoot, projectId, taskId, reason, date) {
156
+ const paths = docPaths(vaultRoot, projectId, taskId);
157
+ readStatus(paths.intent, "intent.md");
158
+ readStatus(paths.plan, "plan.md");
159
+ setStatus(vaultRoot, projectId, taskId, "rejected");
160
+ appendHistory(vaultRoot, projectId, taskId, "rejected", reason, date);
161
+ }
162
+ /**
163
+ * Build gate: throws `PlanningError(NOT_APPROVED)` unless both docs exist
164
+ * and carry status `approved`. Call before any implementation work.
165
+ */
166
+ export function assertApproved(vaultRoot, projectId, taskId) {
167
+ const statuses = getDocStatuses(vaultRoot, projectId, taskId);
168
+ const problems = [];
169
+ for (const [label, status] of [
170
+ ["intent.md", statuses.intent],
171
+ ["plan.md", statuses.plan],
172
+ ]) {
173
+ if (status !== "approved")
174
+ problems.push(`${label} is ${status}`);
175
+ }
176
+ if (problems.length > 0) {
177
+ throw new PlanningError("NOT_APPROVED", `Task ${taskId} is not approved for implementation: ${problems.join("; ")}. ` +
178
+ "Draft, revise, and get both documents approved before coding.");
179
+ }
180
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OLC OpenCode plugin entrypoint (OLC-011).
3
+ *
4
+ * Deliberately minimal: registers one read-only diagnostic tool
5
+ * (`olc_status`) that reports the OLC package version and the detected
6
+ * project location. No file writes happen at import or setup time —
7
+ * acceptance criteria for OLC-011 requires no surprise writes on load.
8
+ *
9
+ * Uses `@opencode/plugin` (v2 promise API, matching the running
10
+ * `opencode` CLI): `Plugin.define({ id, setup })` with
11
+ * `ctx.tool.transform` for tool registration and `ctx.location`
12
+ * for project context.
13
+ */
14
+ import { Plugin } from "@opencode/plugin";
15
+ declare const _default: Plugin.Plugin;
16
+ export default _default;
package/dist/plugin.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * OLC OpenCode plugin entrypoint (OLC-011).
3
+ *
4
+ * Deliberately minimal: registers one read-only diagnostic tool
5
+ * (`olc_status`) that reports the OLC package version and the detected
6
+ * project location. No file writes happen at import or setup time —
7
+ * acceptance criteria for OLC-011 requires no surprise writes on load.
8
+ *
9
+ * Uses `@opencode/plugin` (v2 promise API, matching the running
10
+ * `opencode` CLI): `Plugin.define({ id, setup })` with
11
+ * `ctx.tool.transform` for tool registration and `ctx.location`
12
+ * for project context.
13
+ */
14
+ import { Plugin } from "@opencode/plugin";
15
+ import { OLC_VERSION } from "./index.js";
16
+ export default Plugin.define({
17
+ id: "olc",
18
+ async setup(ctx) {
19
+ await ctx.tool.transform((editor) => {
20
+ editor.namespace({
21
+ name: "olc",
22
+ description: "Obsidian Lean Coder diagnostics",
23
+ });
24
+ editor.add({
25
+ name: "status",
26
+ description: "Report the OLC package version and the detected project location. Read-only, makes no writes.",
27
+ input: {
28
+ type: "object",
29
+ properties: {},
30
+ additionalProperties: false,
31
+ },
32
+ options: { namespace: "olc" },
33
+ execute: async () => {
34
+ return {
35
+ content: JSON.stringify({
36
+ olcVersion: OLC_VERSION,
37
+ directory: ctx.location.directory,
38
+ projectId: ctx.location.project.id,
39
+ canonical: ctx.location.project.canonical,
40
+ }, null, 2),
41
+ };
42
+ },
43
+ });
44
+ });
45
+ },
46
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Resume context (OLC-061): rebuild enough context after an interruption.
3
+ *
4
+ * Read-only. Never resumes coding, switches branches, or closes tasks —
5
+ * every result carries `needsConfirmation: true`, and ambiguous situations
6
+ * set `blocked` with an actionable message instead of guessing.
7
+ *
8
+ * Contents: actual Git state (branch/HEAD/staged/unstaged/untracked),
9
+ * `current-state.md`, open registry tasks (newest first), the recommended
10
+ * task (via OLC-033 `recommendResume`), that task's intent/plan doc
11
+ * statuses, the most recent log events (last verification visible there),
12
+ * and the most recent checkpoint event. Identity is validated first, so an
13
+ * unavailable vault or mismatched project fails before anything is read.
14
+ * A detached HEAD does not nuke the context: branch is reported as null
15
+ * (vault-side checks still apply) and the result is marked blocked.
16
+ */
17
+ import type { ResolvedConfig } from "../config/index.js";
18
+ import { type LoggedEvent } from "../log/index.js";
19
+ import { type TaskEntry } from "../tasks/index.js";
20
+ export interface ResumeTask {
21
+ taskId: string;
22
+ entry: TaskEntry;
23
+ }
24
+ export interface ResumeCheckpoint {
25
+ taskId?: string;
26
+ gitSha: string;
27
+ date: string;
28
+ summary: string;
29
+ }
30
+ export interface ResumeContext {
31
+ branch: string | null;
32
+ head: string;
33
+ staged: string[];
34
+ unstaged: string[];
35
+ untracked: string[];
36
+ currentState: string | null;
37
+ /** Non-terminal registry tasks, newest `updatedAt` first. */
38
+ openTasks: ResumeTask[];
39
+ recommendedTaskId: string | null;
40
+ recommendationNote: string;
41
+ /** Intent/plan doc statuses for the recommended task, null when none. */
42
+ taskDocs: {
43
+ intent: string;
44
+ plan: string;
45
+ } | null;
46
+ /** Newest-first log events (surfaces the last verification). */
47
+ lastEvents: LoggedEvent[];
48
+ lastCheckpoint: ResumeCheckpoint | null;
49
+ /** Always true: resuming requires explicit user confirmation. */
50
+ needsConfirmation: true;
51
+ /** Non-null when ambiguity blocks even a recommendation. */
52
+ blocked: string | null;
53
+ }
54
+ export interface ResumeArgs {
55
+ projectRoot: string;
56
+ config: ResolvedConfig;
57
+ /** How many recent log events to include. Default 5. */
58
+ lastEventsLimit?: number;
59
+ }
60
+ export declare function buildResumeContext(args: ResumeArgs): ResumeContext;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Resume context (OLC-061): rebuild enough context after an interruption.
3
+ *
4
+ * Read-only. Never resumes coding, switches branches, or closes tasks —
5
+ * every result carries `needsConfirmation: true`, and ambiguous situations
6
+ * set `blocked` with an actionable message instead of guessing.
7
+ *
8
+ * Contents: actual Git state (branch/HEAD/staged/unstaged/untracked),
9
+ * `current-state.md`, open registry tasks (newest first), the recommended
10
+ * task (via OLC-033 `recommendResume`), that task's intent/plan doc
11
+ * statuses, the most recent log events (last verification visible there),
12
+ * and the most recent checkpoint event. Identity is validated first, so an
13
+ * unavailable vault or mismatched project fails before anything is read.
14
+ * A detached HEAD does not nuke the context: branch is reported as null
15
+ * (vault-side checks still apply) and the result is marked blocked.
16
+ */
17
+ import { resolve } from "node:path";
18
+ import { IdentityError, assertVaultAccessible, readRegistry as readVaultRegistry, resolveRepoRoot, validateIdentity, } from "../core/project-identity.js";
19
+ import { git, parsePorcelain } from "../git/inspection.js";
20
+ import { readLastEvents } from "../log/index.js";
21
+ import { getDocStatuses } from "../planning/index.js";
22
+ import { TERMINAL_STATUSES, readRegistry, recommendResume, } from "../tasks/index.js";
23
+ import { readVaultDoc, resolveVaultDoc } from "../vault/index.js";
24
+ function readCurrentState(vaultRoot, projectId) {
25
+ try {
26
+ return readVaultDoc(resolveVaultDoc(vaultRoot, projectId, "current-state.md"));
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT")
30
+ return null;
31
+ throw error;
32
+ }
33
+ }
34
+ export function buildResumeContext(args) {
35
+ const limit = args.lastEventsLimit ?? 5;
36
+ let branch;
37
+ let repoRoot;
38
+ let vaultRoot;
39
+ let projectId;
40
+ try {
41
+ const identity = validateIdentity(args.projectRoot, args.config);
42
+ repoRoot = identity.repoRoot;
43
+ branch = identity.branch;
44
+ vaultRoot = identity.vaultRoot;
45
+ projectId = identity.projectId;
46
+ }
47
+ catch (error) {
48
+ if (error instanceof IdentityError && error.code === "DETACHED_HEAD") {
49
+ // Unknown branch must not hide everything else: the vault-side
50
+ // checks still apply, branch is reported as null, and the result
51
+ // is marked blocked below.
52
+ repoRoot = resolveRepoRoot(args.projectRoot);
53
+ vaultRoot = args.config.vaultPath;
54
+ projectId = args.config.projectId;
55
+ assertVaultAccessible(vaultRoot);
56
+ const entry = readVaultRegistry(vaultRoot).projects[projectId];
57
+ if (!entry || resolve(entry.repoRoot) !== repoRoot) {
58
+ throw new IdentityError("PROJECT_MISMATCH", `project_id "${projectId}" is not registered to repository ${repoRoot} in vault ${vaultRoot}. ` +
59
+ "Refusing to touch the wrong project's memory.");
60
+ }
61
+ branch = null;
62
+ }
63
+ else {
64
+ throw error;
65
+ }
66
+ }
67
+ const head = git(repoRoot, ["rev-parse", "HEAD"]);
68
+ const sets = parsePorcelain(git(repoRoot, ["status", "--porcelain=v1", "-uall"]));
69
+ const registry = readRegistry(vaultRoot, projectId);
70
+ const openTasks = Object.entries(registry.tasks)
71
+ .filter(([, e]) => !TERMINAL_STATUSES.includes(e.status))
72
+ .sort((a, b) => (a[1].updatedAt < b[1].updatedAt ? 1 : -1))
73
+ .map(([taskId, entry]) => ({ taskId, entry }));
74
+ const recommendation = recommendResume(vaultRoot, projectId, branch);
75
+ const lastEvents = readLastEvents(vaultRoot, projectId, limit);
76
+ const checkpointEvent = lastEvents.find((e) => e.kind === "checkpoint" && e.gitSha);
77
+ const lastCheckpoint = checkpointEvent?.gitSha
78
+ ? {
79
+ taskId: checkpointEvent.taskId,
80
+ gitSha: checkpointEvent.gitSha,
81
+ date: checkpointEvent.date,
82
+ summary: checkpointEvent.summary,
83
+ }
84
+ : null;
85
+ let blocked = null;
86
+ if (branch === null) {
87
+ blocked =
88
+ "Current branch is unknown (detached HEAD). Tell me which branch and task to resume, and confirm — nothing was switched or closed.";
89
+ }
90
+ else if (recommendation.taskId === null && openTasks.length > 0) {
91
+ blocked =
92
+ `${openTasks.length} open task(s) exist but none is attached to branch ${branch}. ` +
93
+ "Tell me which task to resume, and confirm — nothing was switched or closed.";
94
+ }
95
+ else {
96
+ const onBranch = openTasks.filter((t) => t.entry.branch === branch);
97
+ if (onBranch.length > 1 && onBranch[0].entry.updatedAt === onBranch[1].entry.updatedAt) {
98
+ blocked =
99
+ `Tasks ${onBranch[0].taskId} and ${onBranch[1].taskId} are equally recent on branch ${branch}. ` +
100
+ "Tell me which one to resume, and confirm — nothing was switched or closed.";
101
+ }
102
+ }
103
+ return {
104
+ branch,
105
+ head,
106
+ staged: sets.staged,
107
+ unstaged: sets.unstaged,
108
+ untracked: sets.untracked,
109
+ currentState: readCurrentState(vaultRoot, projectId),
110
+ openTasks,
111
+ recommendedTaskId: recommendation.taskId,
112
+ recommendationNote: recommendation.note,
113
+ taskDocs: recommendation.taskId === null
114
+ ? null
115
+ : getDocStatuses(vaultRoot, projectId, recommendation.taskId),
116
+ lastEvents,
117
+ lastCheckpoint,
118
+ needsConfirmation: true,
119
+ blocked,
120
+ };
121
+ }