@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.
Files changed (125) hide show
  1. package/dist/cli/commands/guard.d.ts +43 -0
  2. package/dist/cli/commands/guard.d.ts.map +1 -0
  3. package/dist/cli/commands/guard.js +200 -0
  4. package/dist/cli/commands/guard.js.map +1 -0
  5. package/dist/cli/commands/index.d.ts +1 -0
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +1 -0
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/item.d.ts.map +1 -1
  10. package/dist/cli/commands/item.js +60 -23
  11. package/dist/cli/commands/item.js.map +1 -1
  12. package/dist/cli/commands/plan-import.js +51 -12
  13. package/dist/cli/commands/plan-import.js.map +1 -1
  14. package/dist/cli/commands/ralph.d.ts.map +1 -1
  15. package/dist/cli/commands/ralph.js +144 -329
  16. package/dist/cli/commands/ralph.js.map +1 -1
  17. package/dist/cli/commands/session/checkpoint.d.ts +19 -0
  18. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -0
  19. package/dist/cli/commands/session/checkpoint.js +161 -0
  20. package/dist/cli/commands/session/checkpoint.js.map +1 -0
  21. package/dist/cli/commands/session/commands.d.ts +18 -0
  22. package/dist/cli/commands/session/commands.d.ts.map +1 -0
  23. package/dist/cli/commands/session/commands.js +259 -0
  24. package/dist/cli/commands/session/commands.js.map +1 -0
  25. package/dist/cli/commands/session/context.d.ts +17 -0
  26. package/dist/cli/commands/session/context.d.ts.map +1 -0
  27. package/dist/cli/commands/session/context.js +493 -0
  28. package/dist/cli/commands/session/context.js.map +1 -0
  29. package/dist/cli/commands/session/create.d.ts +29 -0
  30. package/dist/cli/commands/session/create.d.ts.map +1 -0
  31. package/dist/cli/commands/session/create.js +147 -0
  32. package/dist/cli/commands/session/create.js.map +1 -0
  33. package/dist/cli/commands/session/format.d.ts +27 -0
  34. package/dist/cli/commands/session/format.d.ts.map +1 -0
  35. package/dist/cli/commands/session/format.js +401 -0
  36. package/dist/cli/commands/session/format.js.map +1 -0
  37. package/dist/cli/commands/session/index.d.ts +13 -0
  38. package/dist/cli/commands/session/index.d.ts.map +1 -0
  39. package/dist/cli/commands/session/index.js +17 -0
  40. package/dist/cli/commands/session/index.js.map +1 -0
  41. package/dist/cli/commands/session/log.d.ts +52 -0
  42. package/dist/cli/commands/session/log.d.ts.map +1 -0
  43. package/dist/cli/commands/session/log.js +570 -0
  44. package/dist/cli/commands/session/log.js.map +1 -0
  45. package/dist/cli/commands/session/types.d.ts +230 -0
  46. package/dist/cli/commands/session/types.d.ts.map +1 -0
  47. package/dist/cli/commands/session/types.js +7 -0
  48. package/dist/cli/commands/session/types.js.map +1 -0
  49. package/dist/cli/commands/session.d.ts +4 -179
  50. package/dist/cli/commands/session.d.ts.map +1 -1
  51. package/dist/cli/commands/session.js +6 -1424
  52. package/dist/cli/commands/session.js.map +1 -1
  53. package/dist/cli/commands/setup.d.ts.map +1 -1
  54. package/dist/cli/commands/setup.js +69 -223
  55. package/dist/cli/commands/setup.js.map +1 -1
  56. package/dist/cli/commands/task.d.ts.map +1 -1
  57. package/dist/cli/commands/task.js +95 -37
  58. package/dist/cli/commands/task.js.map +1 -1
  59. package/dist/cli/commands/validate.d.ts.map +1 -1
  60. package/dist/cli/commands/validate.js +23 -7
  61. package/dist/cli/commands/validate.js.map +1 -1
  62. package/dist/cli/index.d.ts.map +1 -1
  63. package/dist/cli/index.js +2 -1
  64. package/dist/cli/index.js.map +1 -1
  65. package/dist/cli/output.d.ts.map +1 -1
  66. package/dist/cli/output.js +14 -2
  67. package/dist/cli/output.js.map +1 -1
  68. package/dist/parser/file-lock.d.ts +14 -0
  69. package/dist/parser/file-lock.d.ts.map +1 -0
  70. package/dist/parser/file-lock.js +124 -0
  71. package/dist/parser/file-lock.js.map +1 -0
  72. package/dist/parser/index.d.ts +1 -0
  73. package/dist/parser/index.d.ts.map +1 -1
  74. package/dist/parser/index.js +1 -0
  75. package/dist/parser/index.js.map +1 -1
  76. package/dist/parser/plan-document.d.ts +36 -0
  77. package/dist/parser/plan-document.d.ts.map +1 -1
  78. package/dist/parser/plan-document.js +75 -8
  79. package/dist/parser/plan-document.js.map +1 -1
  80. package/dist/parser/plans.d.ts.map +1 -1
  81. package/dist/parser/plans.js +28 -102
  82. package/dist/parser/plans.js.map +1 -1
  83. package/dist/parser/shadow.d.ts +5 -1
  84. package/dist/parser/shadow.d.ts.map +1 -1
  85. package/dist/parser/shadow.js +29 -17
  86. package/dist/parser/shadow.js.map +1 -1
  87. package/dist/parser/validate.d.ts +4 -1
  88. package/dist/parser/validate.d.ts.map +1 -1
  89. package/dist/parser/validate.js +50 -35
  90. package/dist/parser/validate.js.map +1 -1
  91. package/dist/parser/yaml.d.ts.map +1 -1
  92. package/dist/parser/yaml.js +322 -297
  93. package/dist/parser/yaml.js.map +1 -1
  94. package/dist/schema/task.d.ts +22 -0
  95. package/dist/schema/task.d.ts.map +1 -1
  96. package/dist/schema/task.js +7 -0
  97. package/dist/schema/task.js.map +1 -1
  98. package/dist/sessions/store.d.ts +254 -1
  99. package/dist/sessions/store.d.ts.map +1 -1
  100. package/dist/sessions/store.js +621 -1
  101. package/dist/sessions/store.js.map +1 -1
  102. package/dist/sessions/types.d.ts +51 -2
  103. package/dist/sessions/types.d.ts.map +1 -1
  104. package/dist/sessions/types.js +25 -0
  105. package/dist/sessions/types.js.map +1 -1
  106. package/dist/strings/labels.d.ts +2 -0
  107. package/dist/strings/labels.d.ts.map +1 -1
  108. package/dist/strings/labels.js +2 -0
  109. package/dist/strings/labels.js.map +1 -1
  110. package/dist/utils/git.d.ts +2 -0
  111. package/dist/utils/git.d.ts.map +1 -1
  112. package/dist/utils/git.js +21 -5
  113. package/dist/utils/git.js.map +1 -1
  114. package/package.json +4 -1
  115. package/plugin/.claude-plugin/marketplace.json +1 -1
  116. package/plugin/.claude-plugin/plugin.json +1 -1
  117. package/plugin/plugins/kspec/skills/review/SKILL.md +37 -0
  118. package/plugin/plugins/kspec/skills/task-work/SKILL.md +16 -0
  119. package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +1 -1
  120. package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +14 -0
  121. package/templates/agents-sections/05-commit-convention.md +14 -0
  122. package/templates/skills/review/SKILL.md +37 -0
  123. package/templates/skills/task-work/SKILL.md +16 -0
  124. package/templates/skills/triage-inbox/SKILL.md +1 -1
  125. package/templates/skills/writing-specs/SKILL.md +14 -0
@@ -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