@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +41 -28
- package/docs/web-ui-parity.md +702 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1370 -473
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function piPackage(): string;
|
package/src/pi-resolver.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
14
|
-
if (
|
|
15
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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
|
|
93
|
-
function
|
|
94
|
-
|
|
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. */
|