ahead-pi 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ahead-pi",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "AHEAD workflow enforcement and context for Pi",
5
5
  "keywords": [
6
6
  "ahead",
package/src/guidance.ts CHANGED
@@ -491,7 +491,7 @@ export function buildHeaderLines(
491
491
  : undefined;
492
492
 
493
493
  return [
494
- `AHEAD · ${workflow.title} · ${position.current}/${position.total} · ${state.phase.title}`,
494
+ `AHEAD · ${workflow.title} · ${state.phase.title} (${position.current}/${position.total})`,
495
495
  ...(workItem ? [workItem] : []),
496
496
  `Goal: ${guide.objective}`,
497
497
  `Required: ${checklist}`,
@@ -511,14 +511,14 @@ export function buildArtifactTemplate(
511
511
  return [
512
512
  `### ${number}. ${prompt}`,
513
513
  `<!-- AHEAD-FIELD:${number}:BEGIN -->`,
514
- "<!-- Required. Replace this comment with your response. Use “Not applicable — reason” only when justified. -->",
514
+ "",
515
515
  "",
516
516
  `<!-- AHEAD-FIELD:${number}:END -->`,
517
517
  "",
518
518
  ];
519
519
  });
520
520
  return [
521
- `# ${title}`,
521
+ "# " + title,
522
522
  "",
523
523
  `AHEAD run: ${run.id}`,
524
524
  `Phase: ${state.phase.id} (visit ${state.phase.visit})`,
@@ -526,7 +526,7 @@ export function buildArtifactTemplate(
526
526
  "",
527
527
  "## Required responses",
528
528
  "",
529
- "Complete every field in your own words. Preserve evidence, uncertainty, and rationale; form completion does not replace accountable judgment.",
529
+ "Write directly under each question, between the AHEAD-FIELD markers. Every field is required; use “Not applicable reason” only when you can justify it.",
530
530
  "",
531
531
  ...fields,
532
532
  ].join("\n");
package/src/index.ts CHANGED
@@ -757,33 +757,32 @@ async function openAheadMode(
757
757
  }
758
758
 
759
759
  actions.push({
760
- label: "Configure project AHEAD policy for future runs",
761
- run: async () => manageProjectConfig(ctx),
762
- });
763
-
764
- actions.push({
765
- label: "Read AHEAD framework guidance for this phase",
766
- run: async () => showAheadGuide(ctx, ""),
767
- });
768
-
769
- actions.push({
770
- label: "Inspect optional skills reviewed for this phase",
771
- run: async () => showRecommendedSkills(ctx),
772
- });
773
-
774
- actions.push({
775
- label: "Explain this phase and its expectations",
760
+ label: "Help · policy, guidance, skills, phase explanation",
776
761
  run: async () => {
777
- ctx.ui.notify(
778
- [
779
- `${state.phase.title} · Human leads, AI assists`,
780
- `Goal: ${guidance.objective}`,
781
- `You: ${guidance.human}`,
782
- `AI: ${guidance.ai}`,
783
- `Gate: ${state.gate.title}`,
784
- ].join("\n"),
785
- "info",
786
- );
762
+ const helpChoice = await ctx.ui.select("Help · pick one", [
763
+ "Configure project AHEAD policy for future runs",
764
+ "Read AHEAD framework guidance for this phase",
765
+ "Inspect optional skills reviewed for this phase",
766
+ "Explain this phase and its expectations",
767
+ ]);
768
+ if (helpChoice === "Configure project AHEAD policy for future runs") {
769
+ await manageProjectConfig(ctx);
770
+ } else if (helpChoice === "Read AHEAD framework guidance for this phase") {
771
+ await showAheadGuide(ctx, "");
772
+ } else if (helpChoice === "Inspect optional skills reviewed for this phase") {
773
+ await showRecommendedSkills(ctx);
774
+ } else if (helpChoice === "Explain this phase and its expectations") {
775
+ ctx.ui.notify(
776
+ [
777
+ state.phase.title,
778
+ `Goal: ${guidance.objective}`,
779
+ `You: ${guidance.human}`,
780
+ `AI: ${guidance.ai}`,
781
+ `Gate: ${state.gate.title}`,
782
+ ].join("\n"),
783
+ "info",
784
+ );
785
+ }
787
786
  },
788
787
  });
789
788
 
@@ -1475,7 +1474,10 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
1475
1474
  parsed.title ||
1476
1475
  linkedTitle ||
1477
1476
  (ctx.hasUI
1478
- ? await ctx.ui.input(`Enter AHEAD mode · ${workflow.title}`, "What work are you doing?")
1477
+ ? await ctx.ui.input(
1478
+ `Enter AHEAD mode · ${workflow.title}`,
1479
+ `Short name for this work — e.g. ${titleExample(workflow.id)}`,
1480
+ )
1479
1481
  : parsed.workItemUrl);
1480
1482
  if (!title?.trim()) {
1481
1483
  return undefined;
@@ -1499,14 +1501,14 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
1499
1501
  await store.save(run);
1500
1502
  await refreshUi(ctx, run);
1501
1503
  const state = engine.deriveState(run);
1504
+ const requiredPhase = state.policy.work_items.required_before_phase;
1502
1505
  ctx.ui.notify(
1503
1506
  [
1504
1507
  `AHEAD mode started · ${workflow.title} · ${run.title}`,
1505
- "Human leads · AI assists",
1506
1508
  ...(state.work_item ? [`Work item: ${state.work_item.url}`] : []),
1507
- ...(state.policy.work_items.required_before_phase
1509
+ ...(requiredPhase && !state.work_item
1508
1510
  ? [
1509
- `Project policy requires a work item before ${state.policy.work_items.required_before_phase}.`,
1511
+ `Before the ${requiredPhase} phase, link a work item: /ahead, then choose “Link or create a work item”.`,
1510
1512
  ]
1511
1513
  : []),
1512
1514
  "This run remains active until an accountable human closes the outcome or uses /ahead-stop.",
@@ -1517,6 +1519,25 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
1517
1519
  return run;
1518
1520
  }
1519
1521
 
1522
+ function titleExample(workflowId: string): string {
1523
+ switch (workflowId) {
1524
+ case "product-change":
1525
+ return "“Add audit log viewer page”";
1526
+ case "internal-improvement":
1527
+ return "“Reduce cola-api cold-start time”";
1528
+ case "corrective-debugging":
1529
+ return "“Fix race in worker claim loop”";
1530
+ case "operational-stabilization":
1531
+ return "“Restore queue throughput after incident”";
1532
+ case "decision":
1533
+ return "“Choose audit-log retention policy”";
1534
+ case "investigation":
1535
+ return "“Why are runs flaking on CI?”";
1536
+ default:
1537
+ return "“Improve X”";
1538
+ }
1539
+ }
1540
+
1520
1541
  async function recordHumanArtifact(
1521
1542
  ctx: ExtensionCommandContext,
1522
1543
  requestedKind: string,
@@ -1549,7 +1570,7 @@ async function recordHumanArtifact(
1549
1570
  }
1550
1571
 
1551
1572
  const template = await humanArtifactTemplate(store, state, run, artifact.kind, artifact.title);
1552
- const content = await ctx.ui.editor(
1573
+ let content = await ctx.ui.editor(
1553
1574
  `AHEAD mode · ${artifact.title} · write in your own words`,
1554
1575
  template,
1555
1576
  );
@@ -1557,17 +1578,39 @@ async function recordHumanArtifact(
1557
1578
  return;
1558
1579
  }
1559
1580
  if (artifact.kind !== "review-disposition") {
1560
- const formErrors = validateArtifactForm(
1561
- content,
1562
- promptsForArtifact(state.workflow_id, state.phase.id, artifact.kind),
1563
- );
1564
- if (formErrors.length > 0) {
1565
- throw new AheadEngineError(
1566
- "artifact_form_incomplete",
1567
- `complete these required fields before saving ${artifact.title}:\n${formErrors
1568
- .map((error) => `- ${error}`)
1569
- .join("\n")}`,
1581
+ // Keep the form open until validation passes or the human explicitly cancels,
1582
+ // so partial input is never discarded by a validation failure.
1583
+ while (true) {
1584
+ const formErrors = validateArtifactForm(
1585
+ content,
1586
+ promptsForArtifact(state.workflow_id, state.phase.id, artifact.kind),
1587
+ );
1588
+ if (formErrors.length === 0) {
1589
+ break;
1590
+ }
1591
+ const retry = await ctx.ui.confirm(
1592
+ "Some required fields are still empty",
1593
+ [
1594
+ `The following fields in ${artifact.title} need a response:`,
1595
+ "",
1596
+ ...formErrors.map((error) => `- ${error}`),
1597
+ "",
1598
+ "Your text is preserved. Reopen to finish, or discard?",
1599
+ ].join("\n"),
1570
1600
  );
1601
+ if (!retry) {
1602
+ ctx.ui.notify("Draft discarded. Nothing was saved.", "info");
1603
+ return;
1604
+ }
1605
+ const revised = await ctx.ui.editor(
1606
+ `AHEAD mode · ${artifact.title} · finish the required fields`,
1607
+ content,
1608
+ );
1609
+ if (!revised?.trim()) {
1610
+ ctx.ui.notify("Draft kept in this dialog only — nothing was saved.", "info");
1611
+ return;
1612
+ }
1613
+ content = revised;
1571
1614
  }
1572
1615
  }
1573
1616
  await validateHumanReviewArtifact(store, state, artifact.kind, content);
@@ -1582,8 +1625,21 @@ async function recordHumanArtifact(
1582
1625
  await store.writeArtifact(path.absolute, content);
1583
1626
  await store.save(updated);
1584
1627
  await refreshUi(ctx, updated);
1628
+ const nextState = engine.deriveState(updated);
1629
+ const nextArtifact = nextState.artifacts.find(
1630
+ (candidate) => candidate.required && !candidate.present,
1631
+ );
1632
+ const nextStep = nextState.gate.accepted
1633
+ ? nextState.gate.title
1634
+ : nextArtifact
1635
+ ? `Next artifact: ${nextArtifact.title}`
1636
+ : `Accept the gate when ready: ${nextState.gate.title}`;
1585
1637
  ctx.ui.notify(
1586
- `Saved ${artifact.title}. AHEAD mode remains active; continue the conversation.`,
1638
+ [
1639
+ `✓ Saved ${artifact.title}.`,
1640
+ `Next: ${nextStep}`,
1641
+ "/ahead is available for the action menu; or just tell me what to do next.",
1642
+ ].join("\n"),
1587
1643
  "info",
1588
1644
  );
1589
1645
  }
@@ -1887,7 +1943,7 @@ async function refreshUi(ctx: ExtensionContext, supplied?: Run): Promise<void> {
1887
1943
  "ahead",
1888
1944
  state.closed
1889
1945
  ? `AHEAD · complete · ${state.workflow_id}`
1890
- : `AHEAD · ${position.current}/${position.total} · ${state.phase.id} · ${action.actor} action`,
1946
+ : `AHEAD · ${state.phase.id} · ${action.actor} action (${position.current}/${position.total})`,
1891
1947
  );
1892
1948
  if (state.closed) {
1893
1949
  ctx.ui.setWidget("ahead", undefined);
@@ -2052,8 +2108,8 @@ async function toolResult(action: () => Promise<unknown>) {
2052
2108
  }
2053
2109
 
2054
2110
  function errorMessage(error: unknown): string {
2055
- if (error instanceof AheadEngineError) {
2056
- return `${error.code}: ${error.message}`;
2057
- }
2111
+ // User-facing messages should read as prose. The internal error code is
2112
+ // available on AheadEngineError.code for tooling, but does not belong in
2113
+ // the user-visible text.
2058
2114
  return error instanceof Error ? error.message : String(error);
2059
2115
  }
package/src/storage.ts CHANGED
@@ -44,17 +44,54 @@ export class RunStore {
44
44
  this.aheadDirectory = join(rootPath, ".ahead");
45
45
  }
46
46
 
47
- newRunId(): string {
48
- const date = new Date().toISOString().slice(0, 10).replaceAll("-", "");
49
- return `${date}-${randomUUID().slice(0, 8)}`;
47
+ private get localDirectory(): string {
48
+ return join(this.aheadDirectory, "local");
50
49
  }
51
50
 
52
- async loadCurrent(): Promise<Run | undefined> {
51
+ private get currentPath(): string {
52
+ return join(this.localDirectory, "current.json");
53
+ }
54
+
55
+ private get legacyCurrentPath(): string {
56
+ return join(this.aheadDirectory, "current.json");
57
+ }
58
+
59
+ private async ensureLocalDirectory(): Promise<void> {
60
+ await mkdir(this.localDirectory, { recursive: true });
61
+ const gitignorePath = join(this.localDirectory, ".gitignore");
53
62
  try {
54
- const pointer = parseCurrentRunPointer(
55
- await readFile(join(this.aheadDirectory, "current.json"), "utf8"),
56
- );
57
- return this.load(pointer.run_id);
63
+ await writeFile(gitignorePath, "*\n!.gitignore\n", { encoding: "utf8", flag: "wx" });
64
+ } catch (error) {
65
+ if (!isExists(error)) {
66
+ throw error;
67
+ }
68
+ }
69
+ }
70
+
71
+ private async writeCurrentPointer(pointer: CurrentRunPointer): Promise<void> {
72
+ await this.ensureLocalDirectory();
73
+ await atomicJson(this.currentPath, pointer);
74
+ // Cleanup legacy pointer at the old shared path, if present.
75
+ try {
76
+ await unlink(this.legacyCurrentPath);
77
+ } catch (error) {
78
+ if (!isMissing(error)) {
79
+ throw error;
80
+ }
81
+ }
82
+ }
83
+
84
+ private async readCurrentPointer(): Promise<string | undefined> {
85
+ try {
86
+ return await readFile(this.currentPath, "utf8");
87
+ } catch (error) {
88
+ if (!isMissing(error)) {
89
+ throw error;
90
+ }
91
+ }
92
+ // Legacy fallback: read from the pre-0.7.0 shared path if the local path is absent.
93
+ try {
94
+ return await readFile(this.legacyCurrentPath, "utf8");
58
95
  } catch (error) {
59
96
  if (isMissing(error)) {
60
97
  return undefined;
@@ -63,6 +100,20 @@ export class RunStore {
63
100
  }
64
101
  }
65
102
 
103
+ newRunId(): string {
104
+ const date = new Date().toISOString().slice(0, 10).replaceAll("-", "");
105
+ return `${date}-${randomUUID().slice(0, 8)}`;
106
+ }
107
+
108
+ async loadCurrent(): Promise<Run | undefined> {
109
+ const content = await this.readCurrentPointer();
110
+ if (content === undefined) {
111
+ return undefined;
112
+ }
113
+ const pointer = parseCurrentRunPointer(content);
114
+ return this.load(pointer.run_id);
115
+ }
116
+
66
117
  async load(runId: string): Promise<Run> {
67
118
  return parseRun(await readFile(this.runPath(runId), "utf8"));
68
119
  }
@@ -139,7 +190,7 @@ export class RunStore {
139
190
  await atomicJson(this.runPath(run.id), run);
140
191
  if (makeCurrent) {
141
192
  const pointer: CurrentRunPointer = { api_version: "ahead.current/v0", run_id: run.id };
142
- await atomicJson(join(this.aheadDirectory, "current.json"), pointer);
193
+ await this.writeCurrentPointer(pointer);
143
194
  }
144
195
  }
145
196
 
@@ -166,7 +217,7 @@ export class RunStore {
166
217
  async resume(runId: string): Promise<Run> {
167
218
  const run = await this.load(runId);
168
219
  const pointer: CurrentRunPointer = { api_version: "ahead.current/v0", run_id: runId };
169
- await atomicJson(join(this.aheadDirectory, "current.json"), pointer);
220
+ await this.writeCurrentPointer(pointer);
170
221
  return run;
171
222
  }
172
223
 
@@ -219,25 +270,31 @@ export class RunStore {
219
270
  }
220
271
 
221
272
  private async clearCurrent(expectedRunId: string): Promise<void> {
222
- const path = join(this.aheadDirectory, "current.json");
223
- let pointer: CurrentRunPointer;
224
- try {
225
- pointer = parseCurrentRunPointer(await readFile(path, "utf8"));
226
- } catch (error) {
227
- if (isMissing(error)) {
228
- return;
273
+ // Clear the current pointer wherever it lives (new local path and legacy shared path).
274
+ for (const path of [this.currentPath, this.legacyCurrentPath]) {
275
+ let pointer: CurrentRunPointer;
276
+ try {
277
+ pointer = parseCurrentRunPointer(await readFile(path, "utf8"));
278
+ } catch (error) {
279
+ if (isMissing(error)) {
280
+ continue;
281
+ }
282
+ throw error;
229
283
  }
230
- throw error;
231
- }
232
- if (pointer.run_id !== expectedRunId) {
233
- throw new Error(
234
- `active AHEAD run changed from ${expectedRunId} to ${pointer.run_id}; stop or resume again`,
235
- );
284
+ if (pointer.run_id !== expectedRunId) {
285
+ throw new Error(
286
+ `active AHEAD run changed from ${expectedRunId} to ${pointer.run_id}; stop or resume again`,
287
+ );
288
+ }
289
+ await unlink(path);
236
290
  }
237
- await unlink(path);
238
291
  }
239
292
  }
240
293
 
294
+ function isExists(error: unknown): boolean {
295
+ return !!error && typeof error === "object" && "code" in error && error.code === "EEXIST";
296
+ }
297
+
241
298
  export function humanActor(cwd: string): Actor {
242
299
  const explicit = process.env.AHEAD_HUMAN_IDENTITY?.trim();
243
300
  if (explicit) {