@kynetic-ai/spec 0.3.0 → 0.5.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/batch-exec.d.ts +0 -9
- package/dist/cli/batch-exec.d.ts.map +1 -1
- package/dist/cli/batch-exec.js +16 -4
- package/dist/cli/batch-exec.js.map +1 -1
- package/dist/cli/commands/derive.d.ts.map +1 -1
- package/dist/cli/commands/derive.js +2 -1
- package/dist/cli/commands/derive.js.map +1 -1
- 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 +18 -0
- package/dist/cli/commands/item.js.map +1 -1
- package/dist/cli/commands/log.d.ts.map +1 -1
- package/dist/cli/commands/log.js +5 -4
- package/dist/cli/commands/log.js.map +1 -1
- package/dist/cli/commands/meta.d.ts.map +1 -1
- package/dist/cli/commands/meta.js +2 -1
- package/dist/cli/commands/meta.js.map +1 -1
- package/dist/cli/commands/plan-import.d.ts.map +1 -1
- package/dist/cli/commands/plan-import.js +100 -30
- 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 +143 -330
- package/dist/cli/commands/ralph.js.map +1 -1
- package/dist/cli/commands/session.d.ts +73 -1
- package/dist/cli/commands/session.d.ts.map +1 -1
- package/dist/cli/commands/session.js +607 -162
- 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 +97 -217
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/skill-install.d.ts +4 -1
- package/dist/cli/commands/skill-install.d.ts.map +1 -1
- package/dist/cli/commands/skill-install.js +62 -5
- package/dist/cli/commands/skill-install.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +128 -59
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/tasks.d.ts.map +1 -1
- package/dist/cli/commands/tasks.js +2 -4
- package/dist/cli/commands/tasks.js.map +1 -1
- package/dist/cli/commands/triage.d.ts.map +1 -1
- package/dist/cli/commands/triage.js +12 -98
- package/dist/cli/commands/triage.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 +18 -4
- package/dist/cli/output.js.map +1 -1
- package/dist/daemon/routes/triage.ts +4 -70
- package/dist/parser/config.d.ts +106 -0
- package/dist/parser/config.d.ts.map +1 -1
- package/dist/parser/config.js +47 -0
- package/dist/parser/config.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 +44 -0
- package/dist/parser/plan-document.d.ts.map +1 -1
- package/dist/parser/plan-document.js +76 -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.map +1 -1
- package/dist/parser/shadow.js +11 -7
- package/dist/parser/shadow.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/ralph/events.d.ts.map +1 -1
- package/dist/ralph/events.js +24 -0
- package/dist/ralph/events.js.map +1 -1
- package/dist/ralph/index.d.ts +1 -1
- package/dist/ralph/index.d.ts.map +1 -1
- package/dist/ralph/index.js +1 -1
- package/dist/ralph/index.js.map +1 -1
- package/dist/ralph/subagent.d.ts +12 -1
- package/dist/ralph/subagent.d.ts.map +1 -1
- package/dist/ralph/subagent.js +22 -3
- package/dist/ralph/subagent.js.map +1 -1
- package/dist/schema/batch.d.ts +2 -0
- package/dist/schema/batch.d.ts.map +1 -1
- package/dist/schema/common.d.ts +6 -0
- package/dist/schema/common.d.ts.map +1 -1
- package/dist/schema/common.js +8 -0
- package/dist/schema/common.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 +226 -1
- package/dist/sessions/store.d.ts.map +1 -1
- package/dist/sessions/store.js +712 -38
- 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/errors.d.ts +4 -0
- package/dist/strings/errors.d.ts.map +1 -1
- package/dist/strings/errors.js +2 -0
- package/dist/strings/errors.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/triage/actions.d.ts +27 -0
- package/dist/triage/actions.d.ts.map +1 -0
- package/dist/triage/actions.js +95 -0
- package/dist/triage/actions.js.map +1 -0
- package/dist/triage/constants.d.ts +6 -0
- package/dist/triage/constants.d.ts.map +1 -0
- package/dist/triage/constants.js +7 -0
- package/dist/triage/constants.js.map +1 -0
- package/dist/triage/index.d.ts +3 -0
- package/dist/triage/index.d.ts.map +1 -0
- package/dist/triage/index.js +3 -0
- package/dist/triage/index.js.map +1 -0
- 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 +1 -1
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/kspec/skills/create-workflow/SKILL.md +235 -0
- package/plugin/plugins/kspec/skills/observations/SKILL.md +143 -0
- package/plugin/plugins/kspec/skills/plan/SKILL.md +343 -0
- package/plugin/plugins/kspec/skills/reflect/SKILL.md +161 -0
- package/plugin/plugins/kspec/skills/review/SKILL.md +230 -0
- package/plugin/plugins/kspec/skills/task-work/SKILL.md +319 -0
- package/plugin/plugins/kspec/skills/triage-automation/SKILL.md +140 -0
- package/plugin/plugins/kspec/skills/triage-inbox/SKILL.md +232 -0
- package/plugin/plugins/kspec/skills/writing-specs/SKILL.md +354 -0
- package/templates/agents-sections/03-task-lifecycle.md +2 -2
- package/templates/agents-sections/04-pr-workflow.md +3 -3
- package/templates/agents-sections/05-commit-convention.md +14 -0
- package/templates/skills/create-workflow/SKILL.md +228 -0
- package/templates/skills/manifest.yaml +45 -0
- package/templates/skills/observations/SKILL.md +137 -0
- package/templates/skills/plan/SKILL.md +336 -0
- package/templates/skills/reflect/SKILL.md +155 -0
- package/templates/skills/review/SKILL.md +223 -0
- package/templates/skills/task-work/SKILL.md +312 -0
- package/templates/skills/triage-automation/SKILL.md +134 -0
- package/templates/skills/triage-inbox/SKILL.md +225 -0
- package/templates/skills/writing-specs/SKILL.md +347 -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).
|
|
@@ -264,6 +363,52 @@ export async function readEvents(specDir, sessionId) {
|
|
|
264
363
|
return [];
|
|
265
364
|
}
|
|
266
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Deduplicate phased tool_call events.
|
|
368
|
+
*
|
|
369
|
+
* ACP SDK 0.14+ sends tool calls in two phases: first with empty rawInput,
|
|
370
|
+
* then with populated rawInput. This merges them by keeping only the version
|
|
371
|
+
* with populated rawInput per toolCallId.
|
|
372
|
+
*/
|
|
373
|
+
export function deduplicatePhasedToolCalls(events) {
|
|
374
|
+
// First pass: find toolCallIds that have a populated rawInput version
|
|
375
|
+
const populatedToolCalls = new Map(); // toolCallId → index
|
|
376
|
+
for (let i = 0; i < events.length; i++) {
|
|
377
|
+
const event = events[i];
|
|
378
|
+
if (event.type !== "session.update")
|
|
379
|
+
continue;
|
|
380
|
+
const data = event.data;
|
|
381
|
+
const update = data?.update;
|
|
382
|
+
if (update?.sessionUpdate !== "tool_call")
|
|
383
|
+
continue;
|
|
384
|
+
const toolCallId = update.toolCallId || update.tool_call_id || update.id;
|
|
385
|
+
if (!toolCallId)
|
|
386
|
+
continue;
|
|
387
|
+
const rawInput = update.rawInput;
|
|
388
|
+
const hasContent = rawInput && Object.keys(rawInput).length > 0;
|
|
389
|
+
if (hasContent) {
|
|
390
|
+
populatedToolCalls.set(toolCallId, i);
|
|
391
|
+
}
|
|
392
|
+
else if (!populatedToolCalls.has(toolCallId)) {
|
|
393
|
+
// First (empty) version - track it in case no populated version exists
|
|
394
|
+
populatedToolCalls.set(toolCallId, i);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Second pass: keep only the best version per toolCallId
|
|
398
|
+
return events.filter((event, i) => {
|
|
399
|
+
if (event.type !== "session.update")
|
|
400
|
+
return true;
|
|
401
|
+
const data = event.data;
|
|
402
|
+
const update = data?.update;
|
|
403
|
+
if (update?.sessionUpdate !== "tool_call")
|
|
404
|
+
return true;
|
|
405
|
+
const toolCallId = update.toolCallId || update.tool_call_id || update.id;
|
|
406
|
+
if (!toolCallId)
|
|
407
|
+
return true;
|
|
408
|
+
// Keep this event only if it's the best version (populated or only version)
|
|
409
|
+
return populatedToolCalls.get(toolCallId) === i;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
267
412
|
/**
|
|
268
413
|
* Read events within a time range.
|
|
269
414
|
*
|
|
@@ -381,7 +526,7 @@ export async function getSessionLogSummary(specDir, sessionId) {
|
|
|
381
526
|
return null;
|
|
382
527
|
const [eventCount, iterationCount, tasksCompleted] = await Promise.all([
|
|
383
528
|
countEventLines(specDir, sessionId),
|
|
384
|
-
|
|
529
|
+
countIterationsBoundaryAware(specDir, sessionId),
|
|
385
530
|
countTaskCompletions(specDir, sessionId),
|
|
386
531
|
]);
|
|
387
532
|
const startMs = new Date(metadata.started_at).getTime();
|
|
@@ -506,17 +651,73 @@ function extractTaskRef(command) {
|
|
|
506
651
|
return match ? match[0] : null;
|
|
507
652
|
}
|
|
508
653
|
/**
|
|
509
|
-
*
|
|
654
|
+
* Find iteration boundaries from prompt.sent events with phase "task-work".
|
|
655
|
+
*
|
|
656
|
+
* Ralph emits these synchronously at the start of each iteration, so their
|
|
657
|
+
* array positions are reliable even when concurrent fire-and-forget events
|
|
658
|
+
* produce duplicate seq numbers.
|
|
659
|
+
*
|
|
660
|
+
* Returns validated, monotonically increasing boundaries.
|
|
661
|
+
*/
|
|
662
|
+
function findIterationBoundaries(events) {
|
|
663
|
+
const raw = [];
|
|
664
|
+
for (let i = 0; i < events.length; i++) {
|
|
665
|
+
const event = events[i];
|
|
666
|
+
if (event.type !== "prompt.sent")
|
|
667
|
+
continue;
|
|
668
|
+
const data = event.data;
|
|
669
|
+
if (data?.phase !== "task-work" ||
|
|
670
|
+
typeof data?.iteration !== "number") {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
raw.push({ index: i, iteration: data.iteration });
|
|
674
|
+
}
|
|
675
|
+
// Validate: filter to monotonically increasing iteration numbers, deduplicate
|
|
676
|
+
const validated = [];
|
|
677
|
+
let lastIter = -Infinity;
|
|
678
|
+
for (const b of raw) {
|
|
679
|
+
if (b.iteration > lastIter) {
|
|
680
|
+
validated.push(b);
|
|
681
|
+
lastIter = b.iteration;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return validated;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Extract task start/complete refs from a slice of events.
|
|
688
|
+
*/
|
|
689
|
+
function extractTaskTransitions(events) {
|
|
690
|
+
const tasksStarted = [];
|
|
691
|
+
const tasksCompleted = [];
|
|
692
|
+
for (const event of events) {
|
|
693
|
+
if (event.type === "session.update") {
|
|
694
|
+
const data = event.data;
|
|
695
|
+
const command = data?.update?.rawInput?.command;
|
|
696
|
+
if (typeof command === "string") {
|
|
697
|
+
if (/\btask start\b/.test(command)) {
|
|
698
|
+
const ref = extractTaskRef(command);
|
|
699
|
+
if (ref)
|
|
700
|
+
tasksStarted.push(ref);
|
|
701
|
+
}
|
|
702
|
+
else if (/\btask complete\b/.test(command)) {
|
|
703
|
+
const ref = extractTaskRef(command);
|
|
704
|
+
if (ref)
|
|
705
|
+
tasksCompleted.push(ref);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return { tasksStarted, tasksCompleted };
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Legacy iteration grouping: groups events by their data.iteration field.
|
|
510
714
|
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
* snapshots exist (e.g., active sessions).
|
|
715
|
+
* Used as fallback for sessions that don't have prompt.sent boundary events
|
|
716
|
+
* with phase "task-work" (pre-boundary sessions or non-ralph sessions).
|
|
514
717
|
*
|
|
515
718
|
* AC: @session-log-show ac-2
|
|
516
719
|
*/
|
|
517
|
-
|
|
518
|
-
const events = await readEvents(specDir, sessionId);
|
|
519
|
-
const snapshotIterations = await getIterationNumbers(specDir, sessionId);
|
|
720
|
+
function legacyIterationGrouping(events, snapshotIterations) {
|
|
520
721
|
// Collect all iteration numbers from both snapshots and events
|
|
521
722
|
const allIterations = new Set(snapshotIterations);
|
|
522
723
|
for (const event of events) {
|
|
@@ -525,14 +726,18 @@ async function computeIterationSummaries(specDir, sessionId) {
|
|
|
525
726
|
allIterations.add(data.iteration);
|
|
526
727
|
}
|
|
527
728
|
}
|
|
528
|
-
// If no iterations found anywhere,
|
|
729
|
+
// If no iterations found anywhere, synthesize iteration-0 only if events exist
|
|
529
730
|
if (allIterations.size === 0) {
|
|
731
|
+
if (events.length === 0) {
|
|
732
|
+
return [];
|
|
733
|
+
}
|
|
734
|
+
const { tasksStarted, tasksCompleted } = extractTaskTransitions(events);
|
|
530
735
|
return [
|
|
531
736
|
{
|
|
532
737
|
iteration: 0,
|
|
533
738
|
event_count: events.length,
|
|
534
|
-
tasks_started:
|
|
535
|
-
tasks_completed:
|
|
739
|
+
tasks_started: tasksStarted,
|
|
740
|
+
tasks_completed: tasksCompleted,
|
|
536
741
|
},
|
|
537
742
|
];
|
|
538
743
|
}
|
|
@@ -543,41 +748,19 @@ async function computeIterationSummaries(specDir, sessionId) {
|
|
|
543
748
|
iterationMap.set(n, []);
|
|
544
749
|
}
|
|
545
750
|
for (const event of events) {
|
|
546
|
-
// Try to get iteration from event data
|
|
547
751
|
const data = event.data;
|
|
548
752
|
const iter = data?.iteration;
|
|
549
753
|
if (typeof iter === "number" && iterationMap.has(iter)) {
|
|
550
754
|
iterationMap.get(iter).push(event);
|
|
551
755
|
}
|
|
552
756
|
else {
|
|
553
|
-
// Events without iteration info (lifecycle events) go to iteration 0
|
|
554
|
-
// or the first known iteration if 0 doesn't exist
|
|
555
757
|
const fallbackIter = iterationMap.has(0) ? 0 : iterations[0];
|
|
556
758
|
iterationMap.get(fallbackIter).push(event);
|
|
557
759
|
}
|
|
558
760
|
}
|
|
559
761
|
const summaries = [];
|
|
560
762
|
for (const [iterNum, iterEvents] of iterationMap) {
|
|
561
|
-
const tasksStarted =
|
|
562
|
-
const tasksCompleted = [];
|
|
563
|
-
for (const event of iterEvents) {
|
|
564
|
-
if (event.type === "session.update") {
|
|
565
|
-
const data = event.data;
|
|
566
|
-
const command = data?.update?.rawInput?.command;
|
|
567
|
-
if (typeof command === "string") {
|
|
568
|
-
if (/\btask start\b/.test(command)) {
|
|
569
|
-
const ref = extractTaskRef(command);
|
|
570
|
-
if (ref)
|
|
571
|
-
tasksStarted.push(ref);
|
|
572
|
-
}
|
|
573
|
-
else if (/\btask complete\b/.test(command)) {
|
|
574
|
-
const ref = extractTaskRef(command);
|
|
575
|
-
if (ref)
|
|
576
|
-
tasksCompleted.push(ref);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
763
|
+
const { tasksStarted, tasksCompleted } = extractTaskTransitions(iterEvents);
|
|
581
764
|
summaries.push({
|
|
582
765
|
iteration: iterNum,
|
|
583
766
|
event_count: iterEvents.length,
|
|
@@ -587,6 +770,83 @@ async function computeIterationSummaries(specDir, sessionId) {
|
|
|
587
770
|
}
|
|
588
771
|
return summaries.sort((a, b) => a.iteration - b.iteration);
|
|
589
772
|
}
|
|
773
|
+
/**
|
|
774
|
+
* Boundary-based iteration grouping: splits events by prompt.sent boundary
|
|
775
|
+
* positions (array indices) instead of trusting data.iteration fields.
|
|
776
|
+
*
|
|
777
|
+
* This is resilient to producer-side bugs where concurrent fire-and-forget
|
|
778
|
+
* event logging captures the wrong iteration number.
|
|
779
|
+
*
|
|
780
|
+
* AC: @session-log-show ac-10
|
|
781
|
+
*/
|
|
782
|
+
function boundaryIterationGrouping(events, boundaries) {
|
|
783
|
+
const summaries = [];
|
|
784
|
+
for (let b = 0; b < boundaries.length; b++) {
|
|
785
|
+
const startIdx = boundaries[b].index;
|
|
786
|
+
const endIdx = b + 1 < boundaries.length ? boundaries[b + 1].index : events.length;
|
|
787
|
+
const iterEvents = events.slice(startIdx, endIdx);
|
|
788
|
+
const { tasksStarted, tasksCompleted } = extractTaskTransitions(iterEvents);
|
|
789
|
+
summaries.push({
|
|
790
|
+
iteration: boundaries[b].iteration,
|
|
791
|
+
event_count: iterEvents.length,
|
|
792
|
+
tasks_started: tasksStarted,
|
|
793
|
+
tasks_completed: tasksCompleted,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
// Pre-boundary events (before the first prompt.sent) merge into first iteration
|
|
797
|
+
if (boundaries.length > 0 && boundaries[0].index > 0) {
|
|
798
|
+
const preBoundaryEvents = events.slice(0, boundaries[0].index);
|
|
799
|
+
const { tasksStarted, tasksCompleted } = extractTaskTransitions(preBoundaryEvents);
|
|
800
|
+
summaries[0].event_count += preBoundaryEvents.length;
|
|
801
|
+
summaries[0].tasks_started = [...tasksStarted, ...summaries[0].tasks_started];
|
|
802
|
+
summaries[0].tasks_completed = [...tasksCompleted, ...summaries[0].tasks_completed];
|
|
803
|
+
}
|
|
804
|
+
return summaries;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Compute per-iteration summaries from events.
|
|
808
|
+
*
|
|
809
|
+
* Uses prompt.sent boundary events (phase: "task-work") when available for
|
|
810
|
+
* accurate index-based grouping. Falls back to legacy data.iteration grouping
|
|
811
|
+
* for sessions without boundaries.
|
|
812
|
+
*
|
|
813
|
+
* AC: @session-log-show ac-2, ac-10
|
|
814
|
+
*/
|
|
815
|
+
async function computeIterationSummaries(specDir, sessionId) {
|
|
816
|
+
const events = await readEvents(specDir, sessionId);
|
|
817
|
+
const boundaries = findIterationBoundaries(events);
|
|
818
|
+
if (boundaries.length > 0) {
|
|
819
|
+
return boundaryIterationGrouping(events, boundaries);
|
|
820
|
+
}
|
|
821
|
+
// Legacy fallback: no prompt.sent boundaries with phase "task-work"
|
|
822
|
+
const snapshotIterations = await getIterationNumbers(specDir, sessionId);
|
|
823
|
+
return legacyIterationGrouping(events, snapshotIterations);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Count iterations using boundary-aware logic, without computing full summaries.
|
|
827
|
+
*
|
|
828
|
+
* For use in getSessionLogSummary() (session log list, session log stats) to
|
|
829
|
+
* ensure iteration_count agrees with session log show.
|
|
830
|
+
*
|
|
831
|
+
* Falls back to counting context-iter-*.json files when no boundaries exist.
|
|
832
|
+
*/
|
|
833
|
+
async function countIterationsBoundaryAware(specDir, sessionId) {
|
|
834
|
+
const events = await readEvents(specDir, sessionId);
|
|
835
|
+
const boundaries = findIterationBoundaries(events);
|
|
836
|
+
if (boundaries.length > 0) {
|
|
837
|
+
return boundaries.length;
|
|
838
|
+
}
|
|
839
|
+
// Legacy fallback: count from context snapshots and event data
|
|
840
|
+
const snapshotIterations = await getIterationNumbers(specDir, sessionId);
|
|
841
|
+
const allIterations = new Set(snapshotIterations);
|
|
842
|
+
for (const event of events) {
|
|
843
|
+
const data = event.data;
|
|
844
|
+
if (typeof data?.iteration === "number") {
|
|
845
|
+
allIterations.add(data.iteration);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return allIterations.size || (events.length > 0 ? 1 : 0);
|
|
849
|
+
}
|
|
590
850
|
/**
|
|
591
851
|
* Get full session detail for session log show.
|
|
592
852
|
*
|
|
@@ -598,9 +858,8 @@ export async function getSessionLogDetail(specDir, sessionId) {
|
|
|
598
858
|
const metadata = await getSession(specDir, sessionId);
|
|
599
859
|
if (!metadata)
|
|
600
860
|
return null;
|
|
601
|
-
const [eventCount,
|
|
861
|
+
const [eventCount, iterations] = await Promise.all([
|
|
602
862
|
countEventLines(specDir, sessionId),
|
|
603
|
-
countIterations(specDir, sessionId),
|
|
604
863
|
computeIterationSummaries(specDir, sessionId),
|
|
605
864
|
]);
|
|
606
865
|
const startMs = new Date(metadata.started_at).getTime();
|
|
@@ -617,7 +876,7 @@ export async function getSessionLogDetail(specDir, sessionId) {
|
|
|
617
876
|
ended_at: metadata.ended_at,
|
|
618
877
|
duration_ms: durationMs,
|
|
619
878
|
event_count: eventCount,
|
|
620
|
-
iteration_count:
|
|
879
|
+
iteration_count: iterations.length,
|
|
621
880
|
iterations,
|
|
622
881
|
};
|
|
623
882
|
}
|
|
@@ -701,6 +960,8 @@ export async function computeToolUsageStats(specDir, sessionIds, limit = 10) {
|
|
|
701
960
|
if (!content.trim())
|
|
702
961
|
continue;
|
|
703
962
|
const lines = content.trim().split("\n");
|
|
963
|
+
// Track seen toolCallIds to deduplicate phased events
|
|
964
|
+
const seenToolCallIds = new Set();
|
|
704
965
|
for (const line of lines) {
|
|
705
966
|
// Quick pre-filter: only parse lines that might be tool_call events
|
|
706
967
|
if (!line.includes('"tool_call"'))
|
|
@@ -710,6 +971,12 @@ export async function computeToolUsageStats(specDir, sessionIds, limit = 10) {
|
|
|
710
971
|
if (event?.type === "session.update") {
|
|
711
972
|
const update = event?.data?.update;
|
|
712
973
|
if (update?.sessionUpdate === "tool_call") {
|
|
974
|
+
// Deduplicate phased tool_call events by toolCallId
|
|
975
|
+
const toolCallId = update?.toolCallId || update?.tool_call_id || update?.id;
|
|
976
|
+
if (toolCallId && seenToolCallIds.has(toolCallId))
|
|
977
|
+
continue;
|
|
978
|
+
if (toolCallId)
|
|
979
|
+
seenToolCallIds.add(toolCallId);
|
|
713
980
|
const toolName = update?._meta?.claudeCode?.toolName || "unknown";
|
|
714
981
|
toolCounts[toolName] = (toolCounts[toolName] || 0) + 1;
|
|
715
982
|
totalToolCalls++;
|
|
@@ -880,6 +1147,8 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
|
|
|
880
1147
|
continue;
|
|
881
1148
|
const matches = [];
|
|
882
1149
|
const lines = content.trim().split("\n");
|
|
1150
|
+
// Track seen tool_call IDs to skip phased duplicates
|
|
1151
|
+
const seenToolCallIds = new Set();
|
|
883
1152
|
for (const line of lines) {
|
|
884
1153
|
if (totalMatches >= limit)
|
|
885
1154
|
break;
|
|
@@ -891,6 +1160,18 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
|
|
|
891
1160
|
// AC: @session-log-search ac-2 - Filter by event type
|
|
892
1161
|
if (options.eventType && event.type !== options.eventType)
|
|
893
1162
|
continue;
|
|
1163
|
+
// Deduplicate phased tool_call events
|
|
1164
|
+
if (event?.type === "session.update") {
|
|
1165
|
+
const update = event?.data?.update;
|
|
1166
|
+
if (update?.sessionUpdate === "tool_call") {
|
|
1167
|
+
const toolCallId = update?.toolCallId || update?.tool_call_id || update?.id;
|
|
1168
|
+
if (toolCallId) {
|
|
1169
|
+
if (seenToolCallIds.has(toolCallId))
|
|
1170
|
+
continue;
|
|
1171
|
+
seenToolCallIds.add(toolCallId);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
894
1175
|
// Verify match in stringified data (not just line, in case pattern appears in metadata)
|
|
895
1176
|
const dataStr = JSON.stringify(event.data);
|
|
896
1177
|
if (!dataStr.toLowerCase().includes(lowerPattern))
|
|
@@ -919,4 +1200,397 @@ export async function searchSessionEvents(specDir, pattern, options = {}) {
|
|
|
919
1200
|
}
|
|
920
1201
|
return results;
|
|
921
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.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
|
+
await upsertDotenvSessionId(envFile, sessionId);
|
|
1284
|
+
return {
|
|
1285
|
+
injected: true,
|
|
1286
|
+
method: "claude_env_file",
|
|
1287
|
+
description: `Wrote KSPEC_SESSION_ID=${sessionId} to CLAUDE_ENV_FILE`,
|
|
1288
|
+
path: envFile,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
// Fallback: write to project .claude/settings.json
|
|
1292
|
+
const settingsDir = path.join(process.cwd(), ".claude");
|
|
1293
|
+
const settingsPath = path.join(settingsDir, "settings.json");
|
|
1294
|
+
await fsPromises.mkdir(settingsDir, { recursive: true });
|
|
1295
|
+
let settings = {};
|
|
1296
|
+
try {
|
|
1297
|
+
const content = await fsPromises.readFile(settingsPath, "utf-8");
|
|
1298
|
+
settings = JSON.parse(content);
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
// Only start fresh for ENOENT; throw on parse errors to avoid overwriting
|
|
1302
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1303
|
+
// File doesn't exist, start fresh
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
throw new Error(`Cannot inject env: .claude/settings.json exists but is not valid JSON. ` +
|
|
1307
|
+
`Fix the file manually or remove it, then retry.`);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// Ensure env section exists
|
|
1311
|
+
if (!settings.env || typeof settings.env !== "object") {
|
|
1312
|
+
settings.env = {};
|
|
1313
|
+
}
|
|
1314
|
+
settings.env.KSPEC_SESSION_ID = sessionId;
|
|
1315
|
+
await fsPromises.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
1316
|
+
return {
|
|
1317
|
+
injected: true,
|
|
1318
|
+
method: "claude_settings",
|
|
1319
|
+
description: `Added KSPEC_SESSION_ID to .claude/settings.json env section`,
|
|
1320
|
+
path: settingsPath,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Inject KSPEC_SESSION_ID into Codex CLI environment.
|
|
1325
|
+
*
|
|
1326
|
+
* Adds to shell_environment_policy.set in codex config.
|
|
1327
|
+
*
|
|
1328
|
+
* AC: @session-creation-and-env-injection ac-inject-codex
|
|
1329
|
+
*/
|
|
1330
|
+
export async function injectCodexEnv(sessionId) {
|
|
1331
|
+
const configDir = path.join(process.env.HOME || process.env.USERPROFILE || "", ".codex");
|
|
1332
|
+
const configPath = path.join(configDir, "config.json");
|
|
1333
|
+
await fsPromises.mkdir(configDir, { recursive: true });
|
|
1334
|
+
let config = {};
|
|
1335
|
+
try {
|
|
1336
|
+
const content = await fsPromises.readFile(configPath, "utf-8");
|
|
1337
|
+
config = JSON.parse(content);
|
|
1338
|
+
}
|
|
1339
|
+
catch (err) {
|
|
1340
|
+
// Only start fresh for ENOENT; throw on parse errors to avoid overwriting
|
|
1341
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1342
|
+
// File doesn't exist, start fresh
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
throw new Error(`Cannot inject env: ~/.codex/config.json exists but is not valid JSON. ` +
|
|
1346
|
+
`Fix the file manually or remove it, then retry.`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
// Ensure shell_environment_policy.set exists
|
|
1350
|
+
if (!config.shell_environment_policy ||
|
|
1351
|
+
typeof config.shell_environment_policy !== "object") {
|
|
1352
|
+
config.shell_environment_policy = {};
|
|
1353
|
+
}
|
|
1354
|
+
const policy = config.shell_environment_policy;
|
|
1355
|
+
if (!policy.set || typeof policy.set !== "object") {
|
|
1356
|
+
policy.set = {};
|
|
1357
|
+
}
|
|
1358
|
+
policy.set.KSPEC_SESSION_ID = sessionId;
|
|
1359
|
+
await fsPromises.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1360
|
+
return {
|
|
1361
|
+
injected: true,
|
|
1362
|
+
method: "codex_config",
|
|
1363
|
+
description: `Added KSPEC_SESSION_ID to Codex config shell_environment_policy.set`,
|
|
1364
|
+
path: configPath,
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Inject KSPEC_SESSION_ID into Gemini CLI environment.
|
|
1369
|
+
*
|
|
1370
|
+
* Writes to .gemini/.env in project root (auto-loaded by Gemini CLI).
|
|
1371
|
+
*/
|
|
1372
|
+
export async function injectGeminiEnv(sessionId) {
|
|
1373
|
+
const dotenvDir = path.join(process.cwd(), ".gemini");
|
|
1374
|
+
const dotenvPath = path.join(dotenvDir, ".env");
|
|
1375
|
+
await fsPromises.mkdir(dotenvDir, { recursive: true });
|
|
1376
|
+
await upsertDotenvSessionId(dotenvPath, sessionId);
|
|
1377
|
+
return {
|
|
1378
|
+
injected: true,
|
|
1379
|
+
method: "gemini_dotenv",
|
|
1380
|
+
description: `Wrote KSPEC_SESSION_ID=${sessionId} to .gemini/.env`,
|
|
1381
|
+
path: dotenvPath,
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Inject KSPEC_SESSION_ID into OpenCode environment.
|
|
1386
|
+
*
|
|
1387
|
+
* Writes to project root .env file (auto-loaded by OpenCode via Bun runtime).
|
|
1388
|
+
* Uses the same dotenv append/replace pattern as other injectors.
|
|
1389
|
+
*/
|
|
1390
|
+
export async function injectOpenCodeEnv(sessionId) {
|
|
1391
|
+
const dotenvPath = path.join(process.cwd(), ".env");
|
|
1392
|
+
await upsertDotenvSessionId(dotenvPath, sessionId);
|
|
1393
|
+
return {
|
|
1394
|
+
injected: true,
|
|
1395
|
+
method: "opencode_dotenv",
|
|
1396
|
+
description: `Wrote KSPEC_SESSION_ID=${sessionId} to .env`,
|
|
1397
|
+
path: dotenvPath,
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Get fallback injection instructions for unknown agent harnesses.
|
|
1402
|
+
*
|
|
1403
|
+
* AC: @session-creation-and-env-injection ac-inject-fallback
|
|
1404
|
+
*/
|
|
1405
|
+
export function getFallbackInjectionInstructions(sessionId) {
|
|
1406
|
+
return {
|
|
1407
|
+
injected: false,
|
|
1408
|
+
method: "fallback",
|
|
1409
|
+
description: `export KSPEC_SESSION_ID=${sessionId}`,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
// ─── Session Validation ───────────────────────────────────────────────────────
|
|
1413
|
+
/**
|
|
1414
|
+
* Validate that the current KSPEC_SESSION_ID points to a valid session.
|
|
1415
|
+
*
|
|
1416
|
+
* AC: @session-creation-and-env-injection ac-invalid-session
|
|
1417
|
+
*
|
|
1418
|
+
* @param specDir - The .kspec directory path
|
|
1419
|
+
* @param sessionId - The session ID to validate
|
|
1420
|
+
* @returns Validation result with error details if invalid
|
|
1421
|
+
*/
|
|
1422
|
+
export async function validateSessionId(specDir, sessionId) {
|
|
1423
|
+
// Check if session directory exists
|
|
1424
|
+
const exists = await sessionExists(specDir, sessionId);
|
|
1425
|
+
if (!exists) {
|
|
1426
|
+
return {
|
|
1427
|
+
valid: false,
|
|
1428
|
+
error: `Session not found: ${sessionId}`,
|
|
1429
|
+
suggestion: `Unset KSPEC_SESSION_ID or create a new session with: kspec session create --agent-type <type>`,
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
// Try to read and validate session metadata
|
|
1433
|
+
const session = await getSession(specDir, sessionId);
|
|
1434
|
+
if (!session) {
|
|
1435
|
+
return {
|
|
1436
|
+
valid: false,
|
|
1437
|
+
error: `Session metadata is corrupt or unreadable: ${sessionId}`,
|
|
1438
|
+
suggestion: `Unset KSPEC_SESSION_ID or create a new session with: kspec session create --agent-type <type>`,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
return { valid: true, session };
|
|
1442
|
+
}
|
|
1443
|
+
// ─── Task Budget ──────────────────────────────────────────────────────────────
|
|
1444
|
+
/**
|
|
1445
|
+
* Atomic JSON write — write to temp file then rename in same directory.
|
|
1446
|
+
* Prevents corruption on crash.
|
|
1447
|
+
* AC: @task-budget-enforcement ac-atomic-write
|
|
1448
|
+
*/
|
|
1449
|
+
async function writeBudgetAtomic(filePath, budget) {
|
|
1450
|
+
const dir = path.dirname(filePath);
|
|
1451
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
1452
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
1453
|
+
const content = JSON.stringify(budget, null, 2) + "\n";
|
|
1454
|
+
await fsPromises.writeFile(tmpPath, content, "utf-8");
|
|
1455
|
+
await fsPromises.rename(tmpPath, filePath);
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Create a budget for a session.
|
|
1459
|
+
*
|
|
1460
|
+
* Writes budget.json to .kspec/sessions/{id}/ on the local filesystem
|
|
1461
|
+
* (NOT committed to shadow branch).
|
|
1462
|
+
*
|
|
1463
|
+
* AC: @session-creation-and-env-injection ac-budget
|
|
1464
|
+
* AC: @session-creation-and-env-injection ac-budget-local
|
|
1465
|
+
*
|
|
1466
|
+
* @param specDir - The .kspec directory path
|
|
1467
|
+
* @param sessionId - Session ID
|
|
1468
|
+
* @param maxPerCycle - Maximum tasks allowed per cycle
|
|
1469
|
+
* @returns The created budget
|
|
1470
|
+
*/
|
|
1471
|
+
export async function createBudget(specDir, sessionId, maxPerCycle) {
|
|
1472
|
+
const budget = {
|
|
1473
|
+
max_per_cycle: maxPerCycle,
|
|
1474
|
+
started_this_cycle: 0,
|
|
1475
|
+
};
|
|
1476
|
+
const validated = TaskBudgetSchema.parse(budget);
|
|
1477
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1478
|
+
await writeBudgetAtomic(budgetPath, validated);
|
|
1479
|
+
return validated;
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Read budget for a session.
|
|
1483
|
+
*
|
|
1484
|
+
* AC: @task-budget-enforcement ac-no-budget
|
|
1485
|
+
*
|
|
1486
|
+
* @param specDir - The .kspec directory path
|
|
1487
|
+
* @param sessionId - Session ID
|
|
1488
|
+
* @returns Budget or null if no budget configured (opt-in)
|
|
1489
|
+
*/
|
|
1490
|
+
export async function getBudget(specDir, sessionId) {
|
|
1491
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1492
|
+
let content;
|
|
1493
|
+
try {
|
|
1494
|
+
content = await fsPromises.readFile(budgetPath, "utf-8");
|
|
1495
|
+
}
|
|
1496
|
+
catch (err) {
|
|
1497
|
+
// File doesn't exist = no budget configured (opt-in)
|
|
1498
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
throw err;
|
|
1502
|
+
}
|
|
1503
|
+
// File exists — parse errors are real failures, not "no budget"
|
|
1504
|
+
const raw = JSON.parse(content);
|
|
1505
|
+
return TaskBudgetSchema.parse(raw);
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Check whether the budget allows starting a new task.
|
|
1509
|
+
*
|
|
1510
|
+
* Returns an object with `allowed` boolean and context about the budget.
|
|
1511
|
+
* When no budget is configured, always allows (opt-in behavior).
|
|
1512
|
+
*
|
|
1513
|
+
* AC: @task-budget-enforcement ac-block-start
|
|
1514
|
+
* AC: @task-budget-enforcement ac-no-budget
|
|
1515
|
+
* AC: @task-budget-enforcement ac-no-session
|
|
1516
|
+
*
|
|
1517
|
+
* @param specDir - The .kspec directory path
|
|
1518
|
+
* @param sessionId - Session ID, or undefined if KSPEC_SESSION_ID not set
|
|
1519
|
+
* @returns Budget check result
|
|
1520
|
+
*/
|
|
1521
|
+
export async function checkBudget(specDir, sessionId) {
|
|
1522
|
+
// AC: @task-budget-enforcement ac-no-session — no session means no check
|
|
1523
|
+
if (!sessionId) {
|
|
1524
|
+
return { allowed: true };
|
|
1525
|
+
}
|
|
1526
|
+
const budget = await getBudget(specDir, sessionId);
|
|
1527
|
+
// AC: @task-budget-enforcement ac-no-budget — no budget means no check
|
|
1528
|
+
if (!budget) {
|
|
1529
|
+
return { allowed: true };
|
|
1530
|
+
}
|
|
1531
|
+
if (budget.started_this_cycle >= budget.max_per_cycle) {
|
|
1532
|
+
return {
|
|
1533
|
+
allowed: false,
|
|
1534
|
+
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.`,
|
|
1535
|
+
budget,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
return { allowed: true, budget };
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Increment the budget counter after a task is successfully started.
|
|
1542
|
+
*
|
|
1543
|
+
* IMPORTANT: Callers must NOT call this for resume cases (task already
|
|
1544
|
+
* in_progress). The budget should only be incremented when a new task
|
|
1545
|
+
* transitions to in_progress, not when resuming an existing one.
|
|
1546
|
+
* See AC: @task-budget-enforcement ac-resume-no-increment
|
|
1547
|
+
*
|
|
1548
|
+
* AC: @task-budget-enforcement ac-increment
|
|
1549
|
+
* AC: @task-budget-enforcement ac-atomic-write
|
|
1550
|
+
*
|
|
1551
|
+
* @param specDir - The .kspec directory path
|
|
1552
|
+
* @param sessionId - Session ID
|
|
1553
|
+
* @returns Updated budget, or null if no budget configured
|
|
1554
|
+
*/
|
|
1555
|
+
export async function incrementBudget(specDir, sessionId) {
|
|
1556
|
+
const budget = await getBudget(specDir, sessionId);
|
|
1557
|
+
if (!budget) {
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
const updated = {
|
|
1561
|
+
...budget,
|
|
1562
|
+
started_this_cycle: budget.started_this_cycle + 1,
|
|
1563
|
+
};
|
|
1564
|
+
const validated = TaskBudgetSchema.parse(updated);
|
|
1565
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1566
|
+
await writeBudgetAtomic(budgetPath, validated);
|
|
1567
|
+
return validated;
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Reset the budget counter to 0 for a new cycle/iteration.
|
|
1571
|
+
*
|
|
1572
|
+
* Called by ralph at iteration boundaries. Single-writer guarantee:
|
|
1573
|
+
* ralph only resets between iterations when the agent is not running.
|
|
1574
|
+
*
|
|
1575
|
+
* AC: @task-budget-enforcement ac-reset
|
|
1576
|
+
* AC: @task-budget-enforcement ac-atomic-write
|
|
1577
|
+
*
|
|
1578
|
+
* @param specDir - The .kspec directory path
|
|
1579
|
+
* @param sessionId - Session ID
|
|
1580
|
+
* @returns Updated budget, or null if no budget configured
|
|
1581
|
+
*/
|
|
1582
|
+
export async function resetBudget(specDir, sessionId) {
|
|
1583
|
+
const budget = await getBudget(specDir, sessionId);
|
|
1584
|
+
if (!budget) {
|
|
1585
|
+
return null;
|
|
1586
|
+
}
|
|
1587
|
+
const updated = {
|
|
1588
|
+
...budget,
|
|
1589
|
+
started_this_cycle: 0,
|
|
1590
|
+
};
|
|
1591
|
+
const validated = TaskBudgetSchema.parse(updated);
|
|
1592
|
+
const budgetPath = getSessionBudgetPath(specDir, sessionId);
|
|
1593
|
+
await writeBudgetAtomic(budgetPath, validated);
|
|
1594
|
+
return validated;
|
|
1595
|
+
}
|
|
922
1596
|
//# sourceMappingURL=store.js.map
|