@kynetic-ai/spec 0.4.0 → 0.6.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/dist/cli/commands/guard.d.ts +43 -0
- package/dist/cli/commands/guard.d.ts.map +1 -0
- package/dist/cli/commands/guard.js +200 -0
- package/dist/cli/commands/guard.js.map +1 -0
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/item.d.ts.map +1 -1
- package/dist/cli/commands/item.js +60 -23
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/plan-import.js +51 -12
- package/dist/cli/commands/plan-import.js.map +1 -1
- package/dist/cli/commands/ralph.d.ts.map +1 -1
- package/dist/cli/commands/ralph.js +144 -329
- package/dist/cli/commands/ralph.js.map +1 -1
- package/dist/cli/commands/session/checkpoint.d.ts +19 -0
- package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
- package/dist/cli/commands/session/checkpoint.js +161 -0
- package/dist/cli/commands/session/checkpoint.js.map +1 -0
- package/dist/cli/commands/session/commands.d.ts +18 -0
- package/dist/cli/commands/session/commands.d.ts.map +1 -0
- package/dist/cli/commands/session/commands.js +259 -0
- package/dist/cli/commands/session/commands.js.map +1 -0
- package/dist/cli/commands/session/context.d.ts +17 -0
- package/dist/cli/commands/session/context.d.ts.map +1 -0
- package/dist/cli/commands/session/context.js +493 -0
- package/dist/cli/commands/session/context.js.map +1 -0
- package/dist/cli/commands/session/create.d.ts +29 -0
- package/dist/cli/commands/session/create.d.ts.map +1 -0
- package/dist/cli/commands/session/create.js +147 -0
- package/dist/cli/commands/session/create.js.map +1 -0
- package/dist/cli/commands/session/format.d.ts +27 -0
- package/dist/cli/commands/session/format.d.ts.map +1 -0
- package/dist/cli/commands/session/format.js +401 -0
- package/dist/cli/commands/session/format.js.map +1 -0
- package/dist/cli/commands/session/index.d.ts +13 -0
- package/dist/cli/commands/session/index.d.ts.map +1 -0
- package/dist/cli/commands/session/index.js +17 -0
- package/dist/cli/commands/session/index.js.map +1 -0
- package/dist/cli/commands/session/log.d.ts +52 -0
- package/dist/cli/commands/session/log.d.ts.map +1 -0
- package/dist/cli/commands/session/log.js +570 -0
- package/dist/cli/commands/session/log.js.map +1 -0
- package/dist/cli/commands/session/types.d.ts +230 -0
- package/dist/cli/commands/session/types.d.ts.map +1 -0
- package/dist/cli/commands/session/types.js +7 -0
- package/dist/cli/commands/session/types.js.map +1 -0
- package/dist/cli/commands/session.d.ts +4 -179
- package/dist/cli/commands/session.d.ts.map +1 -1
- package/dist/cli/commands/session.js +6 -1424
- package/dist/cli/commands/session.js.map +1 -1
- package/dist/cli/commands/setup.d.ts.map +1 -1
- package/dist/cli/commands/setup.js +69 -223
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +95 -37
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/validate.d.ts.map +1 -1
- package/dist/cli/commands/validate.js +23 -7
- package/dist/cli/commands/validate.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/output.d.ts.map +1 -1
- package/dist/cli/output.js +14 -2
- package/dist/cli/output.js.map +1 -1
- package/dist/parser/file-lock.d.ts +14 -0
- package/dist/parser/file-lock.d.ts.map +1 -0
- package/dist/parser/file-lock.js +124 -0
- package/dist/parser/file-lock.js.map +1 -0
- package/dist/parser/index.d.ts +1 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/plan-document.d.ts +36 -0
- package/dist/parser/plan-document.d.ts.map +1 -1
- package/dist/parser/plan-document.js +75 -8
- package/dist/parser/plan-document.js.map +1 -1
- package/dist/parser/plans.d.ts.map +1 -1
- package/dist/parser/plans.js +28 -102
- package/dist/parser/plans.js.map +1 -1
- package/dist/parser/shadow.d.ts +5 -1
- package/dist/parser/shadow.d.ts.map +1 -1
- package/dist/parser/shadow.js +29 -17
- package/dist/parser/shadow.js.map +1 -1
- package/dist/parser/validate.d.ts +4 -1
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +50 -35
- package/dist/parser/validate.js.map +1 -1
- package/dist/parser/yaml.d.ts.map +1 -1
- package/dist/parser/yaml.js +322 -297
- package/dist/parser/yaml.js.map +1 -1
- package/dist/schema/task.d.ts +22 -0
- package/dist/schema/task.d.ts.map +1 -1
- package/dist/schema/task.js +7 -0
- package/dist/schema/task.js.map +1 -1
- package/dist/sessions/store.d.ts +254 -1
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +621 -1
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/types.d.ts +51 -2
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +25 -0
- package/dist/sessions/types.js.map +1 -1
- package/dist/strings/labels.d.ts +2 -0
- package/dist/strings/labels.d.ts.map +1 -1
- package/dist/strings/labels.js +2 -0
- package/dist/strings/labels.js.map +1 -1
- package/dist/utils/git.d.ts +2 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +21 -5
- package/dist/utils/git.js.map +1 -1
- package/package.json +4 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
- package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
- package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
- package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
- package/templates/agents-sections/05-commit-convention.md +14 -0
- package/templates/skills/review/SKILL.md +37 -0
- package/templates/skills/task-work/SKILL.md +16 -0
- package/templates/skills/triage-inbox/SKILL.md +1 -1
- package/templates/skills/writing-specs/SKILL.md +14 -0
package/dist/sessions/store.js
CHANGED
|
@@ -15,11 +15,12 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as fsPromises from "node:fs/promises";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import * as YAML from "yaml";
|
|
18
|
-
import { SessionEventSchema, SessionMetadataSchema, } from "./types.js";
|
|
18
|
+
import { SessionEventSchema, SessionMetadataSchema, TaskBudgetSchema, } from "./types.js";
|
|
19
19
|
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
20
20
|
const SESSIONS_DIR = "sessions";
|
|
21
21
|
const METADATA_FILE = "session.yaml";
|
|
22
22
|
const EVENTS_FILE = "events.jsonl";
|
|
23
|
+
const BUDGET_FILE = "budget.json";
|
|
23
24
|
// ─── Path Helpers ────────────────────────────────────────────────────────────
|
|
24
25
|
/**
|
|
25
26
|
* Get the sessions directory path within a spec directory.
|
|
@@ -51,6 +52,13 @@ export function getSessionEventsPath(specDir, sessionId) {
|
|
|
51
52
|
export function getSessionContextPath(specDir, sessionId, iteration) {
|
|
52
53
|
return path.join(getSessionDir(specDir, sessionId), `context-iter-${iteration}.json`);
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Get the path to a session's budget file.
|
|
57
|
+
* AC: @session-creation-and-env-injection ac-budget-local
|
|
58
|
+
*/
|
|
59
|
+
export function getSessionBudgetPath(specDir, sessionId) {
|
|
60
|
+
return path.join(getSessionDir(specDir, sessionId), BUDGET_FILE);
|
|
61
|
+
}
|
|
54
62
|
// ─── Session CRUD ────────────────────────────────────────────────────────────
|
|
55
63
|
/**
|
|
56
64
|
* Create a new session with metadata.
|
|
@@ -165,6 +173,97 @@ export async function sessionExists(specDir, sessionId) {
|
|
|
165
173
|
return false;
|
|
166
174
|
}
|
|
167
175
|
}
|
|
176
|
+
// ─── End-Loop Signal ────────────────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Request end-loop for a session.
|
|
179
|
+
*
|
|
180
|
+
* Writes end_requested=true and optional end_reason to the session metadata.
|
|
181
|
+
* This is the session-scoped replacement for the marker file approach.
|
|
182
|
+
*
|
|
183
|
+
* AC: @session-end-loop-signal ac-signal
|
|
184
|
+
*
|
|
185
|
+
* @param specDir - The .kspec directory path
|
|
186
|
+
* @param sessionId - Session ID
|
|
187
|
+
* @param reason - Optional reason for ending the loop
|
|
188
|
+
* @returns Updated metadata or null if session not found
|
|
189
|
+
*/
|
|
190
|
+
export async function requestEndLoop(specDir, sessionId, reason) {
|
|
191
|
+
const metadata = await getSession(specDir, sessionId);
|
|
192
|
+
if (!metadata) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const updated = {
|
|
196
|
+
...metadata,
|
|
197
|
+
end_requested: true,
|
|
198
|
+
end_reason: reason,
|
|
199
|
+
};
|
|
200
|
+
const metadataPath = getSessionMetadataPath(specDir, sessionId);
|
|
201
|
+
const content = YAML.stringify(updated, {
|
|
202
|
+
indent: 2,
|
|
203
|
+
lineWidth: 100,
|
|
204
|
+
sortMapEntries: false,
|
|
205
|
+
});
|
|
206
|
+
await fsPromises.writeFile(metadataPath, content, "utf-8");
|
|
207
|
+
return updated;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Check if end-loop has been requested for a session.
|
|
211
|
+
*
|
|
212
|
+
* Only returns requested=true for active sessions. If the session is
|
|
213
|
+
* completed or abandoned, the end-loop signal is no longer relevant
|
|
214
|
+
* (prevents stale KSPEC_SESSION_ID from blocking task starts).
|
|
215
|
+
*
|
|
216
|
+
* AC: @session-end-loop-signal ac-detect
|
|
217
|
+
*
|
|
218
|
+
* @param specDir - The .kspec directory path
|
|
219
|
+
* @param sessionId - Session ID
|
|
220
|
+
* @returns Object with requested flag and optional reason, or null if session not found
|
|
221
|
+
*/
|
|
222
|
+
export async function isEndLoopRequested(specDir, sessionId) {
|
|
223
|
+
const metadata = await getSession(specDir, sessionId);
|
|
224
|
+
if (!metadata) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
requested: metadata.end_requested === true && metadata.status === "active",
|
|
229
|
+
reason: metadata.end_reason,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Close a session with a specific status and reason.
|
|
234
|
+
*
|
|
235
|
+
* Used for all session close paths: normal exit, signal, error.
|
|
236
|
+
*
|
|
237
|
+
* AC: @session-end-loop-signal ac-session-close-normal
|
|
238
|
+
* AC: @session-end-loop-signal ac-session-close-signal
|
|
239
|
+
* AC: @session-end-loop-signal ac-session-close-error
|
|
240
|
+
*
|
|
241
|
+
* @param specDir - The .kspec directory path
|
|
242
|
+
* @param sessionId - Session ID
|
|
243
|
+
* @param status - New status (completed or abandoned)
|
|
244
|
+
* @param reason - Reason for closing
|
|
245
|
+
* @returns Updated metadata or null if session not found
|
|
246
|
+
*/
|
|
247
|
+
export async function closeSession(specDir, sessionId, status, reason) {
|
|
248
|
+
const metadata = await getSession(specDir, sessionId);
|
|
249
|
+
if (!metadata) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const updated = {
|
|
253
|
+
...metadata,
|
|
254
|
+
status,
|
|
255
|
+
ended_at: new Date().toISOString(),
|
|
256
|
+
close_reason: reason,
|
|
257
|
+
};
|
|
258
|
+
const metadataPath = getSessionMetadataPath(specDir, sessionId);
|
|
259
|
+
const content = YAML.stringify(updated, {
|
|
260
|
+
indent: 2,
|
|
261
|
+
lineWidth: 100,
|
|
262
|
+
sortMapEntries: false,
|
|
263
|
+
});
|
|
264
|
+
await fsPromises.writeFile(metadataPath, content, "utf-8");
|
|
265
|
+
return updated;
|
|
266
|
+
}
|
|
168
267
|
// ─── Event Storage ───────────────────────────────────────────────────────────
|
|
169
268
|
/**
|
|
170
269
|
* Get the current event count for a session (for seq assignment).
|
|
@@ -1101,4 +1200,525 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
|
|
|
1101
1200
|
}
|
|
1102
1201
|
return results;
|
|
1103
1202
|
}
|
|
1203
|
+
// ─── Session Creation with Budget ─────────────────────────────────────────────
|
|
1204
|
+
/**
|
|
1205
|
+
* Create a session with an optional task budget in one call.
|
|
1206
|
+
*
|
|
1207
|
+
* This is the library-level entry point for session creation. It creates
|
|
1208
|
+
* the session directory, writes session.yaml, and optionally writes budget.json.
|
|
1209
|
+
* Returns metadata without any console output.
|
|
1210
|
+
*
|
|
1211
|
+
* AC: @session-creation-and-env-injection ac-create
|
|
1212
|
+
* AC: @session-creation-and-env-injection ac-budget
|
|
1213
|
+
* AC: @session-creation-and-env-injection ac-budget-local
|
|
1214
|
+
* AC: @session-creation-and-env-injection ac-library
|
|
1215
|
+
*
|
|
1216
|
+
* @param specDir - The .kspec directory path
|
|
1217
|
+
* @param input - Session creation parameters
|
|
1218
|
+
* @returns Session metadata and optional budget (no console output)
|
|
1219
|
+
*/
|
|
1220
|
+
export async function createSessionWithBudget(specDir, input) {
|
|
1221
|
+
// Create session
|
|
1222
|
+
const session = await createSession(specDir, {
|
|
1223
|
+
id: input.id,
|
|
1224
|
+
agent_type: input.agent_type,
|
|
1225
|
+
task_id: input.task_id,
|
|
1226
|
+
});
|
|
1227
|
+
// Optionally create budget
|
|
1228
|
+
let budget = null;
|
|
1229
|
+
if (input.budget !== undefined && input.budget > 0) {
|
|
1230
|
+
budget = await createBudget(specDir, input.id, input.budget);
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
session_id: input.id,
|
|
1234
|
+
session,
|
|
1235
|
+
budget,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Write or update KSPEC_SESSION_ID in a dotenv-style file.
|
|
1240
|
+
* Replaces an existing KSPEC_SESSION_ID line or appends a new one.
|
|
1241
|
+
*/
|
|
1242
|
+
async function upsertDotenvSessionId(filePath, sessionId) {
|
|
1243
|
+
let content = "";
|
|
1244
|
+
try {
|
|
1245
|
+
content = await fsPromises.readFile(filePath, "utf-8");
|
|
1246
|
+
}
|
|
1247
|
+
catch (err) {
|
|
1248
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1249
|
+
// File doesn't exist yet, start fresh
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
throw err;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
const lines = content.split("\n");
|
|
1256
|
+
const existingIdx = lines.findIndex((l) => l.startsWith("KSPEC_SESSION_ID="));
|
|
1257
|
+
if (existingIdx >= 0) {
|
|
1258
|
+
lines[existingIdx] = `KSPEC_SESSION_ID=${sessionId}`;
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
// Append before final empty line if present
|
|
1262
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
1263
|
+
lines.splice(lines.length - 1, 0, `KSPEC_SESSION_ID=${sessionId}`);
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
lines.push(`KSPEC_SESSION_ID=${sessionId}`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
await fsPromises.writeFile(filePath, lines.join("\n"), "utf-8");
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Inject KSPEC_SESSION_ID into Claude Code environment.
|
|
1273
|
+
*
|
|
1274
|
+
* Strategy:
|
|
1275
|
+
* 1. If CLAUDE_ENV_FILE is set, write to that file
|
|
1276
|
+
* 2. Otherwise, append to project .claude/settings.local.json env section
|
|
1277
|
+
*
|
|
1278
|
+
* AC: @session-creation-and-env-injection ac-inject-claude
|
|
1279
|
+
*/
|
|
1280
|
+
export async function injectClaudeCodeEnv(sessionId) {
|
|
1281
|
+
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
1282
|
+
if (envFile) {
|
|
1283
|
+
const previousValue = await readDotenvSessionId(envFile);
|
|
1284
|
+
await upsertDotenvSessionId(envFile, sessionId);
|
|
1285
|
+
return {
|
|
1286
|
+
injected: true,
|
|
1287
|
+
method: "claude_env_file",
|
|
1288
|
+
description: `Wrote KSPEC_SESSION_ID=${sessionId} to CLAUDE_ENV_FILE`,
|
|
1289
|
+
path: envFile,
|
|
1290
|
+
previousValue,
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
// Fallback: write to project .claude/settings.local.json (gitignored, user-local)
|
|
1294
|
+
// Using settings.local.json avoids dirtying the working tree — settings.json
|
|
1295
|
+
// is checked into the repo. Claude Code merges both, with local taking precedence.
|
|
1296
|
+
const settingsDir = path.join(process.cwd(), ".claude");
|
|
1297
|
+
const settingsPath = path.join(settingsDir, "settings.local.json");
|
|
1298
|
+
await fsPromises.mkdir(settingsDir, { recursive: true });
|
|
1299
|
+
let settings = {};
|
|
1300
|
+
try {
|
|
1301
|
+
const content = await fsPromises.readFile(settingsPath, "utf-8");
|
|
1302
|
+
settings = JSON.parse(content);
|
|
1303
|
+
}
|
|
1304
|
+
catch (err) {
|
|
1305
|
+
// Only start fresh for ENOENT; throw on parse errors to avoid overwriting
|
|
1306
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1307
|
+
// File doesn't exist, start fresh
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
throw new Error(`Cannot inject env: .claude/settings.local.json exists but is not valid JSON. ` +
|
|
1311
|
+
`Fix the file manually or remove it, then retry.`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
// Capture previous value before overwriting
|
|
1315
|
+
const previousValue = settings.env && typeof settings.env === "object"
|
|
1316
|
+
? (settings.env.KSPEC_SESSION_ID ?? null)
|
|
1317
|
+
: null;
|
|
1318
|
+
// Ensure env section exists
|
|
1319
|
+
if (!settings.env || typeof settings.env !== "object") {
|
|
1320
|
+
settings.env = {};
|
|
1321
|
+
}
|
|
1322
|
+
settings.env.KSPEC_SESSION_ID = sessionId;
|
|
1323
|
+
await fsPromises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1324
|
+
return {
|
|
1325
|
+
injected: true,
|
|
1326
|
+
method: "claude_settings",
|
|
1327
|
+
description: `Added KSPEC_SESSION_ID to .claude/settings.local.json env section`,
|
|
1328
|
+
path: settingsPath,
|
|
1329
|
+
previousValue,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Remove or restore KSPEC_SESSION_ID in Claude Code environment.
|
|
1334
|
+
*
|
|
1335
|
+
* Reverses the injection performed by injectClaudeCodeEnv().
|
|
1336
|
+
* If previousValue is provided, restores it instead of deleting.
|
|
1337
|
+
* Best-effort: silently ignores missing files or missing keys.
|
|
1338
|
+
*
|
|
1339
|
+
* @param previousValue - Value to restore, or null/undefined to delete
|
|
1340
|
+
*/
|
|
1341
|
+
export async function removeClaudeCodeEnv(previousValue) {
|
|
1342
|
+
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
1343
|
+
if (envFile) {
|
|
1344
|
+
if (previousValue) {
|
|
1345
|
+
await upsertDotenvSessionId(envFile, previousValue);
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
await removeDotenvSessionId(envFile);
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
// Remove/restore in project .claude/settings.local.json
|
|
1353
|
+
const settingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
|
|
1354
|
+
try {
|
|
1355
|
+
const content = await fsPromises.readFile(settingsPath, "utf-8");
|
|
1356
|
+
const settings = JSON.parse(content);
|
|
1357
|
+
if (settings.env && typeof settings.env === "object") {
|
|
1358
|
+
if (previousValue) {
|
|
1359
|
+
settings.env.KSPEC_SESSION_ID = previousValue;
|
|
1360
|
+
}
|
|
1361
|
+
else {
|
|
1362
|
+
delete settings.env.KSPEC_SESSION_ID;
|
|
1363
|
+
// Remove env section entirely if empty
|
|
1364
|
+
if (Object.keys(settings.env).length === 0) {
|
|
1365
|
+
delete settings.env;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
await fsPromises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
catch {
|
|
1372
|
+
// Best-effort cleanup — file may not exist or may not be valid JSON
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Read existing KSPEC_SESSION_ID from a dotenv-style file.
|
|
1377
|
+
* Returns the value or null if not found.
|
|
1378
|
+
*/
|
|
1379
|
+
async function readDotenvSessionId(filePath) {
|
|
1380
|
+
try {
|
|
1381
|
+
const content = await fsPromises.readFile(filePath, "utf-8");
|
|
1382
|
+
const match = content.match(/^KSPEC_SESSION_ID=(.+)$/m);
|
|
1383
|
+
return match ? match[1] : null;
|
|
1384
|
+
}
|
|
1385
|
+
catch {
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Remove KSPEC_SESSION_ID line from a dotenv-style file.
|
|
1391
|
+
*/
|
|
1392
|
+
async function removeDotenvSessionId(filePath) {
|
|
1393
|
+
try {
|
|
1394
|
+
const content = await fsPromises.readFile(filePath, "utf-8");
|
|
1395
|
+
const lines = content.split("\n");
|
|
1396
|
+
const filtered = lines.filter((l) => !l.startsWith("KSPEC_SESSION_ID="));
|
|
1397
|
+
if (filtered.length !== lines.length) {
|
|
1398
|
+
await fsPromises.writeFile(filePath, filtered.join("\n"), "utf-8");
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
// Best-effort cleanup
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* Inject KSPEC_SESSION_ID into Codex CLI environment.
|
|
1407
|
+
*
|
|
1408
|
+
* Adds to shell_environment_policy.set in codex config.
|
|
1409
|
+
*
|
|
1410
|
+
* AC: @session-creation-and-env-injection ac-inject-codex
|
|
1411
|
+
*/
|
|
1412
|
+
export async function injectCodexEnv(sessionId) {
|
|
1413
|
+
const configDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".codex");
|
|
1414
|
+
const configPath = path.join(configDir, "config.json");
|
|
1415
|
+
await fsPromises.mkdir(configDir, { recursive: true });
|
|
1416
|
+
let config = {};
|
|
1417
|
+
try {
|
|
1418
|
+
const content = await fsPromises.readFile(configPath, "utf-8");
|
|
1419
|
+
config = JSON.parse(content);
|
|
1420
|
+
}
|
|
1421
|
+
catch (err) {
|
|
1422
|
+
// Only start fresh for ENOENT; throw on parse errors to avoid overwriting
|
|
1423
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1424
|
+
// File doesn't exist, start fresh
|
|
1425
|
+
}
|
|
1426
|
+
else {
|
|
1427
|
+
throw new Error(`Cannot inject env: ~/.codex/config.json exists but is not valid JSON. ` +
|
|
1428
|
+
`Fix the file manually or remove it, then retry.`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
// Ensure shell_environment_policy.set exists
|
|
1432
|
+
if (!config.shell_environment_policy ||
|
|
1433
|
+
typeof config.shell_environment_policy !== "object") {
|
|
1434
|
+
config.shell_environment_policy = {};
|
|
1435
|
+
}
|
|
1436
|
+
const policy = config.shell_environment_policy;
|
|
1437
|
+
if (!policy.set || typeof policy.set !== "object") {
|
|
1438
|
+
policy.set = {};
|
|
1439
|
+
}
|
|
1440
|
+
policy.set.KSPEC_SESSION_ID = sessionId;
|
|
1441
|
+
await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1442
|
+
return {
|
|
1443
|
+
injected: true,
|
|
1444
|
+
method: "codex_config",
|
|
1445
|
+
description: `Added KSPEC_SESSION_ID to Codex config shell_environment_policy.set`,
|
|
1446
|
+
path: configPath,
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Inject KSPEC_SESSION_ID into Gemini CLI environment.
|
|
1451
|
+
*
|
|
1452
|
+
* Writes to .gemini/.env in project root (auto-loaded by Gemini CLI).
|
|
1453
|
+
*/
|
|
1454
|
+
export async function injectGeminiEnv(sessionId) {
|
|
1455
|
+
const dotenvDir = path.join(process.cwd(), ".gemini");
|
|
1456
|
+
const dotenvPath = path.join(dotenvDir, ".env");
|
|
1457
|
+
await fsPromises.mkdir(dotenvDir, { recursive: true });
|
|
1458
|
+
await upsertDotenvSessionId(dotenvPath, sessionId);
|
|
1459
|
+
return {
|
|
1460
|
+
injected: true,
|
|
1461
|
+
method: "gemini_dotenv",
|
|
1462
|
+
description: `Wrote KSPEC_SESSION_ID=${sessionId} to .gemini/.env`,
|
|
1463
|
+
path: dotenvPath,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Inject KSPEC_SESSION_ID into OpenCode environment.
|
|
1468
|
+
*
|
|
1469
|
+
* Writes to project root .env file (auto-loaded by OpenCode via Bun runtime).
|
|
1470
|
+
* Uses the same dotenv append/replace pattern as other injectors.
|
|
1471
|
+
*/
|
|
1472
|
+
export async function injectOpenCodeEnv(sessionId) {
|
|
1473
|
+
const dotenvPath = path.join(process.cwd(), ".env");
|
|
1474
|
+
await upsertDotenvSessionId(dotenvPath, sessionId);
|
|
1475
|
+
return {
|
|
1476
|
+
injected: true,
|
|
1477
|
+
method: "opencode_dotenv",
|
|
1478
|
+
description: `Wrote KSPEC_SESSION_ID=${sessionId} to .env`,
|
|
1479
|
+
path: dotenvPath,
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Get fallback injection instructions for unknown agent harnesses.
|
|
1484
|
+
*
|
|
1485
|
+
* AC: @session-creation-and-env-injection ac-inject-fallback
|
|
1486
|
+
*/
|
|
1487
|
+
export function getFallbackInjectionInstructions(sessionId) {
|
|
1488
|
+
return {
|
|
1489
|
+
injected: false,
|
|
1490
|
+
method: "fallback",
|
|
1491
|
+
description: `export KSPEC_SESSION_ID=${sessionId}`,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
// ─── Adapter-based Env Injection ──────────────────────────────────────────────
|
|
1495
|
+
/**
|
|
1496
|
+
* Inject KSPEC_SESSION_ID via the appropriate mechanism for the given adapter.
|
|
1497
|
+
*
|
|
1498
|
+
* Ralph passes env vars to spawned agents via process environment, but some
|
|
1499
|
+
* harnesses (Claude Code, Codex, etc.) sandbox child processes and don't
|
|
1500
|
+
* forward arbitrary parent env vars. This function writes the session ID to
|
|
1501
|
+
* the harness-specific config location so it reaches kspec subprocesses.
|
|
1502
|
+
*
|
|
1503
|
+
* @param adapterId - The adapter identifier (e.g., "claude-agent-acp")
|
|
1504
|
+
* @param sessionId - The session ID to inject
|
|
1505
|
+
* @returns Injection result, or null if no harness-specific injection is needed
|
|
1506
|
+
*/
|
|
1507
|
+
export async function injectEnvForAdapter(adapterId, sessionId) {
|
|
1508
|
+
switch (adapterId) {
|
|
1509
|
+
case "claude-agent-acp":
|
|
1510
|
+
case "claude-code-acp":
|
|
1511
|
+
return injectClaudeCodeEnv(sessionId);
|
|
1512
|
+
// Future harnesses can be added here:
|
|
1513
|
+
// case "codex-acp":
|
|
1514
|
+
// return injectCodexEnv(sessionId);
|
|
1515
|
+
// case "gemini-acp":
|
|
1516
|
+
// return injectGeminiEnv(sessionId);
|
|
1517
|
+
default:
|
|
1518
|
+
return null; // Unknown adapter — rely on process env inheritance
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Remove KSPEC_SESSION_ID from the harness config for the given adapter.
|
|
1523
|
+
*
|
|
1524
|
+
* Reverses the injection performed by injectEnvForAdapter().
|
|
1525
|
+
* If previousValue is provided, restores it instead of deleting.
|
|
1526
|
+
* Best-effort: silently ignores errors.
|
|
1527
|
+
*
|
|
1528
|
+
* @param adapterId - The adapter identifier
|
|
1529
|
+
* @param previousValue - Value to restore, or null/undefined to delete
|
|
1530
|
+
*/
|
|
1531
|
+
export async function removeEnvForAdapter(adapterId, previousValue) {
|
|
1532
|
+
switch (adapterId) {
|
|
1533
|
+
case "claude-agent-acp":
|
|
1534
|
+
case "claude-code-acp":
|
|
1535
|
+
await removeClaudeCodeEnv(previousValue);
|
|
1536
|
+
break;
|
|
1537
|
+
// Future harnesses can be added here
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
// ─── Session Validation ───────────────────────────────────────────────────────
|
|
1541
|
+
/**
|
|
1542
|
+
* Validate that the current KSPEC_SESSION_ID points to a valid session.
|
|
1543
|
+
*
|
|
1544
|
+
* AC: @session-creation-and-env-injection ac-invalid-session
|
|
1545
|
+
*
|
|
1546
|
+
* @param specDir - The .kspec directory path
|
|
1547
|
+
* @param sessionId - The session ID to validate
|
|
1548
|
+
* @returns Validation result with error details if invalid
|
|
1549
|
+
*/
|
|
1550
|
+
export async function validateSessionId(specDir, sessionId) {
|
|
1551
|
+
// Check if session directory exists
|
|
1552
|
+
const exists = await sessionExists(specDir, sessionId);
|
|
1553
|
+
if (!exists) {
|
|
1554
|
+
return {
|
|
1555
|
+
valid: false,
|
|
1556
|
+
error: `Session not found: ${sessionId}`,
|
|
1557
|
+
suggestion: `Unset KSPEC_SESSION_ID or create a new session with: kspec session create --agent-type <type>`,
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
// Try to read and validate session metadata
|
|
1561
|
+
const session = await getSession(specDir, sessionId);
|
|
1562
|
+
if (!session) {
|
|
1563
|
+
return {
|
|
1564
|
+
valid: false,
|
|
1565
|
+
error: `Session metadata is corrupt or unreadable: ${sessionId}`,
|
|
1566
|
+
suggestion: `Unset KSPEC_SESSION_ID or create a new session with: kspec session create --agent-type <type>`,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
return { valid: true, session };
|
|
1570
|
+
}
|
|
1571
|
+
// ─── Task Budget ──────────────────────────────────────────────────────────────
|
|
1572
|
+
/**
|
|
1573
|
+
* Atomic JSON write — write to temp file then rename in same directory.
|
|
1574
|
+
* Prevents corruption on crash.
|
|
1575
|
+
* AC: @task-budget-enforcement ac-atomic-write
|
|
1576
|
+
*/
|
|
1577
|
+
async function writeBudgetAtomic(filePath, budget) {
|
|
1578
|
+
const dir = path.dirname(filePath);
|
|
1579
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
1580
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
1581
|
+
const content = JSON.stringify(budget, null, 2) + "\n";
|
|
1582
|
+
await fsPromises.writeFile(tmpPath, content, "utf-8");
|
|
1583
|
+
await fsPromises.rename(tmpPath, filePath);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Create a budget for a session.
|
|
1587
|
+
*
|
|
1588
|
+
* Writes budget.json to .kspec/sessions/{id}/ on the local filesystem
|
|
1589
|
+
* (NOT committed to shadow branch).
|
|
1590
|
+
*
|
|
1591
|
+
* AC: @session-creation-and-env-injection ac-budget
|
|
1592
|
+
* AC: @session-creation-and-env-injection ac-budget-local
|
|
1593
|
+
*
|
|
1594
|
+
* @param specDir - The .kspec directory path
|
|
1595
|
+
* @param sessionId - Session ID
|
|
1596
|
+
* @param maxPerCycle - Maximum tasks allowed per cycle
|
|
1597
|
+
* @returns The created budget
|
|
1598
|
+
*/
|
|
1599
|
+
export async function createBudget(specDir, sessionId, maxPerCycle) {
|
|
1600
|
+
const budget = {
|
|
1601
|
+
max_per_cycle: maxPerCycle,
|
|
1602
|
+
started_this_cycle: 0,
|
|
1603
|
+
};
|
|
1604
|
+
const validated = TaskBudgetSchema.parse(budget);
|
|
1605
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1606
|
+
await writeBudgetAtomic(budgetPath, validated);
|
|
1607
|
+
return validated;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Read budget for a session.
|
|
1611
|
+
*
|
|
1612
|
+
* AC: @task-budget-enforcement ac-no-budget
|
|
1613
|
+
*
|
|
1614
|
+
* @param specDir - The .kspec directory path
|
|
1615
|
+
* @param sessionId - Session ID
|
|
1616
|
+
* @returns Budget or null if no budget configured (opt-in)
|
|
1617
|
+
*/
|
|
1618
|
+
export async function getBudget(specDir, sessionId) {
|
|
1619
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1620
|
+
let content;
|
|
1621
|
+
try {
|
|
1622
|
+
content = await fsPromises.readFile(budgetPath, "utf-8");
|
|
1623
|
+
}
|
|
1624
|
+
catch (err) {
|
|
1625
|
+
// File doesn't exist = no budget configured (opt-in)
|
|
1626
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
throw err;
|
|
1630
|
+
}
|
|
1631
|
+
// File exists — parse errors are real failures, not "no budget"
|
|
1632
|
+
const raw = JSON.parse(content);
|
|
1633
|
+
return TaskBudgetSchema.parse(raw);
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Check whether the budget allows starting a new task.
|
|
1637
|
+
*
|
|
1638
|
+
* Returns an object with `allowed` boolean and context about the budget.
|
|
1639
|
+
* When no budget is configured, always allows (opt-in behavior).
|
|
1640
|
+
*
|
|
1641
|
+
* AC: @task-budget-enforcement ac-block-start
|
|
1642
|
+
* AC: @task-budget-enforcement ac-no-budget
|
|
1643
|
+
* AC: @task-budget-enforcement ac-no-session
|
|
1644
|
+
*
|
|
1645
|
+
* @param specDir - The .kspec directory path
|
|
1646
|
+
* @param sessionId - Session ID, or undefined if KSPEC_SESSION_ID not set
|
|
1647
|
+
* @returns Budget check result
|
|
1648
|
+
*/
|
|
1649
|
+
export async function checkBudget(specDir, sessionId) {
|
|
1650
|
+
// AC: @task-budget-enforcement ac-no-session — no session means no check
|
|
1651
|
+
if (!sessionId) {
|
|
1652
|
+
return { allowed: true };
|
|
1653
|
+
}
|
|
1654
|
+
const budget = await getBudget(specDir, sessionId);
|
|
1655
|
+
// AC: @task-budget-enforcement ac-no-budget — no budget means no check
|
|
1656
|
+
if (!budget) {
|
|
1657
|
+
return { allowed: true };
|
|
1658
|
+
}
|
|
1659
|
+
if (budget.started_this_cycle >= budget.max_per_cycle) {
|
|
1660
|
+
return {
|
|
1661
|
+
allowed: false,
|
|
1662
|
+
reason: `Task budget exhausted: ${budget.started_this_cycle}/${budget.max_per_cycle} tasks started this cycle. Wrap up current work and let the iteration end naturally without starting new tasks.`,
|
|
1663
|
+
budget,
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
return { allowed: true, budget };
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Increment the budget counter after a task is successfully started.
|
|
1670
|
+
*
|
|
1671
|
+
* IMPORTANT: Callers must NOT call this for resume cases (task already
|
|
1672
|
+
* in_progress). The budget should only be incremented when a new task
|
|
1673
|
+
* transitions to in_progress, not when resuming an existing one.
|
|
1674
|
+
* See AC: @task-budget-enforcement ac-resume-no-increment
|
|
1675
|
+
*
|
|
1676
|
+
* AC: @task-budget-enforcement ac-increment
|
|
1677
|
+
* AC: @task-budget-enforcement ac-atomic-write
|
|
1678
|
+
*
|
|
1679
|
+
* @param specDir - The .kspec directory path
|
|
1680
|
+
* @param sessionId - Session ID
|
|
1681
|
+
* @returns Updated budget, or null if no budget configured
|
|
1682
|
+
*/
|
|
1683
|
+
export async function incrementBudget(specDir, sessionId) {
|
|
1684
|
+
const budget = await getBudget(specDir, sessionId);
|
|
1685
|
+
if (!budget) {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
const updated = {
|
|
1689
|
+
...budget,
|
|
1690
|
+
started_this_cycle: budget.started_this_cycle + 1,
|
|
1691
|
+
};
|
|
1692
|
+
const validated = TaskBudgetSchema.parse(updated);
|
|
1693
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1694
|
+
await writeBudgetAtomic(budgetPath, validated);
|
|
1695
|
+
return validated;
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Reset the budget counter to 0 for a new cycle/iteration.
|
|
1699
|
+
*
|
|
1700
|
+
* Called by ralph at iteration boundaries. Single-writer guarantee:
|
|
1701
|
+
* ralph only resets between iterations when the agent is not running.
|
|
1702
|
+
*
|
|
1703
|
+
* AC: @task-budget-enforcement ac-reset
|
|
1704
|
+
* AC: @task-budget-enforcement ac-atomic-write
|
|
1705
|
+
*
|
|
1706
|
+
* @param specDir - The .kspec directory path
|
|
1707
|
+
* @param sessionId - Session ID
|
|
1708
|
+
* @returns Updated budget, or null if no budget configured
|
|
1709
|
+
*/
|
|
1710
|
+
export async function resetBudget(specDir, sessionId) {
|
|
1711
|
+
const budget = await getBudget(specDir, sessionId);
|
|
1712
|
+
if (!budget) {
|
|
1713
|
+
return null;
|
|
1714
|
+
}
|
|
1715
|
+
const updated = {
|
|
1716
|
+
...budget,
|
|
1717
|
+
started_this_cycle: 0,
|
|
1718
|
+
};
|
|
1719
|
+
const validated = TaskBudgetSchema.parse(updated);
|
|
1720
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1721
|
+
await writeBudgetAtomic(budgetPath, validated);
|
|
1722
|
+
return validated;
|
|
1723
|
+
}
|
|
1104
1724
|
//# sourceMappingURL=store.js.map
|