ahead-pi 0.7.0 → 0.8.1

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.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "AHEAD workflow enforcement and context for Pi",
5
5
  "keywords": [
6
6
  "ahead",
@@ -45,6 +45,9 @@
45
45
  "verify": "npm run build && npm run format:check && npm run lint && node --test ./test/*.test.mjs",
46
46
  "test": "npm run verify && npm run pack:verify"
47
47
  },
48
+ "dependencies": {
49
+ "@earendil-works/pi-ai": "^0.84.4"
50
+ },
48
51
  "devDependencies": {
49
52
  "@earendil-works/pi-coding-agent": "^0.84.1",
50
53
  "@earendil-works/pi-tui": "^0.84.1",
@@ -0,0 +1,143 @@
1
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
2
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ export interface FieldExamplesInput {
5
+ workflowTitle: string;
6
+ phaseTitle: string;
7
+ runTitle: string;
8
+ fields: string[];
9
+ }
10
+
11
+ const EXAMPLES_TIMEOUT_MS = 12_000;
12
+
13
+ export type FieldExamplesSkipped = "no-model" | "no-auth" | "failed";
14
+
15
+ export interface FieldExamplesResult {
16
+ examples?: string[][];
17
+ skipped?: FieldExamplesSkipped;
18
+ }
19
+
20
+ /**
21
+ * Draft two inspiration-only example lines per artifact field.
22
+ *
23
+ * The model is called without repository context on purpose: the examples are
24
+ * creative sparks to react to, not guidance the human should trust. They are
25
+ * rendered as HTML comments inside the field so form validation still treats
26
+ * an untouched field as empty.
27
+ */
28
+ export async function draftFieldExamples(
29
+ ctx: ExtensionCommandContext,
30
+ input: FieldExamplesInput,
31
+ ): Promise<FieldExamplesResult> {
32
+ const model = ctx.model;
33
+ if (!model) {
34
+ return { skipped: "no-model" };
35
+ }
36
+
37
+ const prompt = [
38
+ "You are helping a human begin a short written artifact in a software workflow.",
39
+ `Run title: ${input.runTitle}`,
40
+ `Workflow: ${input.workflowTitle} · Phase: ${input.phaseTitle}`,
41
+ "",
42
+ "You have NO access to the codebase or real project details. For each numbered field, write exactly 2 short example answers that are creative sparks only — plausibly generic and concrete, never claimed as fact about this project. Keep each line under 15 words.",
43
+ "",
44
+ "Output format, nothing else:",
45
+ "1: <example line>",
46
+ "1: <example line>",
47
+ "2: <example line>",
48
+ "2: <example line>",
49
+ "...one pair per field, in order.",
50
+ "",
51
+ "Fields:",
52
+ ...input.fields.map((field, index) => `${index + 1}. ${field}`),
53
+ ].join("\n");
54
+
55
+ try {
56
+ // getProviderAuth returning undefined means no usable credential of any
57
+ // kind could be resolved — only then is a heads-up accurate. A resolved
58
+ // auth object without an apiKey (e.g. OAuth) may still succeed through
59
+ // the adapter's own credential resolution, so we attempt the call.
60
+ const auth = await ctx.modelRegistry.getProviderAuth(model.provider);
61
+ if (!auth) {
62
+ return { skipped: "no-auth" };
63
+ }
64
+ const message = await Promise.race([
65
+ completeSimple(
66
+ model,
67
+ {
68
+ messages: [
69
+ {
70
+ role: "user",
71
+ content: [{ type: "text", text: prompt }],
72
+ timestamp: Date.now(),
73
+ },
74
+ ],
75
+ },
76
+ {
77
+ apiKey: auth.auth.apiKey,
78
+ headers: auth.auth.headers,
79
+ env: auth.env,
80
+ reasoning: "minimal",
81
+ },
82
+ ),
83
+ new Promise<undefined>((resolve) => {
84
+ setTimeout(() => resolve(undefined), EXAMPLES_TIMEOUT_MS);
85
+ }),
86
+ ]);
87
+ if (!message) {
88
+ return { skipped: "failed" };
89
+ }
90
+ const examples = parseExampleLines(message, input.fields.length);
91
+ if (!examples) {
92
+ return { skipped: "failed" };
93
+ }
94
+ return { examples };
95
+ } catch {
96
+ return { skipped: "failed" };
97
+ }
98
+ }
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return typeof value === "object" && value !== null;
102
+ }
103
+
104
+ function textBlocks(content: unknown): string[] {
105
+ if (!Array.isArray(content)) {
106
+ return [];
107
+ }
108
+ const blocks: string[] = [];
109
+ for (const block of content) {
110
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
111
+ blocks.push(block.text);
112
+ }
113
+ }
114
+ return blocks;
115
+ }
116
+
117
+ /** Exported for unit testing; the wire shape is validated defensively at runtime. */
118
+ export function parseExampleLines(message: unknown, fieldCount: number): string[][] | undefined {
119
+ const text = textBlocks(isRecord(message) ? message.content : undefined).join("\n");
120
+
121
+ const perField: string[][] = Array.from({ length: fieldCount }, () => []);
122
+ for (const line of text.split("\n")) {
123
+ const match = /^\s*(\d+)\s*:\s*(.+)$/.exec(line);
124
+ if (!match) {
125
+ continue;
126
+ }
127
+ const fieldIndex = Number.parseInt(match[1] ?? "", 10) - 1;
128
+ const example = match[2]?.trim();
129
+ if (
130
+ Number.isInteger(fieldIndex) &&
131
+ fieldIndex >= 0 &&
132
+ fieldIndex < fieldCount &&
133
+ example &&
134
+ perField[fieldIndex].length < 2
135
+ ) {
136
+ perField[fieldIndex].push(example);
137
+ }
138
+ }
139
+ if (perField.every((examples) => examples.length === 0)) {
140
+ return undefined;
141
+ }
142
+ return perField;
143
+ }
package/src/guidance.ts CHANGED
@@ -532,6 +532,32 @@ export function buildArtifactTemplate(
532
532
  ].join("\n");
533
533
  }
534
534
 
535
+ /**
536
+ * Insert inspiration-only example lines into each field as HTML comments.
537
+ * Validation strips comments, so an untouched field still counts as empty.
538
+ */
539
+ export function insertFieldExamples(template: string, perField: string[][]): string {
540
+ let updated = template;
541
+ for (const [index, examples] of perField.entries()) {
542
+ if (!examples || examples.length === 0) {
543
+ continue;
544
+ }
545
+ const marker = `<!-- AHEAD-FIELD:${index + 1}:BEGIN -->`;
546
+ if (!updated.includes(marker)) {
547
+ continue;
548
+ }
549
+ updated = updated.replace(
550
+ marker,
551
+ [
552
+ marker,
553
+ "<!-- Examples — inspiration only, not requirements. Delete them and write your own answer. -->",
554
+ ...examples.map((example) => `<!-- ~ ${example} ~ -->`),
555
+ ].join("\n"),
556
+ );
557
+ }
558
+ return updated;
559
+ }
560
+
535
561
  export function validateArtifactForm(content: string, prompts: string[]): string[] {
536
562
  const errors: string[] = [];
537
563
  for (const [index, prompt] of prompts.entries()) {
package/src/index.ts CHANGED
@@ -14,12 +14,14 @@ import { AheadEngine, AheadEngineError } from "./engine.js";
14
14
  import {
15
15
  buildArtifactTemplate,
16
16
  buildHeaderLines,
17
+ insertFieldExamples,
17
18
  nextAction,
18
19
  phaseGuide,
19
20
  phasePosition,
20
21
  promptsForArtifact,
21
22
  validateArtifactForm,
22
23
  } from "./guidance.js";
24
+ import { draftFieldExamples } from "./examples.js";
23
25
  import {
24
26
  findReference,
25
27
  loadReferenceIndex,
@@ -740,6 +742,35 @@ async function openAheadMode(
740
742
  });
741
743
  }
742
744
 
745
+ if (
746
+ state.allowed_ai_capabilities.length > 0 &&
747
+ state.artifacts.some(
748
+ (artifact) => artifact.present && artifact.recorded_by?.kind === "human" && artifact.path,
749
+ )
750
+ ) {
751
+ actions.push({
752
+ label: "Ask AI to challenge the latest artifact",
753
+ run: async () => {
754
+ const artifact = [...state.artifacts]
755
+ .toReversed()
756
+ .find(
757
+ (candidate) =>
758
+ candidate.present && candidate.recorded_by?.kind === "human" && candidate.path,
759
+ );
760
+ if (!artifact?.path) {
761
+ return;
762
+ }
763
+ pi.sendUserMessage(
764
+ [
765
+ `AHEAD mode: challenge my ${artifact.title} artifact before I accept the gate.`,
766
+ `Read ${artifact.path} and name the 2-3 weakest points: missing risks, vague claims, or things that would not survive implementation.`,
767
+ "Be specific and brief. Do not rewrite the artifact; I stay the author.",
768
+ ].join("\n"),
769
+ );
770
+ },
771
+ });
772
+ }
773
+
743
774
  if (state.return_targets.length > 0) {
744
775
  actions.push({
745
776
  label: "Return to an earlier phase",
@@ -1475,8 +1506,8 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
1475
1506
  linkedTitle ||
1476
1507
  (ctx.hasUI
1477
1508
  ? await ctx.ui.input(
1478
- `Enter AHEAD mode · ${workflow.title}`,
1479
- `Short name for this work — e.g. ${titleExample(workflow.id)}`,
1509
+ `AHEAD · ${workflow.title} · Name this work — e.g. ${titleExample(workflow.id)}`,
1510
+ "",
1480
1511
  )
1481
1512
  : parsed.workItemUrl);
1482
1513
  if (!title?.trim()) {
@@ -1569,7 +1600,31 @@ async function recordHumanArtifact(
1569
1600
  );
1570
1601
  }
1571
1602
 
1572
- const template = await humanArtifactTemplate(store, state, run, artifact.kind, artifact.title);
1603
+ const prompts = promptsForArtifact(state.workflow_id, state.phase.id, artifact.kind);
1604
+ let template = await humanArtifactTemplate(store, state, run, artifact.kind, artifact.title);
1605
+ if (ctx.hasUI && artifact.kind !== "review-disposition" && prompts.length > 0) {
1606
+ if (ctx.model) {
1607
+ ctx.ui.notify("Drafting two example prompts per field (inspiration only)…", "info");
1608
+ }
1609
+ const draft = await draftFieldExamples(ctx, {
1610
+ workflowTitle: engine.getWorkflow(state.workflow_id).title,
1611
+ phaseTitle: state.phase.title,
1612
+ runTitle: run.title,
1613
+ fields: prompts,
1614
+ });
1615
+ if (draft.examples) {
1616
+ template = insertFieldExamples(template, draft.examples);
1617
+ } else if (draft.skipped === "no-auth") {
1618
+ ctx.ui.notify(
1619
+ "Example prompts unavailable: no usable model credential could be resolved for this session.",
1620
+ "info",
1621
+ );
1622
+ } else if (draft.skipped === "no-model") {
1623
+ ctx.ui.notify("Example prompts unavailable: no active model in this session.", "info");
1624
+ }
1625
+ // "failed" (timeout, unparseable output, transient error) stays silent:
1626
+ // the plain template is the fallback and needs no apology.
1627
+ }
1573
1628
  let content = await ctx.ui.editor(
1574
1629
  `AHEAD mode · ${artifact.title} · write in your own words`,
1575
1630
  template,
@@ -1581,10 +1636,7 @@ async function recordHumanArtifact(
1581
1636
  // Keep the form open until validation passes or the human explicitly cancels,
1582
1637
  // so partial input is never discarded by a validation failure.
1583
1638
  while (true) {
1584
- const formErrors = validateArtifactForm(
1585
- content,
1586
- promptsForArtifact(state.workflow_id, state.phase.id, artifact.kind),
1587
- );
1639
+ const formErrors = validateArtifactForm(content, prompts);
1588
1640
  if (formErrors.length === 0) {
1589
1641
  break;
1590
1642
  }