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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +153 -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 +14 -0
- package/dist/index.js +14 -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,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session summary in plain language (OLC-063).
|
|
3
|
+
*
|
|
4
|
+
* Ends a normal session with a useful, non-technical summary in Indonesian,
|
|
5
|
+
* rendered through the `session-summary` template. Factual claims are mapped
|
|
6
|
+
* to real data only:
|
|
7
|
+
* - verification lines come from the caller's `VerifyOutcome` (never
|
|
8
|
+
* invented — no outcome means "no tests ran");
|
|
9
|
+
* - the checkpoint line appears only when the caller passes the real
|
|
10
|
+
* `checkpointSha` of this session;
|
|
11
|
+
* - open tasks / last events come from `buildResumeContext` (OLC-061).
|
|
12
|
+
*
|
|
13
|
+
* Durability: the full text is returned to the caller (the agent shows it),
|
|
14
|
+
* and a compact `session` event is appended to the activity log (OLC-060),
|
|
15
|
+
* so the next resume surfaces it. No separate summary file — that would
|
|
16
|
+
* need a new path convention for zero retrieval benefit.
|
|
17
|
+
*
|
|
18
|
+
* Honesty limit: this function only runs when the session ends normally. A
|
|
19
|
+
* hard kill produces no summary; the last log event is then the recovery
|
|
20
|
+
* point (see OLC-061). The rendered text says so in its footer.
|
|
21
|
+
*/
|
|
22
|
+
import type { ResolvedConfig } from "../config/index.js";
|
|
23
|
+
import type { VerifyOutcome } from "../verify/index.js";
|
|
24
|
+
export interface SessionSummaryInput {
|
|
25
|
+
/** Any directory inside the project (identity resolution starts here). */
|
|
26
|
+
projectRoot: string;
|
|
27
|
+
config: ResolvedConfig;
|
|
28
|
+
/** What was done, in plain words. Empty = no-code-change session. */
|
|
29
|
+
whatDone: string[];
|
|
30
|
+
/** Real test outcome when tests ran; omit when none ran. */
|
|
31
|
+
verifyOutcome?: VerifyOutcome;
|
|
32
|
+
/** Note shown when no tests ran (e.g. why). */
|
|
33
|
+
testsNote?: string;
|
|
34
|
+
/** Real checkpoint commit SHA created this session, if any. */
|
|
35
|
+
checkpointSha?: string;
|
|
36
|
+
/** Task the checkpoint belongs to, if any. */
|
|
37
|
+
checkpointTaskId?: string;
|
|
38
|
+
/** Why the session stopped without finishing (blocked scenario). */
|
|
39
|
+
blockedReason?: string;
|
|
40
|
+
/** Unfinished work, in plain words. */
|
|
41
|
+
remaining: string[];
|
|
42
|
+
/** Next step, in plain words. */
|
|
43
|
+
nextStep: string;
|
|
44
|
+
/** ISO timestamp; defaults to now. Inject a fixed value in tests. */
|
|
45
|
+
date?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface SessionSummaryResult {
|
|
48
|
+
/** Full Indonesian summary (show this to the user). */
|
|
49
|
+
markdown: string;
|
|
50
|
+
/** Activity-log file the compact `session` event was appended to. */
|
|
51
|
+
logFile: string;
|
|
52
|
+
date: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function buildSessionSummary(input: SessionSummaryInput): SessionSummaryResult;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session summary in plain language (OLC-063).
|
|
3
|
+
*
|
|
4
|
+
* Ends a normal session with a useful, non-technical summary in Indonesian,
|
|
5
|
+
* rendered through the `session-summary` template. Factual claims are mapped
|
|
6
|
+
* to real data only:
|
|
7
|
+
* - verification lines come from the caller's `VerifyOutcome` (never
|
|
8
|
+
* invented — no outcome means "no tests ran");
|
|
9
|
+
* - the checkpoint line appears only when the caller passes the real
|
|
10
|
+
* `checkpointSha` of this session;
|
|
11
|
+
* - open tasks / last events come from `buildResumeContext` (OLC-061).
|
|
12
|
+
*
|
|
13
|
+
* Durability: the full text is returned to the caller (the agent shows it),
|
|
14
|
+
* and a compact `session` event is appended to the activity log (OLC-060),
|
|
15
|
+
* so the next resume surfaces it. No separate summary file — that would
|
|
16
|
+
* need a new path convention for zero retrieval benefit.
|
|
17
|
+
*
|
|
18
|
+
* Honesty limit: this function only runs when the session ends normally. A
|
|
19
|
+
* hard kill produces no summary; the last log event is then the recovery
|
|
20
|
+
* point (see OLC-061). The rendered text says so in its footer.
|
|
21
|
+
*/
|
|
22
|
+
import { logEvent } from "../log/index.js";
|
|
23
|
+
import { buildResumeContext } from "../resume/index.js";
|
|
24
|
+
import { renderTemplate } from "../vault/index.js";
|
|
25
|
+
function bullets(items) {
|
|
26
|
+
return items.map((i) => `- ${i}`).join("\n");
|
|
27
|
+
}
|
|
28
|
+
function oneLine(text) {
|
|
29
|
+
return text.replace(/\s+/g, " ").trim();
|
|
30
|
+
}
|
|
31
|
+
export function buildSessionSummary(input) {
|
|
32
|
+
const iso = input.date ?? new Date().toISOString();
|
|
33
|
+
// Identity gate + real vault state (open tasks, recent events). Throws
|
|
34
|
+
// before anything is written when the vault is unavailable or mismatched.
|
|
35
|
+
const ctx = buildResumeContext({
|
|
36
|
+
projectRoot: input.projectRoot,
|
|
37
|
+
config: input.config,
|
|
38
|
+
lastEventsLimit: 10,
|
|
39
|
+
});
|
|
40
|
+
const whatDone = input.whatDone.length > 0
|
|
41
|
+
? bullets(input.whatDone)
|
|
42
|
+
: "Tidak ada perubahan kode pada sesi ini.";
|
|
43
|
+
const resultLines = [];
|
|
44
|
+
if (input.verifyOutcome) {
|
|
45
|
+
const passed = input.verifyOutcome.results.filter((r) => r.ok).length;
|
|
46
|
+
const total = input.verifyOutcome.results.length;
|
|
47
|
+
resultLines.push(input.verifyOutcome.ok
|
|
48
|
+
? `Verifikasi lolos: ${passed}/${total} pemeriksaan berhasil.`
|
|
49
|
+
: `Verifikasi belum lolos: ${passed}/${total} pemeriksaan berhasil. Perlu ditangani: ${input.verifyOutcome.escalated.join(", ") || "-"}.`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
resultLines.push("Tidak ada verifikasi yang dijalankan pada sesi ini.");
|
|
53
|
+
}
|
|
54
|
+
if (input.checkpointSha) {
|
|
55
|
+
resultLines.push(`Checkpoint tersimpan pada commit ${input.checkpointSha}` +
|
|
56
|
+
(input.checkpointTaskId ? ` (task ${input.checkpointTaskId}).` : "."));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
resultLines.push("Tidak ada checkpoint baru pada sesi ini.");
|
|
60
|
+
}
|
|
61
|
+
if (input.blockedReason) {
|
|
62
|
+
resultLines.push(`Sesi terhenti: ${input.blockedReason}`);
|
|
63
|
+
}
|
|
64
|
+
const results = resultLines.join("\n");
|
|
65
|
+
const tests = input.verifyOutcome
|
|
66
|
+
? [
|
|
67
|
+
...input.verifyOutcome.results.map((r) => `- ${r.name}: ${r.ok ? "lolos" : "gagal"} (${r.attempts.length} percobaan)`),
|
|
68
|
+
...(input.verifyOutcome.ok
|
|
69
|
+
? []
|
|
70
|
+
: [`Perlu arahan pengguna: ${input.verifyOutcome.escalated.join(", ") || "-"}.`]),
|
|
71
|
+
].join("\n")
|
|
72
|
+
: (input.testsNote ?? "Tidak ada test yang dijalankan pada sesi ini.");
|
|
73
|
+
const openLine = ctx.openTasks.length > 0
|
|
74
|
+
? `Task terbuka: ${ctx.openTasks.map((t) => `${t.taskId} (${t.entry.status})`).join(", ")}.`
|
|
75
|
+
: "Tidak ada task terbuka.";
|
|
76
|
+
const remaining = [
|
|
77
|
+
...(input.remaining.length > 0 ? [bullets(input.remaining)] : ["Tidak ada sisa pekerjaan yang tercatat."]),
|
|
78
|
+
openLine,
|
|
79
|
+
...(input.blockedReason ? [`Penghalang: ${input.blockedReason}`] : []),
|
|
80
|
+
].join("\n");
|
|
81
|
+
const markdown = renderTemplate("session-summary", {
|
|
82
|
+
project_id: input.config.projectId,
|
|
83
|
+
date: iso,
|
|
84
|
+
what_done: whatDone,
|
|
85
|
+
results,
|
|
86
|
+
tests,
|
|
87
|
+
remaining,
|
|
88
|
+
next_step: input.nextStep,
|
|
89
|
+
}) + "\nCatatan: ringkasan ini hanya dibuat bila sesi berakhir normal. Bila sesi mati mendadak, event terakhir di log adalah titik pemulihan.\n";
|
|
90
|
+
const headline = input.blockedReason
|
|
91
|
+
? `terhenti: ${oneLine(input.blockedReason)}`
|
|
92
|
+
: input.checkpointSha
|
|
93
|
+
? `selesai, checkpoint ${input.checkpointSha.slice(0, 7)}`
|
|
94
|
+
: "selesai tanpa checkpoint baru";
|
|
95
|
+
const { file } = logEvent({
|
|
96
|
+
projectRoot: input.projectRoot,
|
|
97
|
+
config: input.config,
|
|
98
|
+
event: {
|
|
99
|
+
kind: "session",
|
|
100
|
+
summary: `Ringkasan sesi: ${headline}.`,
|
|
101
|
+
details: `next: ${oneLine(input.nextStep)}`,
|
|
102
|
+
...(input.checkpointSha ? { gitSha: input.checkpointSha } : {}),
|
|
103
|
+
...(input.checkpointTaskId ? { taskId: input.checkpointTaskId } : {}),
|
|
104
|
+
date: iso,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return { markdown, logFile: file, date: iso };
|
|
108
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task registry, branch matching, and status transitions (OLC-033).
|
|
3
|
+
*
|
|
4
|
+
* Stored at `tasks/registry.json` inside the project vault scope:
|
|
5
|
+
* `{ "tasks": { "<taskId>": { "status", "branch", "updatedAt" } } }`.
|
|
6
|
+
*
|
|
7
|
+
* Status transition table (also recorded in docs/DECISIONS.md #7):
|
|
8
|
+
*
|
|
9
|
+
* - draft → approved, rejected
|
|
10
|
+
* - approved → in_progress, draft
|
|
11
|
+
* - in_progress→ review, blocked
|
|
12
|
+
* - blocked → in_progress
|
|
13
|
+
* - review → done, in_progress
|
|
14
|
+
* - rejected → draft
|
|
15
|
+
* - done → (terminal; no outgoing transitions)
|
|
16
|
+
*
|
|
17
|
+
* Rules: a task reaches `done` only through an explicit `setTaskStatus`
|
|
18
|
+
* call after user confirmation — nothing transitions implicitly, and
|
|
19
|
+
* `in_progress` can never jump straight to `done` (must pass `review`).
|
|
20
|
+
* Resume only recommends; it never switches branches or tasks by itself.
|
|
21
|
+
*/
|
|
22
|
+
export type TaskStatus = "draft" | "approved" | "in_progress" | "review" | "blocked" | "rejected" | "done";
|
|
23
|
+
export declare const TRANSITIONS: Record<TaskStatus, TaskStatus[]>;
|
|
24
|
+
export declare const TERMINAL_STATUSES: TaskStatus[];
|
|
25
|
+
export declare class TaskRegistryError extends Error {
|
|
26
|
+
readonly code: "INVALID_TRANSITION" | "ALREADY_REGISTERED" | "UNKNOWN_TASK" | "CORRUPT_REGISTRY";
|
|
27
|
+
constructor(code: "INVALID_TRANSITION" | "ALREADY_REGISTERED" | "UNKNOWN_TASK" | "CORRUPT_REGISTRY", message: string);
|
|
28
|
+
}
|
|
29
|
+
export interface TaskEntry {
|
|
30
|
+
status: TaskStatus;
|
|
31
|
+
branch: string | null;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
}
|
|
34
|
+
export interface TaskRegistry {
|
|
35
|
+
tasks: Record<string, TaskEntry>;
|
|
36
|
+
}
|
|
37
|
+
export declare function readRegistry(vaultRoot: string, projectId: string): TaskRegistry;
|
|
38
|
+
/** Register a new task as `draft`. Duplicate ids are an error, never merged. */
|
|
39
|
+
export declare function registerTask(vaultRoot: string, projectId: string, taskId: string, branch: string | null, date: string): TaskEntry;
|
|
40
|
+
/** (Re)associate a task with a branch. Never switches the actual Git branch. */
|
|
41
|
+
export declare function attachBranch(vaultRoot: string, projectId: string, taskId: string, branch: string | null, date: string): TaskEntry;
|
|
42
|
+
/**
|
|
43
|
+
* Move a task to a new status. Only table-allowed transitions pass;
|
|
44
|
+
* anything else throws `INVALID_TRANSITION` listing what is allowed.
|
|
45
|
+
*/
|
|
46
|
+
export declare function setTaskStatus(vaultRoot: string, projectId: string, taskId: string, to: TaskStatus, date: string): TaskEntry;
|
|
47
|
+
export declare function getTask(vaultRoot: string, projectId: string, taskId: string): TaskEntry | null;
|
|
48
|
+
export interface ResumeRecommendation {
|
|
49
|
+
/** The recommended task, or null when there is nothing unambiguous to suggest. */
|
|
50
|
+
taskId: string | null;
|
|
51
|
+
/** Human-readable note: what was found and what confirmation is needed. */
|
|
52
|
+
note: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Recommend the most recently active non-terminal task on `currentBranch`.
|
|
56
|
+
* Recommendation only: never resumes, switches, or closes anything.
|
|
57
|
+
* Unknown/empty branch or no matching task yields a note asking the user.
|
|
58
|
+
*/
|
|
59
|
+
export declare function recommendResume(vaultRoot: string, projectId: string, currentBranch: string | null): ResumeRecommendation;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task registry, branch matching, and status transitions (OLC-033).
|
|
3
|
+
*
|
|
4
|
+
* Stored at `tasks/registry.json` inside the project vault scope:
|
|
5
|
+
* `{ "tasks": { "<taskId>": { "status", "branch", "updatedAt" } } }`.
|
|
6
|
+
*
|
|
7
|
+
* Status transition table (also recorded in docs/DECISIONS.md #7):
|
|
8
|
+
*
|
|
9
|
+
* - draft → approved, rejected
|
|
10
|
+
* - approved → in_progress, draft
|
|
11
|
+
* - in_progress→ review, blocked
|
|
12
|
+
* - blocked → in_progress
|
|
13
|
+
* - review → done, in_progress
|
|
14
|
+
* - rejected → draft
|
|
15
|
+
* - done → (terminal; no outgoing transitions)
|
|
16
|
+
*
|
|
17
|
+
* Rules: a task reaches `done` only through an explicit `setTaskStatus`
|
|
18
|
+
* call after user confirmation — nothing transitions implicitly, and
|
|
19
|
+
* `in_progress` can never jump straight to `done` (must pass `review`).
|
|
20
|
+
* Resume only recommends; it never switches branches or tasks by itself.
|
|
21
|
+
*/
|
|
22
|
+
import { readVaultDoc, resolveVaultDoc, writeVaultDoc } from "../vault/index.js";
|
|
23
|
+
export const TRANSITIONS = {
|
|
24
|
+
draft: ["approved", "rejected"],
|
|
25
|
+
approved: ["in_progress", "draft"],
|
|
26
|
+
in_progress: ["review", "blocked"],
|
|
27
|
+
blocked: ["in_progress"],
|
|
28
|
+
review: ["done", "in_progress"],
|
|
29
|
+
rejected: ["draft"],
|
|
30
|
+
done: [],
|
|
31
|
+
};
|
|
32
|
+
export const TERMINAL_STATUSES = ["done", "rejected"];
|
|
33
|
+
export class TaskRegistryError extends Error {
|
|
34
|
+
code;
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "TaskRegistryError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-_]{0,63}$/;
|
|
42
|
+
function registryPath(vaultRoot, projectId) {
|
|
43
|
+
return resolveVaultDoc(vaultRoot, projectId, "tasks", "registry.json");
|
|
44
|
+
}
|
|
45
|
+
function isTaskStatus(value) {
|
|
46
|
+
return (typeof value === "string" &&
|
|
47
|
+
Object.keys(TRANSITIONS).includes(value));
|
|
48
|
+
}
|
|
49
|
+
function parseRegistry(text, path) {
|
|
50
|
+
let json;
|
|
51
|
+
try {
|
|
52
|
+
json = JSON.parse(text);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry at ${path} is not valid JSON (${error.message}). Fix or delete it to start fresh.`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof json !== "object" || json === null || Array.isArray(json)) {
|
|
58
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry at ${path} must be a JSON object with a "tasks" map.`);
|
|
59
|
+
}
|
|
60
|
+
const tasks = json["tasks"];
|
|
61
|
+
if (typeof tasks !== "object" || tasks === null || Array.isArray(tasks)) {
|
|
62
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry at ${path} must contain a "tasks" object.`);
|
|
63
|
+
}
|
|
64
|
+
for (const [id, entry] of Object.entries(tasks)) {
|
|
65
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
66
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry entry "${id}" must be an object.`);
|
|
67
|
+
}
|
|
68
|
+
const e = entry;
|
|
69
|
+
if (!isTaskStatus(e["status"])) {
|
|
70
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry entry "${id}" has unknown status ${JSON.stringify(e["status"])}.`);
|
|
71
|
+
}
|
|
72
|
+
if (e["branch"] !== null && typeof e["branch"] !== "string") {
|
|
73
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry entry "${id}" has a non-string branch.`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof e["updatedAt"] !== "string") {
|
|
76
|
+
throw new TaskRegistryError("CORRUPT_REGISTRY", `Task registry entry "${id}" is missing string "updatedAt".`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { tasks: tasks };
|
|
80
|
+
}
|
|
81
|
+
export function readRegistry(vaultRoot, projectId) {
|
|
82
|
+
const path = registryPath(vaultRoot, projectId);
|
|
83
|
+
try {
|
|
84
|
+
return parseRegistry(readVaultDoc(path), path);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error.code === "ENOENT")
|
|
88
|
+
return { tasks: {} };
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function saveRegistry(vaultRoot, projectId, registry) {
|
|
93
|
+
writeVaultDoc(registryPath(vaultRoot, projectId), JSON.stringify(registry, null, 2) + "\n", {
|
|
94
|
+
overwrite: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function checkTaskId(taskId) {
|
|
98
|
+
if (!TASK_ID_PATTERN.test(taskId)) {
|
|
99
|
+
throw new TaskRegistryError("UNKNOWN_TASK", `Invalid task id ${JSON.stringify(taskId)}: use 1–64 letters, digits, dash or underscore.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Register a new task as `draft`. Duplicate ids are an error, never merged. */
|
|
103
|
+
export function registerTask(vaultRoot, projectId, taskId, branch, date) {
|
|
104
|
+
checkTaskId(taskId);
|
|
105
|
+
const registry = readRegistry(vaultRoot, projectId);
|
|
106
|
+
if (registry.tasks[taskId]) {
|
|
107
|
+
throw new TaskRegistryError("ALREADY_REGISTERED", `Task ${taskId} is already registered (status ${registry.tasks[taskId].status}). Use setTaskStatus to move it.`);
|
|
108
|
+
}
|
|
109
|
+
const entry = { status: "draft", branch, updatedAt: date };
|
|
110
|
+
registry.tasks[taskId] = entry;
|
|
111
|
+
saveRegistry(vaultRoot, projectId, registry);
|
|
112
|
+
return entry;
|
|
113
|
+
}
|
|
114
|
+
/** (Re)associate a task with a branch. Never switches the actual Git branch. */
|
|
115
|
+
export function attachBranch(vaultRoot, projectId, taskId, branch, date) {
|
|
116
|
+
const registry = readRegistry(vaultRoot, projectId);
|
|
117
|
+
const entry = registry.tasks[taskId];
|
|
118
|
+
if (!entry) {
|
|
119
|
+
throw new TaskRegistryError("UNKNOWN_TASK", `Task ${taskId} is not registered. Register it before attaching a branch.`);
|
|
120
|
+
}
|
|
121
|
+
entry.branch = branch;
|
|
122
|
+
entry.updatedAt = date;
|
|
123
|
+
saveRegistry(vaultRoot, projectId, registry);
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Move a task to a new status. Only table-allowed transitions pass;
|
|
128
|
+
* anything else throws `INVALID_TRANSITION` listing what is allowed.
|
|
129
|
+
*/
|
|
130
|
+
export function setTaskStatus(vaultRoot, projectId, taskId, to, date) {
|
|
131
|
+
const registry = readRegistry(vaultRoot, projectId);
|
|
132
|
+
const entry = registry.tasks[taskId];
|
|
133
|
+
if (!entry) {
|
|
134
|
+
throw new TaskRegistryError("UNKNOWN_TASK", `Task ${taskId} is not registered. Register it before changing status.`);
|
|
135
|
+
}
|
|
136
|
+
if (!TRANSITIONS[entry.status].includes(to)) {
|
|
137
|
+
const allowed = TRANSITIONS[entry.status];
|
|
138
|
+
throw new TaskRegistryError("INVALID_TRANSITION", `Task ${taskId} cannot move from ${entry.status} to ${to}. ` +
|
|
139
|
+
(allowed.length === 0
|
|
140
|
+
? `${entry.status} is terminal — the task stays open as history.`
|
|
141
|
+
: `Allowed from ${entry.status}: ${allowed.join(", ")}.`));
|
|
142
|
+
}
|
|
143
|
+
entry.status = to;
|
|
144
|
+
entry.updatedAt = date;
|
|
145
|
+
saveRegistry(vaultRoot, projectId, registry);
|
|
146
|
+
return entry;
|
|
147
|
+
}
|
|
148
|
+
export function getTask(vaultRoot, projectId, taskId) {
|
|
149
|
+
return readRegistry(vaultRoot, projectId).tasks[taskId] ?? null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Recommend the most recently active non-terminal task on `currentBranch`.
|
|
153
|
+
* Recommendation only: never resumes, switches, or closes anything.
|
|
154
|
+
* Unknown/empty branch or no matching task yields a note asking the user.
|
|
155
|
+
*/
|
|
156
|
+
export function recommendResume(vaultRoot, projectId, currentBranch) {
|
|
157
|
+
if (!currentBranch) {
|
|
158
|
+
return {
|
|
159
|
+
taskId: null,
|
|
160
|
+
note: "Current branch is unknown — tell me which branch and task to resume, and confirm before I continue.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const registry = readRegistry(vaultRoot, projectId);
|
|
164
|
+
const onBranch = Object.entries(registry.tasks)
|
|
165
|
+
.filter(([, e]) => e.branch === currentBranch && !TERMINAL_STATUSES.includes(e.status))
|
|
166
|
+
.sort((a, b) => (a[1].updatedAt < b[1].updatedAt ? 1 : -1));
|
|
167
|
+
if (onBranch.length === 0) {
|
|
168
|
+
const total = Object.keys(registry.tasks).length;
|
|
169
|
+
return {
|
|
170
|
+
taskId: null,
|
|
171
|
+
note: total === 0
|
|
172
|
+
? `No tasks are registered yet. Tell me which task to start on branch ${currentBranch}.`
|
|
173
|
+
: `No open tasks are attached to branch ${currentBranch} (${total} registered elsewhere). Tell me which task to resume, and confirm.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const [taskId, entry] = onBranch[0];
|
|
177
|
+
return {
|
|
178
|
+
taskId,
|
|
179
|
+
note: `Most recently active open task on ${currentBranch} is ${taskId} ` +
|
|
180
|
+
`(status ${entry.status}, updated ${entry.updatedAt}). Confirm before I resume it — nothing was switched or closed.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taste proposal lifecycle (OLC-064).
|
|
3
|
+
*
|
|
4
|
+
* Project-specific preferences are learned without silently changing
|
|
5
|
+
* behavior: a repeated signal becomes a `pending` proposal carrying its
|
|
6
|
+
* examples/evidence, and only an explicit user approval flips it to
|
|
7
|
+
* `active`. Rejection parks it as `rejected` (history, never applied).
|
|
8
|
+
* Pending rules are never treated as active by any consumer.
|
|
9
|
+
*
|
|
10
|
+
* Scope guarantees:
|
|
11
|
+
* - Identity is validated first; wrong project or unavailable vault throws
|
|
12
|
+
* before any read or write.
|
|
13
|
+
* - Every path resolves inside `<vault>/OLC/Projects/<projectId>/taste/`,
|
|
14
|
+
* so other projects are untouched by construction.
|
|
15
|
+
* - This module only ever touches `taste/` — approved intent/plan docs
|
|
16
|
+
* under `tasks/` cannot be overwritten from here.
|
|
17
|
+
* - Reuses the OLC-023 atomic create-only write: proposals never silently
|
|
18
|
+
* replace an existing rule, and status flips require the rule to be
|
|
19
|
+
* `pending` (terminal `active`/`rejected` states reject further review).
|
|
20
|
+
*/
|
|
21
|
+
import type { ResolvedConfig } from "../config/index.js";
|
|
22
|
+
export type TasteStatus = "pending" | "active" | "rejected";
|
|
23
|
+
export type TasteErrorCode = "INVALID_NAME" | "INVALID_INPUT" | "UNKNOWN_RULE" | "BAD_TRANSITION" | "ALREADY_EXISTS" | "CORRUPT_RULE";
|
|
24
|
+
export declare class TasteError extends Error {
|
|
25
|
+
readonly code: TasteErrorCode;
|
|
26
|
+
constructor(code: TasteErrorCode, message: string);
|
|
27
|
+
}
|
|
28
|
+
export interface TasteRule {
|
|
29
|
+
name: string;
|
|
30
|
+
status: TasteStatus;
|
|
31
|
+
rule: string;
|
|
32
|
+
examples: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ProposeTasteInput {
|
|
35
|
+
/** Any directory inside the project (identity resolution starts here). */
|
|
36
|
+
projectRoot: string;
|
|
37
|
+
config: ResolvedConfig;
|
|
38
|
+
/** Plain name (`no-any` → `taste/no-any.md`). No paths. */
|
|
39
|
+
name: string;
|
|
40
|
+
/** The proposed preference text. Must be non-empty. */
|
|
41
|
+
rule: string;
|
|
42
|
+
/**
|
|
43
|
+
* Observed repeat signals backing the proposal (corrections, accepts,
|
|
44
|
+
* rejects). At least one non-empty entry is required — a proposal
|
|
45
|
+
* without evidence is rejected outright.
|
|
46
|
+
*/
|
|
47
|
+
examples: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Record a repeat signal as a `pending` proposal. Never active on creation:
|
|
51
|
+
* activation needs a separate explicit `reviewTasteRule` approval.
|
|
52
|
+
* Throws before any write on invalid input or failed identity; an existing
|
|
53
|
+
* rule is never silently replaced.
|
|
54
|
+
*/
|
|
55
|
+
export declare function proposeTasteRule(input: ProposeTasteInput): TasteRule;
|
|
56
|
+
/** Read one rule, or null when it does not exist. Identity is checked first. */
|
|
57
|
+
export declare function getTasteRule(projectRoot: string, config: ResolvedConfig, name: string): TasteRule | null;
|
|
58
|
+
export interface ListTasteInput {
|
|
59
|
+
projectRoot: string;
|
|
60
|
+
config: ResolvedConfig;
|
|
61
|
+
/** When set, only rules with this status are returned. */
|
|
62
|
+
status?: TasteStatus;
|
|
63
|
+
}
|
|
64
|
+
/** List rules in the validated project scope, sorted by name. Read-only. */
|
|
65
|
+
export declare function listTasteRules(input: ListTasteInput): TasteRule[];
|
|
66
|
+
export interface ReviewTasteInput {
|
|
67
|
+
/** Any directory inside the project (identity resolution starts here). */
|
|
68
|
+
projectRoot: string;
|
|
69
|
+
config: ResolvedConfig;
|
|
70
|
+
name: string;
|
|
71
|
+
/** True = user approves (pending→active); false = rejects (pending→rejected). */
|
|
72
|
+
approve: boolean;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Explicit user review of a `pending` proposal. Only pending rules move;
|
|
76
|
+
* active/rejected are terminal (re-review throws `BAD_TRANSITION`), and
|
|
77
|
+
* unknown names throw `UNKNOWN_RULE`. Nothing else in the vault is touched.
|
|
78
|
+
*/
|
|
79
|
+
export declare function reviewTasteRule(input: ReviewTasteInput): TasteRule;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taste proposal lifecycle (OLC-064).
|
|
3
|
+
*
|
|
4
|
+
* Project-specific preferences are learned without silently changing
|
|
5
|
+
* behavior: a repeated signal becomes a `pending` proposal carrying its
|
|
6
|
+
* examples/evidence, and only an explicit user approval flips it to
|
|
7
|
+
* `active`. Rejection parks it as `rejected` (history, never applied).
|
|
8
|
+
* Pending rules are never treated as active by any consumer.
|
|
9
|
+
*
|
|
10
|
+
* Scope guarantees:
|
|
11
|
+
* - Identity is validated first; wrong project or unavailable vault throws
|
|
12
|
+
* before any read or write.
|
|
13
|
+
* - Every path resolves inside `<vault>/OLC/Projects/<projectId>/taste/`,
|
|
14
|
+
* so other projects are untouched by construction.
|
|
15
|
+
* - This module only ever touches `taste/` — approved intent/plan docs
|
|
16
|
+
* under `tasks/` cannot be overwritten from here.
|
|
17
|
+
* - Reuses the OLC-023 atomic create-only write: proposals never silently
|
|
18
|
+
* replace an existing rule, and status flips require the rule to be
|
|
19
|
+
* `pending` (terminal `active`/`rejected` states reject further review).
|
|
20
|
+
*/
|
|
21
|
+
import { readdirSync } from "node:fs";
|
|
22
|
+
import { validateIdentity } from "../core/project-identity.js";
|
|
23
|
+
import { parseFrontmatter, readVaultDoc, renderTemplate, resolveVaultDoc, writeVaultDoc, } from "../vault/index.js";
|
|
24
|
+
export class TasteError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "TasteError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function checkName(name) {
|
|
33
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
|
|
34
|
+
throw new TasteError("INVALID_NAME", `Invalid taste rule name ${JSON.stringify(name)}: use letters, digits, dash or underscore, starting with a letter or digit. No paths, no extensions (".md" is added).`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function readOrNull(path) {
|
|
38
|
+
try {
|
|
39
|
+
return readVaultDoc(path);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code === "ENOENT")
|
|
43
|
+
return null;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function listMdFiles(dir) {
|
|
48
|
+
try {
|
|
49
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
50
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
51
|
+
.map((e) => e.name)
|
|
52
|
+
.sort();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === "ENOENT")
|
|
56
|
+
return [];
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const RULE_MARKER = "## Rule (aturan)";
|
|
61
|
+
const EXAMPLES_MARKER = "## Examples / evidence";
|
|
62
|
+
function parseTasteRule(name, text, path) {
|
|
63
|
+
const { data, body } = parseFrontmatter(text);
|
|
64
|
+
const status = data["status"];
|
|
65
|
+
if (status !== "pending" && status !== "active" && status !== "rejected") {
|
|
66
|
+
throw new TasteError("CORRUPT_RULE", `Taste rule at ${path} has unknown status ${JSON.stringify(status)}. Expected pending, active, or rejected — fix or delete the file.`);
|
|
67
|
+
}
|
|
68
|
+
const ruleAt = body.indexOf(RULE_MARKER);
|
|
69
|
+
const examplesAt = body.indexOf(EXAMPLES_MARKER);
|
|
70
|
+
if (ruleAt === -1 || examplesAt === -1 || examplesAt < ruleAt) {
|
|
71
|
+
throw new TasteError("CORRUPT_RULE", `Taste rule at ${path} does not follow the taste-rule template (missing Rule / Examples sections). Fix or delete the file.`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
name,
|
|
75
|
+
status,
|
|
76
|
+
rule: body.slice(ruleAt + RULE_MARKER.length, examplesAt).trim(),
|
|
77
|
+
examples: body.slice(examplesAt + EXAMPLES_MARKER.length).trim(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function rulePath(vaultRoot, projectId, name) {
|
|
81
|
+
return resolveVaultDoc(vaultRoot, projectId, "taste", `${name}.md`);
|
|
82
|
+
}
|
|
83
|
+
function renderRule(projectId, status, name, rule, examples) {
|
|
84
|
+
return renderTemplate("taste-rule", { project_id: projectId, status, name, rule, examples });
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Record a repeat signal as a `pending` proposal. Never active on creation:
|
|
88
|
+
* activation needs a separate explicit `reviewTasteRule` approval.
|
|
89
|
+
* Throws before any write on invalid input or failed identity; an existing
|
|
90
|
+
* rule is never silently replaced.
|
|
91
|
+
*/
|
|
92
|
+
export function proposeTasteRule(input) {
|
|
93
|
+
checkName(input.name);
|
|
94
|
+
if (typeof input.rule !== "string" || input.rule.trim() === "") {
|
|
95
|
+
throw new TasteError("INVALID_INPUT", "Taste rule text must be a non-empty string.");
|
|
96
|
+
}
|
|
97
|
+
const examples = (input.examples ?? []).filter((e) => typeof e === "string" && e.trim() !== "");
|
|
98
|
+
if (examples.length === 0) {
|
|
99
|
+
throw new TasteError("INVALID_INPUT", "A taste proposal needs at least one example/evidence entry (the observed repeat signals).");
|
|
100
|
+
}
|
|
101
|
+
const identity = validateIdentity(input.projectRoot, input.config);
|
|
102
|
+
const path = rulePath(identity.vaultRoot, identity.projectId, input.name);
|
|
103
|
+
if (readOrNull(path) !== null) {
|
|
104
|
+
throw new TasteError("ALREADY_EXISTS", `Taste rule ${JSON.stringify(input.name)} already exists. Review or delete it explicitly — proposals are never merged silently.`);
|
|
105
|
+
}
|
|
106
|
+
const rule = {
|
|
107
|
+
name: input.name,
|
|
108
|
+
status: "pending",
|
|
109
|
+
rule: input.rule.trim(),
|
|
110
|
+
examples: examples.map((e) => `- ${e.trim()}`).join("\n"),
|
|
111
|
+
};
|
|
112
|
+
writeVaultDoc(path, renderRule(identity.projectId, "pending", rule.name, rule.rule, rule.examples));
|
|
113
|
+
return rule;
|
|
114
|
+
}
|
|
115
|
+
/** Read one rule, or null when it does not exist. Identity is checked first. */
|
|
116
|
+
export function getTasteRule(projectRoot, config, name) {
|
|
117
|
+
checkName(name);
|
|
118
|
+
const identity = validateIdentity(projectRoot, config);
|
|
119
|
+
const path = rulePath(identity.vaultRoot, identity.projectId, name);
|
|
120
|
+
const text = readOrNull(path);
|
|
121
|
+
if (text === null)
|
|
122
|
+
return null;
|
|
123
|
+
return parseTasteRule(name, text, path);
|
|
124
|
+
}
|
|
125
|
+
/** List rules in the validated project scope, sorted by name. Read-only. */
|
|
126
|
+
export function listTasteRules(input) {
|
|
127
|
+
const identity = validateIdentity(input.projectRoot, input.config);
|
|
128
|
+
const dir = resolveVaultDoc(identity.vaultRoot, identity.projectId, "taste");
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const file of listMdFiles(dir)) {
|
|
131
|
+
const name = file.slice(0, -".md".length);
|
|
132
|
+
const path = rulePath(identity.vaultRoot, identity.projectId, name);
|
|
133
|
+
const text = readOrNull(path);
|
|
134
|
+
if (text === null)
|
|
135
|
+
continue;
|
|
136
|
+
const rule = parseTasteRule(name, text, path);
|
|
137
|
+
if (input.status === undefined || rule.status === input.status)
|
|
138
|
+
out.push(rule);
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Explicit user review of a `pending` proposal. Only pending rules move;
|
|
144
|
+
* active/rejected are terminal (re-review throws `BAD_TRANSITION`), and
|
|
145
|
+
* unknown names throw `UNKNOWN_RULE`. Nothing else in the vault is touched.
|
|
146
|
+
*/
|
|
147
|
+
export function reviewTasteRule(input) {
|
|
148
|
+
checkName(input.name);
|
|
149
|
+
const identity = validateIdentity(input.projectRoot, input.config);
|
|
150
|
+
const path = rulePath(identity.vaultRoot, identity.projectId, input.name);
|
|
151
|
+
const text = readOrNull(path);
|
|
152
|
+
if (text === null) {
|
|
153
|
+
throw new TasteError("UNKNOWN_RULE", `Taste rule ${JSON.stringify(input.name)} does not exist. Propose it first.`);
|
|
154
|
+
}
|
|
155
|
+
const current = parseTasteRule(input.name, text, path);
|
|
156
|
+
if (current.status !== "pending") {
|
|
157
|
+
throw new TasteError("BAD_TRANSITION", `Taste rule ${JSON.stringify(input.name)} is already ${current.status} — only pending proposals can be reviewed.`);
|
|
158
|
+
}
|
|
159
|
+
const next = input.approve ? "active" : "rejected";
|
|
160
|
+
writeVaultDoc(path, renderRule(identity.projectId, next, current.name, current.rule, current.examples), { overwrite: true });
|
|
161
|
+
return { ...current, status: next };
|
|
162
|
+
}
|