ahead-pi 0.6.0 → 0.8.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 +4 -1
- package/src/examples.ts +127 -0
- package/src/guidance.ts +30 -4
- package/src/index.ts +143 -47
- package/src/storage.ts +81 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ahead-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|
package/src/examples.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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 = 25_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Draft two inspiration-only example lines per artifact field.
|
|
15
|
+
*
|
|
16
|
+
* The model is called without repository context on purpose: the examples are
|
|
17
|
+
* creative sparks to react to, not guidance the human should trust. They are
|
|
18
|
+
* rendered as HTML comments inside the field so form validation still treats
|
|
19
|
+
* an untouched field as empty.
|
|
20
|
+
*/
|
|
21
|
+
export async function draftFieldExamples(
|
|
22
|
+
ctx: ExtensionCommandContext,
|
|
23
|
+
input: FieldExamplesInput,
|
|
24
|
+
): Promise<string[][] | undefined> {
|
|
25
|
+
const model = ctx.model;
|
|
26
|
+
if (!model) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const prompt = [
|
|
31
|
+
"You are helping a human begin a short written artifact in a software workflow.",
|
|
32
|
+
`Run title: ${input.runTitle}`,
|
|
33
|
+
`Workflow: ${input.workflowTitle} · Phase: ${input.phaseTitle}`,
|
|
34
|
+
"",
|
|
35
|
+
"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.",
|
|
36
|
+
"",
|
|
37
|
+
"Output format, nothing else:",
|
|
38
|
+
"1: <example line>",
|
|
39
|
+
"1: <example line>",
|
|
40
|
+
"2: <example line>",
|
|
41
|
+
"2: <example line>",
|
|
42
|
+
"...one pair per field, in order.",
|
|
43
|
+
"",
|
|
44
|
+
"Fields:",
|
|
45
|
+
...input.fields.map((field, index) => `${index + 1}. ${field}`),
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const auth = await ctx.modelRegistry.getProviderAuth(model.provider);
|
|
50
|
+
if (!auth) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const message = await Promise.race([
|
|
54
|
+
completeSimple(
|
|
55
|
+
model,
|
|
56
|
+
{
|
|
57
|
+
messages: [
|
|
58
|
+
{
|
|
59
|
+
role: "user",
|
|
60
|
+
content: [{ type: "text", text: prompt }],
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
apiKey: auth.auth.apiKey,
|
|
67
|
+
headers: auth.auth.headers,
|
|
68
|
+
env: auth.env,
|
|
69
|
+
reasoning: "minimal",
|
|
70
|
+
},
|
|
71
|
+
),
|
|
72
|
+
new Promise<undefined>((resolve) => {
|
|
73
|
+
setTimeout(() => resolve(undefined), EXAMPLES_TIMEOUT_MS);
|
|
74
|
+
}),
|
|
75
|
+
]);
|
|
76
|
+
if (!message) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return parseExampleLines(message, input.fields.length);
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
86
|
+
return typeof value === "object" && value !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function textBlocks(content: unknown): string[] {
|
|
90
|
+
if (!Array.isArray(content)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const blocks: string[] = [];
|
|
94
|
+
for (const block of content) {
|
|
95
|
+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
|
|
96
|
+
blocks.push(block.text);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return blocks;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseExampleLines(message: unknown, fieldCount: number): string[][] | undefined {
|
|
103
|
+
const text = textBlocks(isRecord(message) ? message.content : undefined).join("\n");
|
|
104
|
+
|
|
105
|
+
const perField: string[][] = Array.from({ length: fieldCount }, () => []);
|
|
106
|
+
for (const line of text.split("\n")) {
|
|
107
|
+
const match = /^\s*(\d+)\s*:\s*(.+)$/.exec(line);
|
|
108
|
+
if (!match) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const fieldIndex = Number.parseInt(match[1] ?? "", 10) - 1;
|
|
112
|
+
const example = match[2]?.trim();
|
|
113
|
+
if (
|
|
114
|
+
Number.isInteger(fieldIndex) &&
|
|
115
|
+
fieldIndex >= 0 &&
|
|
116
|
+
fieldIndex < fieldCount &&
|
|
117
|
+
example &&
|
|
118
|
+
perField[fieldIndex].length < 2
|
|
119
|
+
) {
|
|
120
|
+
perField[fieldIndex].push(example);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (perField.every((examples) => examples.length === 0)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return perField;
|
|
127
|
+
}
|
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}
|
|
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
|
-
"
|
|
514
|
+
"",
|
|
515
515
|
"",
|
|
516
516
|
`<!-- AHEAD-FIELD:${number}:END -->`,
|
|
517
517
|
"",
|
|
518
518
|
];
|
|
519
519
|
});
|
|
520
520
|
return [
|
|
521
|
-
|
|
521
|
+
"# " + title,
|
|
522
522
|
"",
|
|
523
523
|
`AHEAD run: ${run.id}`,
|
|
524
524
|
`Phase: ${state.phase.id} (visit ${state.phase.visit})`,
|
|
@@ -526,12 +526,38 @@ export function buildArtifactTemplate(
|
|
|
526
526
|
"",
|
|
527
527
|
"## Required responses",
|
|
528
528
|
"",
|
|
529
|
-
"
|
|
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");
|
|
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",
|
|
@@ -757,33 +788,32 @@ async function openAheadMode(
|
|
|
757
788
|
}
|
|
758
789
|
|
|
759
790
|
actions.push({
|
|
760
|
-
label: "
|
|
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",
|
|
791
|
+
label: "Help · policy, guidance, skills, phase explanation",
|
|
776
792
|
run: async () => {
|
|
777
|
-
ctx.ui.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
793
|
+
const helpChoice = await ctx.ui.select("Help · pick one", [
|
|
794
|
+
"Configure project AHEAD policy for future runs",
|
|
795
|
+
"Read AHEAD framework guidance for this phase",
|
|
796
|
+
"Inspect optional skills reviewed for this phase",
|
|
797
|
+
"Explain this phase and its expectations",
|
|
798
|
+
]);
|
|
799
|
+
if (helpChoice === "Configure project AHEAD policy for future runs") {
|
|
800
|
+
await manageProjectConfig(ctx);
|
|
801
|
+
} else if (helpChoice === "Read AHEAD framework guidance for this phase") {
|
|
802
|
+
await showAheadGuide(ctx, "");
|
|
803
|
+
} else if (helpChoice === "Inspect optional skills reviewed for this phase") {
|
|
804
|
+
await showRecommendedSkills(ctx);
|
|
805
|
+
} else if (helpChoice === "Explain this phase and its expectations") {
|
|
806
|
+
ctx.ui.notify(
|
|
807
|
+
[
|
|
808
|
+
state.phase.title,
|
|
809
|
+
`Goal: ${guidance.objective}`,
|
|
810
|
+
`You: ${guidance.human}`,
|
|
811
|
+
`AI: ${guidance.ai}`,
|
|
812
|
+
`Gate: ${state.gate.title}`,
|
|
813
|
+
].join("\n"),
|
|
814
|
+
"info",
|
|
815
|
+
);
|
|
816
|
+
}
|
|
787
817
|
},
|
|
788
818
|
});
|
|
789
819
|
|
|
@@ -1475,7 +1505,10 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
|
|
|
1475
1505
|
parsed.title ||
|
|
1476
1506
|
linkedTitle ||
|
|
1477
1507
|
(ctx.hasUI
|
|
1478
|
-
? await ctx.ui.input(
|
|
1508
|
+
? await ctx.ui.input(
|
|
1509
|
+
`AHEAD · ${workflow.title} · Name this work — e.g. ${titleExample(workflow.id)}`,
|
|
1510
|
+
"",
|
|
1511
|
+
)
|
|
1479
1512
|
: parsed.workItemUrl);
|
|
1480
1513
|
if (!title?.trim()) {
|
|
1481
1514
|
return undefined;
|
|
@@ -1499,14 +1532,14 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
|
|
|
1499
1532
|
await store.save(run);
|
|
1500
1533
|
await refreshUi(ctx, run);
|
|
1501
1534
|
const state = engine.deriveState(run);
|
|
1535
|
+
const requiredPhase = state.policy.work_items.required_before_phase;
|
|
1502
1536
|
ctx.ui.notify(
|
|
1503
1537
|
[
|
|
1504
1538
|
`AHEAD mode started · ${workflow.title} · ${run.title}`,
|
|
1505
|
-
"Human leads · AI assists",
|
|
1506
1539
|
...(state.work_item ? [`Work item: ${state.work_item.url}`] : []),
|
|
1507
|
-
...(state.
|
|
1540
|
+
...(requiredPhase && !state.work_item
|
|
1508
1541
|
? [
|
|
1509
|
-
`
|
|
1542
|
+
`Before the ${requiredPhase} phase, link a work item: /ahead, then choose “Link or create a work item”.`,
|
|
1510
1543
|
]
|
|
1511
1544
|
: []),
|
|
1512
1545
|
"This run remains active until an accountable human closes the outcome or uses /ahead-stop.",
|
|
@@ -1517,6 +1550,25 @@ async function startRun(ctx: ExtensionCommandContext, request: string): Promise<
|
|
|
1517
1550
|
return run;
|
|
1518
1551
|
}
|
|
1519
1552
|
|
|
1553
|
+
function titleExample(workflowId: string): string {
|
|
1554
|
+
switch (workflowId) {
|
|
1555
|
+
case "product-change":
|
|
1556
|
+
return "“Add audit log viewer page”";
|
|
1557
|
+
case "internal-improvement":
|
|
1558
|
+
return "“Reduce cola-api cold-start time”";
|
|
1559
|
+
case "corrective-debugging":
|
|
1560
|
+
return "“Fix race in worker claim loop”";
|
|
1561
|
+
case "operational-stabilization":
|
|
1562
|
+
return "“Restore queue throughput after incident”";
|
|
1563
|
+
case "decision":
|
|
1564
|
+
return "“Choose audit-log retention policy”";
|
|
1565
|
+
case "investigation":
|
|
1566
|
+
return "“Why are runs flaking on CI?”";
|
|
1567
|
+
default:
|
|
1568
|
+
return "“Improve X”";
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1520
1572
|
async function recordHumanArtifact(
|
|
1521
1573
|
ctx: ExtensionCommandContext,
|
|
1522
1574
|
requestedKind: string,
|
|
@@ -1548,8 +1600,20 @@ async function recordHumanArtifact(
|
|
|
1548
1600
|
);
|
|
1549
1601
|
}
|
|
1550
1602
|
|
|
1551
|
-
const
|
|
1552
|
-
|
|
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
|
+
const examples = await draftFieldExamples(ctx, {
|
|
1607
|
+
workflowTitle: engine.getWorkflow(state.workflow_id).title,
|
|
1608
|
+
phaseTitle: state.phase.title,
|
|
1609
|
+
runTitle: run.title,
|
|
1610
|
+
fields: prompts,
|
|
1611
|
+
});
|
|
1612
|
+
if (examples) {
|
|
1613
|
+
template = insertFieldExamples(template, examples);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
let content = await ctx.ui.editor(
|
|
1553
1617
|
`AHEAD mode · ${artifact.title} · write in your own words`,
|
|
1554
1618
|
template,
|
|
1555
1619
|
);
|
|
@@ -1557,17 +1621,36 @@ async function recordHumanArtifact(
|
|
|
1557
1621
|
return;
|
|
1558
1622
|
}
|
|
1559
1623
|
if (artifact.kind !== "review-disposition") {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1624
|
+
// Keep the form open until validation passes or the human explicitly cancels,
|
|
1625
|
+
// so partial input is never discarded by a validation failure.
|
|
1626
|
+
while (true) {
|
|
1627
|
+
const formErrors = validateArtifactForm(content, prompts);
|
|
1628
|
+
if (formErrors.length === 0) {
|
|
1629
|
+
break;
|
|
1630
|
+
}
|
|
1631
|
+
const retry = await ctx.ui.confirm(
|
|
1632
|
+
"Some required fields are still empty",
|
|
1633
|
+
[
|
|
1634
|
+
`The following fields in ${artifact.title} need a response:`,
|
|
1635
|
+
"",
|
|
1636
|
+
...formErrors.map((error) => `- ${error}`),
|
|
1637
|
+
"",
|
|
1638
|
+
"Your text is preserved. Reopen to finish, or discard?",
|
|
1639
|
+
].join("\n"),
|
|
1640
|
+
);
|
|
1641
|
+
if (!retry) {
|
|
1642
|
+
ctx.ui.notify("Draft discarded. Nothing was saved.", "info");
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
const revised = await ctx.ui.editor(
|
|
1646
|
+
`AHEAD mode · ${artifact.title} · finish the required fields`,
|
|
1647
|
+
content,
|
|
1570
1648
|
);
|
|
1649
|
+
if (!revised?.trim()) {
|
|
1650
|
+
ctx.ui.notify("Draft kept in this dialog only — nothing was saved.", "info");
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
content = revised;
|
|
1571
1654
|
}
|
|
1572
1655
|
}
|
|
1573
1656
|
await validateHumanReviewArtifact(store, state, artifact.kind, content);
|
|
@@ -1582,8 +1665,21 @@ async function recordHumanArtifact(
|
|
|
1582
1665
|
await store.writeArtifact(path.absolute, content);
|
|
1583
1666
|
await store.save(updated);
|
|
1584
1667
|
await refreshUi(ctx, updated);
|
|
1668
|
+
const nextState = engine.deriveState(updated);
|
|
1669
|
+
const nextArtifact = nextState.artifacts.find(
|
|
1670
|
+
(candidate) => candidate.required && !candidate.present,
|
|
1671
|
+
);
|
|
1672
|
+
const nextStep = nextState.gate.accepted
|
|
1673
|
+
? nextState.gate.title
|
|
1674
|
+
: nextArtifact
|
|
1675
|
+
? `Next artifact: ${nextArtifact.title}`
|
|
1676
|
+
: `Accept the gate when ready: ${nextState.gate.title}`;
|
|
1585
1677
|
ctx.ui.notify(
|
|
1586
|
-
|
|
1678
|
+
[
|
|
1679
|
+
`✓ Saved ${artifact.title}.`,
|
|
1680
|
+
`Next: ${nextStep}`,
|
|
1681
|
+
"/ahead is available for the action menu; or just tell me what to do next.",
|
|
1682
|
+
].join("\n"),
|
|
1587
1683
|
"info",
|
|
1588
1684
|
);
|
|
1589
1685
|
}
|
|
@@ -1887,7 +1983,7 @@ async function refreshUi(ctx: ExtensionContext, supplied?: Run): Promise<void> {
|
|
|
1887
1983
|
"ahead",
|
|
1888
1984
|
state.closed
|
|
1889
1985
|
? `AHEAD · complete · ${state.workflow_id}`
|
|
1890
|
-
: `AHEAD · ${
|
|
1986
|
+
: `AHEAD · ${state.phase.id} · ${action.actor} action (${position.current}/${position.total})`,
|
|
1891
1987
|
);
|
|
1892
1988
|
if (state.closed) {
|
|
1893
1989
|
ctx.ui.setWidget("ahead", undefined);
|
|
@@ -2052,8 +2148,8 @@ async function toolResult(action: () => Promise<unknown>) {
|
|
|
2052
2148
|
}
|
|
2053
2149
|
|
|
2054
2150
|
function errorMessage(error: unknown): string {
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2151
|
+
// User-facing messages should read as prose. The internal error code is
|
|
2152
|
+
// available on AheadEngineError.code for tooling, but does not belong in
|
|
2153
|
+
// the user-visible text.
|
|
2058
2154
|
return error instanceof Error ? error.message : String(error);
|
|
2059
2155
|
}
|
package/src/storage.ts
CHANGED
|
@@ -44,17 +44,54 @@ export class RunStore {
|
|
|
44
44
|
this.aheadDirectory = join(rootPath, ".ahead");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return `${date}-${randomUUID().slice(0, 8)}`;
|
|
47
|
+
private get localDirectory(): string {
|
|
48
|
+
return join(this.aheadDirectory, "local");
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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) {
|