litcodex-ai 0.3.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/LICENSE +21 -0
- package/README.md +62 -0
- package/bin/litcodex.js +12 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +183 -0
- package/dist/config-migration/backup.d.ts +2 -0
- package/dist/config-migration/backup.js +42 -0
- package/dist/config-migration/catalog.d.ts +22 -0
- package/dist/config-migration/catalog.js +99 -0
- package/dist/config-migration/cli.d.ts +14 -0
- package/dist/config-migration/cli.js +85 -0
- package/dist/config-migration/config-paths.d.ts +4 -0
- package/dist/config-migration/config-paths.js +64 -0
- package/dist/config-migration/errors.d.ts +11 -0
- package/dist/config-migration/errors.js +28 -0
- package/dist/config-migration/index.d.ts +44 -0
- package/dist/config-migration/index.js +210 -0
- package/dist/config-migration/multi-agent-v2-guard.d.ts +2 -0
- package/dist/config-migration/multi-agent-v2-guard.js +106 -0
- package/dist/config-migration/root-settings.d.ts +6 -0
- package/dist/config-migration/root-settings.js +104 -0
- package/dist/config-migration/state.d.ts +16 -0
- package/dist/config-migration/state.js +40 -0
- package/dist/config-migration/toml-shape.d.ts +8 -0
- package/dist/config-migration/toml-shape.js +107 -0
- package/dist/install/codex.d.ts +34 -0
- package/dist/install/codex.js +94 -0
- package/dist/install/doctor.d.ts +12 -0
- package/dist/install/doctor.js +83 -0
- package/dist/install/errors.d.ts +19 -0
- package/dist/install/errors.js +43 -0
- package/dist/install/execute.d.ts +39 -0
- package/dist/install/execute.js +193 -0
- package/dist/install/index.d.ts +19 -0
- package/dist/install/index.js +193 -0
- package/dist/install/marketplace.d.ts +5 -0
- package/dist/install/marketplace.js +10 -0
- package/dist/install/plan.d.ts +3 -0
- package/dist/install/plan.js +54 -0
- package/dist/install/render-plan.d.ts +3 -0
- package/dist/install/render-plan.js +10 -0
- package/dist/install/types.d.ts +45 -0
- package/dist/install/types.js +5 -0
- package/model-catalog.json +31 -0
- package/node_modules/@litcodex/lit-loop/CHANGELOG.md +19 -0
- package/node_modules/@litcodex/lit-loop/LICENSE +21 -0
- package/node_modules/@litcodex/lit-loop/NOTICE +8 -0
- package/node_modules/@litcodex/lit-loop/README.md +37 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-explorer.toml +75 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-librarian.toml +98 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-litwork-reviewer.toml +21 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-metis.toml +64 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-momus.toml +68 -0
- package/node_modules/@litcodex/lit-loop/agents/litcodex-plan.toml +163 -0
- package/node_modules/@litcodex/lit-loop/directive.md +85 -0
- package/node_modules/@litcodex/lit-loop/directives/lit-plan.md +286 -0
- package/node_modules/@litcodex/lit-loop/directives/litgoal.md +103 -0
- package/node_modules/@litcodex/lit-loop/directives/litwork.md +363 -0
- package/node_modules/@litcodex/lit-loop/dist/_scaffold.d.ts +1 -0
- package/node_modules/@litcodex/lit-loop/dist/_scaffold.js +3 -0
- package/node_modules/@litcodex/lit-loop/dist/cli.d.ts +6 -0
- package/node_modules/@litcodex/lit-loop/dist/cli.js +44 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-goal-instruction.d.ts +18 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-goal-instruction.js +94 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-hook.d.ts +38 -0
- package/node_modules/@litcodex/lit-loop/dist/codex-hook.js +126 -0
- package/node_modules/@litcodex/lit-loop/dist/directive.d.ts +35 -0
- package/node_modules/@litcodex/lit-loop/dist/directive.js +80 -0
- package/node_modules/@litcodex/lit-loop/dist/goal-status.d.ts +12 -0
- package/node_modules/@litcodex/lit-loop/dist/goal-status.js +25 -0
- package/node_modules/@litcodex/lit-loop/dist/guards.d.ts +73 -0
- package/node_modules/@litcodex/lit-loop/dist/guards.js +215 -0
- package/node_modules/@litcodex/lit-loop/dist/hook-cli.d.ts +17 -0
- package/node_modules/@litcodex/lit-loop/dist/hook-cli.js +94 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-cli.d.ts +19 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-cli.js +106 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-render.d.ts +7 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-render.js +39 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-types.d.ts +52 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor-types.js +7 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor.d.ts +21 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-doctor.js +283 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-errors.d.ts +15 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-errors.js +43 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-handlers.d.ts +18 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-handlers.js +311 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-model.d.ts +51 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-model.js +165 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-stdout.d.ts +6 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-stdout.js +11 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-types.d.ts +26 -0
- package/node_modules/@litcodex/lit-loop/dist/loop-types.js +8 -0
- package/node_modules/@litcodex/lit-loop/dist/markers.d.ts +9 -0
- package/node_modules/@litcodex/lit-loop/dist/markers.js +14 -0
- package/node_modules/@litcodex/lit-loop/dist/modes.d.ts +15 -0
- package/node_modules/@litcodex/lit-loop/dist/modes.js +56 -0
- package/node_modules/@litcodex/lit-loop/dist/state-paths.d.ts +41 -0
- package/node_modules/@litcodex/lit-loop/dist/state-paths.js +111 -0
- package/node_modules/@litcodex/lit-loop/dist/state-store.d.ts +39 -0
- package/node_modules/@litcodex/lit-loop/dist/state-store.js +419 -0
- package/node_modules/@litcodex/lit-loop/dist/state-types.d.ts +90 -0
- package/node_modules/@litcodex/lit-loop/dist/state-types.js +61 -0
- package/node_modules/@litcodex/lit-loop/dist/trigger.d.ts +54 -0
- package/node_modules/@litcodex/lit-loop/dist/trigger.js +75 -0
- package/node_modules/@litcodex/lit-loop/package.json +27 -0
- package/package.json +30 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// src/state-store.ts — M08/T13 durable state store (the ONLY fs authority for loop state).
|
|
2
|
+
//
|
|
3
|
+
// Resolves the state dir from the project root (never the module location), writes goals.json
|
|
4
|
+
// ATOMICALLY via tmp-write→fsync→rename (a crash mid-write can never leave a half-truncated
|
|
5
|
+
// plan), APPENDS ledger lines without rewriting, VALIDATES the plan schema on read, and on a
|
|
6
|
+
// corruption QUARANTINES the bad bytes to a timestamped .bak + writes a recovery report +
|
|
7
|
+
// appends a state_recovered ledger line + throws a typed recoverable error (NEVER auto-recreate,
|
|
8
|
+
// NEVER discard goals). Serializes same-process mutations per state dir. It MUST NEVER read,
|
|
9
|
+
// write, create, or reference the legacy runtime dir — runtime state lives ONLY under `.litcodex/lit-loop`.
|
|
10
|
+
import { appendFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
11
|
+
import { isAbsolute, join } from "node:path";
|
|
12
|
+
import { LIT_LOOP_GOALS, litLoopBriefPath, litLoopDir, litLoopEvidenceDir, litLoopGoalsPath, litLoopLedgerPath, litLoopRelativeDir, normalizeSessionId, repoRelative, } from "./state-paths.js";
|
|
13
|
+
import { isGoalId, iso, isUserModel, LitLoopStateError, } from "./state-types.js";
|
|
14
|
+
// Re-export the path-layer scope helpers M09/M11 import via the store barrel.
|
|
15
|
+
export { evidencePath, resolveLoopScope } from "./state-paths.js";
|
|
16
|
+
/** Alias of `litLoopDir` under the name M09 (loop model) and M11 (doctor) import. */
|
|
17
|
+
export function resolveLoopStateDir(repoRoot, scope) {
|
|
18
|
+
return litLoopDir(repoRoot, scope);
|
|
19
|
+
}
|
|
20
|
+
// ── errno helpers ────────────────────────────────────────────────────────────
|
|
21
|
+
function errnoCode(err) {
|
|
22
|
+
return typeof err === "object" && err !== null && "code" in err
|
|
23
|
+
? err.code
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
function writeFailed(message, cause, details) {
|
|
27
|
+
return new LitLoopStateError(message, "LIT_LOOP_WRITE_FAILED", {
|
|
28
|
+
cause,
|
|
29
|
+
details: { ...details, cause: errnoCode(cause) },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
// ── atomic write primitive (tmp in same dir → fsync → rename) ────────────────
|
|
33
|
+
async function atomicWrite(targetPath, contents) {
|
|
34
|
+
const dir = join(targetPath, "..");
|
|
35
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
36
|
+
const tmp = `${targetPath}.${process.pid}.${Date.now()}.${rand}.tmp`;
|
|
37
|
+
let handle;
|
|
38
|
+
try {
|
|
39
|
+
handle = await open(tmp, "w");
|
|
40
|
+
await handle.writeFile(contents, "utf8");
|
|
41
|
+
await handle.sync(); // fsync: durability of tmp bytes before publish
|
|
42
|
+
await handle.close();
|
|
43
|
+
handle = undefined;
|
|
44
|
+
await rename(tmp, targetPath); // POSIX rename over existing target is atomic
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (handle !== undefined) {
|
|
48
|
+
await handle.close().catch(() => undefined);
|
|
49
|
+
}
|
|
50
|
+
await unlink(tmp).catch(() => undefined); // best-effort cleanup; prior target untouched
|
|
51
|
+
throw writeFailed(`failed to write ${repoRelative(targetPath, dir)}`, err, { path: targetPath });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ── plan validation (the single schema gate, A3 C7 + addendum §B.3) ──────────
|
|
55
|
+
function isIsoString(v) {
|
|
56
|
+
return typeof v === "string" && v.length > 0 && !Number.isNaN(Date.parse(v));
|
|
57
|
+
}
|
|
58
|
+
function field(obj, key) {
|
|
59
|
+
return obj[key];
|
|
60
|
+
}
|
|
61
|
+
function validateGoal(goal) {
|
|
62
|
+
if (typeof goal !== "object" || goal === null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (!isGoalId(field(goal, "id"))) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (typeof field(goal, "title") !== "string" || typeof field(goal, "objective") !== "string") {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const statuses = ["pending", "in_progress", "complete", "failed", "blocked"];
|
|
72
|
+
const status = field(goal, "status");
|
|
73
|
+
if (typeof status !== "string" || !statuses.includes(status)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const attempt = field(goal, "attempt");
|
|
77
|
+
if (typeof attempt !== "number" || !Number.isInteger(attempt) || attempt < 0) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!isIsoString(field(goal, "createdAt")) || !isIsoString(field(goal, "updatedAt"))) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const successCriteria = field(goal, "successCriteria");
|
|
84
|
+
if (!Array.isArray(successCriteria)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const critStatuses = ["pending", "pass", "fail", "blocked"];
|
|
88
|
+
for (const crit of successCriteria) {
|
|
89
|
+
if (typeof crit !== "object" || crit === null) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const id = field(crit, "id");
|
|
93
|
+
if (typeof id !== "string" || !/^C\d{3}$/.test(id)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (typeof field(crit, "scenario") !== "string" || typeof field(crit, "expectedEvidence") !== "string") {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (!isUserModel(field(crit, "userModel"))) {
|
|
100
|
+
return false; // REJECTS "adversarial" and any non-3-value model
|
|
101
|
+
}
|
|
102
|
+
const cs = field(crit, "status");
|
|
103
|
+
if (typeof cs !== "string" || !critStatuses.includes(cs)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const cap = field(crit, "capturedEvidence");
|
|
107
|
+
if (!(cap === null || typeof cap === "string")) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** Structural plan validation. Returns the reason string on failure, or null when valid. */
|
|
114
|
+
function planInvalidReason(plan) {
|
|
115
|
+
if (typeof plan !== "object" || plan === null) {
|
|
116
|
+
return "plan is not an object";
|
|
117
|
+
}
|
|
118
|
+
if (field(plan, "version") !== 1) {
|
|
119
|
+
return `version must be 1 (got ${JSON.stringify(field(plan, "version"))})`;
|
|
120
|
+
}
|
|
121
|
+
if (!isIsoString(field(plan, "createdAt")) || !isIsoString(field(plan, "updatedAt"))) {
|
|
122
|
+
return "createdAt/updatedAt must be ISO-8601 strings";
|
|
123
|
+
}
|
|
124
|
+
for (const f of ["briefPath", "goalsPath", "ledgerPath", "evidenceDir"]) {
|
|
125
|
+
if (typeof field(plan, f) !== "string") {
|
|
126
|
+
return `${f} must be a string`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const sessionId = field(plan, "sessionId");
|
|
130
|
+
if (!(sessionId === null || typeof sessionId === "string")) {
|
|
131
|
+
return "sessionId must be a string or null";
|
|
132
|
+
}
|
|
133
|
+
const goals = field(plan, "goals");
|
|
134
|
+
if (!Array.isArray(goals)) {
|
|
135
|
+
return "goals must be an array";
|
|
136
|
+
}
|
|
137
|
+
for (const goal of goals) {
|
|
138
|
+
if (!validateGoal(goal)) {
|
|
139
|
+
return "a goal failed schema validation";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const activeGoalId = field(plan, "activeGoalId");
|
|
143
|
+
if (activeGoalId !== undefined) {
|
|
144
|
+
if (typeof activeGoalId !== "string") {
|
|
145
|
+
return "activeGoalId must be a string when present";
|
|
146
|
+
}
|
|
147
|
+
const ids = new Set(goals.map((g) => g.id));
|
|
148
|
+
if (!ids.has(activeGoalId)) {
|
|
149
|
+
return `activeGoalId ${JSON.stringify(activeGoalId)} does not match any goal`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
function validatePlan(plan) {
|
|
155
|
+
const reason = planInvalidReason(plan);
|
|
156
|
+
if (reason !== null) {
|
|
157
|
+
throw new LitLoopStateError(`invalid lit-loop plan: ${reason}`, "LIT_LOOP_PLAN_INVALID", {
|
|
158
|
+
details: { reason },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// ── existence probe (A3 C6: false ONLY on ENOENT, throws WRITE_FAILED otherwise) ──
|
|
163
|
+
function assertAbsolute(absPath) {
|
|
164
|
+
if (typeof absPath !== "string" || !isAbsolute(absPath)) {
|
|
165
|
+
throw new LitLoopStateError(`path must be absolute (got ${JSON.stringify(absPath)})`, "LIT_LOOP_REPO_ROOT_INVALID", { details: { absPath } });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function statExists(absPath) {
|
|
169
|
+
assertAbsolute(absPath);
|
|
170
|
+
try {
|
|
171
|
+
await stat(absPath);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (errnoCode(err) === "ENOENT") {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
// A permission / IO / loop error MUST NOT masquerade as "absent".
|
|
179
|
+
throw writeFailed(`stat failed for ${absPath}`, err, { path: absPath });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ── brief-file read (M09-addendum §A1.1: inert data, no normalization) ───────
|
|
183
|
+
export class BriefFileMissingError extends Error {
|
|
184
|
+
constructor(path, cause) {
|
|
185
|
+
super(`brief file not found or not a regular file: ${path}`, cause === undefined ? undefined : { cause });
|
|
186
|
+
this.name = "BriefFileMissingError";
|
|
187
|
+
this.path = path;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export class BriefFileUnreadableError extends Error {
|
|
191
|
+
constructor(path, cause) {
|
|
192
|
+
super(`brief file is unreadable: ${path}`, cause === undefined ? undefined : { cause });
|
|
193
|
+
this.name = "BriefFileUnreadableError";
|
|
194
|
+
this.path = path;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Read `briefFilePath` as INERT UTF-8 text. The path is used AS GIVEN — no normalization, no
|
|
199
|
+
* re-rooting against `repoRoot`, no rejection (the bytes are data, never re-interpreted as a
|
|
200
|
+
* path/flag). ENOENT/EISDIR → BriefFileMissingError; EACCES/EIO → BriefFileUnreadableError.
|
|
201
|
+
*/
|
|
202
|
+
export async function readBriefFile(_repoRoot, briefFilePath) {
|
|
203
|
+
try {
|
|
204
|
+
return await readFile(briefFilePath, "utf8");
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const code = errnoCode(err);
|
|
208
|
+
if (code === "ENOENT" || code === "EISDIR") {
|
|
209
|
+
throw new BriefFileMissingError(briefFilePath, err);
|
|
210
|
+
}
|
|
211
|
+
throw new BriefFileUnreadableError(briefFilePath, err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// ── mutation lock (canonical + alias, SAME reference; A3 C6) ─────────────────
|
|
215
|
+
const mutationLocks = new Map();
|
|
216
|
+
export function withMutationLock(repoRoot, scope, body) {
|
|
217
|
+
const key = `${repoRoot}\0${litLoopRelativeDir(scope)}`;
|
|
218
|
+
const prior = mutationLocks.get(key) ?? Promise.resolve();
|
|
219
|
+
const run = prior.then(body, body); // run after prior settles either way
|
|
220
|
+
// Store a poison-proof tail so a rejected body never blocks subsequent locks.
|
|
221
|
+
mutationLocks.set(key, run.catch(() => undefined));
|
|
222
|
+
return run;
|
|
223
|
+
}
|
|
224
|
+
/** @deprecated Alias of `withMutationLock` (SAME function reference, same lock map). */
|
|
225
|
+
export const withStateMutationLock = withMutationLock;
|
|
226
|
+
// ── writePlan (crash-atomic) ─────────────────────────────────────────────────
|
|
227
|
+
export async function writePlan(repoRoot, plan, scope) {
|
|
228
|
+
validatePlan(plan); // throws LIT_LOOP_PLAN_INVALID before any write — never write a bad object
|
|
229
|
+
const dir = litLoopDir(repoRoot, scope);
|
|
230
|
+
try {
|
|
231
|
+
await mkdir(dir, { recursive: true });
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
throw writeFailed(`failed to create ${dir}`, err, { path: dir });
|
|
235
|
+
}
|
|
236
|
+
await atomicWrite(litLoopGoalsPath(repoRoot, scope), `${JSON.stringify(plan, null, 2)}\n`);
|
|
237
|
+
}
|
|
238
|
+
// ── writeBrief (atomic, intentional overwrite; addendum §A) ──────────────────
|
|
239
|
+
export async function writeBrief(repoRoot, brief, scope) {
|
|
240
|
+
const dir = litLoopDir(repoRoot, scope);
|
|
241
|
+
try {
|
|
242
|
+
await mkdir(dir, { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
throw writeFailed(`failed to create ${dir}`, err, { path: dir });
|
|
246
|
+
}
|
|
247
|
+
await atomicWrite(litLoopBriefPath(repoRoot, scope), brief);
|
|
248
|
+
}
|
|
249
|
+
// ── appendLedger (append-only, never read-modify-write) ──────────────────────
|
|
250
|
+
export async function appendLedger(repoRoot, entry, scope) {
|
|
251
|
+
const dir = litLoopDir(repoRoot, scope);
|
|
252
|
+
try {
|
|
253
|
+
await mkdir(dir, { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
throw writeFailed(`failed to create ${dir}`, err, { path: dir });
|
|
257
|
+
}
|
|
258
|
+
const filled = { ...entry, at: entry.at ?? iso() };
|
|
259
|
+
try {
|
|
260
|
+
await appendFile(litLoopLedgerPath(repoRoot, scope), `${JSON.stringify(filled)}\n`, "utf8");
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
throw writeFailed("failed to append ledger entry", err, { kind: entry.kind });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export async function readLedger(repoRoot, scope) {
|
|
267
|
+
let raw;
|
|
268
|
+
try {
|
|
269
|
+
raw = await readFile(litLoopLedgerPath(repoRoot, scope), "utf8");
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (errnoCode(err) === "ENOENT") {
|
|
273
|
+
return { entries: [], skipped: 0 };
|
|
274
|
+
}
|
|
275
|
+
throw writeFailed("failed to read ledger", err);
|
|
276
|
+
}
|
|
277
|
+
const entries = [];
|
|
278
|
+
let skipped = 0;
|
|
279
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
280
|
+
if (line.trim() === "") {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
entries.push(JSON.parse(line));
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
skipped += 1; // fail-open: one bad line never blinds the audit trail
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { entries, skipped };
|
|
291
|
+
}
|
|
292
|
+
// ── ensureEvidenceDir ────────────────────────────────────────────────────────
|
|
293
|
+
export async function ensureEvidenceDir(repoRoot, scope) {
|
|
294
|
+
const dir = litLoopEvidenceDir(repoRoot, scope);
|
|
295
|
+
try {
|
|
296
|
+
await mkdir(dir, { recursive: true });
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
throw writeFailed(`failed to create ${dir}`, err, { path: dir });
|
|
300
|
+
}
|
|
301
|
+
return dir;
|
|
302
|
+
}
|
|
303
|
+
// ── corruption recovery (backup + report + ledger; NEVER auto-recreate) ──────
|
|
304
|
+
function compactIso() {
|
|
305
|
+
// e.g. 20260613T090100Z — filesystem-safe, sortable.
|
|
306
|
+
return iso()
|
|
307
|
+
.replace(/[-:]/g, "")
|
|
308
|
+
.replace(/\.\d{3}Z$/, "Z");
|
|
309
|
+
}
|
|
310
|
+
async function recoverCorrupt(repoRoot, scope, raw, cause) {
|
|
311
|
+
const dir = litLoopDir(repoRoot, scope);
|
|
312
|
+
const goalsPath = litLoopGoalsPath(repoRoot, scope);
|
|
313
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
314
|
+
const backupPath = `${goalsPath}.corrupt-${compactIso()}.bak`;
|
|
315
|
+
const reportPath = join(dir, "state-recovery.json");
|
|
316
|
+
// 1. Preserve the exact bad bytes for forensics — never delete the user's data.
|
|
317
|
+
await writeFile(backupPath, raw, "utf8").catch(() => undefined);
|
|
318
|
+
// 2. Machine-readable recovery report.
|
|
319
|
+
const report = {
|
|
320
|
+
at: iso(),
|
|
321
|
+
file: LIT_LOOP_GOALS,
|
|
322
|
+
backup: repoRelative(backupPath, repoRoot),
|
|
323
|
+
reason,
|
|
324
|
+
byteLength: Buffer.byteLength(raw, "utf8"),
|
|
325
|
+
};
|
|
326
|
+
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8").catch(() => undefined);
|
|
327
|
+
// 3. Best-effort ledger line (recovery does not depend on the ledger succeeding).
|
|
328
|
+
await appendLedger(repoRoot, {
|
|
329
|
+
at: iso(),
|
|
330
|
+
kind: "state_recovered",
|
|
331
|
+
message: `lit-loop plan corrupt: ${reason}`,
|
|
332
|
+
before: { backup: report.backup },
|
|
333
|
+
}, scope).catch(() => undefined);
|
|
334
|
+
throw new LitLoopStateError(`lit-loop plan at ${repoRelative(goalsPath, repoRoot)} is corrupt; backed up.`, "LIT_LOOP_PLAN_CORRUPT", { cause, details: { backup: report.backup, report: repoRelative(reportPath, repoRoot) } });
|
|
335
|
+
}
|
|
336
|
+
// ── readPlan ─────────────────────────────────────────────────────────────────
|
|
337
|
+
export async function readPlan(repoRoot, scope) {
|
|
338
|
+
const goalsPath = litLoopGoalsPath(repoRoot, scope);
|
|
339
|
+
let raw;
|
|
340
|
+
try {
|
|
341
|
+
raw = await readFile(goalsPath, "utf8");
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
if (errnoCode(err) === "ENOENT") {
|
|
345
|
+
throw new LitLoopStateError(`lit-loop plan not found at ${repoRelative(goalsPath, repoRoot)}`, "LIT_LOOP_PLAN_MISSING", { details: { path: repoRelative(goalsPath, repoRoot) } });
|
|
346
|
+
}
|
|
347
|
+
throw writeFailed(`failed to read ${goalsPath}`, err, { path: goalsPath });
|
|
348
|
+
}
|
|
349
|
+
let parsed;
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(raw);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
return recoverCorrupt(repoRoot, scope, raw, err);
|
|
355
|
+
}
|
|
356
|
+
const reason = planInvalidReason(parsed);
|
|
357
|
+
if (reason !== null) {
|
|
358
|
+
return recoverCorrupt(repoRoot, scope, raw, new LitLoopStateError(reason, "LIT_LOOP_PLAN_INVALID", { details: { reason } }));
|
|
359
|
+
}
|
|
360
|
+
return parsed;
|
|
361
|
+
}
|
|
362
|
+
// ── initState (idempotent; dir → brief → plan → ledger) ──────────────────────
|
|
363
|
+
export async function initState(repoRoot, args) {
|
|
364
|
+
const scope = { sessionId: args.sessionId ?? null };
|
|
365
|
+
const dir = litLoopDir(repoRoot, scope); // asserts repoRoot absolute
|
|
366
|
+
try {
|
|
367
|
+
await mkdir(dir, { recursive: true });
|
|
368
|
+
await mkdir(litLoopEvidenceDir(repoRoot, scope), { recursive: true });
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
throw writeFailed(`failed to create ${dir}`, err, { path: dir });
|
|
372
|
+
}
|
|
373
|
+
// brief.md — write only if absent (never clobber a human-edited brief).
|
|
374
|
+
const briefPath = litLoopBriefPath(repoRoot, scope);
|
|
375
|
+
if (!(await statExists(briefPath))) {
|
|
376
|
+
await atomicWrite(briefPath, args.brief);
|
|
377
|
+
}
|
|
378
|
+
// goals.json — return an existing valid plan unchanged (idempotent).
|
|
379
|
+
if (await statExists(litLoopGoalsPath(repoRoot, scope))) {
|
|
380
|
+
const existing = await readPlan(repoRoot, scope);
|
|
381
|
+
return existing;
|
|
382
|
+
}
|
|
383
|
+
const now = iso();
|
|
384
|
+
const sessionId = normalizeSessionId(args.sessionId);
|
|
385
|
+
const plan = {
|
|
386
|
+
version: 1,
|
|
387
|
+
createdAt: now,
|
|
388
|
+
updatedAt: now,
|
|
389
|
+
briefPath: repoRelative(briefPath, repoRoot),
|
|
390
|
+
goalsPath: repoRelative(litLoopGoalsPath(repoRoot, scope), repoRoot),
|
|
391
|
+
ledgerPath: repoRelative(litLoopLedgerPath(repoRoot, scope), repoRoot),
|
|
392
|
+
evidenceDir: repoRelative(litLoopEvidenceDir(repoRoot, scope), repoRoot),
|
|
393
|
+
sessionId,
|
|
394
|
+
goals: [],
|
|
395
|
+
};
|
|
396
|
+
await writePlan(repoRoot, plan, scope);
|
|
397
|
+
await appendLedger(repoRoot, { at: now, kind: "plan_created", message: "Initialized lit-loop state" }, scope);
|
|
398
|
+
return plan;
|
|
399
|
+
}
|
|
400
|
+
// ── exit-code mapping (A3 C7 corrected + addendum §A.2: 3/4/5) ───────────────
|
|
401
|
+
export function exitCodeFor(err) {
|
|
402
|
+
if (err instanceof LitLoopStateError) {
|
|
403
|
+
switch (err.code) {
|
|
404
|
+
case "LIT_LOOP_PLAN_MISSING":
|
|
405
|
+
return 3;
|
|
406
|
+
case "LIT_LOOP_PLAN_CORRUPT":
|
|
407
|
+
return 4;
|
|
408
|
+
case "LIT_LOOP_WRITE_FAILED":
|
|
409
|
+
return 5;
|
|
410
|
+
case "LIT_LOOP_PLAN_INVALID":
|
|
411
|
+
case "LIT_LOOP_EVIDENCE_NAME_UNSAFE":
|
|
412
|
+
case "LIT_LOOP_REPO_ROOT_INVALID":
|
|
413
|
+
return 2;
|
|
414
|
+
default:
|
|
415
|
+
return 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return 1;
|
|
419
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export type LitLoopGoalStatus = "pending" | "in_progress" | "complete" | "failed" | "blocked";
|
|
2
|
+
export type LitLoopCriterionStatus = "pending" | "pass" | "fail" | "blocked";
|
|
3
|
+
/**
|
|
4
|
+
* MVP user-model union — exactly three values (A3 C7 + S08-addendum §B.1.1). `"adversarial"`
|
|
5
|
+
* was dropped: M09's builder emits only these three, so admitting a fourth created a no-op
|
|
6
|
+
* branch + validation mismatch. Re-introducing it is a coordinated M08+M09 change.
|
|
7
|
+
*/
|
|
8
|
+
export type LitLoopUserModel = "happy" | "edge" | "regression";
|
|
9
|
+
/** Frozen tuple of the three user-model values (enumerable, single source). */
|
|
10
|
+
export declare const LIT_LOOP_USER_MODELS: readonly LitLoopUserModel[];
|
|
11
|
+
/** The 13-member ledger event-kind union: M08's 11 ∪ M09's 10 (A3 C7). */
|
|
12
|
+
export declare const LIT_LOOP_LEDGER_EVENT_KINDS: readonly ["plan_created", "goal_added", "goal_started", "goal_resumed", "goal_retried", "goal_completed", "goal_failed", "goal_blocked", "evidence_captured", "criterion_failed", "criterion_blocked", "criteria_revised", "state_recovered"];
|
|
13
|
+
export type LitLoopLedgerEventKind = (typeof LIT_LOOP_LEDGER_EVENT_KINDS)[number];
|
|
14
|
+
/** Goal-id: uppercase `G`, exactly 3 digits, optional non-empty lowercase-slug. `G001-` invalid. */
|
|
15
|
+
export declare const LIT_LOOP_GOAL_ID_RE: RegExp;
|
|
16
|
+
/** Criterion-id: uppercase `C`, exactly 3 digits. */
|
|
17
|
+
export declare const LIT_LOOP_CRITERION_ID_RE: RegExp;
|
|
18
|
+
export interface LitLoopCriterion {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly scenario: string;
|
|
21
|
+
readonly userModel: LitLoopUserModel;
|
|
22
|
+
readonly expectedEvidence: string;
|
|
23
|
+
capturedEvidence: string | null;
|
|
24
|
+
status: LitLoopCriterionStatus;
|
|
25
|
+
capturedAt?: string;
|
|
26
|
+
notes?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface LitLoopGoal {
|
|
29
|
+
id: string;
|
|
30
|
+
title: string;
|
|
31
|
+
objective: string;
|
|
32
|
+
status: LitLoopGoalStatus;
|
|
33
|
+
successCriteria: LitLoopCriterion[];
|
|
34
|
+
attempt: number;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
startedAt?: string;
|
|
38
|
+
completedAt?: string;
|
|
39
|
+
failedAt?: string;
|
|
40
|
+
evidence?: string;
|
|
41
|
+
failureReason?: string;
|
|
42
|
+
}
|
|
43
|
+
export interface LitLoopPlan {
|
|
44
|
+
version: 1;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
briefPath: string;
|
|
48
|
+
goalsPath: string;
|
|
49
|
+
ledgerPath: string;
|
|
50
|
+
evidenceDir: string;
|
|
51
|
+
sessionId: string | null;
|
|
52
|
+
activeGoalId?: string;
|
|
53
|
+
goals: LitLoopGoal[];
|
|
54
|
+
}
|
|
55
|
+
export interface LitLoopLedgerEntry {
|
|
56
|
+
at: string;
|
|
57
|
+
kind: LitLoopLedgerEventKind;
|
|
58
|
+
goalId?: string;
|
|
59
|
+
criterionId?: string;
|
|
60
|
+
goalStatus?: LitLoopGoalStatus;
|
|
61
|
+
criterionStatus?: LitLoopCriterionStatus;
|
|
62
|
+
message?: string;
|
|
63
|
+
evidence?: string;
|
|
64
|
+
before?: unknown;
|
|
65
|
+
after?: unknown;
|
|
66
|
+
}
|
|
67
|
+
/** Caller-supplied ledger entry: `at` is optional because `appendLedger` fills it when absent. */
|
|
68
|
+
export type LitLoopLedgerInput = Omit<LitLoopLedgerEntry, "at"> & {
|
|
69
|
+
at?: string;
|
|
70
|
+
};
|
|
71
|
+
export interface LitLoopStateErrorOptions {
|
|
72
|
+
readonly cause?: unknown;
|
|
73
|
+
readonly details?: Record<string, unknown>;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The single error type the store throws. Consumers branch on the stable SCREAMING_SNAKE
|
|
77
|
+
* `.code` (NOT on `instanceof` subclasses — none exist). `details` is undefined unless supplied.
|
|
78
|
+
*/
|
|
79
|
+
export declare class LitLoopStateError extends Error {
|
|
80
|
+
readonly name = "LitLoopStateError";
|
|
81
|
+
readonly code: string;
|
|
82
|
+
readonly details?: Record<string, unknown>;
|
|
83
|
+
constructor(message: string, code: string, opts?: LitLoopStateErrorOptions);
|
|
84
|
+
}
|
|
85
|
+
/** Single clock seam. Tests may inject a fake by spying on this module export. */
|
|
86
|
+
export declare function iso(): string;
|
|
87
|
+
export declare function isUserModel(value: unknown): value is LitLoopUserModel;
|
|
88
|
+
export declare function isLedgerEventKind(value: unknown): value is LitLoopLedgerEventKind;
|
|
89
|
+
export declare function isGoalId(value: unknown): value is string;
|
|
90
|
+
export declare function isCriterionId(value: unknown): value is string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/state-types.ts — M08/T13 SINGLE schema source (A3 C7, S08-addendum §B).
|
|
2
|
+
//
|
|
3
|
+
// The persisted TypeScript shapes for the lit-loop durable state, the status/kind unions, the
|
|
4
|
+
// `LitLoopStateError` class, the `iso()` clock seam, and the small pure validators the store
|
|
5
|
+
// (and M09's plan builder) share. Types + error + pure predicates only; ZERO I/O. M09's
|
|
6
|
+
// `loop-types.ts` is a re-export barrel of these shapes — no parallel schema is declared anywhere.
|
|
7
|
+
/** Frozen tuple of the three user-model values (enumerable, single source). */
|
|
8
|
+
export const LIT_LOOP_USER_MODELS = Object.freeze(["happy", "edge", "regression"]);
|
|
9
|
+
/** The 13-member ledger event-kind union: M08's 11 ∪ M09's 10 (A3 C7). */
|
|
10
|
+
export const LIT_LOOP_LEDGER_EVENT_KINDS = [
|
|
11
|
+
"plan_created",
|
|
12
|
+
"goal_added",
|
|
13
|
+
"goal_started",
|
|
14
|
+
"goal_resumed",
|
|
15
|
+
"goal_retried",
|
|
16
|
+
"goal_completed",
|
|
17
|
+
"goal_failed",
|
|
18
|
+
"goal_blocked",
|
|
19
|
+
"evidence_captured",
|
|
20
|
+
"criterion_failed",
|
|
21
|
+
"criterion_blocked",
|
|
22
|
+
"criteria_revised",
|
|
23
|
+
"state_recovered",
|
|
24
|
+
];
|
|
25
|
+
// ── Id regexes (A3 Part D / S08-addendum §B.1.2) ─────────────────────────────
|
|
26
|
+
/** Goal-id: uppercase `G`, exactly 3 digits, optional non-empty lowercase-slug. `G001-` invalid. */
|
|
27
|
+
export const LIT_LOOP_GOAL_ID_RE = /^G\d{3}(-[a-z0-9-]+)?$/;
|
|
28
|
+
/** Criterion-id: uppercase `C`, exactly 3 digits. */
|
|
29
|
+
export const LIT_LOOP_CRITERION_ID_RE = /^C\d{3}$/;
|
|
30
|
+
/**
|
|
31
|
+
* The single error type the store throws. Consumers branch on the stable SCREAMING_SNAKE
|
|
32
|
+
* `.code` (NOT on `instanceof` subclasses — none exist). `details` is undefined unless supplied.
|
|
33
|
+
*/
|
|
34
|
+
export class LitLoopStateError extends Error {
|
|
35
|
+
constructor(message, code, opts) {
|
|
36
|
+
super(message, opts?.cause === undefined ? undefined : { cause: opts.cause });
|
|
37
|
+
this.name = "LitLoopStateError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
if (opts?.details !== undefined) {
|
|
40
|
+
this.details = opts.details;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ── Clock seam ───────────────────────────────────────────────────────────────
|
|
45
|
+
/** Single clock seam. Tests may inject a fake by spying on this module export. */
|
|
46
|
+
export function iso() {
|
|
47
|
+
return new Date().toISOString();
|
|
48
|
+
}
|
|
49
|
+
// ── Pure validators (shared by the store's validatePlan and M09's builder tests) ──
|
|
50
|
+
export function isUserModel(value) {
|
|
51
|
+
return typeof value === "string" && LIT_LOOP_USER_MODELS.includes(value);
|
|
52
|
+
}
|
|
53
|
+
export function isLedgerEventKind(value) {
|
|
54
|
+
return typeof value === "string" && LIT_LOOP_LEDGER_EVENT_KINDS.includes(value);
|
|
55
|
+
}
|
|
56
|
+
export function isGoalId(value) {
|
|
57
|
+
return typeof value === "string" && LIT_LOOP_GOAL_ID_RE.test(value);
|
|
58
|
+
}
|
|
59
|
+
export function isCriterionId(value) {
|
|
60
|
+
return typeof value === "string" && LIT_LOOP_CRITERION_ID_RE.test(value);
|
|
61
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** The accepted bounded lit-family tokens, longest-first (ordering is load-bearing — see below). */
|
|
2
|
+
export type LitTriggerToken = "lit-loop" | "lit-plan" | "litcodex" | "litgoal" | "litwork" | "lit";
|
|
3
|
+
/**
|
|
4
|
+
* Frozen tuple of the accepted tokens in match-priority (longest-first) order.
|
|
5
|
+
* Exported so tests and the mode router (modes.ts) can enumerate without re-deriving.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LIT_TRIGGER_TOKENS: readonly LitTriggerToken[];
|
|
8
|
+
/**
|
|
9
|
+
* The single canonical matcher. Unicode + case-insensitive, NON-global, NON-sticky.
|
|
10
|
+
* Boundaries: a token must be preceded by start-of-string OR a non `[letter|number|_]`
|
|
11
|
+
* code point, and must NOT be followed by `[letter|number|_|-]`.
|
|
12
|
+
*
|
|
13
|
+
* MUST NOT carry the /g or /y flag: a global regex retains `lastIndex` between calls and would
|
|
14
|
+
* make `.test()` return alternating results for the same input. Longest-first alternation
|
|
15
|
+
* (`lit-loop|lit-plan|litcodex|litgoal|litwork|lit`) guarantees a longer family token wins over a
|
|
16
|
+
* bare `lit` at the same start; the trailing-`-` lookahead means `lit work` (space) is a bare `lit`
|
|
17
|
+
* while `litwork` (glued) is the work mode. No nested quantifiers / no backreferences ⇒ ReDoS-free.
|
|
18
|
+
*/
|
|
19
|
+
export declare const LIT_TRIGGER_PATTERN: RegExp;
|
|
20
|
+
/** Structured result of a successful match. */
|
|
21
|
+
export interface LitTriggerMatch {
|
|
22
|
+
/** The normalized accepted token (always lowercase canonical form). */
|
|
23
|
+
token: LitTriggerToken;
|
|
24
|
+
/** The exact source substring that matched, preserving the user's original casing. */
|
|
25
|
+
raw: string;
|
|
26
|
+
/**
|
|
27
|
+
* UTF-16 code-unit index of the first character of `raw` within the input prompt.
|
|
28
|
+
*
|
|
29
|
+
* INFORMATIONAL / DIAGNOSTIC ONLY. This is a UTF-16 code-unit offset (NOT a Unicode
|
|
30
|
+
* code-point offset). Consumers MUST NOT use it to slice, splice, or index into the
|
|
31
|
+
* prompt: an astral-plane character (e.g. an emoji) before the token makes the UTF-16
|
|
32
|
+
* offset diverge from the code-point offset, so slicing by `index` is unsafe by contract.
|
|
33
|
+
* Use it for logging / ordering ("which match came first") only. If a downstream module
|
|
34
|
+
* ever needs a surrogate-pair-safe offset, it MUST compute the conversion itself; this
|
|
35
|
+
* module will never emit a code-point offset.
|
|
36
|
+
*/
|
|
37
|
+
index: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns true iff `prompt` contains at least one bounded lit trigger.
|
|
41
|
+
*
|
|
42
|
+
* @param prompt Arbitrary untrusted user text (may be empty, multi-line, mixed-script).
|
|
43
|
+
* @throws TypeError if `prompt` is not a string (defensive guard for `unknown` callers).
|
|
44
|
+
*/
|
|
45
|
+
export declare function isLitTriggerPrompt(prompt: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Returns the FIRST bounded lit trigger match in document order, or null if none.
|
|
48
|
+
* Deterministic: identical input always yields the identical result.
|
|
49
|
+
*
|
|
50
|
+
* @param prompt Arbitrary untrusted user text.
|
|
51
|
+
* @returns LitTriggerMatch for the earliest match (lowest `index`), else null.
|
|
52
|
+
* @throws TypeError if `prompt` is not a string.
|
|
53
|
+
*/
|
|
54
|
+
export declare function matchLitTrigger(prompt: string): LitTriggerMatch | null;
|