obsidian-lean-coder 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +137 -2
- package/agents/olc-build.md +47 -0
- package/agents/olc-plan.md +44 -0
- package/dist/approval/index.d.ts +53 -0
- package/dist/approval/index.js +123 -0
- package/dist/build/index.d.ts +52 -0
- package/dist/build/index.js +146 -0
- package/dist/config/index.d.ts +56 -0
- package/dist/config/index.js +136 -0
- package/dist/core/project-identity.d.ts +53 -0
- package/dist/core/project-identity.js +141 -0
- package/dist/git/checkpoint.d.ts +63 -0
- package/dist/git/checkpoint.js +123 -0
- package/dist/git/inspection.d.ts +35 -0
- package/dist/git/inspection.js +78 -0
- package/dist/git/policy.d.ts +65 -0
- package/dist/git/policy.js +88 -0
- package/dist/git/restore.d.ts +63 -0
- package/dist/git/restore.js +143 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/init/index.d.ts +45 -0
- package/dist/init/index.js +178 -0
- package/dist/log/index.d.ts +68 -0
- package/dist/log/index.js +224 -0
- package/dist/memory/index.d.ts +89 -0
- package/dist/memory/index.js +166 -0
- package/dist/planning/index.d.ts +57 -0
- package/dist/planning/index.js +180 -0
- package/dist/plugin.d.ts +16 -0
- package/dist/plugin.js +46 -0
- package/dist/resume/index.d.ts +60 -0
- package/dist/resume/index.js +121 -0
- package/dist/session/index.d.ts +54 -0
- package/dist/session/index.js +108 -0
- package/dist/tasks/index.d.ts +59 -0
- package/dist/tasks/index.js +182 -0
- package/dist/taste/index.d.ts +79 -0
- package/dist/taste/index.js +162 -0
- package/dist/validation/index.d.ts +58 -0
- package/dist/validation/index.js +72 -0
- package/dist/vault/index.d.ts +50 -0
- package/dist/vault/index.js +153 -0
- package/dist/verify/index.d.ts +78 -0
- package/dist/verify/index.js +137 -0
- package/package.json +36 -4
- package/skills/olc-explore/SKILL.md +35 -0
- package/templates/.gitkeep +0 -0
- package/templates/activity-log.md +10 -0
- package/templates/checkpoint.md +24 -0
- package/templates/decision.md +19 -0
- package/templates/explore-report.md +23 -0
- package/templates/intent.md +23 -0
- package/templates/plan.md +27 -0
- package/templates/session-summary.md +26 -0
- package/templates/taste-rule.md +14 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OLC-040 — bounded `olc-build` task execution frame.
|
|
3
|
+
*
|
|
4
|
+
* The agent does the coding; this module enforces the bounds:
|
|
5
|
+
* 1. `beginTask` — identity (OLC-021, via the loaded OLC-020 config) and the
|
|
6
|
+
* approval gate (OLC-034) must pass, otherwise implementation is blocked
|
|
7
|
+
* before it starts. Returns a frame carrying ONLY the approved task's
|
|
8
|
+
* context (intent/plan bodies), never the whole vault. The caller declares
|
|
9
|
+
* which repo-relative paths it will touch; the registry task moves
|
|
10
|
+
* approved→in_progress.
|
|
11
|
+
* 2. `recordDecision` — small in-scope technical decisions go to history.md.
|
|
12
|
+
* 3. `completeTask` — identity re-checked, then the working tree is diffed:
|
|
13
|
+
* any change outside the declared paths throws SCOPE_CHANGED (out-of-scope
|
|
14
|
+
* work is proposed via the error, not performed). Clean frames move the
|
|
15
|
+
* registry task in_progress→review.
|
|
16
|
+
*
|
|
17
|
+
* Git access here is deliberately minimal (HEAD sha + porcelain status);
|
|
18
|
+
* the full inspection module is OLC-051's job and may consolidate these.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { assertBuildReady } from "../approval/index.js";
|
|
22
|
+
import { validateIdentity } from "../core/project-identity.js";
|
|
23
|
+
import { getTask, setTaskStatus } from "../tasks/index.js";
|
|
24
|
+
import { readVaultDoc, resolveVaultDoc, writeVaultDoc } from "../vault/index.js";
|
|
25
|
+
export class BuildError extends Error {
|
|
26
|
+
code;
|
|
27
|
+
details;
|
|
28
|
+
constructor(code, message, details) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "BuildError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.details = details;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function today(input) {
|
|
36
|
+
return input ?? new Date().toISOString().slice(0, 10);
|
|
37
|
+
}
|
|
38
|
+
function git(projectRoot, ...args) {
|
|
39
|
+
try {
|
|
40
|
+
// trimEnd only: porcelain's leading XY column may start with a space
|
|
41
|
+
// (e.g. " M file" for unstaged changes) and .trim() would eat it.
|
|
42
|
+
return execFileSync("git", args, { cwd: projectRoot, encoding: "utf8" }).replace(/\s+$/, "");
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new BuildError("CONTEXT_MISMATCH", `Build blocked: cannot inspect git state in ${projectRoot} (${error.message}).`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalize(p) {
|
|
49
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
50
|
+
}
|
|
51
|
+
/** Repo-relative changed paths (staged + unstaged + untracked) vs current HEAD. */
|
|
52
|
+
export function changedPaths(projectRoot) {
|
|
53
|
+
const out = git(projectRoot, "status", "--porcelain=v1", "-uall");
|
|
54
|
+
const paths = [];
|
|
55
|
+
for (const line of out.split("\n")) {
|
|
56
|
+
if (!line.trim())
|
|
57
|
+
continue;
|
|
58
|
+
let path = line.slice(3).trim();
|
|
59
|
+
const arrow = path.indexOf(" -> ");
|
|
60
|
+
if (arrow >= 0)
|
|
61
|
+
path = path.slice(arrow + 4);
|
|
62
|
+
if (path.startsWith('"') && path.endsWith('"'))
|
|
63
|
+
path = JSON.parse(path);
|
|
64
|
+
paths.push(normalize(path));
|
|
65
|
+
}
|
|
66
|
+
return paths;
|
|
67
|
+
}
|
|
68
|
+
function appendTaskHistory(vaultRoot, projectId, taskId, kind, reason, date) {
|
|
69
|
+
const history = resolveVaultDoc(vaultRoot, projectId, "tasks", taskId, "history.md");
|
|
70
|
+
let existing = "";
|
|
71
|
+
try {
|
|
72
|
+
existing = readVaultDoc(history);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
existing = "";
|
|
76
|
+
}
|
|
77
|
+
const entry = `## ${date} — ${kind}\n\n${reason}\n`;
|
|
78
|
+
writeVaultDoc(history, existing === "" ? `# History — ${taskId}\n\n${entry}` : `${existing}\n${entry}`, { overwrite: true });
|
|
79
|
+
}
|
|
80
|
+
export function beginTask(input) {
|
|
81
|
+
const vaultRoot = input.config.vaultPath;
|
|
82
|
+
const projectId = input.config.projectId;
|
|
83
|
+
const startDir = input.startDir ?? process.cwd();
|
|
84
|
+
// Identity is checked from the caller's directory; the resolved repo root
|
|
85
|
+
// comes from git itself (OLC-021), never from config.
|
|
86
|
+
let repoRoot;
|
|
87
|
+
try {
|
|
88
|
+
repoRoot = git(startDir, "rev-parse", "--show-toplevel");
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
throw new BuildError("CONTEXT_MISMATCH", `Build blocked: cannot determine repository root (${error.message}).`);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
validateIdentity(repoRoot, input.config);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw new BuildError("CONTEXT_MISMATCH", `Build blocked: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
assertBuildReady(vaultRoot, projectId, input.taskId);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw new BuildError("NOT_READY", `Build blocked: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
const task = getTask(vaultRoot, projectId, input.taskId);
|
|
106
|
+
if (task === null) {
|
|
107
|
+
throw new BuildError("NO_TASK", `Build blocked: task "${input.taskId}" is not registered.`);
|
|
108
|
+
}
|
|
109
|
+
if (task.status === "approved") {
|
|
110
|
+
setTaskStatus(vaultRoot, projectId, input.taskId, "in_progress", today(input.date));
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
taskId: input.taskId,
|
|
114
|
+
intentBody: readVaultDoc(resolveVaultDoc(vaultRoot, projectId, "tasks", input.taskId, "intent.md")),
|
|
115
|
+
planBody: readVaultDoc(resolveVaultDoc(vaultRoot, projectId, "tasks", input.taskId, "plan.md")),
|
|
116
|
+
declaredPaths: (input.touchPaths ?? []).map(normalize),
|
|
117
|
+
baseHead: git(repoRoot, "rev-parse", "HEAD"),
|
|
118
|
+
startedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function recordDecision(frame, config, text, date) {
|
|
122
|
+
if (!text.trim())
|
|
123
|
+
throw new BuildError("NO_TASK", "Decision text must be non-empty.");
|
|
124
|
+
appendTaskHistory(config.vaultPath, config.projectId, frame.taskId, "build-decision", text.trim(), today(date));
|
|
125
|
+
}
|
|
126
|
+
export function completeTask(frame, config, repoRoot, date) {
|
|
127
|
+
try {
|
|
128
|
+
validateIdentity(repoRoot, config);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
throw new BuildError("CONTEXT_MISMATCH", `Completion blocked: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
const declared = new Set(frame.declaredPaths);
|
|
134
|
+
const changed = changedPaths(repoRoot);
|
|
135
|
+
const outOfScope = changed.filter((p) => !declared.has(p));
|
|
136
|
+
if (outOfScope.length > 0) {
|
|
137
|
+
throw new BuildError("SCOPE_CHANGED", `Completion blocked: ${outOfScope.length} changed path(s) outside the declared scope: ${outOfScope.join(", ")}. ` +
|
|
138
|
+
"Propose a plan revision instead — out-of-scope work is not completed silently.", outOfScope);
|
|
139
|
+
}
|
|
140
|
+
const task = getTask(config.vaultPath, config.projectId, frame.taskId);
|
|
141
|
+
if (task !== null && task.status === "in_progress") {
|
|
142
|
+
setTaskStatus(config.vaultPath, config.projectId, frame.taskId, "review", today(date));
|
|
143
|
+
}
|
|
144
|
+
appendTaskHistory(config.vaultPath, config.projectId, frame.taskId, "build-complete", `Build completed within declared scope (${changed.length} path(s)).`, today(date));
|
|
145
|
+
return { changed };
|
|
146
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OLC project configuration (`.olc/config.json`).
|
|
3
|
+
*
|
|
4
|
+
* Minimal validated config per PRD §7.5 and OLC-020:
|
|
5
|
+
* - `schemaVersion` (currently 1; unknown future versions fail safely)
|
|
6
|
+
* - `projectId` (required; also used as the vault directory name)
|
|
7
|
+
* - `vaultPath` (optional in file; see precedence below)
|
|
8
|
+
*
|
|
9
|
+
* Vault-path precedence (highest first):
|
|
10
|
+
* 1. `OLC_VAULT_PATH` environment variable
|
|
11
|
+
* 2. `vaultPath` field in `.olc/config.json`
|
|
12
|
+
* 3. Neither → `ConfigError` (no silent default or fallback directory)
|
|
13
|
+
*
|
|
14
|
+
* Relative paths (env or file) resolve against the project root, never cwd,
|
|
15
|
+
* so results are deterministic regardless of where the process runs.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CONFIG_SCHEMA_VERSION = 1;
|
|
18
|
+
export declare const CONFIG_REL_PATH = ".olc/config.json";
|
|
19
|
+
export declare const VAULT_PATH_ENV = "OLC_VAULT_PATH";
|
|
20
|
+
export type ConfigErrorCode = "MISSING_CONFIG" | "UNREADABLE_CONFIG" | "INVALID_JSON" | "INVALID_CONFIG" | "UNSUPPORTED_SCHEMA" | "SECRET_IN_CONFIG" | "MISSING_VAULT_PATH";
|
|
21
|
+
export declare class ConfigError extends Error {
|
|
22
|
+
readonly code: ConfigErrorCode;
|
|
23
|
+
constructor(code: ConfigErrorCode, message: string);
|
|
24
|
+
}
|
|
25
|
+
export interface OlcConfig {
|
|
26
|
+
schemaVersion: number;
|
|
27
|
+
projectId: string;
|
|
28
|
+
vaultPath?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ResolvedConfig extends OlcConfig {
|
|
31
|
+
/** Absolute vault path after applying precedence rules. Always present. */
|
|
32
|
+
vaultPath: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate unknown parsed JSON into an `OlcConfig`. Pure (no I/O).
|
|
36
|
+
* Throws `ConfigError` with an actionable message on any problem.
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseConfig(json: unknown): OlcConfig;
|
|
39
|
+
/**
|
|
40
|
+
* Read and validate `.olc/config.json` under `projectRoot`.
|
|
41
|
+
* Returns `null` when the file does not exist (missing ≠ invalid;
|
|
42
|
+
* callers such as init decide what missing means). Throws `ConfigError`
|
|
43
|
+
* when the file exists but cannot be read or parsed.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readConfigFile(projectRoot: string): OlcConfig | null;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the vault path applying precedence: env > config file > error.
|
|
48
|
+
* Returns an absolute path (relative inputs resolve against `projectRoot`).
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveVaultPath(config: OlcConfig, projectRoot: string, env?: NodeJS.ProcessEnv): string;
|
|
51
|
+
/**
|
|
52
|
+
* Load `.olc/config.json` under `projectRoot` and resolve the vault path.
|
|
53
|
+
* Throws `ConfigError` when the file is missing, invalid, or no vault path
|
|
54
|
+
* is configured from either source.
|
|
55
|
+
*/
|
|
56
|
+
export declare function loadConfig(projectRoot: string, env?: NodeJS.ProcessEnv): ResolvedConfig;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OLC project configuration (`.olc/config.json`).
|
|
3
|
+
*
|
|
4
|
+
* Minimal validated config per PRD §7.5 and OLC-020:
|
|
5
|
+
* - `schemaVersion` (currently 1; unknown future versions fail safely)
|
|
6
|
+
* - `projectId` (required; also used as the vault directory name)
|
|
7
|
+
* - `vaultPath` (optional in file; see precedence below)
|
|
8
|
+
*
|
|
9
|
+
* Vault-path precedence (highest first):
|
|
10
|
+
* 1. `OLC_VAULT_PATH` environment variable
|
|
11
|
+
* 2. `vaultPath` field in `.olc/config.json`
|
|
12
|
+
* 3. Neither → `ConfigError` (no silent default or fallback directory)
|
|
13
|
+
*
|
|
14
|
+
* Relative paths (env or file) resolve against the project root, never cwd,
|
|
15
|
+
* so results are deterministic regardless of where the process runs.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { isAbsolute, resolve } from "node:path";
|
|
19
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
20
|
+
export const CONFIG_REL_PATH = ".olc/config.json";
|
|
21
|
+
export const VAULT_PATH_ENV = "OLC_VAULT_PATH";
|
|
22
|
+
/** Safe as a single vault directory name: no separators, no leading dots. */
|
|
23
|
+
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-_]{0,63}$/;
|
|
24
|
+
/** Top-level keys that look like credentials are rejected (PRD: no secrets in config). */
|
|
25
|
+
const SECRET_KEY_PATTERN = /secret|token|password|passwd|api[-_]?key/i;
|
|
26
|
+
export class ConfigError extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "ConfigError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate unknown parsed JSON into an `OlcConfig`. Pure (no I/O).
|
|
39
|
+
* Throws `ConfigError` with an actionable message on any problem.
|
|
40
|
+
*/
|
|
41
|
+
export function parseConfig(json) {
|
|
42
|
+
if (!isRecord(json)) {
|
|
43
|
+
throw new ConfigError("INVALID_CONFIG", "Invalid OLC config: expected a JSON object at the top level. " +
|
|
44
|
+
`Fix ${CONFIG_REL_PATH} or re-run init to regenerate it.`);
|
|
45
|
+
}
|
|
46
|
+
for (const key of Object.keys(json)) {
|
|
47
|
+
if (SECRET_KEY_PATTERN.test(key)) {
|
|
48
|
+
throw new ConfigError("SECRET_IN_CONFIG", `Invalid OLC config: key "${key}" looks like a credential. ` +
|
|
49
|
+
"Do not store secrets in .olc/config.json — remove the key and use your shell or secret manager instead.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const allowed = new Set(["schemaVersion", "projectId", "vaultPath"]);
|
|
53
|
+
const unknownKeys = Object.keys(json).filter((k) => !allowed.has(k));
|
|
54
|
+
if (unknownKeys.length > 0) {
|
|
55
|
+
throw new ConfigError("INVALID_CONFIG", `Invalid OLC config: unknown key(s) ${unknownKeys.map((k) => `"${k}"`).join(", ")}. ` +
|
|
56
|
+
`Allowed keys are "schemaVersion", "projectId", "vaultPath". Fix ${CONFIG_REL_PATH}.`);
|
|
57
|
+
}
|
|
58
|
+
const { schemaVersion, projectId, vaultPath } = json;
|
|
59
|
+
if (typeof schemaVersion !== "number" || !Number.isInteger(schemaVersion)) {
|
|
60
|
+
throw new ConfigError("INVALID_CONFIG", 'Invalid OLC config: "schemaVersion" must be an integer. ' +
|
|
61
|
+
`Fix ${CONFIG_REL_PATH} (expected ${CONFIG_SCHEMA_VERSION}).`);
|
|
62
|
+
}
|
|
63
|
+
if (schemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
64
|
+
throw new ConfigError("UNSUPPORTED_SCHEMA", `Unsupported OLC config schema version ${schemaVersion} (this OLC supports ${CONFIG_SCHEMA_VERSION}). ` +
|
|
65
|
+
"Upgrade OLC to a version that understands this config, or regenerate the config with init.");
|
|
66
|
+
}
|
|
67
|
+
if (typeof projectId !== "string" || projectId.trim() === "") {
|
|
68
|
+
throw new ConfigError("INVALID_CONFIG", 'Invalid OLC config: "projectId" must be a non-empty string. ' +
|
|
69
|
+
`Fix ${CONFIG_REL_PATH} (it becomes the vault directory name).`);
|
|
70
|
+
}
|
|
71
|
+
if (!PROJECT_ID_PATTERN.test(projectId)) {
|
|
72
|
+
throw new ConfigError("INVALID_CONFIG", `Invalid OLC config: "projectId" ${JSON.stringify(projectId)} is not allowed. ` +
|
|
73
|
+
"Use 1–64 characters of letters, digits, dash or underscore, starting with a letter or digit " +
|
|
74
|
+
`(it becomes a vault directory name, so separators and dots are forbidden). Fix ${CONFIG_REL_PATH}.`);
|
|
75
|
+
}
|
|
76
|
+
if (vaultPath !== undefined && (typeof vaultPath !== "string" || vaultPath.trim() === "")) {
|
|
77
|
+
throw new ConfigError("INVALID_CONFIG", 'Invalid OLC config: "vaultPath" must be a non-empty string when present. ' +
|
|
78
|
+
`Remove it or set a real path in ${CONFIG_REL_PATH}.`);
|
|
79
|
+
}
|
|
80
|
+
const config = { schemaVersion, projectId };
|
|
81
|
+
if (typeof vaultPath === "string")
|
|
82
|
+
config.vaultPath = vaultPath;
|
|
83
|
+
return config;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read and validate `.olc/config.json` under `projectRoot`.
|
|
87
|
+
* Returns `null` when the file does not exist (missing ≠ invalid;
|
|
88
|
+
* callers such as init decide what missing means). Throws `ConfigError`
|
|
89
|
+
* when the file exists but cannot be read or parsed.
|
|
90
|
+
*/
|
|
91
|
+
export function readConfigFile(projectRoot) {
|
|
92
|
+
const path = resolve(projectRoot, CONFIG_REL_PATH);
|
|
93
|
+
let text;
|
|
94
|
+
try {
|
|
95
|
+
text = readFileSync(path, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
return null;
|
|
100
|
+
throw new ConfigError("UNREADABLE_CONFIG", `Cannot read OLC config at ${path}: ${error.message}. Check file permissions.`);
|
|
101
|
+
}
|
|
102
|
+
let json;
|
|
103
|
+
try {
|
|
104
|
+
json = JSON.parse(text);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new ConfigError("INVALID_JSON", `Invalid OLC config: ${path} is not valid JSON (${error.message}). Fix the file or re-run init.`);
|
|
108
|
+
}
|
|
109
|
+
return parseConfig(json);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the vault path applying precedence: env > config file > error.
|
|
113
|
+
* Returns an absolute path (relative inputs resolve against `projectRoot`).
|
|
114
|
+
*/
|
|
115
|
+
export function resolveVaultPath(config, projectRoot, env = process.env) {
|
|
116
|
+
const fromEnv = env[VAULT_PATH_ENV]?.trim();
|
|
117
|
+
const raw = fromEnv ? fromEnv : config.vaultPath?.trim();
|
|
118
|
+
if (!raw) {
|
|
119
|
+
throw new ConfigError("MISSING_VAULT_PATH", `No vault path configured: set the ${VAULT_PATH_ENV} environment variable ` +
|
|
120
|
+
`or add "vaultPath" to ${CONFIG_REL_PATH}. OLC never invents a vault location.`);
|
|
121
|
+
}
|
|
122
|
+
return isAbsolute(raw) ? raw : resolve(projectRoot, raw);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Load `.olc/config.json` under `projectRoot` and resolve the vault path.
|
|
126
|
+
* Throws `ConfigError` when the file is missing, invalid, or no vault path
|
|
127
|
+
* is configured from either source.
|
|
128
|
+
*/
|
|
129
|
+
export function loadConfig(projectRoot, env = process.env) {
|
|
130
|
+
const path = resolve(projectRoot, CONFIG_REL_PATH);
|
|
131
|
+
const config = readConfigFile(projectRoot);
|
|
132
|
+
if (config === null) {
|
|
133
|
+
throw new ConfigError("MISSING_CONFIG", `No OLC config found at ${path}. Run init to create one (it asks for project_id and vault path).`);
|
|
134
|
+
}
|
|
135
|
+
return { ...config, vaultPath: resolveVaultPath(config, projectRoot, env) };
|
|
136
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project and vault identity checks (OLC-021, PRD §7.5 items 1–5 subset).
|
|
3
|
+
*
|
|
4
|
+
* Before any OLC-dependent operation, `validateIdentity` proves:
|
|
5
|
+
* 1. The working directory is inside a git repository (`resolveRepoRoot`).
|
|
6
|
+
* 2. The repository has an active branch (`resolveBranch`).
|
|
7
|
+
* 3. The vault root exists and is accessible (no fallback storage is created).
|
|
8
|
+
* 4. The vault registry (`OLC/registry.json`) exists, parses, and contains
|
|
9
|
+
* this config's `projectId` mapped to THIS repository root.
|
|
10
|
+
*
|
|
11
|
+
* Any failure throws `IdentityError` with an actionable message. The caller
|
|
12
|
+
* must block the operation — never work around a failed check.
|
|
13
|
+
*/
|
|
14
|
+
import type { ResolvedConfig } from "../config/index.js";
|
|
15
|
+
/** Vault registry location, relative to the vault root. */
|
|
16
|
+
export declare const REGISTRY_REL_PATH = "OLC/registry.json";
|
|
17
|
+
export type IdentityErrorCode = "NOT_A_REPO" | "GIT_FAILED" | "DETACHED_HEAD" | "VAULT_UNAVAILABLE" | "MISSING_REGISTRY" | "INVALID_REGISTRY" | "UNREGISTERED_PROJECT" | "PROJECT_MISMATCH";
|
|
18
|
+
export declare class IdentityError extends Error {
|
|
19
|
+
readonly code: IdentityErrorCode;
|
|
20
|
+
constructor(code: IdentityErrorCode, message: string);
|
|
21
|
+
}
|
|
22
|
+
export interface ProjectIdentity {
|
|
23
|
+
repoRoot: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
vaultRoot: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Walk up from `startDir` to find the repository root (directory holding
|
|
30
|
+
* `.git`, which may be a dir or a worktree pointer file). Throws NOT_A_REPO.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveRepoRoot(startDir: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Current branch of the repository. Throws DETACHED_HEAD when HEAD is
|
|
35
|
+
* detached (identity needs an explicit branch) and GIT_FAILED when the
|
|
36
|
+
* branch cannot be determined (e.g. unborn HEAD with no commits yet).
|
|
37
|
+
*/
|
|
38
|
+
export declare function resolveBranch(repoRoot: string): string;
|
|
39
|
+
/** Registry shape: project_id -> repository root that owns it. */
|
|
40
|
+
export interface VaultRegistry {
|
|
41
|
+
projects: Record<string, {
|
|
42
|
+
repoRoot: string;
|
|
43
|
+
}>;
|
|
44
|
+
}
|
|
45
|
+
/** Throw VAULT_UNAVAILABLE unless `vaultRoot` exists and is a readable directory. */
|
|
46
|
+
export declare function assertVaultAccessible(vaultRoot: string): void;
|
|
47
|
+
/** Read and validate the vault registry. Throws MISSING_REGISTRY / INVALID_REGISTRY. */
|
|
48
|
+
export declare function readRegistry(vaultRoot: string): VaultRegistry;
|
|
49
|
+
/**
|
|
50
|
+
* Full identity gate. `startDir` is any directory inside the project
|
|
51
|
+
* (usually cwd); `config` is the loaded + vault-resolved OLC config.
|
|
52
|
+
*/
|
|
53
|
+
export declare function validateIdentity(startDir: string, config: ResolvedConfig): ProjectIdentity;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project and vault identity checks (OLC-021, PRD §7.5 items 1–5 subset).
|
|
3
|
+
*
|
|
4
|
+
* Before any OLC-dependent operation, `validateIdentity` proves:
|
|
5
|
+
* 1. The working directory is inside a git repository (`resolveRepoRoot`).
|
|
6
|
+
* 2. The repository has an active branch (`resolveBranch`).
|
|
7
|
+
* 3. The vault root exists and is accessible (no fallback storage is created).
|
|
8
|
+
* 4. The vault registry (`OLC/registry.json`) exists, parses, and contains
|
|
9
|
+
* this config's `projectId` mapped to THIS repository root.
|
|
10
|
+
*
|
|
11
|
+
* Any failure throws `IdentityError` with an actionable message. The caller
|
|
12
|
+
* must block the operation — never work around a failed check.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
17
|
+
/** Vault registry location, relative to the vault root. */
|
|
18
|
+
export const REGISTRY_REL_PATH = "OLC/registry.json";
|
|
19
|
+
export class IdentityError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "IdentityError";
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Walk up from `startDir` to find the repository root (directory holding
|
|
29
|
+
* `.git`, which may be a dir or a worktree pointer file). Throws NOT_A_REPO.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveRepoRoot(startDir) {
|
|
32
|
+
let dir = resolve(startDir);
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (existsSync(resolve(dir, ".git")))
|
|
35
|
+
return dir;
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir) {
|
|
38
|
+
throw new IdentityError("NOT_A_REPO", `Not a git repository: no .git found from ${resolve(startDir)} up to the filesystem root. ` +
|
|
39
|
+
"Run OLC inside a git repository (checkpoints and identity need one).");
|
|
40
|
+
}
|
|
41
|
+
dir = parent;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Run a git command, converting any failure into an IdentityError. */
|
|
45
|
+
function git(args, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
const detail = error.stderr ?? error.message;
|
|
51
|
+
throw new IdentityError("GIT_FAILED", `git ${args.join(" ")} failed in ${cwd}: ${String(detail).trim()}. Is git installed and the repository intact?`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Current branch of the repository. Throws DETACHED_HEAD when HEAD is
|
|
56
|
+
* detached (identity needs an explicit branch) and GIT_FAILED when the
|
|
57
|
+
* branch cannot be determined (e.g. unborn HEAD with no commits yet).
|
|
58
|
+
*/
|
|
59
|
+
export function resolveBranch(repoRoot) {
|
|
60
|
+
const name = git(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
|
|
61
|
+
if (name === "HEAD") {
|
|
62
|
+
const sha = git(["rev-parse", "HEAD"], repoRoot);
|
|
63
|
+
throw new IdentityError("DETACHED_HEAD", `Repository at ${repoRoot} has a detached HEAD (${sha}). ` +
|
|
64
|
+
"Check out a branch before running OLC so checkpoints land somewhere traceable.");
|
|
65
|
+
}
|
|
66
|
+
return name;
|
|
67
|
+
}
|
|
68
|
+
function isRegistry(value) {
|
|
69
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
70
|
+
return false;
|
|
71
|
+
const projects = value.projects;
|
|
72
|
+
if (typeof projects !== "object" || projects === null || Array.isArray(projects))
|
|
73
|
+
return false;
|
|
74
|
+
return Object.values(projects).every((entry) => typeof entry === "object" && entry !== null && typeof entry.repoRoot === "string");
|
|
75
|
+
}
|
|
76
|
+
/** Throw VAULT_UNAVAILABLE unless `vaultRoot` exists and is a readable directory. */
|
|
77
|
+
export function assertVaultAccessible(vaultRoot) {
|
|
78
|
+
try {
|
|
79
|
+
readdirSync(vaultRoot);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const err = error;
|
|
83
|
+
const reason = err.code === "ENOENT"
|
|
84
|
+
? "it does not exist"
|
|
85
|
+
: err.code === "EACCES" || err.code === "EPERM"
|
|
86
|
+
? "permission denied"
|
|
87
|
+
: (err.message ?? "it is not accessible");
|
|
88
|
+
throw new IdentityError("VAULT_UNAVAILABLE", `Vault at ${vaultRoot} is unavailable (${reason}). ` +
|
|
89
|
+
"Check the path (OLC_VAULT_PATH or vaultPath) and permissions. OLC never creates fallback storage.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Read and validate the vault registry. Throws MISSING_REGISTRY / INVALID_REGISTRY. */
|
|
93
|
+
export function readRegistry(vaultRoot) {
|
|
94
|
+
const path = resolve(vaultRoot, REGISTRY_REL_PATH);
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = readFileSync(path, "utf8");
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === "ENOENT") {
|
|
101
|
+
throw new IdentityError("MISSING_REGISTRY", `No vault registry at ${path}. Run init to register this project in the vault.`);
|
|
102
|
+
}
|
|
103
|
+
throw new IdentityError("VAULT_UNAVAILABLE", `Cannot read vault registry at ${path}: ${error.message}. Check permissions.`);
|
|
104
|
+
}
|
|
105
|
+
let json;
|
|
106
|
+
try {
|
|
107
|
+
json = JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw new IdentityError("INVALID_REGISTRY", `Vault registry at ${path} is not valid JSON (${error.message}). Fix it or re-run init.`);
|
|
111
|
+
}
|
|
112
|
+
if (!isRegistry(json)) {
|
|
113
|
+
throw new IdentityError("INVALID_REGISTRY", `Vault registry at ${path} has the wrong shape. Expected {"projects": {"<project_id>": {"repoRoot": "<abs path>"}}}. Fix it or re-run init.`);
|
|
114
|
+
}
|
|
115
|
+
return json;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Full identity gate. `startDir` is any directory inside the project
|
|
119
|
+
* (usually cwd); `config` is the loaded + vault-resolved OLC config.
|
|
120
|
+
*/
|
|
121
|
+
export function validateIdentity(startDir, config) {
|
|
122
|
+
const repoRoot = resolveRepoRoot(startDir);
|
|
123
|
+
const branch = resolveBranch(repoRoot);
|
|
124
|
+
const vaultRoot = config.vaultPath;
|
|
125
|
+
if (!isAbsolute(vaultRoot)) {
|
|
126
|
+
throw new IdentityError("VAULT_UNAVAILABLE", `Resolved vault path ${JSON.stringify(vaultRoot)} is not absolute. This is an OLC bug — report it.`);
|
|
127
|
+
}
|
|
128
|
+
assertVaultAccessible(vaultRoot);
|
|
129
|
+
const registry = readRegistry(vaultRoot);
|
|
130
|
+
const entry = registry.projects[config.projectId];
|
|
131
|
+
if (!entry) {
|
|
132
|
+
throw new IdentityError("UNREGISTERED_PROJECT", `project_id "${config.projectId}" is not registered in vault ${vaultRoot}. ` +
|
|
133
|
+
"Run init to register it before using project memory, plans, or checkpoints.");
|
|
134
|
+
}
|
|
135
|
+
if (resolve(entry.repoRoot) !== repoRoot) {
|
|
136
|
+
throw new IdentityError("PROJECT_MISMATCH", `project_id "${config.projectId}" is registered to a different repository ` +
|
|
137
|
+
`(${resolve(entry.repoRoot)}) than the one you are in (${repoRoot}). ` +
|
|
138
|
+
"Refusing to touch the wrong project's memory. Use init with a new project_id for this checkout, or move to the registered repository.");
|
|
139
|
+
}
|
|
140
|
+
return { repoRoot, branch, projectId: config.projectId, vaultRoot };
|
|
141
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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 type { ResolvedConfig } from "../config/index.js";
|
|
20
|
+
import { type SessionContext } from "../validation/index.js";
|
|
21
|
+
export type CheckpointErrorCode = "PATH_OUTSIDE_REPO" | "OWNED_PATH_PRESTAGED" | "FOREIGN_EDIT" | "MISSING_OWNED_FILE";
|
|
22
|
+
export declare class CheckpointError extends Error {
|
|
23
|
+
readonly code: CheckpointErrorCode;
|
|
24
|
+
constructor(code: CheckpointErrorCode, message: string);
|
|
25
|
+
}
|
|
26
|
+
export interface CheckpointInput {
|
|
27
|
+
/** Any directory inside the project (identity resolution starts here). */
|
|
28
|
+
projectRoot: string;
|
|
29
|
+
config: ResolvedConfig;
|
|
30
|
+
taskId: string;
|
|
31
|
+
/** Repo-relative paths OLC owns for this checkpoint (files must exist). */
|
|
32
|
+
ownedPaths: string[];
|
|
33
|
+
/** Write-time content snapshot per repo-relative owned path. */
|
|
34
|
+
snapshots: Record<string, string>;
|
|
35
|
+
summary: string;
|
|
36
|
+
verification?: string;
|
|
37
|
+
/** Defaults to current time (pass explicit dates in tests). */
|
|
38
|
+
date?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Session context from `startSession`. When present the commit is also
|
|
41
|
+
* revalidated against it (vault unmounted / re-registered / branch
|
|
42
|
+
* switched between operations blocks with zero writes).
|
|
43
|
+
*/
|
|
44
|
+
session?: SessionContext;
|
|
45
|
+
}
|
|
46
|
+
export type CheckpointResult = {
|
|
47
|
+
status: "clean";
|
|
48
|
+
branch: string;
|
|
49
|
+
head: string;
|
|
50
|
+
} | {
|
|
51
|
+
status: "committed";
|
|
52
|
+
branch: string;
|
|
53
|
+
sha: string;
|
|
54
|
+
committedPaths: string[];
|
|
55
|
+
recordPath: string;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Append a checkpoint-template entry to `tasks/<taskId>/checkpoint.md`.
|
|
59
|
+
* First call writes the full template; later calls append the body after `---`.
|
|
60
|
+
* Returns the vault-absolute record path. Exported for reuse by `restore.ts`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function appendCheckpointRecord(vaultRoot: string, projectId: string, taskId: string, vars: Record<string, string>): string;
|
|
63
|
+
export declare function createCheckpoint(input: CheckpointInput): CheckpointResult;
|