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,45 @@
1
+ /**
2
+ * Safe, idempotent project initialization (OLC-022, PRD §7.4 init flow).
3
+ *
4
+ * `initProject` never overwrites an existing file: every target is either
5
+ * created (when missing) or reported as preserved / attention-needed.
6
+ * Anything requiring a human choice (confirming the proposed project_id,
7
+ * providing a vault path, resolving a duplicate id) is returned as
8
+ * `status: "needs-decision"` with an actionable message — the caller
9
+ * (agent or user) supplies the answer on a follow-up call. No prompting
10
+ * happens inside this module, so it stays testable and non-interactive.
11
+ *
12
+ * Report entry actions: `created` | `preserved` | `attention`.
13
+ * With `dryRun: true` nothing is written; `created` then means "would create".
14
+ */
15
+ export declare const OLC_DIR = ".olc";
16
+ export declare const CONFIG_FILE = "config.json";
17
+ export declare const AGENTS_FILE = "AGENTS.md";
18
+ export interface InitEntry {
19
+ path: string;
20
+ action: "created" | "preserved" | "attention";
21
+ note?: string;
22
+ }
23
+ export interface InitReport {
24
+ status: "ready" | "needs-decision";
25
+ projectRoot: string;
26
+ repoRoot: string;
27
+ projectId: string;
28
+ vaultRoot: string | null;
29
+ dryRun: boolean;
30
+ entries: InitEntry[];
31
+ /** Present when status is needs-decision: what the caller must supply. */
32
+ decision?: string;
33
+ }
34
+ export interface InitOptions {
35
+ projectRoot: string;
36
+ /** Explicitly approved project_id. Omit to get a proposal (needs-decision). */
37
+ projectId?: string;
38
+ /** Explicit vault path. Falls back to OLC_VAULT_PATH, then existing config. */
39
+ vaultPath?: string;
40
+ env?: NodeJS.ProcessEnv;
41
+ dryRun?: boolean;
42
+ }
43
+ /** Derive a safe project_id proposal from the repository directory name. */
44
+ export declare function proposeProjectId(repoRoot: string): string;
45
+ export declare function initProject(options: InitOptions): InitReport;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Safe, idempotent project initialization (OLC-022, PRD §7.4 init flow).
3
+ *
4
+ * `initProject` never overwrites an existing file: every target is either
5
+ * created (when missing) or reported as preserved / attention-needed.
6
+ * Anything requiring a human choice (confirming the proposed project_id,
7
+ * providing a vault path, resolving a duplicate id) is returned as
8
+ * `status: "needs-decision"` with an actionable message — the caller
9
+ * (agent or user) supplies the answer on a follow-up call. No prompting
10
+ * happens inside this module, so it stays testable and non-interactive.
11
+ *
12
+ * Report entry actions: `created` | `preserved` | `attention`.
13
+ * With `dryRun: true` nothing is written; `created` then means "would create".
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { basename, join, resolve } from "node:path";
17
+ import { CONFIG_SCHEMA_VERSION, VAULT_PATH_ENV, parseConfig } from "../config/index.js";
18
+ import { REGISTRY_REL_PATH, resolveRepoRoot } from "../core/project-identity.js";
19
+ export const OLC_DIR = ".olc";
20
+ export const CONFIG_FILE = "config.json";
21
+ export const AGENTS_FILE = "AGENTS.md";
22
+ const AGENTS_STUB = (projectId) => `# OLC project instructions — ${projectId}\n\n` +
23
+ "Review and customize this file. OLC agents read it before memory operations.\n";
24
+ const VAULT_INDEX = (projectId) => `# ${projectId}\n\nOLC project memory root. See current-state.md for the latest status.\n`;
25
+ const VAULT_STATE = (projectId) => `# Current state — ${projectId}\n\nInitialized. No activity recorded yet.\n`;
26
+ /** Derive a safe project_id proposal from the repository directory name. */
27
+ export function proposeProjectId(repoRoot) {
28
+ const cleaned = basename(resolve(repoRoot))
29
+ .toLowerCase()
30
+ .replace(/[^a-z0-9-_]+/g, "-")
31
+ .replace(/^-+|-+$/g, "")
32
+ .slice(0, 64);
33
+ return cleaned || "project";
34
+ }
35
+ function readRegistryFile(path) {
36
+ if (!existsSync(path))
37
+ return { registry: { projects: {} }, existed: false };
38
+ const json = JSON.parse(readFileSync(path, "utf8"));
39
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
40
+ throw new Error("invalid registry shape");
41
+ }
42
+ const projects = json.projects;
43
+ if (typeof projects !== "object" || projects === null)
44
+ throw new Error("invalid registry shape");
45
+ return { registry: json, existed: true };
46
+ }
47
+ function planFile(path, content, dryRun) {
48
+ if (existsSync(path))
49
+ return { path, action: "preserved" };
50
+ if (!dryRun) {
51
+ mkdirSync(join(path, ".."), { recursive: true });
52
+ writeFileSync(path, content);
53
+ }
54
+ return { path, action: "created" };
55
+ }
56
+ export function initProject(options) {
57
+ const dryRun = options.dryRun === true;
58
+ const env = options.env ?? process.env;
59
+ const projectRoot = resolve(options.projectRoot);
60
+ const repoRoot = resolveRepoRoot(projectRoot);
61
+ const base = { projectRoot, repoRoot, projectId: "", vaultRoot: null, dryRun, entries: [] };
62
+ // 1. Project id: explicit, existing config, or proposal needing approval.
63
+ let projectId = options.projectId?.trim();
64
+ const configPath = join(projectRoot, OLC_DIR, CONFIG_FILE);
65
+ let existingConfigId = null;
66
+ if (existsSync(configPath)) {
67
+ try {
68
+ existingConfigId = parseConfig(JSON.parse(readFileSync(configPath, "utf8"))).projectId;
69
+ }
70
+ catch {
71
+ base.entries.push({
72
+ path: configPath,
73
+ action: "attention",
74
+ note: "Existing config is invalid — fix or delete it, then re-run init. It was NOT overwritten.",
75
+ });
76
+ return { ...base, status: "needs-decision", decision: "repair-or-remove-invalid-config" };
77
+ }
78
+ }
79
+ if (!projectId) {
80
+ if (existingConfigId) {
81
+ projectId = existingConfigId;
82
+ }
83
+ else {
84
+ return {
85
+ ...base,
86
+ projectId: proposeProjectId(repoRoot),
87
+ status: "needs-decision",
88
+ decision: `Proposed project_id "${proposeProjectId(repoRoot)}" (from repository name). ` +
89
+ "Re-run init with projectId set to this value to approve, or pass a different one.",
90
+ };
91
+ }
92
+ }
93
+ base.projectId = projectId;
94
+ // 2. Vault path: explicit > env > existing config > ask.
95
+ let vaultInput = options.vaultPath?.trim() || env[VAULT_PATH_ENV]?.trim() || null;
96
+ if (!vaultInput && existingConfigId && existingConfigId === projectId) {
97
+ try {
98
+ const parsed = parseConfig(JSON.parse(readFileSync(configPath, "utf8")));
99
+ vaultInput = parsed.vaultPath?.trim() || null;
100
+ }
101
+ catch {
102
+ /* already validated above; unreachable */
103
+ }
104
+ }
105
+ if (!vaultInput) {
106
+ return {
107
+ ...base,
108
+ status: "needs-decision",
109
+ decision: `No vault path for project "${projectId}". Re-run init with vaultPath set, ` +
110
+ `or set the ${VAULT_PATH_ENV} environment variable. OLC never invents a vault location.`,
111
+ };
112
+ }
113
+ const vaultRoot = resolve(repoRoot, vaultInput);
114
+ base.vaultRoot = vaultRoot;
115
+ // 3. Duplicate project_id check BEFORE any write.
116
+ const registryPath = join(vaultRoot, REGISTRY_REL_PATH);
117
+ let registry;
118
+ let registryExisted;
119
+ try {
120
+ ({ registry, existed: registryExisted } = readRegistryFile(registryPath));
121
+ }
122
+ catch {
123
+ base.entries.push({
124
+ path: registryPath,
125
+ action: "attention",
126
+ note: "Existing registry is corrupt — fix it or back it up, then re-run init. Nothing was written.",
127
+ });
128
+ return { ...base, status: "needs-decision", decision: "repair-corrupt-registry" };
129
+ }
130
+ const owner = registry.projects[projectId]?.repoRoot;
131
+ if (owner && resolve(owner) !== repoRoot) {
132
+ return {
133
+ ...base,
134
+ status: "needs-decision",
135
+ decision: `project_id "${projectId}" is already registered to another repository (${resolve(owner)}). ` +
136
+ "Re-run init with a different projectId for this checkout. Nothing was written.",
137
+ };
138
+ }
139
+ // 4. Write phase (skipped entirely under dryRun): create only missing files.
140
+ const entries = base.entries;
141
+ if (!existsSync(configPath)) {
142
+ const content = JSON.stringify({ schemaVersion: CONFIG_SCHEMA_VERSION, projectId, vaultPath: vaultInput }, null, 2) + "\n";
143
+ if (!dryRun) {
144
+ mkdirSync(join(configPath, ".."), { recursive: true });
145
+ writeFileSync(configPath, content);
146
+ }
147
+ entries.push({ path: configPath, action: "created" });
148
+ }
149
+ else {
150
+ entries.push({ path: configPath, action: "preserved" });
151
+ }
152
+ entries.push(planFile(join(projectRoot, OLC_DIR, AGENTS_FILE), AGENTS_STUB(projectId), dryRun));
153
+ const projectDir = join(vaultRoot, "OLC", "Projects", projectId);
154
+ entries.push(planFile(join(projectDir, "index.md"), VAULT_INDEX(projectId), dryRun));
155
+ entries.push(planFile(join(projectDir, "current-state.md"), VAULT_STATE(projectId), dryRun));
156
+ for (const sub of ["decisions", "tasks", "logs", "memory", "taste"]) {
157
+ const dir = join(projectDir, sub);
158
+ if (existsSync(dir)) {
159
+ entries.push({ path: dir, action: "preserved" });
160
+ }
161
+ else {
162
+ if (!dryRun)
163
+ mkdirSync(dir, { recursive: true });
164
+ entries.push({ path: dir, action: "created" });
165
+ }
166
+ }
167
+ if (!registryExisted || !owner) {
168
+ if (!dryRun) {
169
+ mkdirSync(join(registryPath, ".."), { recursive: true });
170
+ writeFileSync(registryPath, JSON.stringify({ projects: { ...registry.projects, [projectId]: { repoRoot } } }, null, 2) + "\n");
171
+ }
172
+ entries.push({ path: registryPath, action: "created" });
173
+ }
174
+ else {
175
+ entries.push({ path: registryPath, action: "preserved" });
176
+ }
177
+ return { ...base, status: "ready" };
178
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Activity event log (OLC-060).
3
+ *
4
+ * Important OLC activity is recorded as durable Markdown under the validated
5
+ * project scope: `<vault>/OLC/Projects/<project_id>/logs/YYYY-MM-DD.md`
6
+ * (one file per day — that date split IS the rotation; no size-based
7
+ * rotation for MVP).
8
+ *
9
+ * Design constraints (from the task spec):
10
+ * - Every write validates vault identity first (OLC-021); invalid vault →
11
+ * throw with zero files created.
12
+ * - Appends use a single O_APPEND write, so an interruption can never
13
+ * corrupt previously written entries. A file missing its trailing newline
14
+ * (crash remnant) is normalized before the next append.
15
+ * - Secrets are scrubbed, never stored: known credential patterns in
16
+ * summary/details are replaced with `[redacted]` and the entry is marked.
17
+ * - This module never hooks tool calls; events are recorded only by explicit
18
+ * `logEvent` calls, so there is no per-tool-call noise by construction.
19
+ * - Readers take explicit paths; callers that cross project boundaries
20
+ * (e.g. resume) must run `validateIdentity` themselves first.
21
+ */
22
+ import type { ResolvedConfig } from "../config/index.js";
23
+ export type LogErrorCode = "INVALID_EVENT" | "INVALID_DATE" | "LOG_WRITE_FAILED" | "LOG_READ_FAILED";
24
+ export declare class LogError extends Error {
25
+ readonly code: LogErrorCode;
26
+ constructor(code: LogErrorCode, message: string);
27
+ }
28
+ export type LogEventKind = "milestone" | "checkpoint" | "task-status" | "decision" | "verify" | "session" | "scope-change";
29
+ export interface LogEventInput {
30
+ kind: LogEventKind;
31
+ summary: string;
32
+ taskId?: string;
33
+ details?: string;
34
+ gitSha?: string;
35
+ /** ISO timestamp; defaults to now. Inject a fixed value in tests. */
36
+ date?: string;
37
+ }
38
+ export interface LoggedEvent {
39
+ kind: string;
40
+ summary: string;
41
+ taskId?: string;
42
+ details?: string;
43
+ gitSha?: string;
44
+ date: string;
45
+ redacted: boolean;
46
+ }
47
+ export interface LogEventArgs {
48
+ projectRoot: string;
49
+ config: ResolvedConfig;
50
+ event: LogEventInput;
51
+ }
52
+ /**
53
+ * Validate identity, then append one event to the day's log file.
54
+ * Returns the file path and the stored (scrubbed) entry.
55
+ */
56
+ export declare function logEvent(args: LogEventArgs): {
57
+ file: string;
58
+ entry: LoggedEvent;
59
+ };
60
+ /** Parse entries from log Markdown (frontmatter header ignored). */
61
+ export declare function parseEntries(text: string): LoggedEvent[];
62
+ /** Read one day's log file; missing file → empty list (not an error). */
63
+ export declare function readLogDay(vaultRoot: string, projectId: string, day: string): LoggedEvent[];
64
+ /**
65
+ * Newest-first events across day files, up to `limit`. Day files sort
66
+ * lexicographically (YYYY-MM-DD), so no directory walk beyond `logs/`.
67
+ */
68
+ export declare function readLastEvents(vaultRoot: string, projectId: string, limit: number): LoggedEvent[];
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Activity event log (OLC-060).
3
+ *
4
+ * Important OLC activity is recorded as durable Markdown under the validated
5
+ * project scope: `<vault>/OLC/Projects/<project_id>/logs/YYYY-MM-DD.md`
6
+ * (one file per day — that date split IS the rotation; no size-based
7
+ * rotation for MVP).
8
+ *
9
+ * Design constraints (from the task spec):
10
+ * - Every write validates vault identity first (OLC-021); invalid vault →
11
+ * throw with zero files created.
12
+ * - Appends use a single O_APPEND write, so an interruption can never
13
+ * corrupt previously written entries. A file missing its trailing newline
14
+ * (crash remnant) is normalized before the next append.
15
+ * - Secrets are scrubbed, never stored: known credential patterns in
16
+ * summary/details are replaced with `[redacted]` and the entry is marked.
17
+ * - This module never hooks tool calls; events are recorded only by explicit
18
+ * `logEvent` calls, so there is no per-tool-call noise by construction.
19
+ * - Readers take explicit paths; callers that cross project boundaries
20
+ * (e.g. resume) must run `validateIdentity` themselves first.
21
+ */
22
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { validateIdentity } from "../core/project-identity.js";
25
+ import { projectScopeRoot, withFrontmatter } from "../vault/index.js";
26
+ export class LogError extends Error {
27
+ code;
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.name = "LogError";
31
+ this.code = code;
32
+ }
33
+ }
34
+ /** Credential shapes that must never reach the vault. Documented, minimal. */
35
+ const SECRET_PATTERNS = [
36
+ /sk-[A-Za-z0-9_-]{8,}/g,
37
+ /gh[pousr]_[A-Za-z0-9_]+/g,
38
+ /xox[bpas]-[A-Za-z0-9-]+/g,
39
+ /AKIA[0-9A-Z]{16}/g,
40
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g,
41
+ /(password|passwd|secret|token)\s*[:=]\s*\S+/gi,
42
+ ];
43
+ function scrubSecrets(text) {
44
+ let out = text;
45
+ let redacted = false;
46
+ for (const pattern of SECRET_PATTERNS) {
47
+ pattern.lastIndex = 0;
48
+ if (pattern.test(out)) {
49
+ redacted = true;
50
+ pattern.lastIndex = 0;
51
+ out = out.replace(pattern, "[redacted]");
52
+ }
53
+ }
54
+ return { text: out, redacted };
55
+ }
56
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
57
+ function dayOf(iso) {
58
+ if (!ISO_DATE_RE.test(iso)) {
59
+ throw new LogError("INVALID_DATE", `Invalid log event date ${JSON.stringify(iso)}: expected an ISO timestamp like 2026-10-12T14:03:22Z.`);
60
+ }
61
+ return iso.slice(0, 10);
62
+ }
63
+ function logsDir(vaultRoot, projectId) {
64
+ return join(projectScopeRoot(vaultRoot, projectId), "logs");
65
+ }
66
+ function ensureDayFile(path, projectId, day) {
67
+ if (existsSync(path))
68
+ return;
69
+ mkdirSync(join(path, ".."), { recursive: true });
70
+ // "wx" fails when another process won the race — that is fine, the file
71
+ // (with an identical header) already exists and we just append below.
72
+ try {
73
+ const header = withFrontmatter({ project: projectId, type: "activity-log", date: day }, "");
74
+ appendFileSync(path, header.endsWith("\n") ? header : header + "\n", { flag: "wx" });
75
+ }
76
+ catch (error) {
77
+ if (error.code !== "EEXIST") {
78
+ throw new LogError("LOG_WRITE_FAILED", `Cannot create log file at ${path}: ${error.message}. Check vault permissions.`);
79
+ }
80
+ }
81
+ }
82
+ function formatEntry(event, iso) {
83
+ const scrubbedSummary = scrubSecrets(event.summary);
84
+ const scrubbedDetails = event.details !== undefined ? scrubSecrets(event.details) : null;
85
+ const redacted = scrubbedSummary.redacted || (scrubbedDetails?.redacted ?? false);
86
+ const lines = [`## ${iso} — ${event.kind}${event.taskId ? ` — task ${event.taskId}` : ""}`];
87
+ lines.push(`- summary: ${scrubbedSummary.text}`);
88
+ if (scrubbedDetails !== null)
89
+ lines.push(`- details: ${scrubbedDetails.text}`);
90
+ if (event.gitSha)
91
+ lines.push(`- sha: ${event.gitSha}`);
92
+ if (redacted)
93
+ lines.push(`- redacted: true`);
94
+ return lines.join("\n") + "\n";
95
+ }
96
+ /**
97
+ * Validate identity, then append one event to the day's log file.
98
+ * Returns the file path and the stored (scrubbed) entry.
99
+ */
100
+ export function logEvent(args) {
101
+ const { event } = args;
102
+ if (!event || typeof event.summary !== "string" || event.summary.trim() === "") {
103
+ throw new LogError("INVALID_EVENT", "Invalid log event: a non-empty summary is required.");
104
+ }
105
+ if (typeof event.kind !== "string" || event.kind.trim() === "") {
106
+ throw new LogError("INVALID_EVENT", "Invalid log event: a kind is required (e.g. milestone, checkpoint, session).");
107
+ }
108
+ const identity = validateIdentity(args.projectRoot, args.config);
109
+ const iso = event.date ?? new Date().toISOString();
110
+ const day = dayOf(iso);
111
+ const dir = logsDir(identity.vaultRoot, identity.projectId);
112
+ const file = join(dir, `${day}.md`);
113
+ ensureDayFile(file, identity.projectId, day);
114
+ // Normalize a crash remnant: a file not ending in newline gets one, so the
115
+ // new entry always starts on its own line and prior entries stay intact.
116
+ // An unreadable target fails here as LOG_WRITE_FAILED, before any append.
117
+ let tail;
118
+ try {
119
+ tail = readFileSync(file, "utf8");
120
+ }
121
+ catch (error) {
122
+ throw new LogError("LOG_WRITE_FAILED", `Cannot read log file at ${file}: ${error.message}. Check vault permissions.`);
123
+ }
124
+ const prefix = tail === "" || tail.endsWith("\n") ? "" : "\n";
125
+ const body = formatEntry(event, iso);
126
+ try {
127
+ appendFileSync(file, prefix + body);
128
+ }
129
+ catch (error) {
130
+ throw new LogError("LOG_WRITE_FAILED", `Cannot append to log file at ${file}: ${error.message}. Check vault permissions.`);
131
+ }
132
+ const parsed = parseEntries(prefix + body);
133
+ return { file, entry: parsed[parsed.length - 1] };
134
+ }
135
+ /** Parse entries from log Markdown (frontmatter header ignored). */
136
+ export function parseEntries(text) {
137
+ const events = [];
138
+ const normalized = text.startsWith("## ") ? "\n" + text : text;
139
+ const chunks = normalized.split("\n## ");
140
+ for (let i = 1; i < chunks.length; i++) {
141
+ const lines = chunks[i].split("\n");
142
+ const head = lines[0].split(" — ");
143
+ if (head.length < 2)
144
+ continue;
145
+ const [iso, kind, taskPart] = head;
146
+ if (!ISO_DATE_RE.test(iso) || !kind)
147
+ continue;
148
+ const entry = { kind, summary: "", date: iso, redacted: false };
149
+ if (taskPart?.startsWith("task "))
150
+ entry.taskId = taskPart.slice(5);
151
+ let current = null;
152
+ for (const line of lines.slice(1)) {
153
+ if (line.startsWith("## ") || line === "")
154
+ break;
155
+ const m = /^- (summary|details|sha|redacted): ?(.*)$/.exec(line);
156
+ if (m) {
157
+ const [, key, value] = m;
158
+ if (key === "summary") {
159
+ entry.summary = value;
160
+ current = "summary";
161
+ }
162
+ else if (key === "details") {
163
+ entry.details = value;
164
+ current = "details";
165
+ }
166
+ else if (key === "sha") {
167
+ entry.gitSha = value;
168
+ current = null;
169
+ }
170
+ else if (key === "redacted" && value.trim() === "true") {
171
+ entry.redacted = true;
172
+ current = null;
173
+ }
174
+ else
175
+ current = null;
176
+ }
177
+ else if (line.startsWith(" ") && (current === "summary" || current === "details")) {
178
+ entry[current] += "\n" + line.slice(2);
179
+ }
180
+ else {
181
+ current = null;
182
+ }
183
+ }
184
+ events.push(entry);
185
+ }
186
+ return events;
187
+ }
188
+ /** Read one day's log file; missing file → empty list (not an error). */
189
+ export function readLogDay(vaultRoot, projectId, day) {
190
+ const file = join(logsDir(vaultRoot, projectId), `${day}.md`);
191
+ if (!existsSync(file))
192
+ return [];
193
+ try {
194
+ return parseEntries(readFileSync(file, "utf8"));
195
+ }
196
+ catch (error) {
197
+ throw new LogError("LOG_READ_FAILED", `Cannot read log file at ${file}: ${error.message}. Check vault permissions.`);
198
+ }
199
+ }
200
+ /**
201
+ * Newest-first events across day files, up to `limit`. Day files sort
202
+ * lexicographically (YYYY-MM-DD), so no directory walk beyond `logs/`.
203
+ */
204
+ export function readLastEvents(vaultRoot, projectId, limit) {
205
+ const dir = logsDir(vaultRoot, projectId);
206
+ let files;
207
+ try {
208
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
209
+ }
210
+ catch (error) {
211
+ if (error.code === "ENOENT")
212
+ return [];
213
+ throw new LogError("LOG_READ_FAILED", `Cannot list log directory at ${dir}: ${error.message}. Check vault permissions.`);
214
+ }
215
+ const out = [];
216
+ for (const file of files) {
217
+ const events = readLogDay(vaultRoot, projectId, file.slice(0, 10));
218
+ for (let i = events.length - 1; i >= 0 && out.length < limit; i--)
219
+ out.push(events[i]);
220
+ if (out.length >= limit)
221
+ break;
222
+ }
223
+ return out;
224
+ }
@@ -0,0 +1,89 @@
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 type { ResolvedConfig } from "../config/index.js";
15
+ import { type LoggedEvent } from "../log/index.js";
16
+ export type MemoryErrorCode = "INVALID_NAME" | "INVALID_INPUT";
17
+ export declare class MemoryError extends Error {
18
+ readonly code: MemoryErrorCode;
19
+ constructor(code: MemoryErrorCode, message: string);
20
+ }
21
+ /** Directories searched for relevant docs — always inside one project scope. */
22
+ declare const SEARCH_DIRS: readonly ["memory", "decisions"];
23
+ export interface MemoryMatch {
24
+ /** Which scope dir the doc lives in (`memory/` or `decisions/`). */
25
+ dir: (typeof SEARCH_DIRS)[number];
26
+ file: string;
27
+ score: number;
28
+ excerpt: string;
29
+ truncated: boolean;
30
+ }
31
+ export interface RecallTaskDocs {
32
+ taskId: string;
33
+ intent: string | null;
34
+ plan: string | null;
35
+ }
36
+ export interface RecallInput {
37
+ /** Any directory inside the project (identity resolution starts here). */
38
+ projectRoot: string;
39
+ config: ResolvedConfig;
40
+ /** Free-text query; empty/omitted means "base context only, no doc scan". */
41
+ query?: string;
42
+ /** When set, that task's intent/plan docs are included (missing → null). */
43
+ taskId?: string;
44
+ /** Default true: include recent log events (query-filtered when set). */
45
+ includeLogs?: boolean;
46
+ /** Max log events to consider. Default 10. */
47
+ logLimit?: number;
48
+ /** Max doc matches. Default 8. */
49
+ limit?: number;
50
+ }
51
+ export interface RecallResult {
52
+ projectId: string;
53
+ branch: string;
54
+ /** Project index, or null when not yet written. */
55
+ index: string | null;
56
+ /** Current state, or null when not yet written. */
57
+ currentState: string | null;
58
+ taskDocs: RecallTaskDocs | null;
59
+ /** Relevant docs, best score first (empty without a query). */
60
+ matches: MemoryMatch[];
61
+ /** Recent log events, newest first (query-filtered when a query is set). */
62
+ logEvents: LoggedEvent[];
63
+ }
64
+ /**
65
+ * Recall relevant memory. Validates identity first (wrong project or
66
+ * unavailable vault throws before any read), then reads index + state,
67
+ * the requested task's docs, query-matching scope docs, and recent logs.
68
+ * Read-only: never writes, never leaves the validated project scope.
69
+ */
70
+ export declare function recallMemory(input: RecallInput): RecallResult;
71
+ export interface RecordMemoryInput {
72
+ /** Any directory inside the project (identity resolution starts here). */
73
+ projectRoot: string;
74
+ config: ResolvedConfig;
75
+ /** Plain name (`auth-notes` → `memory/auth-notes.md`). No paths. */
76
+ name: string;
77
+ /** Markdown body. Must be non-empty. */
78
+ content: string;
79
+ /** Must be true to replace an existing note. Default false. */
80
+ overwrite?: boolean;
81
+ }
82
+ /**
83
+ * Record one memory note inside the validated project scope.
84
+ * Identity failures throw before any write; existing notes are never
85
+ * silently replaced (`overwrite: true` required).
86
+ * Returns the vault-relative path of the note.
87
+ */
88
+ export declare function recordMemory(input: RecordMemoryInput): string;
89
+ export {};