ahead-pi 0.2.1 → 0.3.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.
Files changed (91) hide show
  1. package/README.md +17 -7
  2. package/dist/ahead_wasm.wasm +0 -0
  3. package/generated/corrective-debugging/ai-audit.md +39 -0
  4. package/generated/corrective-debugging/ai-review.md +46 -0
  5. package/generated/corrective-debugging/characterize.md +53 -0
  6. package/generated/corrective-debugging/conclude.md +59 -0
  7. package/generated/corrective-debugging/correction.md +53 -0
  8. package/generated/corrective-debugging/deploy.md +38 -0
  9. package/generated/corrective-debugging/human-review.md +45 -0
  10. package/generated/corrective-debugging/implement.md +42 -0
  11. package/generated/corrective-debugging/investigate.md +59 -0
  12. package/generated/corrective-debugging/manifest.json +30 -0
  13. package/generated/corrective-debugging/model.md +53 -0
  14. package/generated/corrective-debugging/outcome.md +38 -0
  15. package/generated/corrective-debugging/plan.md +53 -0
  16. package/generated/corrective-debugging/verify.md +47 -0
  17. package/generated/decision/compare.md +45 -0
  18. package/generated/decision/criteria.md +45 -0
  19. package/generated/decision/decide.md +45 -0
  20. package/generated/decision/frame.md +45 -0
  21. package/generated/decision/manifest.json +21 -0
  22. package/generated/decision/options.md +47 -0
  23. package/generated/decision/publish.md +38 -0
  24. package/generated/decision/research.md +45 -0
  25. package/generated/internal-improvement/ai-audit.md +39 -0
  26. package/generated/internal-improvement/ai-review.md +46 -0
  27. package/generated/internal-improvement/baseline.md +46 -0
  28. package/generated/internal-improvement/decision.md +45 -0
  29. package/generated/internal-improvement/deploy.md +38 -0
  30. package/generated/internal-improvement/human-review.md +45 -0
  31. package/generated/internal-improvement/implement.md +42 -0
  32. package/generated/internal-improvement/invariants.md +38 -0
  33. package/generated/internal-improvement/manifest.json +29 -0
  34. package/generated/internal-improvement/options.md +47 -0
  35. package/generated/internal-improvement/outcome.md +38 -0
  36. package/generated/internal-improvement/plan.md +53 -0
  37. package/generated/internal-improvement/target.md +45 -0
  38. package/generated/internal-improvement/verify.md +45 -0
  39. package/generated/investigation/bound.md +45 -0
  40. package/generated/investigation/conclude.md +45 -0
  41. package/generated/investigation/explore.md +60 -0
  42. package/generated/investigation/frame.md +45 -0
  43. package/generated/investigation/gather.md +45 -0
  44. package/generated/investigation/manifest.json +21 -0
  45. package/generated/investigation/synthesize.md +51 -0
  46. package/generated/operational-stabilization/assess.md +46 -0
  47. package/generated/operational-stabilization/execute-observe.md +45 -0
  48. package/generated/operational-stabilization/manifest.json +19 -0
  49. package/generated/operational-stabilization/monitor.md +45 -0
  50. package/generated/operational-stabilization/outcome.md +38 -0
  51. package/generated/operational-stabilization/respond.md +40 -0
  52. package/generated/operational-stabilization/verify-recovery.md +45 -0
  53. package/generated/product-change/ai-audit.md +7 -4
  54. package/generated/product-change/ai-review.md +15 -5
  55. package/generated/product-change/decision.md +11 -2
  56. package/generated/product-change/define.md +4 -2
  57. package/generated/product-change/deploy.md +4 -2
  58. package/generated/product-change/human-review.md +11 -2
  59. package/generated/product-change/implement.md +4 -2
  60. package/generated/product-change/manifest.json +8 -3
  61. package/generated/product-change/options.md +11 -2
  62. package/generated/product-change/outcome.md +4 -2
  63. package/generated/product-change/plan.md +17 -2
  64. package/generated/product-change/questions.md +17 -2
  65. package/generated/product-change/research.md +11 -2
  66. package/generated/product-change/verify.md +4 -2
  67. package/generated/recommended-skills.json +24 -0
  68. package/generated/reference/docs/design/adapted-skill-guidance.md +27 -0
  69. package/generated/reference/docs/design/executable-workflows.md +20 -8
  70. package/generated/reference/docs/design/instruction-authoring.md +28 -0
  71. package/generated/reference/docs/design/review-workbench.md +37 -0
  72. package/generated/reference/docs/recommended-skills.md +19 -0
  73. package/generated/reference/docs/workflows/README.md +3 -3
  74. package/generated/reference/docs/workflows/corrective-debugging.md +37 -19
  75. package/generated/reference/docs/workflows/decision.md +2 -2
  76. package/generated/reference/docs/workflows/internal-improvement.md +35 -23
  77. package/generated/reference/docs/workflows/investigation.md +3 -1
  78. package/generated/reference/docs/workflows/operational-stabilization.md +14 -12
  79. package/generated/reference/docs/workflows/product-change.md +14 -3
  80. package/generated/reference/index.json +131 -13
  81. package/package.json +34 -25
  82. package/src/engine.ts +26 -7
  83. package/src/flow-guides.ts +168 -0
  84. package/src/guidance.ts +218 -72
  85. package/src/index.ts +603 -175
  86. package/src/reference-viewer.ts +20 -18
  87. package/src/reference.ts +65 -14
  88. package/src/review.ts +360 -0
  89. package/src/skills.ts +133 -0
  90. package/src/storage.ts +77 -13
  91. package/src/types.ts +1 -0
@@ -1,13 +1,5 @@
1
- import {
2
- getMarkdownTheme,
3
- type ExtensionCommandContext,
4
- } from "@earendil-works/pi-coding-agent";
5
- import {
6
- Markdown,
7
- matchesKey,
8
- truncateToWidth,
9
- visibleWidth,
10
- } from "@earendil-works/pi-tui";
1
+ import { getMarkdownTheme, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Markdown, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
3
 
12
4
  export async function showReferenceViewer(
13
5
  ctx: ExtensionCommandContext,
@@ -41,7 +33,9 @@ export async function showReferenceViewer(
41
33
  `${border("│")}${pad(` ${theme.fg("accent", theme.bold(title))}`)}${border("│")}`,
42
34
  `${border("│")}${pad(theme.fg("dim", ` ↑ ${scrollOffset} lines · ↓ ${remaining} lines`))}${border("│")}`,
43
35
  ];
44
- for (const line of visible) lines.push(`${border("│")}${pad(` ${line}`)}${border("│")}`);
36
+ for (const line of visible) {
37
+ lines.push(`${border("│")}${pad(` ${line}`)}${border("│")}`);
38
+ }
45
39
  for (let index = visible.length; index < pageSize; index += 1) {
46
40
  lines.push(`${border("│")}${pad("")}${border("│")}`);
47
41
  }
@@ -56,13 +50,21 @@ export async function showReferenceViewer(
56
50
  },
57
51
  handleInput(data: string): void {
58
52
  const maxOffset = Math.max(0, totalLines - pageSize);
59
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done();
60
- else if (matchesKey(data, "up")) scrollOffset = Math.max(0, scrollOffset - 1);
61
- else if (matchesKey(data, "down")) scrollOffset = Math.min(maxOffset, scrollOffset + 1);
62
- else if (matchesKey(data, "pageUp")) scrollOffset = Math.max(0, scrollOffset - pageSize);
63
- else if (matchesKey(data, "pageDown")) scrollOffset = Math.min(maxOffset, scrollOffset + pageSize);
64
- else if (matchesKey(data, "home")) scrollOffset = 0;
65
- else if (matchesKey(data, "end")) scrollOffset = maxOffset;
53
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
54
+ done();
55
+ } else if (matchesKey(data, "up")) {
56
+ scrollOffset = Math.max(0, scrollOffset - 1);
57
+ } else if (matchesKey(data, "down")) {
58
+ scrollOffset = Math.min(maxOffset, scrollOffset + 1);
59
+ } else if (matchesKey(data, "pageUp")) {
60
+ scrollOffset = Math.max(0, scrollOffset - pageSize);
61
+ } else if (matchesKey(data, "pageDown")) {
62
+ scrollOffset = Math.min(maxOffset, scrollOffset + pageSize);
63
+ } else if (matchesKey(data, "home")) {
64
+ scrollOffset = 0;
65
+ } else if (matchesKey(data, "end")) {
66
+ scrollOffset = maxOffset;
67
+ }
66
68
  tui.requestRender();
67
69
  },
68
70
  };
package/src/reference.ts CHANGED
@@ -9,6 +9,7 @@ export interface ReferenceEntry {
9
9
  title: string;
10
10
  summary: string;
11
11
  phases: string[];
12
+ workflows: string[];
12
13
  }
13
14
 
14
15
  interface ReferenceIndex {
@@ -19,28 +20,38 @@ interface ReferenceIndex {
19
20
  let indexPromise: Promise<ReferenceIndex> | undefined;
20
21
 
21
22
  export async function loadReferenceIndex(): Promise<ReferenceIndex> {
22
- indexPromise ??= readFile(`${referenceDirectory}index.json`, "utf8")
23
- .then((content) => JSON.parse(content) as ReferenceIndex);
23
+ indexPromise ??= readFile(`${referenceDirectory}index.json`, "utf8").then(parseReferenceIndex);
24
24
  return indexPromise;
25
25
  }
26
26
 
27
- export async function relevantReferences(phaseId?: string): Promise<ReferenceEntry[]> {
27
+ export async function relevantReferences(
28
+ workflowId?: string,
29
+ phaseId?: string,
30
+ ): Promise<ReferenceEntry[]> {
28
31
  const { references } = await loadReferenceIndex();
29
- if (!phaseId) return references.filter((entry) => entry.phases.includes("*"));
30
- return references.filter((entry) => entry.phases.includes("*") || entry.phases.includes(phaseId));
32
+ return references.filter(
33
+ (entry) =>
34
+ (entry.workflows.includes("*") || (!!workflowId && entry.workflows.includes(workflowId))) &&
35
+ (entry.phases.includes("*") || (!!phaseId && entry.phases.includes(phaseId))),
36
+ );
31
37
  }
32
38
 
33
39
  export async function findReference(topic: string): Promise<ReferenceEntry | undefined> {
34
40
  const normalized = normalize(topic);
35
41
  const { references } = await loadReferenceIndex();
36
- return references.find((entry) =>
37
- normalize(entry.id) === normalized
38
- || normalize(entry.path) === normalized
39
- || normalize(entry.title) === normalized
40
- ) ?? references.find((entry) =>
41
- normalize(entry.id).includes(normalized)
42
- || normalize(entry.path).includes(normalized)
43
- || normalize(entry.title).includes(normalized)
42
+ return (
43
+ references.find(
44
+ (entry) =>
45
+ normalize(entry.id) === normalized ||
46
+ normalize(entry.path) === normalized ||
47
+ normalize(entry.title) === normalized,
48
+ ) ??
49
+ references.find(
50
+ (entry) =>
51
+ normalize(entry.id).includes(normalized) ||
52
+ normalize(entry.path).includes(normalized) ||
53
+ normalize(entry.title).includes(normalized),
54
+ )
44
55
  );
45
56
  }
46
57
 
@@ -52,5 +63,45 @@ export async function readReference(entry: ReferenceEntry): Promise<string> {
52
63
  }
53
64
 
54
65
  function normalize(value: string): string {
55
- return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
66
+ return value
67
+ .trim()
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, "-")
70
+ .replace(/^-|-$/g, "");
71
+ }
72
+
73
+ function parseReferenceIndex(content: string): ReferenceIndex {
74
+ const value: unknown = JSON.parse(content);
75
+ if (!isRecord(value)) {
76
+ throw new Error("Invalid packaged AHEAD reference index");
77
+ }
78
+ const generatedFrom = value.generated_from;
79
+ const references = value.references;
80
+ if (
81
+ !Array.isArray(generatedFrom) ||
82
+ !generatedFrom.every((entry) => typeof entry === "string") ||
83
+ !Array.isArray(references) ||
84
+ !references.every(isReferenceEntry)
85
+ ) {
86
+ throw new Error("Invalid packaged AHEAD reference index");
87
+ }
88
+ return { generated_from: generatedFrom, references };
89
+ }
90
+
91
+ function isReferenceEntry(value: unknown): value is ReferenceEntry {
92
+ return (
93
+ isRecord(value) &&
94
+ typeof value.id === "string" &&
95
+ typeof value.path === "string" &&
96
+ typeof value.title === "string" &&
97
+ typeof value.summary === "string" &&
98
+ Array.isArray(value.phases) &&
99
+ value.phases.every((phase) => typeof phase === "string") &&
100
+ Array.isArray(value.workflows) &&
101
+ value.workflows.every((workflow) => typeof workflow === "string")
102
+ );
103
+ }
104
+
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === "object" && value !== null;
56
107
  }
package/src/review.ts ADDED
@@ -0,0 +1,360 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { lstat, readFile, readlink } from "node:fs/promises";
4
+ import { isAbsolute, resolve } from "node:path";
5
+
6
+ const excludedAheadPathspec = ":(exclude).ahead/**";
7
+ const findingHeadingPattern = /^##\s+(AR-\d{3,})\b/gim;
8
+ const fingerprintPattern = /^AHEAD-Review-Snapshot:\s*([a-f0-9]{64})\s*$/im;
9
+
10
+ export interface SourceLocation {
11
+ path: string;
12
+ line?: number;
13
+ column?: number;
14
+ }
15
+
16
+ export interface ReviewSnapshot {
17
+ api_version: "ahead.review-snapshot/v0.1";
18
+ base_ref: string;
19
+ base_oid: string;
20
+ merge_base_oid: string;
21
+ head_oid: string;
22
+ captured_at: string;
23
+ fingerprint: string;
24
+ status: string;
25
+ changed_files: string[];
26
+ untracked_files: Array<{ path: string; sha256: string }>;
27
+ diff: string;
28
+ }
29
+
30
+ export interface ReviewHost {
31
+ showDiff(snapshot: ReviewSnapshot): Promise<void>;
32
+ openLocation(location: SourceLocation): Promise<boolean>;
33
+ }
34
+
35
+ export async function collectReviewSnapshot(root: string): Promise<ReviewSnapshot> {
36
+ const headOid = gitValue(root, ["rev-parse", "HEAD^{commit}"]);
37
+ const base = headOid ? resolveReviewBase(root) : "empty-tree";
38
+ const baseOid = headOid
39
+ ? git(root, ["rev-parse", `${base}^{commit}`])
40
+ : git(root, ["hash-object", "-t", "tree", "--stdin"], "");
41
+ const mergeBaseOid = headOid ? git(root, ["merge-base", baseOid, headOid]) : baseOid;
42
+ const pathspec = ["--", ".", excludedAheadPathspec];
43
+ const status = git(root, ["status", "--porcelain=v1", "--untracked-files=all", ...pathspec]);
44
+ const diff = git(root, [
45
+ "diff",
46
+ "--no-ext-diff",
47
+ "--binary",
48
+ "--unified=3",
49
+ mergeBaseOid,
50
+ ...pathspec,
51
+ ]);
52
+ const trackedFiles = gitPaths(root, [
53
+ "diff",
54
+ "--name-only",
55
+ "--diff-filter=ACDMRTUXB",
56
+ "-z",
57
+ mergeBaseOid,
58
+ ...pathspec,
59
+ ]);
60
+ const untrackedPaths = gitPaths(root, [
61
+ "ls-files",
62
+ "--others",
63
+ "--exclude-standard",
64
+ "-z",
65
+ "--",
66
+ ".",
67
+ excludedAheadPathspec,
68
+ ]);
69
+ const untrackedFiles = [];
70
+ for (const path of untrackedPaths) {
71
+ const absolute = safeRepositoryPath(root, path);
72
+ const metadata = await lstat(absolute);
73
+ const content = metadata.isSymbolicLink()
74
+ ? Buffer.from(`symlink:${await readlink(absolute)}`)
75
+ : metadata.isFile()
76
+ ? await readFile(absolute)
77
+ : Buffer.from(`special:${metadata.mode}`);
78
+ untrackedFiles.push({ path, sha256: createHash("sha256").update(content).digest("hex") });
79
+ }
80
+ const changedFiles = [...new Set([...trackedFiles, ...untrackedPaths])].toSorted();
81
+ const fingerprint = createHash("sha256")
82
+ .update(baseOid)
83
+ .update("\0")
84
+ .update(mergeBaseOid)
85
+ .update("\0")
86
+ .update(headOid ?? "UNBORN")
87
+ .update("\0")
88
+ .update(status)
89
+ .update("\0")
90
+ .update(diff)
91
+ .update("\0")
92
+ .update(JSON.stringify(untrackedFiles))
93
+ .digest("hex");
94
+ return {
95
+ api_version: "ahead.review-snapshot/v0.1",
96
+ base_ref: base,
97
+ base_oid: baseOid,
98
+ merge_base_oid: mergeBaseOid,
99
+ head_oid: headOid ?? "UNBORN",
100
+ captured_at: new Date().toISOString(),
101
+ fingerprint,
102
+ status,
103
+ changed_files: changedFiles,
104
+ untracked_files: untrackedFiles,
105
+ diff,
106
+ };
107
+ }
108
+
109
+ export function reviewSnapshotMarkdown(snapshot: ReviewSnapshot): string {
110
+ return `# Review snapshot
111
+
112
+ AHEAD-Review-Snapshot: ${snapshot.fingerprint}
113
+
114
+ - Base ref: \`${snapshot.base_ref}\`
115
+ - Base commit: \`${snapshot.base_oid}\`
116
+ - Merge base: \`${snapshot.merge_base_oid}\`
117
+ - HEAD: \`${snapshot.head_oid}\`
118
+ - Captured: ${snapshot.captured_at}
119
+ - Changed files: ${snapshot.changed_files.length}
120
+ - Untracked files: ${snapshot.untracked_files.length}
121
+
122
+ ## Changed files
123
+
124
+ ${snapshot.changed_files.length ? snapshot.changed_files.map((path) => `- \`${path}\``).join("\n") : "No engineering changes detected."}
125
+
126
+ ## Working-tree status
127
+
128
+ \`\`\`text
129
+ ${snapshot.status || "clean"}
130
+ \`\`\``;
131
+ }
132
+
133
+ export function reviewRequest(snapshot: ReviewSnapshot): string {
134
+ return [
135
+ "AHEAD mode: independently review the exact current changeset before human review.",
136
+ `AHEAD-Review-Snapshot: ${snapshot.fingerprint}`,
137
+ `Review the diff from merge base ${snapshot.merge_base_oid} through the working tree, including the listed untracked files.`,
138
+ `Changed files: ${snapshot.changed_files.length ? snapshot.changed_files.join(", ") : "none"}.`,
139
+ "First call ahead_get_review_snapshot and confirm its fingerprint still matches. If it differs, stop and tell the human the snapshot changed.",
140
+ "Review correctness, security, tests, architecture, plan compliance, operations, and maintainability without modifying files.",
141
+ "Use this exact Markdown shape for each finding: `## AR-001`, then nonempty `- Severity:`, `- Category:`, `- Location:`, `- Evidence:`, `- Impact:`, and `- Explanation:` fields. Use `## No material findings` with supporting evidence only when there are none.",
142
+ "End with `## Areas not assessed` and a nonempty statement. Keep questions separate from findings.",
143
+ "Include the exact AHEAD-Review-Snapshot line in the artifact. Record only AI findings as ai-review with ahead_record_artifact. Do not propose or record the human disposition, accept the gate, or transition the run.",
144
+ ].join("\n");
145
+ }
146
+
147
+ export function extractFindingIds(content: string): string[] {
148
+ return [
149
+ ...new Set([...content.matchAll(findingHeadingPattern)].map((match) => match[1])),
150
+ ].toSorted();
151
+ }
152
+
153
+ export function extractReviewFingerprint(content: string): string | undefined {
154
+ return content.match(fingerprintPattern)?.[1];
155
+ }
156
+
157
+ export function validateAiReviewArtifact(content: string, expectedFingerprint: string): string[] {
158
+ const errors = [];
159
+ if (extractReviewFingerprint(content) !== expectedFingerprint) {
160
+ errors.push("the AI review is not bound to the current changeset snapshot");
161
+ }
162
+ const headingIds = [...content.matchAll(findingHeadingPattern)].map((match) => match[1]);
163
+ const findingIds = [...new Set(headingIds)];
164
+ if (findingIds.length !== headingIds.length) {
165
+ errors.push("AI review finding identifiers must be unique");
166
+ }
167
+ if (findingIds.length === 0) {
168
+ const noFindings = markdownSection(content, "No material findings");
169
+ if (!hasSubstantiveContent(noFindings)) {
170
+ errors.push(
171
+ "AI review must contain structured AR findings or a supported no-findings statement",
172
+ );
173
+ }
174
+ }
175
+ for (const id of findingIds) {
176
+ const section = markdownSection(content, id);
177
+ for (const field of ["Severity", "Category", "Location", "Evidence", "Impact", "Explanation"]) {
178
+ if (!section?.match(new RegExp(`^- ${field}:[ \\t]*(\\S.*)$`, "im"))?.[1]?.trim()) {
179
+ errors.push(`${id} needs a nonempty ${field.toLowerCase()} field`);
180
+ }
181
+ }
182
+ }
183
+ if (!hasSubstantiveContent(markdownSection(content, "Areas not assessed"))) {
184
+ errors.push("AI review must state which material areas were not assessed, including none");
185
+ }
186
+ return errors;
187
+ }
188
+
189
+ export function reviewDispositionTemplate(snapshot: ReviewSnapshot, findingIds: string[]): string {
190
+ const dispositions = findingIds.length
191
+ ? findingIds
192
+ .map(
193
+ (id) => `## ${id}
194
+
195
+ - Disposition: <!-- fixed | invalid | accepted-risk | follow-up -->
196
+ - Rationale and evidence:
197
+ - Resulting change or follow-up:
198
+ `,
199
+ )
200
+ .join("\n")
201
+ : `## No material findings
202
+
203
+ - Confirmation: <!-- Confirm that the AI review reported no material AR findings. -->
204
+ `;
205
+ return `# Human disposition of AI review findings
206
+
207
+ AHEAD-Review-Snapshot: ${snapshot.fingerprint}
208
+
209
+ <!-- This is a human-owned record. Validate the AI findings; do not copy an AI decision. Every material AR finding must have one allowed disposition and rationale. -->
210
+
211
+ ${dispositions}`;
212
+ }
213
+
214
+ export function validateReviewDisposition(
215
+ content: string,
216
+ expectedFingerprint: string,
217
+ findingIds: string[],
218
+ ): string[] {
219
+ const errors = [];
220
+ if (extractReviewFingerprint(content) !== expectedFingerprint) {
221
+ errors.push("the disposition is not bound to the current AI review snapshot");
222
+ }
223
+ for (const id of findingIds) {
224
+ const escaped = id.replaceAll("-", "\\-");
225
+ const section = content.match(
226
+ new RegExp(`##\\s+${escaped}([\\s\\S]*?)(?=\\n##\\s+AR-|$)`, "i"),
227
+ )?.[1];
228
+ const disposition = section?.match(
229
+ /Disposition:\s*(fixed|invalid|accepted-risk|follow-up)\s*$/im,
230
+ );
231
+ const rationale = section?.match(/Rationale and evidence:[ \t]*(.+)[ \t]*$/im)?.[1]?.trim();
232
+ if (!disposition || !rationale) {
233
+ errors.push(`${id} needs fixed, invalid, accepted-risk, or follow-up with rationale`);
234
+ }
235
+ }
236
+ if (
237
+ findingIds.length === 0 &&
238
+ !content.match(/Confirmation:[ \t]*(?!<!--)(\S.*)[ \t]*$/im)?.[1]?.trim()
239
+ ) {
240
+ errors.push("confirm that the AI review reported no material findings");
241
+ }
242
+ return errors;
243
+ }
244
+
245
+ export function openInConfiguredEditor(root: string, location: SourceLocation): boolean {
246
+ const command = configuredEditorCommand();
247
+ if (!command) {
248
+ return false;
249
+ }
250
+ const absolute = safeRepositoryPath(root, location.path);
251
+ const line = location.line ?? 1;
252
+ const column = location.column ?? 1;
253
+ const target = `${absolute}:${line}:${column}`;
254
+ const result = spawnSync(command, ["--goto", target], { stdio: "ignore" });
255
+ return !result.error && result.status === 0;
256
+ }
257
+
258
+ function resolveReviewBase(root: string): string {
259
+ const explicit = process.env.AHEAD_REVIEW_BASE?.trim();
260
+ if (explicit) {
261
+ if (gitOptional(root, ["rev-parse", "--verify", `${explicit}^{commit}`])) {
262
+ return explicit;
263
+ }
264
+ throw new Error(`AHEAD_REVIEW_BASE does not identify a commit: ${explicit}`);
265
+ }
266
+ const candidates = [
267
+ "origin/HEAD",
268
+ "origin/main",
269
+ "origin/master",
270
+ "main",
271
+ "master",
272
+ "@{upstream}",
273
+ "HEAD^",
274
+ ];
275
+ for (const candidate of candidates) {
276
+ if (gitOptional(root, ["rev-parse", "--verify", `${candidate}^{commit}`])) {
277
+ return candidate;
278
+ }
279
+ }
280
+ return "HEAD";
281
+ }
282
+
283
+ function configuredEditorCommand(): string | undefined {
284
+ const explicit = process.env.AHEAD_EDITOR?.trim();
285
+ if (explicit === "vscode" || explicit === "code") {
286
+ return "code";
287
+ }
288
+ if (explicit === "none") {
289
+ return undefined;
290
+ }
291
+ return process.env.VSCODE_IPC_HOOK_CLI || process.env.TERM_PROGRAM === "vscode"
292
+ ? "code"
293
+ : undefined;
294
+ }
295
+
296
+ function safeRepositoryPath(root: string, path: string): string {
297
+ if (isAbsolute(path)) {
298
+ throw new Error("review path must be relative to the repository");
299
+ }
300
+ const repositoryRoot = resolve(root);
301
+ const absolute = resolve(repositoryRoot, path);
302
+ if (absolute !== repositoryRoot && !absolute.startsWith(`${repositoryRoot}/`)) {
303
+ throw new Error("review path escaped the repository");
304
+ }
305
+ return absolute;
306
+ }
307
+
308
+ function git(root: string, args: string[], input?: string): string {
309
+ return execFileSync("git", args, {
310
+ cwd: root,
311
+ encoding: "utf8",
312
+ input,
313
+ maxBuffer: 32 * 1024 * 1024,
314
+ stdio: ["ignore", "pipe", "pipe"],
315
+ }).trimEnd();
316
+ }
317
+
318
+ function gitPaths(root: string, args: string[]): string[] {
319
+ const output = execFileSync("git", args, {
320
+ cwd: root,
321
+ maxBuffer: 32 * 1024 * 1024,
322
+ stdio: ["ignore", "pipe", "pipe"],
323
+ });
324
+ return output.toString("utf8").split("\0").filter(Boolean);
325
+ }
326
+
327
+ function gitValue(root: string, args: string[]): string | undefined {
328
+ try {
329
+ return git(root, args);
330
+ } catch {
331
+ return undefined;
332
+ }
333
+ }
334
+
335
+ function gitOptional(root: string, args: string[]): boolean {
336
+ try {
337
+ git(root, args);
338
+ return true;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ function markdownSection(content: string, heading: string): string | undefined {
345
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
346
+ const match = new RegExp(`^##\\s+${escaped}\\s*$`, "im").exec(content);
347
+ if (!match) {
348
+ return undefined;
349
+ }
350
+ const remaining = content.slice(match.index + match[0].length).replace(/^\r?\n/, "");
351
+ const nextHeading = remaining.search(/^##\s+/m);
352
+ return nextHeading < 0 ? remaining : remaining.slice(0, nextHeading);
353
+ }
354
+
355
+ function hasSubstantiveContent(content: string | undefined): boolean {
356
+ return !!content
357
+ ?.split("\n")
358
+ .map((line) => line.trim())
359
+ .find((line) => line && !line.startsWith("<!--"));
360
+ }
package/src/skills.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const catalogPath = fileURLToPath(new URL("../generated/recommended-skills.json", import.meta.url));
5
+
6
+ export interface RecommendedSkill {
7
+ id: string;
8
+ title: string;
9
+ summary: string;
10
+ source: string;
11
+ skill: string;
12
+ reviewed_ref: string;
13
+ reviewed_url: string;
14
+ license: string;
15
+ install: string;
16
+ workflows: string[];
17
+ phases: string[];
18
+ compatibility: string[];
19
+ }
20
+
21
+ export interface RecommendedSkillCatalog {
22
+ api_version: "ahead.recommended-skills/v0.1";
23
+ reviewed_at: string;
24
+ skills: RecommendedSkill[];
25
+ }
26
+
27
+ let cachedCatalog: RecommendedSkillCatalog | undefined;
28
+
29
+ export async function loadRecommendedSkills(): Promise<RecommendedSkillCatalog> {
30
+ cachedCatalog ??= parseCatalog(await readFile(catalogPath, "utf8"));
31
+ return cachedCatalog;
32
+ }
33
+
34
+ export function relevantRecommendedSkills(
35
+ catalog: RecommendedSkillCatalog,
36
+ workflowId?: string,
37
+ phaseId?: string,
38
+ ): RecommendedSkill[] {
39
+ if (!workflowId || !phaseId) {
40
+ return [];
41
+ }
42
+ return catalog.skills.filter(
43
+ (skill) => skill.workflows.includes(workflowId) && skill.phases.includes(phaseId),
44
+ );
45
+ }
46
+
47
+ export function recommendedSkillsMarkdown(
48
+ catalog: RecommendedSkillCatalog,
49
+ skills: RecommendedSkill[] = catalog.skills,
50
+ ): string {
51
+ const entries = skills.map(
52
+ (skill) => `## ${skill.title}
53
+
54
+ ${skill.summary}
55
+
56
+ - Reviewed source: ${skill.reviewed_url}
57
+ - Reviewed revision: \`${skill.reviewed_ref}\`
58
+ - License: ${skill.license}
59
+ - Applicable workflows: ${skill.workflows.join(", ")}
60
+ - Applicable phases: ${skill.phases.join(", ")}
61
+
62
+ Compatibility with AHEAD:
63
+
64
+ ${skill.compatibility.map((constraint) => `- ${constraint}`).join("\n")}
65
+
66
+ Optional install after you inspect the source:
67
+
68
+ \`\`\`sh
69
+ ${skill.install}
70
+ \`\`\``,
71
+ );
72
+ return `# AHEAD recommended skills
73
+
74
+ Catalog reviewed ${catalog.reviewed_at}. AHEAD does not bundle or install these skills. The active AHEAD workflow remains authoritative.
75
+
76
+ ${entries.length ? entries.join("\n\n") : "No recommended skill applies to this phase."}`;
77
+ }
78
+
79
+ function parseCatalog(content: string): RecommendedSkillCatalog {
80
+ const value: unknown = JSON.parse(content);
81
+ if (!isRecord(value) || value.api_version !== "ahead.recommended-skills/v0.1") {
82
+ throw new Error("invalid AHEAD recommended-skill catalog");
83
+ }
84
+ if (typeof value.reviewed_at !== "string" || !Array.isArray(value.skills)) {
85
+ throw new Error("invalid AHEAD recommended-skill catalog metadata");
86
+ }
87
+ const skills = value.skills.map(parseSkill);
88
+ return { api_version: value.api_version, reviewed_at: value.reviewed_at, skills };
89
+ }
90
+
91
+ function parseSkill(value: unknown): RecommendedSkill {
92
+ if (!isRecord(value)) {
93
+ throw new Error("invalid AHEAD recommended-skill entry");
94
+ }
95
+ return {
96
+ id: requiredString(value, "id"),
97
+ title: requiredString(value, "title"),
98
+ summary: requiredString(value, "summary"),
99
+ source: requiredString(value, "source"),
100
+ skill: requiredString(value, "skill"),
101
+ reviewed_ref: requiredString(value, "reviewed_ref"),
102
+ reviewed_url: requiredString(value, "reviewed_url"),
103
+ license: requiredString(value, "license"),
104
+ install: requiredString(value, "install"),
105
+ workflows: requiredStringArray(value, "workflows"),
106
+ phases: requiredStringArray(value, "phases"),
107
+ compatibility: requiredStringArray(value, "compatibility"),
108
+ };
109
+ }
110
+
111
+ function requiredString(value: Record<string, unknown>, key: string): string {
112
+ const field = value[key];
113
+ if (typeof field !== "string" || field.length === 0) {
114
+ throw new Error(`invalid recommended-skill ${key}`);
115
+ }
116
+ return field;
117
+ }
118
+
119
+ function requiredStringArray(value: Record<string, unknown>, key: string): string[] {
120
+ const field = value[key];
121
+ if (!isStringArray(field)) {
122
+ throw new Error(`invalid recommended-skill ${key}`);
123
+ }
124
+ return field;
125
+ }
126
+
127
+ function isStringArray(value: unknown): value is string[] {
128
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
129
+ }
130
+
131
+ function isRecord(value: unknown): value is Record<string, unknown> {
132
+ return typeof value === "object" && value !== null;
133
+ }