@alphacoder-v0/pi-loops 0.22.8 → 0.24.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/CHANGELOG.md +664 -0
  2. package/CONTRACT.md +7 -7
  3. package/README.md +91 -63
  4. package/README.zh-CN.md +59 -39
  5. package/docs/cli.md +272 -83
  6. package/docs/configuration.md +86 -24
  7. package/docs/design.md +395 -56
  8. package/docs/downstream.md +2 -2
  9. package/docs/goal.md +12 -3
  10. package/docs/hooks.md +6 -7
  11. package/docs/loops.md +288 -59
  12. package/docs/mcp.md +22 -14
  13. package/docs/recipes.md +6 -0
  14. package/docs/screenshot.png +0 -0
  15. package/docs/triggers.md +120 -26
  16. package/docs/troubleshooting.md +37 -26
  17. package/docs/web-ui-parity.md +691 -65
  18. package/package.json +4 -3
  19. package/recipes/autoresearch/recipe.toml +1 -0
  20. package/recipes/changelog-draft/recipe.toml +1 -0
  21. package/recipes/ci-sweeper/recipe.toml +1 -0
  22. package/recipes/daily-digest/recipe.toml +1 -0
  23. package/recipes/deps-sweeper/recipe.toml +1 -0
  24. package/recipes/ecosystem/recipe.toml +1 -0
  25. package/recipes/issue-loop/recipe.toml +1 -0
  26. package/recipes/pr-watch/recipe.toml +1 -0
  27. package/skills/pi-loops/SKILL.md +6 -4
  28. package/src/activity.ts +199 -0
  29. package/src/archive.ts +21 -9
  30. package/src/args.ts +6 -2
  31. package/src/cli-entry.mjs +12 -3
  32. package/src/cli.ts +746 -125
  33. package/src/config.ts +53 -5
  34. package/src/conversation-rows.ts +469 -0
  35. package/src/danger.ts +227 -6
  36. package/src/engine.ts +1240 -0
  37. package/src/goal.ts +71 -0
  38. package/src/held-read.ts +27 -0
  39. package/src/hooks.ts +49 -18
  40. package/src/host-control-channel.ts +455 -40
  41. package/src/host-control.ts +602 -111
  42. package/src/host-entry.mjs +5 -3
  43. package/src/host-runtime.ts +455 -42
  44. package/src/host.ts +659 -123
  45. package/src/inbox-card.ts +278 -0
  46. package/src/inbox.ts +139 -62
  47. package/src/job-edit.ts +7 -1
  48. package/src/job-health.ts +14 -2
  49. package/src/job-lines.ts +201 -13
  50. package/src/job-owner.ts +74 -13
  51. package/src/job-tune.ts +110 -0
  52. package/src/lease.ts +537 -0
  53. package/src/lock.ts +7 -2
  54. package/src/mcp-pool.ts +66 -14
  55. package/src/mcp.ts +1 -1
  56. package/src/open-file.ts +54 -0
  57. package/src/page-children.ts +1507 -0
  58. package/src/page-devices.ts +192 -0
  59. package/src/page-server.ts +3150 -0
  60. package/src/path-precheck.ts +215 -0
  61. package/src/paths.ts +24 -0
  62. package/src/pi-floor.ts +10 -0
  63. package/src/pi-loops.ts +1301 -450
  64. package/src/pi-resolver.d.mts +1 -0
  65. package/src/pi-resolver.mjs +110 -10
  66. package/src/presence.ts +12 -63
  67. package/src/protocol.ts +10 -1
  68. package/src/recipe.ts +43 -1
  69. package/src/requests.ts +354 -0
  70. package/src/rule-lines.ts +68 -0
  71. package/src/run-card.ts +105 -0
  72. package/src/runner.ts +18 -1
  73. package/src/schedule.ts +1 -2
  74. package/src/scheduler.ts +595 -245
  75. package/src/sdk-runner.ts +321 -27
  76. package/src/seen.ts +278 -0
  77. package/src/session-head.ts +21 -0
  78. package/src/share.ts +12 -2
  79. package/src/snapshot.ts +3 -2
  80. package/src/store.ts +81 -8
  81. package/src/tools.ts +196 -65
  82. package/src/trigger-runtime.ts +164 -127
  83. package/src/triggers.ts +39 -7
  84. package/src/trust.ts +46 -2
  85. package/src/ui-prefs.ts +16 -5
  86. package/src/watch-plan.ts +174 -0
  87. package/src/web.d.mts +3 -0
  88. package/src/web.mjs +2260 -2340
  89. package/src/window-executor.ts +459 -0
  90. package/src/window-guard.ts +158 -0
  91. package/docs/agents/issue-tracker.md +0 -34
@@ -0,0 +1 @@
1
+ export function piPackage(): string;
@@ -1,20 +1,99 @@
1
1
  // Where pi's packages are, for everything that runs outside pi's own loader: the `node --import` hook
2
2
  // for the headless host (src/host-entry.mjs) and the tests, and the jiti aliases in src/ts-entry.mjs.
3
- // PI_LOOPS_PI_PACKAGE names pi's package dir; else the one beside this Node, else the global install.
3
+ // PI_LOOPS_PI_PACKAGE names pi's package dir; else pi's managed install (the `pi` on PATH, then the
4
+ // agent directory's); else the one beside this Node; else the global npm install.
4
5
  import { execSync } from "node:child_process";
5
6
  import * as fs from "node:fs";
7
+ import * as os from "node:os";
6
8
  import * as path from "node:path";
7
9
  import { pathToFileURL } from "node:url";
8
10
 
11
+ const PI_SCOPE = path.join("@earendil-works", "pi-coding-agent");
12
+ const hasPi = (dir) => fs.existsSync(path.join(dir, "package.json"));
13
+
14
+ /**
15
+ * pi's managed install under `agentDir`: `install/current-version` names a release, and the release
16
+ * holds pi under `install/releases/<version>/node_modules`. Undefined when the version file is not
17
+ * there or not a plain version, or when that release has no pi in it.
18
+ */
19
+ function managedPi(agentDir) {
20
+ let version;
21
+ try {
22
+ version = fs.readFileSync(path.join(agentDir, "install", "current-version"), "utf8").trim();
23
+ } catch {
24
+ return undefined; // not a managed install
25
+ }
26
+ // The same check pi's own launcher (`<agentDir>/bin/pi`) makes before it trusts the file.
27
+ if (!/^[0-9A-Za-z._+-]+$/.test(version) || version === "." || version === "..") return undefined;
28
+ const dir = path.join(agentDir, "install", "releases", version, "node_modules", PI_SCOPE);
29
+ return hasPi(dir) ? dir : undefined;
30
+ }
31
+
32
+ /** The first `pi` on `pathEnv`, with every link followed; undefined when there is none. */
33
+ function piOnPath(pathEnv) {
34
+ for (const entry of (pathEnv ?? "").split(path.delimiter)) {
35
+ if (!entry) continue;
36
+ try {
37
+ const real = fs.realpathSync(path.join(entry, "pi"));
38
+ if (fs.statSync(real).isFile()) return real;
39
+ } catch {
40
+ /* no pi in this directory */
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ /**
47
+ * Where pi's package is, and every place looked at on the way. Pure apart from reading the disk:
48
+ * the environment (which `npm root -g` runs in too), this Node's path and the `npm root -g` question
49
+ * are passed in.
50
+ */
51
+ export function findPiPackage({ env = process.env, execPath = process.execPath, npmRoot = () => execSync("npm root -g", { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] }).trim() } = {}) {
52
+ const looked = [];
53
+ const told = env.PI_LOOPS_PI_PACKAGE;
54
+ if (told) {
55
+ if (hasPi(told)) return { dir: told, looked };
56
+ looked.push(`${told} (PI_LOOPS_PI_PACKAGE)`);
57
+ }
58
+ // A managed install: `pi` on PATH links to `<agentDir>/bin/pi`, a launcher beside `install/`.
59
+ const onPath = piOnPath(env.PATH);
60
+ if (onPath && path.basename(onPath) === "pi" && path.basename(path.dirname(onPath)) === "bin") {
61
+ const root = path.dirname(path.dirname(onPath));
62
+ const found = managedPi(root);
63
+ if (found) return { dir: found, looked };
64
+ looked.push(`${path.join(root, "install")} (the pi on PATH, ${onPath})`);
65
+ }
66
+ const home = env.HOME || os.homedir();
67
+ const agentDir = env.PI_CODING_AGENT_DIR ? env.PI_CODING_AGENT_DIR.replace(/^~(?=$|\/)/, home) : path.join(home, ".pi", "agent");
68
+ const managed = managedPi(agentDir);
69
+ if (managed) return { dir: managed, looked };
70
+ looked.push(path.join(agentDir, "install"));
71
+ const beside = path.join(path.dirname(path.dirname(execPath)), "lib", "node_modules", PI_SCOPE);
72
+ if (hasPi(beside)) return { dir: beside, looked };
73
+ looked.push(beside);
74
+ // The last resort, and the one that can fail on its own: no npm on PATH.
75
+ try {
76
+ const global = path.join(npmRoot(), PI_SCOPE);
77
+ if (hasPi(global)) return { dir: global, looked };
78
+ looked.push(`${global} (npm root -g)`);
79
+ } catch {
80
+ looked.push("npm root -g (npm did not answer)");
81
+ }
82
+ return { dir: undefined, looked };
83
+ }
84
+
85
+ /** The one error for no pi anywhere: every place `findPiPackage` looked at, and the way out. */
86
+ export function noPiError(looked) {
87
+ return new Error(`no pi package found; looked in ${looked.join(", ")}. Set PI_LOOPS_PI_PACKAGE to the directory of your @earendil-works/pi-coding-agent install`);
88
+ }
89
+
9
90
  let pkg;
10
- /** pi's package directory: what was told, else the one beside this Node, else the global install. */
91
+ /** pi's package directory (see `findPiPackage`), or an error that names every place looked at. */
11
92
  export function piPackage() {
12
93
  if (pkg) return pkg;
13
- const told = process.env.PI_LOOPS_PI_PACKAGE;
14
- if (told && fs.existsSync(path.join(told, "package.json"))) return (pkg = told);
15
- const guess = path.join(path.dirname(path.dirname(process.execPath)), "lib", "node_modules", "@earendil-works", "pi-coding-agent");
16
- if (fs.existsSync(path.join(guess, "package.json"))) return (pkg = guess);
17
- return (pkg = path.join(execSync("npm root -g", { encoding: "utf8" }).trim(), "@earendil-works", "pi-coding-agent"));
94
+ const { dir, looked } = findPiPackage();
95
+ if (!dir) throw noPiError(looked);
96
+ return (pkg = dir);
18
97
  }
19
98
  // Relative to pi's package directory.
20
99
  const MAP = {
@@ -24,6 +103,28 @@ const MAP = {
24
103
  "@earendil-works/pi-tui": "node_modules/@earendil-works/pi-tui/dist/index.js",
25
104
  typebox: "node_modules/typebox/build/index.mjs",
26
105
  };
106
+ /**
107
+ * Where one of pi's packages is: nested under pi's own node_modules (npm's layout for a global pi),
108
+ * else beside pi (a hoisted install, or pnpm's store, where pi's real directory sits among its
109
+ * dependencies). Only the nested place was looked at, and anywhere else the background process —
110
+ * which every loop now runs in — could not start.
111
+ */
112
+ function piFile(rel) {
113
+ const dir = piPackage();
114
+ const nested = path.join(dir, rel);
115
+ if (!rel.startsWith("node_modules/") || fs.existsSync(nested)) return nested;
116
+ let real = dir;
117
+ try {
118
+ real = fs.realpathSync(dir);
119
+ } catch {
120
+ /* not there: the nested path is as good an answer */
121
+ }
122
+ // Only where Node itself would look: the node_modules that holds pi's scope directory.
123
+ const root = path.dirname(path.dirname(real));
124
+ if (path.basename(root) !== "node_modules") return nested;
125
+ const beside = path.join(root, rel.slice("node_modules/".length));
126
+ return fs.existsSync(beside) ? beside : nested;
127
+ }
27
128
  /**
28
129
  * The same map as an absolute-path table, which is the shape jiti's `alias` option wants — for when
29
130
  * jiti loads this package's TypeScript instead of Node (src/ts-entry.mjs). It is derived from `MAP`
@@ -31,10 +132,9 @@ const MAP = {
31
132
  * missing are dropped, exactly as the hook below declines to short-circuit for them.
32
133
  */
33
134
  export function piAliases() {
34
- const dir = piPackage();
35
135
  const out = {};
36
136
  for (const [specifier, rel] of Object.entries(MAP)) {
37
- const file = path.join(dir, rel);
137
+ const file = piFile(rel);
38
138
  if (fs.existsSync(file)) out[specifier] = file;
39
139
  }
40
140
  return out;
@@ -42,7 +142,7 @@ export function piAliases() {
42
142
  export async function resolve(specifier, context, next) {
43
143
  const rel = MAP[specifier];
44
144
  if (rel) {
45
- const file = path.join(piPackage(), rel);
145
+ const file = piFile(rel);
46
146
  if (fs.existsSync(file)) return { url: pathToFileURL(file).href, shortCircuit: true };
47
147
  }
48
148
  return next(specifier, context);
package/src/presence.ts CHANGED
@@ -1,19 +1,14 @@
1
1
  /**
2
- * Presence registry: which pi processes are alive on this machine, in which project and
3
- * session. One small file per process under `<dir>/presence/`, refreshed on every scheduler
4
- * tick and removed on shutdown; stale entries (no heartbeat for 90 s, or a dead pid on this
5
- * host) are ignored and pruned.
6
- *
7
- * It restores what session scoping would give for free: a dynamic rule is checked by the pi
8
- * session that created it, and failing that by a pi that is *in that project*, so promotions land
9
- * in the right chat. Only when no pi is open there does the machine leader step in (and the result
10
- * goes to the inbox).
2
+ * Presence: the one file per live pi that pi-loops 0.22 windows write under `<dir>/presence/`
3
+ * (refreshed every tick, pruned after 90 s or a dead pid). This build no longer writes it: who
4
+ * holds a conversation is the lease's question (src/lease.ts). It is still read during an upgrade,
5
+ * to see a conversation a 0.22 window has open (`olderPiOnSession`), and so the host's dead-session
6
+ * sweep does not park a plain job whose session is open only there. Deleted with the 0.22 bridge.
11
7
  */
12
8
  import * as fs from "node:fs";
13
- import * as os from "node:os";
14
9
  import * as path from "node:path";
15
10
  import { pidAlive, writeFileAtomic } from "./lock.ts";
16
- import { realpathish } from "./paths.ts";
11
+ import { withinProject } from "./paths.ts";
17
12
  import { stamp } from "./schedule.ts";
18
13
 
19
14
  export const PRESENCE_STALE_MS = 90_000;
@@ -29,6 +24,8 @@ export interface PresenceEntry {
29
24
  cwd: string;
30
25
  /** "host" = the headless process that keeps the clock while no interactive pi is open. */
31
26
  kind?: PresenceKind;
27
+ /** Written by builds that take a session lease (src/lease.ts). Its absence marks a 0.22.x pi, which fires a shared session's plain jobs without one. */
28
+ leases?: true;
32
29
  heartbeatAt: string;
33
30
  }
34
31
 
@@ -89,60 +86,12 @@ export class PresenceRegistry {
89
86
  }
90
87
  }
91
88
 
92
- /** A root so broad that containment would mean "everything": never treated as one project. */
93
- function tooBroad(dir: string): boolean {
94
- return dir === "/" || dir === os.homedir() || path.dirname(dir) === dir;
95
- }
96
-
97
- /** True when `p` is the project rooted at `root` or a directory inside it, symlinks resolved. */
98
- export function withinProject(root: string, p: string): boolean {
99
- const a = realpathish(root);
100
- const b = realpathish(p);
101
- if (!a || !b) return false;
102
- if (a === b) return true;
103
- // Containment is what makes a worktree or a subdirectory the same project. `/` and `$HOME`
104
- // contain everything, so a rule created with one of those as its cwd governs only itself.
105
- if (tooBroad(a)) return false;
106
- const rel = path.relative(a, b);
107
- return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
108
- }
109
-
110
- /** A worktree, a symlinked path or a subdirectory is the same project, as the runtime treats it. */
111
- export function sameProject(a: string, b: string): boolean {
112
- return withinProject(b, a) || withinProject(a, b);
113
- }
114
-
115
- /**
116
- * The process that should act for the project rooted at `cwd` on `host`: a live pi opened in it —
117
- * or in a subdirectory of it, or through a symlink to it — lowest pid as the tie-break so every
118
- * process computes the same answer. `undefined` when no pi is open there: the caller falls back
119
- * to the machine leader.
120
- */
121
- export function chooseCwdOwner(entries: PresenceEntry[], cwd: string, host: string): PresenceEntry | undefined {
122
- const here = entries.filter((e) => e.host === host && e.kind !== "host" && withinProject(cwd, e.cwd));
123
- if (!here.length) return undefined;
124
- return [...here].sort((a, b) => a.pid - b.pid || a.instance.localeCompare(b.instance))[0];
125
- }
126
-
127
- /**
128
- * The process that evaluates one rule of the project rooted at `cwd`.
129
- *
130
- * Rules are machine-global, which is what lets them outlive the session that created them — and it
131
- * costs the one thing a per-session file would have given away: knowing whose conversation a
132
- * promotion belongs in. This is how that is won back. While the creating session is open on this
133
- * host it owns its rule; once that session is gone the project's cwd owner takes it, and only with
134
- * no pi open in the project at all does the machine leader (whose results go to the inbox, having
135
- * no conversation to claim).
136
- */
137
- export function chooseRuleOwner(entries: PresenceEntry[], cwd: string, host: string, sessionId?: string): PresenceEntry | undefined {
138
- const session = sessionId ? entries.find((e) => e.sessionId === sessionId && e.host === host && e.kind !== "host") : undefined;
139
- return session ?? chooseCwdOwner(entries, cwd, host);
89
+ /** A live pi without session leases that has `sessionId` open too: both would fire its plain jobs. */
90
+ export function olderPiOnSession(entries: PresenceEntry[], sessionId: string | undefined, self: PresenceSelf): PresenceEntry | undefined {
91
+ if (!sessionId) return undefined;
92
+ return entries.find((e) => e.sessionId === sessionId && !e.leases && e.kind !== "host" && !isSelf(e, self));
140
93
  }
141
94
 
142
95
  export function isSelf(entry: PresenceEntry | undefined, self: PresenceSelf): boolean {
143
96
  return !!entry && entry.pid === self.pid && entry.host === self.host && entry.instance === self.instance;
144
97
  }
145
-
146
- export function localHost(): string {
147
- return os.hostname();
148
- }
package/src/protocol.ts CHANGED
@@ -52,6 +52,15 @@ export function capChars(text: string, max: number): string {
52
52
  return `${chars.slice(0, max).join("")}…`;
53
53
  }
54
54
 
55
+ /**
56
+ * A person's reason and a finding's text go inside the `[dismissed]` block as data. Written as they
57
+ * are, a `[/dismissed]` in either would end the block early and put the rest of the line where the
58
+ * run reads its task; spaced, it reads the same to a person and closes nothing.
59
+ */
60
+ function unclosing(text: string): string {
61
+ return text.replace(/\[\/(\s*)dismissed\]/gi, "[/ dismissed]");
62
+ }
63
+
55
64
  export function composeLoopPrompt(action: string, previousState: string | undefined, meta?: { name?: string; runAt?: string; dismissed?: DismissedFeedback[] }): string {
56
65
  const state = previousState && previousState.trim() ? capChars(previousState, LOOP_STATE_MAX_CHARS) : FIRST_RUN_MARKER;
57
66
  /**
@@ -64,7 +73,7 @@ export function composeLoopPrompt(action: string, previousState: string | undefi
64
73
  const feedback = dismissed.length
65
74
  ? [
66
75
  `${DISMISSED_OPEN} (findings of yours a person dismissed since your previous run, and why — do not report these again unless what they describe has changed; put what you need to remember that in your notes)`,
67
- ...dismissed.map((d) => `- "${capChars(d.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS)}" — ${capChars(d.reason.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS)}`),
76
+ ...dismissed.map((d) => `- "${unclosing(capChars(d.text.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS))}" — ${unclosing(capChars(d.reason.replace(/\s+/g, " "), INBOX_TEXT_MAX_CHARS))}`),
68
77
  DISMISSED_CLOSE,
69
78
  "",
70
79
  ]
package/src/recipe.ts CHANGED
@@ -40,6 +40,8 @@ export interface RecipeJob {
40
40
  export interface RecipeManifest {
41
41
  name: string;
42
42
  summary: string;
43
+ /** One sentence of at most twelve words: what the loop does, as a starter card shows it. */
44
+ line?: string;
43
45
  needsTracker: boolean;
44
46
  levels: AutonomyLevel[];
45
47
  /**
@@ -92,6 +94,45 @@ export type PreflightCheck = (typeof PREFLIGHT_CHECKS)[number];
92
94
 
93
95
  export const RECIPE_TIERS = ["starter", "advanced"] as const;
94
96
  export type RecipeTier = (typeof RECIPE_TIERS)[number];
97
+ /**
98
+ * Who owns the loops an install creates. pi writes a conversation's session file only once it holds
99
+ * a user or assistant message, and `/recipe add` is neither (a starter's Add in a new page
100
+ * conversation is the usual case): a loop named after that conversation would name one that never
101
+ * exists. Such an install's loops belong to the project instead, with no creating conversation.
102
+ */
103
+ export function installScope(sessionFile: string | undefined, exists: (p: string) => boolean = fs.existsSync): { projectOwned?: true } {
104
+ return sessionFile && exists(sessionFile) ? {} : { projectOwned: true };
105
+ }
106
+
107
+ /** The order an empty project is offered the starters in; one not named here comes after, by name. */
108
+ export const STARTER_ORDER = ["daily-digest", "pr-watch", "changelog-draft"] as const;
109
+ /** How many starters an empty project is offered. */
110
+ export const STARTERS_SHOWN = 3;
111
+
112
+ /** A starter as the page offers it: `Add` installs it with `--level report`, so it reads only. */
113
+ export interface Starter {
114
+ name: string;
115
+ summary: string;
116
+ /** The card's one line: the manifest's `line`, or its summary when it has none. */
117
+ line: string;
118
+ readsOnly: boolean;
119
+ needsTracker: boolean;
120
+ budgetHintUsd?: number;
121
+ }
122
+
123
+ /** The starters an empty project is offered: `starter` recipes that have a `report` level, in `STARTER_ORDER`. */
124
+ export function starters(recipes: readonly Recipe[]): Starter[] {
125
+ const rank = (name: string) => {
126
+ const at = (STARTER_ORDER as readonly string[]).indexOf(name);
127
+ return at < 0 ? STARTER_ORDER.length : at;
128
+ };
129
+ return recipes
130
+ .map((r) => r.manifest)
131
+ .filter((m) => m.tier === "starter" && m.levels.includes("report"))
132
+ .sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name))
133
+ .slice(0, STARTERS_SHOWN)
134
+ .map((m) => ({ name: m.name, summary: m.summary, line: m.line ?? m.summary, readsOnly: true, needsTracker: m.needsTracker, ...(m.budgetHintUsd !== undefined ? { budgetHintUsd: m.budgetHintUsd } : {}) }));
135
+ }
95
136
 
96
137
  export const MANIFEST_FILE = "recipe.toml";
97
138
  export const RECORD_FILE = ".recipe.json";
@@ -165,6 +206,7 @@ export function parseManifest(text: string, opts: { now?: number } = {}): Recipe
165
206
  const name = requireStr(doc, "name", where);
166
207
  if (!NAME_RE.test(name)) throw new Error(`${where}: name must be lowercase letters, digits and dashes (got "${name}")`);
167
208
  const summary = requireStr(doc, "summary", where);
209
+ const line = str(doc, "line", where);
168
210
  const levelsRaw = strList(doc, "levels", where);
169
211
  if (!levelsRaw?.length) throw new Error(`${where}: levels must list at least one of ${AUTONOMY_LEVELS.join(", ")}`);
170
212
  const levels: AutonomyLevel[] = [];
@@ -227,7 +269,7 @@ export function parseManifest(text: string, opts: { now?: number } = {}): Recipe
227
269
  }
228
270
  jobs.push(job);
229
271
  }
230
- return { name, summary, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, tier: tierRaw as RecipeTier, usefulWhen, needs, needsPropose, setup, files, budgetHintUsd: budget as number | undefined, jobs };
272
+ return { name, summary, line, needsTracker: bool(doc, "needs_tracker", where) ?? false, levels, tier: tierRaw as RecipeTier, usefulWhen, needs, needsPropose, setup, files, budgetHintUsd: budget as number | undefined, jobs };
231
273
  }
232
274
 
233
275
  /** Relative, inside the directory, and a single file: no `..`, no absolute path, no trailing slash. */