pi-matt-pocock 0.2.2 → 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 (70) hide show
  1. package/ARCHITECTURE.zh-CN.md +37 -19
  2. package/README.md +80 -13
  3. package/package.json +12 -7
  4. package/procedures/code-review.md +25 -17
  5. package/procedures/codebase-design.md +11 -10
  6. package/procedures/deslop.md +4 -2
  7. package/procedures/diagnosing-bugs.md +25 -25
  8. package/procedures/domain-modeling.md +18 -20
  9. package/procedures/grill-me.md +1 -1
  10. package/procedures/grill-with-docs.md +1 -1
  11. package/procedures/grilling.md +29 -5
  12. package/procedures/handoff.md +1 -1
  13. package/procedures/implement.md +7 -1
  14. package/procedures/improve-codebase-architecture.md +22 -22
  15. package/procedures/pr.md +169 -0
  16. package/procedures/prototype.md +7 -6
  17. package/procedures/research.md +2 -2
  18. package/procedures/retro.md +42 -0
  19. package/procedures/setup-matt-pocock-skills.md +26 -22
  20. package/procedures/tdd.md +17 -15
  21. package/procedures/teach.md +11 -7
  22. package/procedures/to-questionnaire.md +7 -5
  23. package/procedures/to-spec.md +2 -2
  24. package/procedures/to-tickets.md +12 -12
  25. package/procedures/triage.md +22 -22
  26. package/procedures/wait-what.md +1 -1
  27. package/procedures/wayfinder.md +24 -24
  28. package/procedures/wizard.md +7 -7
  29. package/procedures/writing-for-agents.md +14 -14
  30. package/{procedures → references}/ADR-FORMAT.md +11 -11
  31. package/{procedures → references}/AGENT-BRIEF.md +4 -4
  32. package/{procedures → references}/DEEPENING.md +3 -3
  33. package/{procedures → references}/DESIGN-IT-TWICE.md +7 -7
  34. package/{procedures → references}/GLOSSARY-FORMAT.md +9 -2
  35. package/{procedures → references}/HTML-REPORT.md +15 -15
  36. package/{procedures → references}/LEARNING-RECORD-FORMAT.md +10 -10
  37. package/{procedures → references}/LOGIC.md +5 -5
  38. package/{procedures → references}/MISSION-FORMAT.md +2 -2
  39. package/{procedures → references}/OUT-OF-SCOPE.md +11 -11
  40. package/{procedures → references}/RESOURCES-FORMAT.md +1 -1
  41. package/{procedures → references}/SKILL-MECHANICS.md +3 -3
  42. package/{procedures → references}/UI.md +14 -14
  43. package/{procedures → references}/bdd-best-practices.md +3 -3
  44. package/{procedures → references}/bdd.md +9 -9
  45. package/{procedures → references}/domain.md +12 -12
  46. package/references/issue-tracker-github.md +50 -0
  47. package/{procedures → references}/issue-tracker-gitlab.md +9 -9
  48. package/{procedures → references}/issue-tracker-local.md +3 -3
  49. package/{procedures → references}/phase-boundaries.md +8 -8
  50. package/src/catalog.json +49 -28
  51. package/src/catalog.ts +58 -2
  52. package/src/index.ts +521 -383
  53. package/src/native-dialog.ts +149 -213
  54. package/src/resolver.ts +30 -14
  55. package/src/workflow.ts +55 -16
  56. package/upstream/UPSTREAM.md +116 -0
  57. package/{scripts → upstream}/check-upstream-sync.mjs +12 -12
  58. package/{upstream-selection.json → upstream/upstream-selection.json} +9 -7
  59. package/UPSTREAM.md +0 -63
  60. package/procedures/CONTEXT-FORMAT.md +0 -60
  61. package/procedures/issue-tracker-github.md +0 -45
  62. /package/{procedures → assets}/hitl-loop.template.sh +0 -0
  63. /package/{procedures → assets}/template.sh +0 -0
  64. /package/{procedures → references}/GLOSSARY.md +0 -0
  65. /package/{procedures → references}/gherkin-guide.md +0 -0
  66. /package/{procedures → references}/mocking.md +0 -0
  67. /package/{procedures → references}/testing-anti-patterns.md +0 -0
  68. /package/{procedures → references}/tests.md +0 -0
  69. /package/{procedures → references}/triage-labels.md +0 -0
  70. /package/{procedures → references}/writing-great-skills.md +0 -0
@@ -1,30 +1,31 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
- import * as os from "node:os";
4
3
  import * as path from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export interface PiMattPocockConfig {
7
7
  useNativeDialog: boolean;
8
8
  }
9
9
 
10
+ /**
11
+ * Resolve the package config inside Pi's agent directory, so
12
+ * PI_CODING_AGENT_DIR overrides apply like they do for the rest of Pi.
13
+ */
10
14
  export function defaultConfigFile(): string {
11
- return path.join(os.homedir(), ".pi", "agent", "pi-matt-pocock.json");
15
+ return path.join(getAgentDir(), "matt-pocock.json");
12
16
  }
13
17
 
14
18
  /**
15
- * Load package configuration from ~/.pi/agent/pi-matt-pocock.json
19
+ * Load package configuration from Pi's agent directory (matt-pocock.json)
16
20
  * Defaults to useNativeDialog: false if the file is absent or invalid.
17
21
  */
18
22
  export function loadConfig(configPath?: string): PiMattPocockConfig {
19
23
  const targetPath = configPath ?? process.env.PI_MATT_POCOCK_CONFIG_FILE ?? defaultConfigFile();
20
24
  try {
21
- if (!fs.existsSync(targetPath)) {
22
- return { useNativeDialog: false };
23
- }
24
25
  const raw = fs.readFileSync(targetPath, "utf8");
25
26
  const parsed = JSON.parse(raw);
26
27
  return {
27
- useNativeDialog: Boolean(parsed?.useNativeDialog),
28
+ useNativeDialog: parsed?.useNativeDialog === true,
28
29
  };
29
30
  } catch {
30
31
  return { useNativeDialog: false };
@@ -67,6 +68,85 @@ export function isNativeDialogSupported(options: NativeDialogEnvironment = {}):
67
68
  return true;
68
69
  }
69
70
 
71
+ interface JxaResult {
72
+ stdout: string;
73
+ timedOut: boolean;
74
+ aborted: boolean;
75
+ }
76
+
77
+ function normalizeTimeoutSeconds(value: number | undefined): number | undefined {
78
+ if (value === undefined) return undefined;
79
+ if (!Number.isFinite(value) || value <= 0) {
80
+ throw new Error("Native dialog timeout must be a finite number greater than zero.");
81
+ }
82
+ if (value * 1000 > 2_147_483_647) {
83
+ throw new Error("Native dialog timeout exceeds the Node.js timer limit.");
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function validateDialogButtons(buttons: string[], defaultButton: string, cancelButton: string | undefined): void {
89
+ if (buttons.some((button) => !button.trim()) || new Set(buttons).size !== buttons.length) {
90
+ throw new Error("Native dialog buttons must be non-empty and unique.");
91
+ }
92
+ if (!buttons.includes(defaultButton)) {
93
+ throw new Error(`Native dialog default button is not offered: ${defaultButton}`);
94
+ }
95
+ if (cancelButton !== undefined && !buttons.includes(cancelButton)) {
96
+ throw new Error(`Native dialog cancel button is not offered: ${cancelButton}`);
97
+ }
98
+ }
99
+
100
+ function parseNativeJson(stdout: string, message: string): unknown {
101
+ try {
102
+ return JSON.parse(stdout.trim());
103
+ } catch {
104
+ throw new Error(message);
105
+ }
106
+ }
107
+
108
+ /** Close native dialogs with their owning tool; always reap the subprocess. */
109
+ async function runJxa(script: string, options: { timeoutSeconds?: number; signal?: AbortSignal }): Promise<JxaResult> {
110
+ if (options.signal?.aborted) return { stdout: "", timedOut: false, aborted: true };
111
+ return new Promise((resolve, reject) => {
112
+ const proc = spawn("osascript", ["-l", "JavaScript", "-e", script], {
113
+ stdio: ["ignore", "pipe", "pipe"],
114
+ });
115
+ let stdout = "";
116
+ let stderr = "";
117
+ let timedOut = false;
118
+ let aborted = false;
119
+ let timer: NodeJS.Timeout | undefined;
120
+ let escalation: NodeJS.Timeout | undefined;
121
+ const stop = () => {
122
+ proc.kill("SIGTERM");
123
+ escalation ??= setTimeout(() => proc.kill("SIGKILL"), 500);
124
+ };
125
+ const abort = () => { aborted = true; stop(); };
126
+ const cleanup = () => {
127
+ if (timer) clearTimeout(timer);
128
+ if (escalation) clearTimeout(escalation);
129
+ options.signal?.removeEventListener("abort", abort);
130
+ };
131
+ options.signal?.addEventListener("abort", abort, { once: true });
132
+ if (options.signal?.aborted) abort();
133
+ if (options.timeoutSeconds !== undefined && options.timeoutSeconds > 0) {
134
+ timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutSeconds * 1000);
135
+ }
136
+ proc.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); });
137
+ proc.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); });
138
+ proc.once("error", (error) => { cleanup(); reject(error); });
139
+ proc.once("close", (code) => {
140
+ cleanup();
141
+ if (!aborted && !timedOut && code !== 0 && !stderr.includes("-128") && !stderr.includes("User canceled")) {
142
+ reject(new Error("Native dialog process failed."));
143
+ } else {
144
+ resolve({ stdout, timedOut, aborted });
145
+ }
146
+ });
147
+ });
148
+ }
149
+
70
150
  export interface MacosChooseFromListOptions {
71
151
  title?: string;
72
152
  prompt: string;
@@ -75,6 +155,7 @@ export interface MacosChooseFromListOptions {
75
155
  okButtonName?: string;
76
156
  cancelButtonName?: string;
77
157
  timeoutSeconds?: number;
158
+ signal?: AbortSignal;
78
159
  }
79
160
 
80
161
  export interface MacosChooseFromListResult {
@@ -119,77 +200,16 @@ export async function macosChooseFromList(
119
200
  JSON.stringify(res);
120
201
  `;
121
202
 
122
- return new Promise<MacosChooseFromListResult>((resolve) => {
123
- const proc = spawn("osascript", ["-l", "JavaScript", "-e", jxaScript], {
124
- stdio: ["ignore", "pipe", "pipe"],
125
- });
126
-
127
- let stdout = "";
128
- let stderr = "";
129
- let isTimedOut = false;
130
- let timer: NodeJS.Timeout | undefined;
131
- let killEscalation: NodeJS.Timeout | undefined;
132
-
133
- if (options.timeoutSeconds !== undefined && options.timeoutSeconds > 0) {
134
- timer = setTimeout(() => {
135
- isTimedOut = true;
136
- try {
137
- proc.kill("SIGTERM");
138
- } catch {
139
- // ignore kill errors
140
- }
141
- killEscalation = setTimeout(() => {
142
- try {
143
- proc.kill("SIGKILL");
144
- } catch {
145
- // ignore kill errors
146
- }
147
- }, 500);
148
- }, options.timeoutSeconds * 1000);
149
- }
150
-
151
- proc.stdout.on("data", (chunk: Buffer) => {
152
- stdout += chunk.toString("utf8");
153
- });
154
- proc.stderr.on("data", (chunk: Buffer) => {
155
- stderr += chunk.toString("utf8");
156
- });
157
-
158
- proc.on("error", () => {
159
- if (timer) clearTimeout(timer);
160
- if (killEscalation) clearTimeout(killEscalation);
161
- resolve({ action: "cancelled" });
162
- });
163
-
164
- proc.on("close", (code) => {
165
- if (timer) clearTimeout(timer);
166
- if (killEscalation) clearTimeout(killEscalation);
167
-
168
- if (isTimedOut) {
169
- return resolve({ action: "timed_out" });
170
- }
171
-
172
- if (code === 0 && stdout.trim()) {
173
- try {
174
- const parsed = JSON.parse(stdout.trim());
175
- if (Array.isArray(parsed) && parsed.length > 0 && typeof parsed[0] === "string") {
176
- return resolve({ action: "selected", item: parsed[0] });
177
- }
178
- if (parsed === false) {
179
- return resolve({ action: "cancelled" });
180
- }
181
- } catch {
182
- // fallback
183
- }
184
- }
185
-
186
- if (stderr.includes("-128") || stderr.includes("User canceled")) {
187
- return resolve({ action: "cancelled" });
188
- }
189
-
190
- resolve({ action: "cancelled" });
191
- });
192
- });
203
+ const result = await runJxa(jxaScript, { ...options, timeoutSeconds: normalizeTimeoutSeconds(options.timeoutSeconds) });
204
+ if (result.aborted) return { action: "cancelled" };
205
+ if (result.timedOut) return { action: "timed_out" };
206
+ if (!result.stdout.trim()) return { action: "cancelled" };
207
+ const parsed: unknown = parseNativeJson(result.stdout, "Invalid native selection result.");
208
+ if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string" && items.includes(parsed[0])) {
209
+ return { action: "selected", item: parsed[0] };
210
+ }
211
+ if (parsed === false) return { action: "cancelled" };
212
+ throw new Error("Invalid native selection result.");
193
213
  }
194
214
 
195
215
  export interface MacosInputDialogOptions {
@@ -200,6 +220,7 @@ export interface MacosInputDialogOptions {
200
220
  defaultButton?: string;
201
221
  cancelButton?: string;
202
222
  timeoutSeconds?: number;
223
+ signal?: AbortSignal;
203
224
  }
204
225
 
205
226
  export interface MacosInputDialogResult {
@@ -214,85 +235,15 @@ export async function macosInputDialog(
214
235
  options: MacosInputDialogOptions,
215
236
  envOptions: NativeDialogEnvironment = {},
216
237
  ): Promise<MacosInputDialogResult> {
217
- if (!isNativeDialogSupported(envOptions)) {
218
- throw new Error("Native macOS dialog is not supported in this environment (non-macOS or SSH session).");
219
- }
220
-
221
- const buttons = options.buttons && options.buttons.length > 0 ? options.buttons : ["取消", "确定"];
222
- if (buttons.length > 3) {
223
- throw new Error(`macOS displayDialog supports at most 3 buttons, got ${buttons.length}`);
224
- }
225
-
226
- const defaultButton = options.defaultButton ?? buttons[buttons.length - 1];
227
- const cancelButton = options.cancelButton ?? (buttons.includes("取消") ? "取消" : (buttons.includes("Cancel") ? "Cancel" : undefined));
228
- const timeoutSeconds = options.timeoutSeconds && options.timeoutSeconds > 0 ? Math.floor(options.timeoutSeconds) : undefined;
229
-
230
- const jxaScript = `
231
- const app = Application.currentApplication();
232
- app.includeStandardAdditions = true;
233
- app.activate();
234
- const opts = {
235
- buttons: ${JSON.stringify(buttons)},
236
- defaultButton: ${JSON.stringify(defaultButton)},
237
- ${cancelButton ? `cancelButton: ${JSON.stringify(cancelButton)},` : ""}
238
- ${options.title ? `withTitle: ${JSON.stringify(options.title)},` : ""}
239
- defaultAnswer: ${JSON.stringify(options.defaultAnswer ?? "")},
240
- ${timeoutSeconds !== undefined ? `givingUpAfter: ${timeoutSeconds},` : ""}
241
- };
242
- const res = app.displayDialog(${JSON.stringify(options.prompt)}, opts);
243
- JSON.stringify(res);
244
- `;
245
-
246
- return new Promise<MacosInputDialogResult>((resolve) => {
247
- const proc = spawn("osascript", ["-l", "JavaScript", "-e", jxaScript], {
248
- stdio: ["ignore", "pipe", "pipe"],
249
- });
250
-
251
- let stdout = "";
252
- let stderr = "";
253
-
254
- proc.stdout.on("data", (chunk: Buffer) => {
255
- stdout += chunk.toString("utf8");
256
- });
257
- proc.stderr.on("data", (chunk: Buffer) => {
258
- stderr += chunk.toString("utf8");
259
- });
260
-
261
- proc.on("error", () => {
262
- resolve({ action: "cancelled" });
263
- });
264
-
265
- proc.on("close", (code) => {
266
- if (code === 0 && stdout.trim()) {
267
- try {
268
- const parsed = JSON.parse(stdout.trim()) as {
269
- buttonReturned?: string;
270
- textReturned?: string;
271
- gaveUp?: boolean;
272
- };
273
- if (parsed.gaveUp) {
274
- return resolve({
275
- action: "timed_out",
276
- text: parsed.textReturned,
277
- });
278
- }
279
- const isCancel = cancelButton && parsed.buttonReturned === cancelButton;
280
- return resolve({
281
- action: isCancel ? "cancelled" : "confirmed",
282
- text: parsed.textReturned,
283
- });
284
- } catch {
285
- // fallback
286
- }
287
- }
288
-
289
- if (stderr.includes("-128") || stderr.includes("User canceled")) {
290
- return resolve({ action: "cancelled" });
291
- }
292
-
293
- resolve({ action: "cancelled" });
294
- });
295
- });
238
+ const buttons = options.buttons?.length ? options.buttons : ["取消", "确定"];
239
+ const result = await displayDialog({
240
+ ...options,
241
+ message: options.prompt,
242
+ defaultAnswer: options.defaultAnswer ?? "",
243
+ buttons,
244
+ cancelButton: options.cancelButton ?? (buttons.includes("取消") ? "取消" : buttons.includes("Cancel") ? "Cancel" : undefined),
245
+ }, envOptions, "Invalid native input result.");
246
+ return { action: result.action, text: result.text };
296
247
  }
297
248
 
298
249
  export interface MacosPromptOptions {
@@ -302,6 +253,7 @@ export interface MacosPromptOptions {
302
253
  defaultButton?: string;
303
254
  cancelButton?: string;
304
255
  timeoutSeconds?: number;
256
+ signal?: AbortSignal;
305
257
  }
306
258
 
307
259
  export interface MacosPromptResult {
@@ -317,6 +269,14 @@ export interface MacosPromptResult {
317
269
  export async function macosPrompt(
318
270
  options: MacosPromptOptions,
319
271
  envOptions: NativeDialogEnvironment = {},
272
+ ): Promise<MacosPromptResult> {
273
+ return displayDialog(options, envOptions, "Invalid native prompt result.");
274
+ }
275
+
276
+ async function displayDialog(
277
+ options: MacosPromptOptions & { title?: string },
278
+ envOptions: NativeDialogEnvironment,
279
+ invalidMessage: string,
320
280
  ): Promise<MacosPromptResult> {
321
281
  if (!isNativeDialogSupported(envOptions)) {
322
282
  throw new Error("Native macOS dialog is not supported in this environment (non-macOS or SSH session).");
@@ -329,7 +289,9 @@ export async function macosPrompt(
329
289
 
330
290
  const defaultButton = options.defaultButton ?? buttons[buttons.length - 1];
331
291
  const cancelButton = options.cancelButton ?? (buttons.includes("Cancel") ? "Cancel" : undefined);
332
- const timeoutSeconds = options.timeoutSeconds && options.timeoutSeconds > 0 ? Math.floor(options.timeoutSeconds) : undefined;
292
+ validateDialogButtons(buttons, defaultButton, cancelButton);
293
+ const timeoutSeconds = normalizeTimeoutSeconds(options.timeoutSeconds);
294
+ const nativeTimeoutSeconds = timeoutSeconds === undefined ? undefined : Math.ceil(timeoutSeconds);
333
295
 
334
296
  const jxaScript = `
335
297
  const app = Application.currentApplication();
@@ -339,65 +301,39 @@ export async function macosPrompt(
339
301
  buttons: ${JSON.stringify(buttons)},
340
302
  defaultButton: ${JSON.stringify(defaultButton)},
341
303
  ${cancelButton ? `cancelButton: ${JSON.stringify(cancelButton)},` : ""}
304
+ ${options.title ? `withTitle: ${JSON.stringify(options.title)},` : ""}
342
305
  ${options.defaultAnswer !== undefined ? `defaultAnswer: ${JSON.stringify(options.defaultAnswer)},` : ""}
343
- ${timeoutSeconds !== undefined ? `givingUpAfter: ${timeoutSeconds},` : ""}
306
+ ${nativeTimeoutSeconds !== undefined ? `givingUpAfter: ${nativeTimeoutSeconds},` : ""}
344
307
  };
345
308
  const res = app.displayDialog(${JSON.stringify(options.message)}, opts);
346
309
  JSON.stringify(res);
347
310
  `;
348
311
 
349
- return new Promise<MacosPromptResult>((resolve) => {
350
- const proc = spawn("osascript", ["-l", "JavaScript", "-e", jxaScript], {
351
- stdio: ["ignore", "pipe", "pipe"],
352
- });
353
-
354
- let stdout = "";
355
- let stderr = "";
356
-
357
- proc.stdout.on("data", (chunk: Buffer) => {
358
- stdout += chunk.toString("utf8");
359
- });
360
- proc.stderr.on("data", (chunk: Buffer) => {
361
- stderr += chunk.toString("utf8");
362
- });
363
-
364
- proc.on("error", () => {
365
- resolve({ action: "cancelled", gaveUp: false });
366
- });
367
-
368
- proc.on("close", (code) => {
369
- if (code === 0 && stdout.trim()) {
370
- try {
371
- const parsed = JSON.parse(stdout.trim()) as {
372
- buttonReturned?: string;
373
- textReturned?: string;
374
- gaveUp?: boolean;
375
- };
376
- if (parsed.gaveUp) {
377
- return resolve({
378
- action: "timed_out",
379
- button: parsed.buttonReturned || undefined,
380
- text: parsed.textReturned,
381
- gaveUp: true,
382
- });
383
- }
384
- const isCancel = cancelButton && parsed.buttonReturned === cancelButton;
385
- return resolve({
386
- action: isCancel ? "cancelled" : "confirmed",
387
- button: parsed.buttonReturned,
388
- text: parsed.textReturned,
389
- gaveUp: false,
390
- });
391
- } catch {
392
- // JSON parsing failure fallback
393
- }
394
- }
395
-
396
- if (stderr.includes("-128") || stderr.includes("User canceled")) {
397
- return resolve({ action: "cancelled", gaveUp: false });
398
- }
399
-
400
- resolve({ action: "cancelled", gaveUp: false });
401
- });
402
- });
312
+ const result = await runJxa(jxaScript, { ...options, timeoutSeconds });
313
+ if (result.aborted) return { action: "cancelled", gaveUp: false };
314
+ if (result.timedOut) return { action: "timed_out", gaveUp: true };
315
+ if (!result.stdout.trim()) return { action: "cancelled", gaveUp: false };
316
+ const parsed = parseNativeJson(result.stdout, invalidMessage);
317
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(invalidMessage);
318
+ const record = parsed as { buttonReturned?: unknown; textReturned?: unknown; gaveUp?: unknown };
319
+ if (
320
+ (record.buttonReturned !== undefined && typeof record.buttonReturned !== "string")
321
+ || (record.textReturned !== undefined && typeof record.textReturned !== "string")
322
+ || (record.gaveUp !== undefined && typeof record.gaveUp !== "boolean")
323
+ ) throw new Error(invalidMessage);
324
+ if (record.gaveUp === true) {
325
+ return {
326
+ action: "timed_out",
327
+ gaveUp: true,
328
+ button: typeof record.buttonReturned === "string" ? record.buttonReturned : undefined,
329
+ text: typeof record.textReturned === "string" ? record.textReturned : undefined,
330
+ };
331
+ }
332
+ if (typeof record.buttonReturned !== "string" || !buttons.includes(record.buttonReturned)) throw new Error(invalidMessage);
333
+ return {
334
+ action: record.buttonReturned === cancelButton ? "cancelled" : "confirmed",
335
+ button: record.buttonReturned,
336
+ text: typeof record.textReturned === "string" ? record.textReturned : undefined,
337
+ gaveUp: false,
338
+ };
403
339
  }
package/src/resolver.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
2
  import {
4
3
  findProcedure,
5
4
  findProcedureByFile,
6
5
  normalizeProcedureId,
6
+ resourcePath,
7
7
  type ProcedureDefinition,
8
8
  } from "./catalog.ts";
9
9
 
@@ -17,8 +17,6 @@ export interface ResolvedProcedureBundle {
17
17
  byteLength: number;
18
18
  }
19
19
 
20
- const procedureDirectory = fileURLToPath(new URL("../procedures/", import.meta.url));
21
-
22
20
  function requireDefinition(id: string): ProcedureDefinition {
23
21
  const definition = findProcedure(id);
24
22
  if (!definition) throw new Error(`Unknown Matt Pocock procedure: ${id}`);
@@ -26,11 +24,18 @@ function requireDefinition(id: string): ProcedureDefinition {
26
24
  }
27
25
 
28
26
  function loadContent(definition: ProcedureDefinition): string {
29
- const body = readFileSync(`${procedureDirectory}${definition.file}`, "utf8").trim();
30
- return body.replace(/\]\((\.\/)?([A-Za-z0-9.-]+)(#[^)]+)?\)/g, (match, _prefix, file, anchor = "") => {
31
- const target = findProcedureByFile(file);
32
- return target ? `](procedure:${target.id}${anchor})` : match;
33
- });
27
+ const body = readFileSync(resourcePath(definition), "utf8").trim();
28
+ // A resource link is a bare file name or one reached through a relative path that
29
+ // starts with `./` or `../`, so the accepted form is enumerated rather than
30
+ // guessed: a URL (https:, mailto:), a protocol-relative host, a bare host path or
31
+ // any unknown name can never match. A rewritten link keeps its anchor and title.
32
+ return body.replace(
33
+ /\]\(((?:(?:\.\.?\/)(?:[^)\s/]+\/)*)?)([A-Za-z0-9._-]+)(#[^)]*)?(\s+(?:"[^"]*"|'[^']*'))?\)/g,
34
+ (match, _relative: string, file: string, anchor = "", title = "") => {
35
+ const resolved = findProcedureByFile(file);
36
+ return resolved ? `](procedure:${resolved.id}${anchor}${title})` : match;
37
+ },
38
+ );
34
39
  }
35
40
 
36
41
  function dependencyClosure(root: string): ProcedureDefinition[] {
@@ -61,9 +66,7 @@ function renderSource(definition: ProcedureDefinition): string {
61
66
  }
62
67
 
63
68
  function resolvedBundle(root: string, definitions: ProcedureDefinition[]): ResolvedProcedureBundle {
64
- const unique = definitions.filter((definition, index) => (
65
- definitions.findIndex((candidate) => candidate.id === definition.id) === index
66
- ));
69
+ const unique = [...new Map(definitions.map((definition) => [definition.id, definition])).values()];
67
70
  const content = unique.map(renderSource).join("\n\n");
68
71
  const byteLength = Buffer.byteLength(content, "utf8");
69
72
  if (byteLength > MAX_PROCEDURE_BUNDLE_BYTES) {
@@ -132,6 +135,18 @@ export interface ProcedureDelta {
132
135
  availableReferences: string[];
133
136
  }
134
137
 
138
+ /**
139
+ * Delivery ids are persisted session data, so they can outlive catalog aliases
140
+ * and may contain entries from a previous package revision. Keep only canonical
141
+ * ids that still exist; otherwise an old alias can cause a procedure to be
142
+ * delivered twice, while an unknown id needlessly grows every future state.
143
+ */
144
+ function canonicalDeliveryIds(delivered: string[] | undefined): string[] {
145
+ return [...new Set((delivered ?? [])
146
+ .map((id) => findProcedure(id)?.id)
147
+ .filter((id): id is string => id !== undefined))];
148
+ }
149
+
135
150
  /**
136
151
  * The part of the active context this session has not been shown yet.
137
152
  *
@@ -144,14 +159,15 @@ export interface ProcedureDelta {
144
159
  export function resolveProcedureDelta(
145
160
  activeProcedure: string,
146
161
  loadedReferences: string[],
147
- delivered: string[],
162
+ delivered?: string[],
148
163
  ): ProcedureDelta {
149
164
  const context = resolveWorkflowContext(activeProcedure, loadedReferences);
150
- const already = new Set(delivered);
165
+ const canonicalDelivered = canonicalDeliveryIds(delivered);
166
+ const already = new Set(canonicalDelivered);
151
167
  const pending = context.loaded.filter((id) => !already.has(id));
152
168
  return {
153
169
  content: pending.map((id) => renderSource(requireDefinition(id))).join("\n\n"),
154
- delivered: [...new Set([...delivered, ...context.loaded])],
170
+ delivered: [...new Set([...canonicalDelivered, ...context.loaded])],
155
171
  availableReferences: context.availableReferences,
156
172
  };
157
173
  }