ahead-pi 0.1.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/src/engine.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import type {
3
+ Actor,
4
+ Capability,
5
+ EngineErrorShape,
6
+ EventAction,
7
+ Run,
8
+ RunState,
9
+ WorkflowDefinition,
10
+ } from "./types.js";
11
+
12
+ const ENGINE_API_VERSION = "ahead.engine/v0";
13
+
14
+ interface WasmExports extends WebAssembly.Exports {
15
+ memory: WebAssembly.Memory;
16
+ ahead_alloc(length: number): number;
17
+ ahead_dealloc(pointer: number, length: number): void;
18
+ ahead_dispatch(pointer: number, length: number): bigint;
19
+ }
20
+
21
+ interface EngineResponse<T> {
22
+ ok: boolean;
23
+ result?: T;
24
+ error?: EngineErrorShape;
25
+ }
26
+
27
+ export class AheadEngineError extends Error {
28
+ constructor(
29
+ readonly code: string,
30
+ message: string,
31
+ ) {
32
+ super(message);
33
+ this.name = "AheadEngineError";
34
+ }
35
+ }
36
+
37
+ export class AheadEngine {
38
+ private constructor(private readonly wasm: WasmExports) {}
39
+
40
+ static async load(path: string): Promise<AheadEngine> {
41
+ const bytes = await readFile(path);
42
+ const instantiated = await WebAssembly.instantiate(bytes, {});
43
+ const wasm = instantiated.instance.exports as WasmExports;
44
+ for (const name of ["memory", "ahead_alloc", "ahead_dealloc", "ahead_dispatch"]) {
45
+ if (!(name in wasm)) {
46
+ throw new Error(`AHEAD WebAssembly module is missing export: ${name}`);
47
+ }
48
+ }
49
+ return new AheadEngine(wasm);
50
+ }
51
+
52
+ getWorkflow(workflowId = "product-change"): WorkflowDefinition {
53
+ return this.call("get_workflow", { workflow_id: workflowId });
54
+ }
55
+
56
+ createRun(input: {
57
+ id: string;
58
+ title: string;
59
+ owner: Actor;
60
+ timestamp: string;
61
+ workflow_id?: string;
62
+ }): Run {
63
+ return this.call("create_run", input);
64
+ }
65
+
66
+ deriveState(run: Run): RunState {
67
+ return this.call("derive_state", { run });
68
+ }
69
+
70
+ validateRun(run: Run): RunState {
71
+ return this.call("validate_run", { run });
72
+ }
73
+
74
+ applyEvent(run: Run, actor: Actor, action: EventAction): Run {
75
+ return this.call("apply_event", {
76
+ run,
77
+ event: { actor, timestamp: new Date().toISOString(), action },
78
+ });
79
+ }
80
+
81
+ toolAllowed(run: Run, capability: Capability): { allowed: boolean; reason: string; capability: Capability } {
82
+ return this.call("tool_allowed", { run, capability });
83
+ }
84
+
85
+ private call<T>(operation: string, input: unknown): T {
86
+ const request = new TextEncoder().encode(
87
+ JSON.stringify({ api_version: ENGINE_API_VERSION, operation, input }),
88
+ );
89
+ const inputPointer = this.wasm.ahead_alloc(request.length);
90
+ new Uint8Array(this.wasm.memory.buffer, inputPointer, request.length).set(request);
91
+
92
+ let outputPointer = 0;
93
+ let outputLength = 0;
94
+ try {
95
+ const packed = this.wasm.ahead_dispatch(inputPointer, request.length);
96
+ outputPointer = Number(packed >> 32n);
97
+ outputLength = Number(packed & 0xffff_ffffn);
98
+ const output = new Uint8Array(this.wasm.memory.buffer, outputPointer, outputLength).slice();
99
+ const response = JSON.parse(new TextDecoder().decode(output)) as EngineResponse<T>;
100
+ if (!response.ok || response.result === undefined) {
101
+ const error = response.error ?? { code: "unknown_engine_error", message: "AHEAD engine call failed" };
102
+ throw new AheadEngineError(error.code, error.message);
103
+ }
104
+ return response.result;
105
+ } finally {
106
+ this.wasm.ahead_dealloc(inputPointer, request.length);
107
+ if (outputPointer !== 0 && outputLength !== 0) {
108
+ this.wasm.ahead_dealloc(outputPointer, outputLength);
109
+ }
110
+ }
111
+ }
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,463 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Type } from "typebox";
9
+ import { AheadEngine, AheadEngineError } from "./engine.js";
10
+ import { humanActor, projectRoot, RunStore } from "./storage.js";
11
+ import type { Actor, Capability, EventAction, Run, RunState } from "./types.js";
12
+
13
+ const wasmPath =
14
+ process.env.AHEAD_WASM_PATH || fileURLToPath(new URL("../dist/ahead_wasm.wasm", import.meta.url));
15
+ const instructionDirectory = fileURLToPath(new URL("../generated/product-change", import.meta.url));
16
+ const enginePromise = AheadEngine.load(wasmPath);
17
+ const instructions = new Map<string, string>();
18
+
19
+ const toolCapabilities: Record<string, Capability> = {
20
+ read: "inspect",
21
+ grep: "inspect",
22
+ find: "inspect",
23
+ ls: "inspect",
24
+ edit: "modify",
25
+ write: "modify",
26
+ bash: "execute",
27
+ };
28
+
29
+ const EmptyParams = Type.Object({});
30
+ const RecordArtifactParams = Type.Object({
31
+ kind: Type.String({ description: "Artifact kind permitted for AI in the active phase" }),
32
+ content: Type.String({ description: "Complete Markdown artifact content", maxLength: 100_000 }),
33
+ });
34
+
35
+ export default function aheadExtension(pi: ExtensionAPI): void {
36
+ pi.registerCommand("ahead-start", {
37
+ description: "Start a Product Change workflow owned by the current human",
38
+ handler: async (args, ctx) => command(ctx, async () => {
39
+ const engine = await enginePromise;
40
+ const store = storeFor(ctx);
41
+ const current = await store.loadCurrent();
42
+ if (current && !engine.deriveState(current).closed) {
43
+ throw new AheadEngineError(
44
+ "active_run_exists",
45
+ `run ${current.id} is still active; close it before starting another`,
46
+ );
47
+ }
48
+ const title = args.trim() || (ctx.hasUI ? await ctx.ui.input("AHEAD Product Change", "Run title") : undefined);
49
+ if (!title?.trim()) return;
50
+ const owner = humanActor(store.projectRoot);
51
+ const run = engine.createRun({
52
+ id: store.newRunId(),
53
+ title: title.trim(),
54
+ owner,
55
+ timestamp: new Date().toISOString(),
56
+ workflow_id: "product-change",
57
+ });
58
+ await store.save(run);
59
+ await refreshUi(ctx, run);
60
+ ctx.ui.notify(`Started AHEAD run ${run.id}. Record the human-owned problem with /ahead-record problem.`, "info");
61
+ }),
62
+ });
63
+
64
+ pi.registerCommand("ahead-status", {
65
+ description: "Show the active AHEAD phase, evidence, gate, and blockers",
66
+ handler: async (_args, ctx) => command(ctx, async () => {
67
+ const run = await requireRun(ctx);
68
+ const state = (await enginePromise).deriveState(run);
69
+ await refreshUi(ctx, run);
70
+ ctx.ui.notify(formatState(state), state.blockers.length ? "warning" : "info");
71
+ }),
72
+ });
73
+
74
+ pi.registerCommand("ahead-record", {
75
+ description: "Write and record a human-owned artifact for the active phase",
76
+ handler: async (args, ctx) => command(ctx, async () => {
77
+ if (!ctx.hasUI) throw new Error("/ahead-record requires interactive or RPC UI support");
78
+ const engine = await enginePromise;
79
+ const store = storeFor(ctx);
80
+ const run = await requireRun(ctx);
81
+ const state = engine.deriveState(run);
82
+ const allowed = state.artifacts.filter((artifact) => artifact.actor !== "ai");
83
+ let kind = args.trim();
84
+ if (!kind) {
85
+ kind = (await ctx.ui.select(
86
+ `Record human artifact · ${state.phase.title}`,
87
+ allowed.map((artifact) => artifact.kind),
88
+ )) ?? "";
89
+ }
90
+ const artifact = allowed.find((candidate) => candidate.kind === kind);
91
+ if (!artifact) {
92
+ throw new AheadEngineError(
93
+ "artifact_not_human_owned",
94
+ `human cannot record ${kind || "that artifact"} in phase ${state.phase.id}; choose: ${allowed.map((item) => item.kind).join(", ")}`,
95
+ );
96
+ }
97
+ const content = await ctx.ui.editor(
98
+ `AHEAD · ${artifact.title}`,
99
+ artifactTemplate(run, state, artifact.kind, artifact.title),
100
+ );
101
+ if (!content?.trim()) return;
102
+ const path = store.artifactPath(run, state.phase.id, artifact.kind);
103
+ const action: EventAction = {
104
+ type: "artifact_recorded",
105
+ phase: state.phase.id,
106
+ kind: artifact.kind,
107
+ path: path.relative,
108
+ };
109
+ const updated = engine.applyEvent(run, humanActor(store.projectRoot), action);
110
+ await store.writeArtifact(path.absolute, content);
111
+ await store.save(updated);
112
+ await refreshUi(ctx, updated);
113
+ ctx.ui.notify(`Recorded ${artifact.kind} as ${path.relative}`, "info");
114
+ }),
115
+ });
116
+
117
+ pi.registerCommand("ahead-accept", {
118
+ description: "Human acceptance of the active phase gate",
119
+ handler: async (_args, ctx) => command(ctx, async () => {
120
+ if (!ctx.hasUI) throw new Error("/ahead-accept requires interactive or RPC UI support");
121
+ const engine = await enginePromise;
122
+ const store = storeFor(ctx);
123
+ const run = await requireRun(ctx);
124
+ const state = engine.deriveState(run);
125
+ const confirmed = await ctx.ui.confirm(
126
+ `Accept ${state.gate.id}?`,
127
+ `${state.gate.title}\n\nThis records human acceptance as ${humanActor(store.projectRoot).identity}.`,
128
+ );
129
+ if (!confirmed) return;
130
+ const updated = engine.applyEvent(run, humanActor(store.projectRoot), {
131
+ type: "gate_accepted",
132
+ phase: state.phase.id,
133
+ gate: state.gate.id,
134
+ });
135
+ await store.save(updated);
136
+ await refreshUi(ctx, updated);
137
+ ctx.ui.notify(`Accepted gate ${state.gate.id}. Use /ahead-advance when ready.`, "info");
138
+ }),
139
+ });
140
+
141
+ pi.registerCommand("ahead-advance", {
142
+ description: "Human transition to the next phase, or close the final phase",
143
+ handler: async (_args, ctx) => command(ctx, async () => {
144
+ if (!ctx.hasUI) throw new Error("/ahead-advance requires interactive or RPC UI support");
145
+ const engine = await enginePromise;
146
+ const store = storeFor(ctx);
147
+ const run = await requireRun(ctx);
148
+ const state = engine.deriveState(run);
149
+ const destination = state.phase.next ?? "closed";
150
+ const confirmed = await ctx.ui.confirm(
151
+ state.phase.next ? `Advance to ${state.phase.next}?` : "Close this AHEAD run?",
152
+ `Current phase: ${state.phase.title}\nDestination: ${destination}\nActor: ${humanActor(store.projectRoot).identity}`,
153
+ );
154
+ if (!confirmed) return;
155
+ const action: EventAction = state.phase.next
156
+ ? {
157
+ type: "phase_transitioned",
158
+ from: state.phase.id,
159
+ to: state.phase.next,
160
+ direction: "advance",
161
+ }
162
+ : { type: "run_closed", phase: state.phase.id };
163
+ const updated = engine.applyEvent(run, humanActor(store.projectRoot), action);
164
+ await store.save(updated);
165
+ await refreshUi(ctx, updated);
166
+ ctx.ui.notify(state.phase.next ? `Advanced to ${state.phase.next}.` : "AHEAD run closed.", "info");
167
+ }),
168
+ });
169
+
170
+ pi.registerCommand("ahead-return", {
171
+ description: "Human return to an allowed earlier phase with a recorded reason",
172
+ handler: async (args, ctx) => command(ctx, async () => {
173
+ if (!ctx.hasUI) throw new Error("/ahead-return requires interactive or RPC UI support");
174
+ const engine = await enginePromise;
175
+ const store = storeFor(ctx);
176
+ const run = await requireRun(ctx);
177
+ const state = engine.deriveState(run);
178
+ if (!state.return_targets.length) {
179
+ throw new AheadEngineError("no_return_target", `phase ${state.phase.id} has no return transition`);
180
+ }
181
+ let target = args.trim();
182
+ if (!target) target = (await ctx.ui.select("Return to which phase?", state.return_targets)) ?? "";
183
+ if (!state.return_targets.includes(target)) {
184
+ throw new AheadEngineError(
185
+ "invalid_return",
186
+ `phase ${state.phase.id} can return only to: ${state.return_targets.join(", ")}`,
187
+ );
188
+ }
189
+ const reason = await ctx.ui.editor(`Why return to ${target}?`);
190
+ if (!reason?.trim()) return;
191
+ const confirmed = await ctx.ui.confirm(
192
+ `Return to ${target}?`,
193
+ "This creates a new phase visit. Earlier artifacts remain in history but will not satisfy the reopened gate.",
194
+ );
195
+ if (!confirmed) return;
196
+ const updated = engine.applyEvent(run, humanActor(store.projectRoot), {
197
+ type: "phase_transitioned",
198
+ from: state.phase.id,
199
+ to: target,
200
+ direction: "return",
201
+ reason: reason.trim(),
202
+ });
203
+ await store.save(updated);
204
+ await refreshUi(ctx, updated);
205
+ ctx.ui.notify(`Returned to ${target}. New evidence and human gate acceptance are required.`, "warning");
206
+ }),
207
+ });
208
+
209
+ pi.registerCommand("ahead-help", {
210
+ description: "Show AHEAD Pi commands and the human/AI boundary",
211
+ handler: async (_args, ctx) => {
212
+ ctx.ui.notify(
213
+ [
214
+ "/ahead-start [title] — start a human-owned Product Change run",
215
+ "/ahead-status — show phase, evidence, gate, and blockers",
216
+ "/ahead-record [kind] — human writes an artifact",
217
+ "/ahead-accept — human accepts the current gate",
218
+ "/ahead-advance — human advances or closes",
219
+ "/ahead-return [phase] — human reopens an allowed earlier phase",
220
+ "",
221
+ "AI can inspect context and record only AI-permitted artifacts. It cannot accept gates or transition the run.",
222
+ ].join("\n"),
223
+ "info",
224
+ );
225
+ },
226
+ });
227
+
228
+ pi.registerTool({
229
+ name: "ahead_get_context",
230
+ label: "AHEAD context",
231
+ description: "Read the authoritative active AHEAD workflow state, phase contract, artifacts, gate, and blockers.",
232
+ promptSnippet: "Read the active AHEAD workflow state and human/AI boundaries.",
233
+ parameters: EmptyParams,
234
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
235
+ return toolResult(async () => {
236
+ const run = await requireRun(ctx);
237
+ const state = (await enginePromise).deriveState(run);
238
+ return { run, state };
239
+ });
240
+ },
241
+ });
242
+
243
+ pi.registerTool({
244
+ name: "ahead_record_artifact",
245
+ label: "Record AHEAD artifact",
246
+ description: "Persist an AI-owned or shared artifact permitted by the active phase. Cannot record human-owned artifacts.",
247
+ promptSnippet: "Record an AI-permitted artifact in the active AHEAD run.",
248
+ parameters: RecordArtifactParams,
249
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
250
+ return toolResult(async () => {
251
+ const engine = await enginePromise;
252
+ const store = storeFor(ctx);
253
+ const run = await requireRun(ctx);
254
+ const state = engine.deriveState(run);
255
+ const artifact = state.artifacts.find((candidate) => candidate.kind === params.kind);
256
+ if (!artifact || artifact.actor === "human") {
257
+ throw new AheadEngineError(
258
+ "artifact_not_ai_owned",
259
+ `AI cannot record ${params.kind} in phase ${state.phase.id}`,
260
+ );
261
+ }
262
+ const path = store.artifactPath(run, state.phase.id, artifact.kind);
263
+ const updated = engine.applyEvent(run, aiActor(ctx), {
264
+ type: "artifact_recorded",
265
+ phase: state.phase.id,
266
+ kind: artifact.kind,
267
+ path: path.relative,
268
+ });
269
+ await store.writeArtifact(path.absolute, params.content);
270
+ await store.save(updated);
271
+ await refreshUi(ctx, updated);
272
+ return {
273
+ recorded: artifact.kind,
274
+ path: path.relative,
275
+ state: engine.deriveState(updated),
276
+ };
277
+ });
278
+ },
279
+ });
280
+
281
+ pi.registerTool({
282
+ name: "ahead_request_transition",
283
+ label: "Request AHEAD transition",
284
+ description: "Report whether a human can advance the active AHEAD phase. This tool never accepts a gate or transitions state.",
285
+ parameters: EmptyParams,
286
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
287
+ return toolResult(async () => {
288
+ const state = (await enginePromise).deriveState(await requireRun(ctx));
289
+ return {
290
+ requested: true,
291
+ transitioned: false,
292
+ message: state.can_advance
293
+ ? `The gate is accepted. Ask the human to use /ahead-advance to ${state.phase.next ?? "close the run"}.`
294
+ : "The phase cannot advance. The human must resolve the blockers and accept the gate.",
295
+ blockers: state.blockers,
296
+ };
297
+ });
298
+ },
299
+ });
300
+
301
+ pi.registerTool({
302
+ name: "ahead_validate",
303
+ label: "Validate AHEAD run",
304
+ description: "Replay and validate the active AHEAD event log against the embedded workflow contract.",
305
+ parameters: EmptyParams,
306
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
307
+ return toolResult(async () => ({ valid: true, state: (await enginePromise).validateRun(await requireRun(ctx)) }));
308
+ },
309
+ });
310
+
311
+ pi.on("session_start", async (_event, ctx) => {
312
+ try {
313
+ await refreshUi(ctx);
314
+ } catch (error) {
315
+ ctx.ui.setStatus("ahead", "AHEAD · invalid state");
316
+ ctx.ui.notify(errorMessage(error), "error");
317
+ }
318
+ });
319
+
320
+ pi.on("before_agent_start", async (event, ctx) => {
321
+ const run = await storeFor(ctx).loadCurrent();
322
+ if (!run) return;
323
+ const state = (await enginePromise).deriveState(run);
324
+ const phaseInstructions = await loadInstructions(state.phase.id);
325
+ const liveContext = [
326
+ "# Live AHEAD run",
327
+ `- Run: ${run.id} — ${run.title}`,
328
+ `- Phase: ${state.phase.id} visit ${state.phase.visit}`,
329
+ `- Gate accepted: ${state.gate.accepted}`,
330
+ `- Current blockers: ${state.blockers.length ? state.blockers.join("; ") : "none"}`,
331
+ `- Allowed AI capabilities: ${state.allowed_ai_capabilities.length ? state.allowed_ai_capabilities.join(", ") : "none"}`,
332
+ ].join("\n");
333
+ return { systemPrompt: `${event.systemPrompt}\n\n${phaseInstructions}\n\n${liveContext}\n` };
334
+ });
335
+
336
+ pi.on("tool_call", async (event, ctx) => {
337
+ if (event.toolName.startsWith("ahead_")) return;
338
+ const run = await storeFor(ctx).loadCurrent();
339
+ if (!run) return;
340
+ const capability = toolCapabilities[event.toolName];
341
+ if (!capability) {
342
+ return {
343
+ block: true,
344
+ reason: `AHEAD blocked unclassified tool ${event.toolName}. The Pi adapter must map every model-invoked tool to an explicit workflow capability.`,
345
+ };
346
+ }
347
+ try {
348
+ const decision = (await enginePromise).toolAllowed(run, capability);
349
+ if (!decision.allowed) return { block: true, reason: `AHEAD: ${decision.reason}` };
350
+ } catch (error) {
351
+ return { block: true, reason: `AHEAD state validation failed: ${errorMessage(error)}` };
352
+ }
353
+ });
354
+ }
355
+
356
+ function storeFor(ctx: ExtensionContext): RunStore {
357
+ return new RunStore(projectRoot(ctx.cwd));
358
+ }
359
+
360
+ async function requireRun(ctx: ExtensionContext): Promise<Run> {
361
+ const run = await storeFor(ctx).loadCurrent();
362
+ if (!run) throw new AheadEngineError("no_active_run", "no active AHEAD run; use /ahead-start [title]");
363
+ return run;
364
+ }
365
+
366
+ function aiActor(ctx: ExtensionContext): Actor {
367
+ const model = ctx.model;
368
+ return {
369
+ kind: "ai",
370
+ identity: model ? `${model.provider}/${model.id}` : "pi/unknown-model",
371
+ };
372
+ }
373
+
374
+ async function refreshUi(ctx: ExtensionContext, supplied?: Run): Promise<void> {
375
+ const run = supplied ?? (await storeFor(ctx).loadCurrent());
376
+ if (!run) {
377
+ ctx.ui.setStatus("ahead", undefined);
378
+ ctx.ui.setWidget("ahead", undefined);
379
+ return;
380
+ }
381
+ const state = (await enginePromise).deriveState(run);
382
+ ctx.ui.setStatus(
383
+ "ahead",
384
+ state.closed
385
+ ? `AHEAD · ${state.workflow_id} · closed`
386
+ : `AHEAD · ${state.phase.id}#${state.phase.visit} · ${state.blockers.length} blocker${state.blockers.length === 1 ? "" : "s"}`,
387
+ );
388
+ ctx.ui.setWidget(
389
+ "ahead",
390
+ [
391
+ `AHEAD · ${run.title}`,
392
+ state.closed
393
+ ? "Closed"
394
+ : `${state.phase.title} · visit ${state.phase.visit} · gate ${state.gate.accepted ? "accepted" : "open"}`,
395
+ state.blockers.length
396
+ ? `Next: ${state.blockers[0]}`
397
+ : state.phase.next
398
+ ? "Next: /ahead-advance"
399
+ : "Next: /ahead-advance (closes run)",
400
+ ],
401
+ { placement: "aboveEditor" },
402
+ );
403
+ }
404
+
405
+ async function loadInstructions(phase: string): Promise<string> {
406
+ const cached = instructions.get(phase);
407
+ if (cached) return cached;
408
+ const content = await readFile(`${instructionDirectory}/${phase}.md`, "utf8");
409
+ instructions.set(phase, content);
410
+ return content;
411
+ }
412
+
413
+ function formatState(state: RunState): string {
414
+ const artifacts = state.artifacts
415
+ .map((artifact) => `${artifact.present ? "✓" : artifact.required ? "○" : "·"} ${artifact.kind} (${artifact.actor})`)
416
+ .join("\n");
417
+ return [
418
+ `${state.title} · ${state.workflow_id}@${state.workflow_version}`,
419
+ `Phase: ${state.phase.title} (${state.phase.id}) · visit ${state.phase.visit}`,
420
+ `Gate: ${state.gate.id} · ${state.gate.accepted ? `accepted by ${state.gate.accepted_by?.identity}` : "open"}`,
421
+ `AI capabilities: ${state.allowed_ai_capabilities.join(", ") || "none"}`,
422
+ "Artifacts:",
423
+ artifacts,
424
+ `Blockers: ${state.blockers.join("; ") || "none"}`,
425
+ `Return targets: ${state.return_targets.join(", ") || "none"}`,
426
+ ].join("\n");
427
+ }
428
+
429
+ function artifactTemplate(run: Run, state: RunState, kind: string, title: string): string {
430
+ return [
431
+ `# ${title}`,
432
+ "",
433
+ `AHEAD run: ${run.id}`,
434
+ `Phase: ${state.phase.id} (visit ${state.phase.visit})`,
435
+ `Artifact: ${kind}`,
436
+ "",
437
+ "<!-- Replace this comment with the human-authored record. Preserve evidence, uncertainty, and rationale. -->",
438
+ "",
439
+ ].join("\n");
440
+ }
441
+
442
+ async function command(ctx: ExtensionCommandContext, action: () => Promise<void>): Promise<void> {
443
+ try {
444
+ await action();
445
+ } catch (error) {
446
+ ctx.ui.notify(errorMessage(error), "error");
447
+ }
448
+ }
449
+
450
+ async function toolResult(action: () => Promise<unknown>) {
451
+ try {
452
+ const result = await action();
453
+ return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], details: result };
454
+ } catch (error) {
455
+ const message = errorMessage(error);
456
+ return { content: [{ type: "text" as const, text: `AHEAD error: ${message}` }], details: { error: message }, isError: true };
457
+ }
458
+ }
459
+
460
+ function errorMessage(error: unknown): string {
461
+ if (error instanceof AheadEngineError) return `${error.code}: ${error.message}`;
462
+ return error instanceof Error ? error.message : String(error);
463
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import type { Actor, Run } from "./types.js";
6
+
7
+ interface CurrentRunPointer {
8
+ api_version: "ahead.current/v0";
9
+ run_id: string;
10
+ }
11
+
12
+ export class RunStore {
13
+ readonly aheadDirectory: string;
14
+
15
+ constructor(readonly projectRoot: string) {
16
+ this.aheadDirectory = join(projectRoot, ".ahead");
17
+ }
18
+
19
+ newRunId(): string {
20
+ const date = new Date().toISOString().slice(0, 10).replaceAll("-", "");
21
+ return `${date}-${randomUUID().slice(0, 8)}`;
22
+ }
23
+
24
+ async loadCurrent(): Promise<Run | undefined> {
25
+ try {
26
+ const pointer = JSON.parse(await readFile(join(this.aheadDirectory, "current.json"), "utf8")) as CurrentRunPointer;
27
+ if (pointer.api_version !== "ahead.current/v0" || !pointer.run_id) {
28
+ throw new Error("invalid .ahead/current.json");
29
+ }
30
+ return this.load(pointer.run_id);
31
+ } catch (error) {
32
+ if (isMissing(error)) return undefined;
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ async load(runId: string): Promise<Run> {
38
+ return JSON.parse(await readFile(this.runPath(runId), "utf8")) as Run;
39
+ }
40
+
41
+ async save(run: Run, makeCurrent = true): Promise<void> {
42
+ await atomicJson(this.runPath(run.id), run);
43
+ if (makeCurrent) {
44
+ const pointer: CurrentRunPointer = { api_version: "ahead.current/v0", run_id: run.id };
45
+ await atomicJson(join(this.aheadDirectory, "current.json"), pointer);
46
+ }
47
+ }
48
+
49
+ artifactPath(run: Run, phase: string, kind: string): { absolute: string; relative: string } {
50
+ const sequence = String(run.events.length + 1).padStart(4, "0");
51
+ const absolute = join(this.aheadDirectory, "runs", run.id, "artifacts", `${sequence}-${phase}-${kind}.md`);
52
+ return { absolute, relative: relative(this.projectRoot, absolute) };
53
+ }
54
+
55
+ async writeArtifact(path: string, content: string): Promise<void> {
56
+ const resolved = resolve(path);
57
+ const artifactsRoot = resolve(this.aheadDirectory, "runs");
58
+ if (!resolved.startsWith(`${artifactsRoot}/`)) {
59
+ throw new Error("artifact path escaped .ahead/runs");
60
+ }
61
+ await mkdir(dirname(resolved), { recursive: true });
62
+ await writeFile(resolved, `${content.trim()}\n`, { encoding: "utf8", flag: "wx" });
63
+ }
64
+
65
+ private runPath(runId: string): string {
66
+ if (!/^[A-Za-z0-9._-]+$/.test(runId)) throw new Error("unsafe AHEAD run id");
67
+ return join(this.aheadDirectory, "runs", runId, "run.json");
68
+ }
69
+ }
70
+
71
+ export function humanActor(cwd: string): Actor {
72
+ const explicit = process.env.AHEAD_HUMAN_IDENTITY?.trim();
73
+ if (explicit) return { kind: "human", identity: explicit };
74
+ for (const key of ["user.email", "user.name"]) {
75
+ try {
76
+ const value = execFileSync("git", ["config", key], {
77
+ cwd,
78
+ encoding: "utf8",
79
+ stdio: ["ignore", "pipe", "ignore"],
80
+ }).trim();
81
+ if (value) return { kind: "human", identity: value };
82
+ } catch {
83
+ // Fall through to the next local identity source.
84
+ }
85
+ }
86
+ return { kind: "human", identity: process.env.USER?.trim() || "local-human" };
87
+ }
88
+
89
+ export function projectRoot(cwd: string): string {
90
+ try {
91
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
92
+ cwd,
93
+ encoding: "utf8",
94
+ stdio: ["ignore", "pipe", "ignore"],
95
+ }).trim();
96
+ if (root) return root;
97
+ } catch {
98
+ // AHEAD can also persist beside work that is not yet in Git.
99
+ }
100
+ return resolve(cwd);
101
+ }
102
+
103
+ async function atomicJson(path: string, value: unknown): Promise<void> {
104
+ await mkdir(dirname(path), { recursive: true });
105
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
106
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
107
+ await rename(temporary, path);
108
+ }
109
+
110
+ function isMissing(error: unknown): boolean {
111
+ return !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
112
+ }