passalong 0.20.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -72
- package/bin/passalong +217 -378
- package/package.json +4 -4
- package/skill/SKILL.md +55 -167
- package/src/api.js +100 -190
- package/src/capture.js +15 -51
- package/src/checks.js +5 -5
- package/src/hooks.js +9 -18
- package/src/mcp.js +691 -1484
- package/src/passalong.js +365 -702
- package/src/store.js +18 -28
- package/src/work.js +416 -0
- package/src/guide.js +0 -811
package/src/passalong.js
CHANGED
|
@@ -8,28 +8,17 @@ import { basename, join } from "node:path";
|
|
|
8
8
|
import * as api from "./api.js";
|
|
9
9
|
import { context } from "./capture.js";
|
|
10
10
|
import { refusal, runChecks } from "./checks.js";
|
|
11
|
-
import {
|
|
12
|
-
bugGuide,
|
|
13
|
-
ID_RE,
|
|
14
|
-
parse,
|
|
15
|
-
STATUSES,
|
|
16
|
-
serialize,
|
|
17
|
-
stamp,
|
|
18
|
-
stripPlaceholders,
|
|
19
|
-
unheldFields,
|
|
20
|
-
validate,
|
|
21
|
-
} from "./guide.js";
|
|
22
11
|
import * as store from "./store.js";
|
|
12
|
+
import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
|
|
23
13
|
|
|
24
14
|
export class PassalongError extends Error {}
|
|
25
15
|
|
|
26
|
-
/** "team" or "team/handle" → { team, to }. */
|
|
27
16
|
/**
|
|
28
17
|
* Split `--to` into a team and who inside it: "team", "team/@handle", or "team/#group".
|
|
29
18
|
*
|
|
30
19
|
* Only the `@` is stripped. The `#` is load-bearing and travels into the frontmatter as written —
|
|
31
20
|
* it is the sigil the server reads to tell a person from a group, and dropping it here would turn
|
|
32
|
-
*
|
|
21
|
+
* work for six people into a lookup for a teammate who does not exist.
|
|
33
22
|
*/
|
|
34
23
|
export function parseTarget(to) {
|
|
35
24
|
if (!to) return {};
|
|
@@ -38,142 +27,124 @@ export function parseTarget(to) {
|
|
|
38
27
|
}
|
|
39
28
|
|
|
40
29
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* {
|
|
30
|
+
* Store a piece of work from its markdown: fill in what is missing, validate, and send it. `to`
|
|
31
|
+
* addresses it: "team", "team/@handle", or "team/#group"; with no team it goes to the account's
|
|
32
|
+
* current one. `parent` names the work it came out of. Returns { work, url, notified }.
|
|
44
33
|
*/
|
|
45
|
-
export async function
|
|
34
|
+
export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}) {
|
|
46
35
|
const c = context(cwd);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
author: c.author,
|
|
52
|
-
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
53
|
-
},
|
|
54
|
-
);
|
|
36
|
+
let work = stamp(parse(markdown), {
|
|
37
|
+
author: c.author,
|
|
38
|
+
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
39
|
+
});
|
|
55
40
|
const target = parseTarget(to);
|
|
56
|
-
if (target.team)
|
|
57
|
-
if (target.to)
|
|
58
|
-
if (
|
|
59
|
-
!guide.meta.team &&
|
|
60
|
-
(guide.meta.kind === "bug" || guide.meta.kind === "task") &&
|
|
61
|
-
api.loggedIn()
|
|
62
|
-
) {
|
|
41
|
+
if (target.team) work.meta.team = target.team;
|
|
42
|
+
if (target.to) work.meta.to = target.to;
|
|
43
|
+
if (!work.meta.team && api.loggedIn()) {
|
|
63
44
|
const team = api.currentTeam();
|
|
64
|
-
if (team)
|
|
45
|
+
if (team) work.meta.team = team;
|
|
65
46
|
}
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
throw new PassalongError("`to:` needs a team — address a handoff as team/handle");
|
|
47
|
+
// An id is used as it is; a link or a file is read for the id in its own frontmatter. `validate()`
|
|
48
|
+
// below is what refuses work that names itself.
|
|
49
|
+
if (parent) work.meta.parent = await resolveId(String(parent).trim());
|
|
50
|
+
if (work.meta.to && !work.meta.team)
|
|
51
|
+
throw new PassalongError("`to:` needs a team — address it as team/@handle");
|
|
72
52
|
const errors = [
|
|
73
53
|
...unheldFields(markdown).map(
|
|
74
54
|
(k) =>
|
|
75
55
|
`frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped — move it into the body (a fenced yaml block keeps it as written)`,
|
|
76
56
|
),
|
|
77
|
-
...validate(
|
|
57
|
+
...validate(work),
|
|
78
58
|
];
|
|
79
59
|
if (errors.length)
|
|
80
|
-
throw new PassalongError(`
|
|
60
|
+
throw new PassalongError(`this is not ready to send:\n - ${errors.join("\n - ")}`);
|
|
81
61
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
guide = { ...guide, meta: { ...guide.meta, url } };
|
|
87
|
-
return { guide, url, synced: true, notified: Boolean(res.notified) };
|
|
62
|
+
needsSync("sending work");
|
|
63
|
+
const res = await api.publish(work.meta.id, serialize(work));
|
|
64
|
+
work = { ...work, meta: { ...work.meta, url: res.url } };
|
|
65
|
+
return { work, url: res.url, notified: Boolean(res.notified) };
|
|
88
66
|
}
|
|
89
67
|
|
|
90
68
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* three that landed are real guides someone can already act on.
|
|
69
|
+
* One piece of work or several, written from fields rather than markdown: what an agent hands over
|
|
70
|
+
* when it writes work down. Each may name the earlier ones it needs by position (`after: [0, 1]`),
|
|
71
|
+
* and those become its `blocked_by`, so they are handed out in an order that works and the
|
|
72
|
+
* independent ones run side by side.
|
|
96
73
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
74
|
+
* Only earlier ones: work that waits on itself or on a later one never finishes, and it is refused
|
|
75
|
+
* before anything is written rather than discovered as a queue that never moves. `draft` holds them
|
|
76
|
+
* all in Draft, for a plan a person should read before any of it runs.
|
|
100
77
|
*/
|
|
101
|
-
export async function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const { report } = await api.createReport({
|
|
111
|
-
title,
|
|
112
|
-
environment,
|
|
113
|
-
team,
|
|
114
|
-
to: target.to || "",
|
|
78
|
+
export async function createWork(items, { draft = false, to, cwd = process.cwd() } = {}) {
|
|
79
|
+
if (!Array.isArray(items) || !items.length)
|
|
80
|
+
throw new PassalongError("say what the work is: at least one item");
|
|
81
|
+
items.forEach((item, i) => {
|
|
82
|
+
for (const j of item.after || [])
|
|
83
|
+
if (!Number.isInteger(j) || j < 0 || j >= i)
|
|
84
|
+
throw new PassalongError(
|
|
85
|
+
`item ${i} ("${item.title}") can only wait on an earlier one, not ${j}`,
|
|
86
|
+
);
|
|
115
87
|
});
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
.map((f) => f.id)
|
|
135
|
-
.join(", ")}) before failing: ${err.message}`,
|
|
136
|
-
);
|
|
88
|
+
const repo = context(cwd).repo;
|
|
89
|
+
const made = [];
|
|
90
|
+
for (const item of items) {
|
|
91
|
+
const meta = {
|
|
92
|
+
title: item.title,
|
|
93
|
+
summary: item.summary,
|
|
94
|
+
...(draft ? { status: "draft" } : {}),
|
|
95
|
+
target_context: item.repo ?? repo,
|
|
96
|
+
...(item.tags?.length ? { tags: item.tags } : {}),
|
|
97
|
+
};
|
|
98
|
+
const after = (item.after || []).map((j) => made[j].id);
|
|
99
|
+
if (after.length) meta.blocked_by = after;
|
|
100
|
+
const { work, url } = await publish(serialize({ meta, body: item.body || "" }), {
|
|
101
|
+
cwd,
|
|
102
|
+
to: item.to || to,
|
|
103
|
+
parent: item.parent,
|
|
104
|
+
});
|
|
105
|
+
made.push({ id: work.meta.id, title: work.meta.title, status: work.meta.status, url });
|
|
137
106
|
}
|
|
138
|
-
return
|
|
107
|
+
return made;
|
|
139
108
|
}
|
|
140
109
|
|
|
141
110
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
111
|
+
* Replace a piece of work's text, by its id: the whole document, frontmatter first, as `find_work`
|
|
112
|
+
* handed it over. What it is called and what it says are both in there.
|
|
113
|
+
*/
|
|
114
|
+
export async function updateText(id, markdown, { cwd = process.cwd() } = {}) {
|
|
115
|
+
const g = parse(markdown);
|
|
116
|
+
if (g.meta.id && g.meta.id !== id)
|
|
117
|
+
throw new PassalongError(`the document says it is ${g.meta.id}, not ${id}`);
|
|
118
|
+
return publish(serialize({ meta: { ...g.meta, id }, body: g.body }), { cwd });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a reference (id, share URL, or local file path) to a piece of work. An id is the server's
|
|
123
|
+
* to answer: nothing keeps a copy on this machine. `peek` reads without recording a pull.
|
|
144
124
|
*/
|
|
145
125
|
export async function resolve(ref, { peek = false } = {}) {
|
|
146
126
|
if (/^https?:\/\//.test(ref)) {
|
|
147
127
|
const md = await api.fetchShared(ref);
|
|
148
|
-
return {
|
|
128
|
+
return { work: parse(md), from: "link" };
|
|
149
129
|
}
|
|
150
130
|
if (ref.endsWith(".md") && existsSync(ref)) {
|
|
151
|
-
return {
|
|
131
|
+
return { work: parse(readFileSync(ref, "utf8")), from: "file" };
|
|
152
132
|
}
|
|
153
133
|
if (!ID_RE.test(ref))
|
|
154
134
|
throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
|
|
155
|
-
needsSync("reading
|
|
135
|
+
needsSync("reading work by id");
|
|
156
136
|
try {
|
|
157
|
-
return {
|
|
137
|
+
return { work: parse(await api.get(ref, { peek })), from: "sync" };
|
|
158
138
|
} catch (err) {
|
|
159
|
-
if (err.status === 404) throw new PassalongError(`no
|
|
139
|
+
if (err.status === 404) throw new PassalongError(`no work "${ref}"`);
|
|
160
140
|
throw err;
|
|
161
141
|
}
|
|
162
142
|
}
|
|
163
143
|
|
|
164
144
|
/**
|
|
165
|
-
* Resolve a reference to its id alone, without fetching
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* so the guide body they used to fetch on the way was never read. That fetch was not free:
|
|
169
|
-
* `GET /v1/guides/:id` records a pull, which is how somebody saying "not me" on a handoff
|
|
170
|
-
* registered as taking delivery of it — inflating the guide's pull count and moving it into the
|
|
171
|
-
* sender's "landed" queue. An id needs no lookup at all, and a link or a file carries the id in
|
|
172
|
-
* its own frontmatter.
|
|
173
|
-
*
|
|
174
|
-
* Unlike `resolve()`, a bare id is not checked for existence here. The server answers that when
|
|
175
|
-
* the write lands, and "no such guide" from the route the write went to is the same news one
|
|
176
|
-
* round trip earlier.
|
|
145
|
+
* Resolve a reference to its id alone, without fetching it. A write needs an id and nothing else,
|
|
146
|
+
* and `GET /v1/work/:id` records a pull; a link or a file carries the id in its own frontmatter.
|
|
147
|
+
* A bare id is not checked here: the route the write goes to answers that.
|
|
177
148
|
*/
|
|
178
149
|
export async function resolveId(ref) {
|
|
179
150
|
if (/^https?:\/\//.test(ref)) return parse(await api.fetchShared(ref)).meta.id;
|
|
@@ -182,18 +153,7 @@ export async function resolveId(ref) {
|
|
|
182
153
|
throw new PassalongError(`"${ref}" is not a passalong id, share link, or .md file`);
|
|
183
154
|
}
|
|
184
155
|
|
|
185
|
-
/**
|
|
186
|
-
* Pull a guide and return it, so the caller can put the text straight into an agent's context. A
|
|
187
|
-
* pull of someone else's guide is recorded server-side, which is how the sender sees the transfer
|
|
188
|
-
* landed. Nothing is written unless `save` asks: then it lands in `.passalong/<id>.md` here.
|
|
189
|
-
*/
|
|
190
|
-
export async function pull(ref, { cwd = process.cwd(), save = false } = {}) {
|
|
191
|
-
const { guide, from } = await resolve(ref);
|
|
192
|
-
const markdown = serialize(guide);
|
|
193
|
-
return { guide, from, path: save ? saveTo(cwd, guide.meta.id, markdown) : null, markdown };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Write a guide into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
|
|
156
|
+
/** Write a piece of work into `.passalong/<id>.md` under `cwd`, when somebody asked for a file. */
|
|
197
157
|
function saveTo(cwd, id, markdown) {
|
|
198
158
|
const path = join(localDir(cwd), `${id}.md`);
|
|
199
159
|
writeFileSync(path, markdown);
|
|
@@ -203,9 +163,9 @@ function saveTo(cwd, id, markdown) {
|
|
|
203
163
|
/**
|
|
204
164
|
* `.passalong/` in a working directory, made if it is missing.
|
|
205
165
|
*
|
|
206
|
-
*
|
|
207
|
-
* read. Committing this directory would publish
|
|
208
|
-
*
|
|
166
|
+
* Work saved there carries its own share URL in the frontmatter, and that URL needs no account to
|
|
167
|
+
* read. Committing this directory would publish it to anyone who can see the repo, so the directory
|
|
168
|
+
* ignores itself. Delete its .gitignore if you do want it in the repo.
|
|
209
169
|
*/
|
|
210
170
|
function localDir(cwd) {
|
|
211
171
|
const dir = join(cwd, ".passalong");
|
|
@@ -216,13 +176,12 @@ function localDir(cwd) {
|
|
|
216
176
|
}
|
|
217
177
|
|
|
218
178
|
/**
|
|
219
|
-
* Who this worktree is to the
|
|
179
|
+
* Who this worktree is to the queue, minted the first time it asks.
|
|
220
180
|
*
|
|
221
181
|
* A claim belongs to an agent, not an account: one person runs several, and "no two agents on one
|
|
222
|
-
*
|
|
223
|
-
* `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and
|
|
224
|
-
* its
|
|
225
|
-
* let two worktrees on one laptop share a lock; per session would lose the task on every restart.
|
|
182
|
+
* piece of work" means nothing if the server cannot tell them apart. The id lives in the worktree's
|
|
183
|
+
* own `.passalong/agent.json`, so a restarted session in the same worktree is the same agent and
|
|
184
|
+
* gets its work back, and a second worktree of the same repo is a different agent.
|
|
226
185
|
*/
|
|
227
186
|
export function agent(cwd = process.cwd()) {
|
|
228
187
|
const c = context(cwd);
|
|
@@ -253,44 +212,24 @@ function knownAgent(cwd = process.cwd()) {
|
|
|
253
212
|
function needsSync(what) {
|
|
254
213
|
if (!api.loggedIn())
|
|
255
214
|
throw new PassalongError(
|
|
256
|
-
`${what} needs sync —
|
|
215
|
+
`${what} needs sync — work lives on the server (run \`passalong login\`)`,
|
|
257
216
|
);
|
|
258
217
|
}
|
|
259
218
|
|
|
260
|
-
/** Every task you can see, with where it is and who has it. */
|
|
261
|
-
export async function tasks() {
|
|
262
|
-
needsSync("the task queue");
|
|
263
|
-
return (await api.tasks()).tasks;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
219
|
/**
|
|
267
|
-
* Take the next
|
|
268
|
-
* `null` when there is nothing to do.
|
|
220
|
+
* Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
|
|
221
|
+
* several. `null` when there is nothing to do. `alone` asks only for work a person marked safe to
|
|
222
|
+
* run unattended (the runner).
|
|
269
223
|
*/
|
|
270
|
-
export async function
|
|
271
|
-
needsSync("taking
|
|
272
|
-
const { task } = await api.nextTask({ ...agent(cwd), any });
|
|
273
|
-
|
|
274
|
-
return { ...task, path: save ? saveTo(agent(cwd).worktree, task.id, task.markdown) : null };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/** Word from the agent holding a task: renews its lease, and `note` becomes the board's line. */
|
|
278
|
-
export async function taskProgress(id, note, { cwd = process.cwd() } = {}) {
|
|
279
|
-
needsSync("reporting progress");
|
|
280
|
-
const { agent: a } = agent(cwd);
|
|
281
|
-
return api.taskProgress(id, note ? { agent: a, note } : { agent: a });
|
|
224
|
+
export async function nextWork({ cwd = process.cwd(), any = false, alone = false } = {}) {
|
|
225
|
+
needsSync("taking work");
|
|
226
|
+
const { task } = await api.nextTask({ ...agent(cwd), any, ...(alone ? { alone: true } : {}) });
|
|
227
|
+
return task || null;
|
|
282
228
|
}
|
|
283
229
|
|
|
284
230
|
/**
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* The server refuses the call without it anyway; the reason to check first is that publishing the
|
|
288
|
-
* write-up and then being refused leaves a guide nobody asked for behind every refused hand-in.
|
|
289
|
-
* The rule and the wording are the server's — see evidenceProblem() in apps/api/src/claims.ts.
|
|
290
|
-
*/
|
|
291
|
-
/**
|
|
292
|
-
* The claim generation this worktree was given when it took a guide, kept in `.passalong/held.json`
|
|
293
|
-
* beside `agent.json`. See apps/api/migrations/0029_claim_fence.sql.
|
|
231
|
+
* The claim generation this worktree was given when it took a piece of work, kept in
|
|
232
|
+
* `.passalong/held.json` beside `agent.json`. See apps/api/migrations/0029_claim_fence.sql.
|
|
294
233
|
*
|
|
295
234
|
* The agent never sees this number and is never asked for it — a model echoing an integer back
|
|
296
235
|
* three calls later is not a lock. `take` writes it, and every call that writes to the claim sends
|
|
@@ -310,7 +249,7 @@ function heldFences(cwd) {
|
|
|
310
249
|
}
|
|
311
250
|
}
|
|
312
251
|
|
|
313
|
-
/** The number this worktree holds for a
|
|
252
|
+
/** The number this worktree holds for a piece of work, or undefined when it has none to send. */
|
|
314
253
|
function fenceFor(id, cwd) {
|
|
315
254
|
const n = heldFences(cwd)[id];
|
|
316
255
|
return Number.isInteger(n) ? n : undefined;
|
|
@@ -326,6 +265,10 @@ function rememberFence(id, fence, cwd) {
|
|
|
326
265
|
/** A check a runner executed: the command it ran, and what the process returned. See checks.js. */
|
|
327
266
|
const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
|
|
328
267
|
|
|
268
|
+
/**
|
|
269
|
+
* What a hand-in has to carry, checked before it is sent. The rule and the wording are the
|
|
270
|
+
* server's — see evidenceProblem() in apps/api/src/claims.ts.
|
|
271
|
+
*/
|
|
329
272
|
function requireEvidence(evidence) {
|
|
330
273
|
const said = String(evidence || "").trim();
|
|
331
274
|
if (said.length >= 16) return;
|
|
@@ -337,52 +280,20 @@ function requireEvidence(evidence) {
|
|
|
337
280
|
}
|
|
338
281
|
|
|
339
282
|
/**
|
|
340
|
-
* The
|
|
341
|
-
*
|
|
342
|
-
* Pass `markdown` and this writes that guide too, with `parent:` naming the task, so the write-up
|
|
343
|
-
* and the finish are one step and neither can be skipped or pointed at the wrong guide. Pass
|
|
344
|
-
* `report` instead when the guide is already published.
|
|
345
|
-
*/
|
|
346
|
-
export async function finishTask(
|
|
347
|
-
id,
|
|
348
|
-
{ report, markdown, evidence = "", pr = "", note = "", cwd = process.cwd() } = {},
|
|
349
|
-
) {
|
|
350
|
-
needsSync("finishing a task");
|
|
351
|
-
requireEvidence(evidence);
|
|
352
|
-
if (!report && markdown) {
|
|
353
|
-
const g = parse(markdown);
|
|
354
|
-
// A task's report is a transfer guide, and this code is what is making one — so it says so
|
|
355
|
-
// rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
|
|
356
|
-
// agent's hand-in because its write-up left a line off would be this function's omission
|
|
357
|
-
// charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
|
|
358
|
-
// author's to leave open, because the caller already knows the answer.
|
|
359
|
-
report = (
|
|
360
|
-
await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
|
|
361
|
-
cwd,
|
|
362
|
-
})
|
|
363
|
-
).guide.meta.id;
|
|
364
|
-
}
|
|
365
|
-
if (!report)
|
|
366
|
-
throw new PassalongError(
|
|
367
|
-
"finishing needs the write-up: `markdown` for a transfer guide about the work, or `report` with the id of one",
|
|
368
|
-
);
|
|
369
|
-
return api.finishTask(id, { agent: agent(cwd).agent, report, evidence, pr, note });
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* The four verbs every guide answers to, whatever its kind (docs/V2.md §11). Each answer carries
|
|
374
|
-
* `next`, the calls that make sense from here, and `say` when the move is to stop.
|
|
283
|
+
* The calls every piece of work answers to. Each answer carries `next`, the calls that make sense
|
|
284
|
+
* from here, and `say` when the move is to stop.
|
|
375
285
|
*
|
|
376
|
-
* take: this worktree's agent takes
|
|
377
|
-
* document comes back in the answer, and is written to
|
|
286
|
+
* take: this worktree's agent takes work `id` (an id, a share link or a saved .md), or with no id
|
|
287
|
+
* the next one waiting for it. The document comes back in the answer, and is written to
|
|
288
|
+
* `.passalong/<id>.md` only when `save` asks.
|
|
378
289
|
*/
|
|
379
290
|
export async function take(id, { cwd = process.cwd(), any = false, save = false } = {}) {
|
|
380
291
|
needsSync("taking work");
|
|
381
292
|
const who = agent(cwd);
|
|
382
293
|
const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
|
|
383
|
-
if (!r.
|
|
384
|
-
rememberFence(r.
|
|
385
|
-
return { ...r, path: save ? saveTo(who.worktree, r.
|
|
294
|
+
if (!r.work?.markdown) return r;
|
|
295
|
+
rememberFence(r.work.id, r.work.fence, cwd);
|
|
296
|
+
return { ...r, path: save ? saveTo(who.worktree, r.work.id, r.work.markdown) : null };
|
|
386
297
|
}
|
|
387
298
|
|
|
388
299
|
/** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
|
|
@@ -397,35 +308,25 @@ export async function progress(id, note, { cwd = process.cwd() } = {}) {
|
|
|
397
308
|
}
|
|
398
309
|
|
|
399
310
|
/**
|
|
400
|
-
*
|
|
401
|
-
* `
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* Every hand-in carries `evidence`: what was run and what came back. The write-up and the `ok` are
|
|
405
|
-
* both the agent's word for its own work; evidence is the part the reviewer can check.
|
|
311
|
+
* Finished here: the work goes to Done with what was shown. Every hand-in carries `evidence` or
|
|
312
|
+
* `checks` — what was run and what came back — because the note is the agent's word for its own
|
|
313
|
+
* work and evidence is the part a person can check. `writeup` is what had to be adapted.
|
|
406
314
|
*/
|
|
407
315
|
export async function handIn(
|
|
408
316
|
id,
|
|
409
317
|
{
|
|
410
|
-
ok,
|
|
411
318
|
note = "",
|
|
412
319
|
evidence = "",
|
|
413
320
|
checks = [],
|
|
414
321
|
writeup = "",
|
|
415
322
|
risk = "",
|
|
416
|
-
markdown,
|
|
417
|
-
report,
|
|
418
323
|
pr = "",
|
|
419
324
|
cwd = process.cwd(),
|
|
420
325
|
} = {},
|
|
421
326
|
) {
|
|
422
327
|
needsSync("handing work in");
|
|
423
|
-
// A check that names a command is run here, before anything is sent.
|
|
424
|
-
//
|
|
425
|
-
// The MCP server did this and this function did not, so "put the command in `cmd` and it is
|
|
426
|
-
// executed here" was true over one transport and a quiet lie over the other: a `cmd` handed to
|
|
427
|
-
// this function was passed along as a string nothing had executed. Checks that already carry an
|
|
428
|
-
// exit came from the server's own runner and are left alone, so nothing runs twice.
|
|
328
|
+
// A check that names a command is run here, before anything is sent. Checks that already carry
|
|
329
|
+
// an exit came from the MCP server's own runner and are left alone, so nothing runs twice.
|
|
429
330
|
const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
|
|
430
331
|
if (pending.length) {
|
|
431
332
|
const done = runChecks(pending, { cwd });
|
|
@@ -435,24 +336,10 @@ export async function handIn(
|
|
|
435
336
|
throw new Error(refusal(done.failed, { ran: done.ran, total: checks.length }));
|
|
436
337
|
}
|
|
437
338
|
}
|
|
438
|
-
// Checks are evidence, sorted against
|
|
439
|
-
//
|
|
440
|
-
// evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
|
|
339
|
+
// Checks are evidence, sorted against what they answer: bringing them is bringing it. A check
|
|
340
|
+
// whose command was actually run is exempt from the length rule — its exit code is the evidence.
|
|
441
341
|
if (checks.length) checks.forEach((c) => (verified(c) ? null : requireEvidence(c?.ran)));
|
|
442
342
|
else requireEvidence(evidence);
|
|
443
|
-
if (!report && markdown) {
|
|
444
|
-
const g = parse(markdown);
|
|
445
|
-
// A task's report is a transfer guide, and this code is what is making one — so it says so
|
|
446
|
-
// rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
|
|
447
|
-
// agent's hand-in because its write-up left a line off would be this function's omission
|
|
448
|
-
// charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
|
|
449
|
-
// author's to leave open, because the caller already knows the answer.
|
|
450
|
-
report = (
|
|
451
|
-
await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
|
|
452
|
-
cwd,
|
|
453
|
-
})
|
|
454
|
-
).guide.meta.id;
|
|
455
|
-
}
|
|
456
343
|
const fence = fenceFor(id, cwd);
|
|
457
344
|
return api.handIn(id, {
|
|
458
345
|
agent: agent(cwd).agent,
|
|
@@ -464,50 +351,30 @@ export async function handIn(
|
|
|
464
351
|
checks: checks.map((c) => ({
|
|
465
352
|
check: c.check,
|
|
466
353
|
ran: c.ran,
|
|
467
|
-
// What the agent says happened, in plain words, for the person's table.
|
|
468
|
-
// not said: the row then shows the line that was asked and keeps its run folded.
|
|
354
|
+
// What the agent says happened, in plain words, for the person's table.
|
|
469
355
|
...(c.says ? { says: c.says } : {}),
|
|
470
356
|
// Only present on a check that was run. The server keeps them apart the same way.
|
|
471
357
|
...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
|
|
472
358
|
})),
|
|
473
359
|
}
|
|
474
360
|
: {}),
|
|
475
|
-
|
|
476
|
-
// Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
|
|
477
|
-
// to adapt, and the field is for the ones that do.
|
|
361
|
+
// Each left out when there is nothing to say, rather than sent as "".
|
|
478
362
|
...(writeup ? { writeup } : {}),
|
|
479
|
-
// What it could break, for the reviewer. Left out when empty, like the write-up.
|
|
480
363
|
...(risk ? { risk } : {}),
|
|
481
|
-
...(
|
|
364
|
+
...(pr ? { pr } : {}),
|
|
482
365
|
});
|
|
483
366
|
}
|
|
484
367
|
|
|
485
368
|
/**
|
|
486
|
-
* Give
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* is dropped so the next read shows the new `to:`.
|
|
369
|
+
* Give work to someone else in its team: `@handle` for one person, `#group` for the people who do
|
|
370
|
+
* a thing, or "team" (or empty) for everyone. Whoever held it and is left out has it taken back and
|
|
371
|
+
* is told.
|
|
490
372
|
*/
|
|
491
373
|
export async function assign(ref, to) {
|
|
492
|
-
needsSync("
|
|
374
|
+
needsSync("giving work to someone");
|
|
493
375
|
const id = await resolveId(ref);
|
|
494
376
|
const target = !to || to === "team" || to === "everyone" ? "" : String(to).trim();
|
|
495
|
-
|
|
496
|
-
store.remove(id);
|
|
497
|
-
return r;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Take back something you sent: it is private again and gone from every inbox it was in. Refused
|
|
502
|
-
* while somebody is on it. Whoever already opened it keeps what they pulled. The local copy is
|
|
503
|
-
* dropped so the next read shows it without the address.
|
|
504
|
-
*/
|
|
505
|
-
export async function recall(ref) {
|
|
506
|
-
needsSync("taking a guide back");
|
|
507
|
-
const id = await resolveId(ref);
|
|
508
|
-
const r = await api.recall(id);
|
|
509
|
-
store.remove(id);
|
|
510
|
-
return r;
|
|
377
|
+
return api.assign(id, target);
|
|
511
378
|
}
|
|
512
379
|
|
|
513
380
|
/** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
|
|
@@ -540,43 +407,81 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
|
|
|
540
407
|
});
|
|
541
408
|
}
|
|
542
409
|
|
|
410
|
+
/** Why a piece of work needs its author, or "" when it does not. Mirrors needsWhy() in mcp-http.ts. */
|
|
411
|
+
export function needsWhy(t) {
|
|
412
|
+
if (!t.mine) return "";
|
|
413
|
+
if (t.state === "draft") return "in Draft: read it and mark it Ready";
|
|
414
|
+
if (t.state === "stalled") return "its agent went quiet";
|
|
415
|
+
if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
|
|
416
|
+
if (/^BLOCKED:/i.test(t.claim?.note || ""))
|
|
417
|
+
return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
|
|
418
|
+
return "";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The work board, the way `find_work` with nothing hands it over and `passalong board` prints it:
|
|
423
|
+
* what needs you, who is on what, and what is ready to take. Built from `/v1/tasks` (every piece of
|
|
424
|
+
* work you can see, with its state) and `/v1/working` (everything someone holds).
|
|
425
|
+
*/
|
|
426
|
+
export async function board() {
|
|
427
|
+
needsSync("the board");
|
|
428
|
+
const [{ tasks }, { working }] = await Promise.all([api.tasks(), api.working()]);
|
|
429
|
+
const hub = `${api.baseUrl()}/hub`;
|
|
430
|
+
const who = (b = {}) => (b.you ? "You" : b.name || (b.handle ? `@${b.handle}` : "A teammate"));
|
|
431
|
+
const needs = tasks
|
|
432
|
+
.map((t) => ({ id: t.id, title: t.title, why: needsWhy(t), url: `${hub}/w/${t.id}` }))
|
|
433
|
+
.filter((t) => t.why);
|
|
434
|
+
return {
|
|
435
|
+
hub,
|
|
436
|
+
counts: {
|
|
437
|
+
needs: needs.length,
|
|
438
|
+
working: working.length,
|
|
439
|
+
open: tasks.filter((t) => ["ready", "blocked", "draft"].includes(t.state)).length,
|
|
440
|
+
done: tasks.filter((t) => t.state === "done").length,
|
|
441
|
+
},
|
|
442
|
+
needs,
|
|
443
|
+
working: working.map((w) => ({
|
|
444
|
+
id: w.id,
|
|
445
|
+
title: w.title,
|
|
446
|
+
who: who(w.by),
|
|
447
|
+
note: w.note,
|
|
448
|
+
stalled: w.state === "stalled",
|
|
449
|
+
url: w.url,
|
|
450
|
+
})),
|
|
451
|
+
ready: tasks
|
|
452
|
+
.filter((t) => t.state === "ready")
|
|
453
|
+
.map((t) => ({
|
|
454
|
+
id: t.id,
|
|
455
|
+
title: t.title,
|
|
456
|
+
target: t.target || "",
|
|
457
|
+
to: t.to || "",
|
|
458
|
+
url: t.url,
|
|
459
|
+
})),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
543
463
|
/**
|
|
544
|
-
* What this worktree's agent holds right now, and what is waiting
|
|
545
|
-
*
|
|
546
|
-
* — a hook must never fail a session over a count.
|
|
464
|
+
* What this worktree's agent holds right now, and what is waiting: what the session-start and stop
|
|
465
|
+
* hooks read (src/hooks.js). Counts are best effort — a hook must never fail a session over one.
|
|
547
466
|
*/
|
|
548
467
|
export async function now({ cwd = process.cwd() } = {}) {
|
|
549
468
|
needsSync("seeing what you hold");
|
|
550
469
|
// Read, never minted: the hooks run in every repo a session opens, and a worktree that has never
|
|
551
|
-
// taken anything has no agent — and should not be given a .passalong/
|
|
470
|
+
// taken anything has no agent — and should not be given a .passalong/ project for asking.
|
|
552
471
|
const who = knownAgent(cwd);
|
|
553
|
-
const [w, t
|
|
554
|
-
api.working(),
|
|
555
|
-
api.tasks(),
|
|
556
|
-
api.inbox(),
|
|
557
|
-
api.handedIn(),
|
|
558
|
-
]);
|
|
472
|
+
const [w, t] = await Promise.allSettled([api.working(), api.tasks()]);
|
|
559
473
|
if (w.status === "rejected") throw w.reason;
|
|
560
474
|
const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
|
|
561
475
|
const tasks = t.status === "fulfilled" ? t.value.tasks : [];
|
|
562
|
-
|
|
476
|
+
return {
|
|
477
|
+
held,
|
|
563
478
|
ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
|
|
564
|
-
|
|
479
|
+
needs: tasks.filter((x) => needsWhy(x)).length,
|
|
480
|
+
agent: who.agent,
|
|
565
481
|
};
|
|
566
|
-
// Everything waiting on the person, the way the hub's Needs you counts it: their tasks handed in
|
|
567
|
-
// or stuck, handoffs of theirs handed in, and guides handed to them.
|
|
568
|
-
const review = tasks.filter(
|
|
569
|
-
(x) =>
|
|
570
|
-
x.mine &&
|
|
571
|
-
(x.state === "review" ||
|
|
572
|
-
x.state === "stalled" ||
|
|
573
|
-
(x.state === "claimed" && (x.claim?.asking || /^BLOCKED:/i.test(x.claim?.note || "")))),
|
|
574
|
-
).length;
|
|
575
|
-
const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
|
|
576
|
-
return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
|
|
577
482
|
}
|
|
578
483
|
|
|
579
|
-
/** Finished work back to To do, with why
|
|
484
|
+
/** Finished work back to To do, with why. */
|
|
580
485
|
export async function reopen(id, why) {
|
|
581
486
|
needsSync("reopening work");
|
|
582
487
|
if (!String(why || "").trim())
|
|
@@ -584,98 +489,16 @@ export async function reopen(id, why) {
|
|
|
584
489
|
return api.reopen(await resolveId(id), why);
|
|
585
490
|
}
|
|
586
491
|
|
|
587
|
-
/**
|
|
588
|
-
* The gate, a person's three answers to a task. Approve: the work in review is done. Reject: it
|
|
589
|
-
* goes back to Ready with `why` on it for the next agent. Release: a task an agent holds, live or
|
|
590
|
-
* stalled, goes back to Ready with a note saying where that agent left it.
|
|
591
|
-
*
|
|
592
|
-
* The local copy is dropped after each, so the next read of the task fetches what the server made
|
|
593
|
-
* of it — a reject or a release rewrites the document, and a stale copy would hide exactly the
|
|
594
|
-
* line the next reader needs.
|
|
595
|
-
*/
|
|
596
|
-
export async function approveTask(id) {
|
|
597
|
-
needsSync("approving work");
|
|
598
|
-
const { guide } = await resolve(id, { peek: true });
|
|
599
|
-
const r = await (guide.meta.kind === "task" ? api.approveTask(id) : api.closeGuide(id));
|
|
600
|
-
store.remove(id);
|
|
601
|
-
return r;
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
export async function rejectTask(id, why) {
|
|
605
|
-
needsSync("sending work back");
|
|
606
|
-
if (!String(why || "").trim())
|
|
607
|
-
throw new PassalongError("say why: whoever takes it next reads it");
|
|
608
|
-
const { guide } = await resolve(id, { peek: true });
|
|
609
|
-
const r = await (guide.meta.kind === "task" ? api.rejectTask(id, why) : api.sendBack(id, why));
|
|
610
|
-
store.remove(id);
|
|
611
|
-
return r;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/**
|
|
615
|
-
* Take it back from whoever is holding it — a task, a handoff or a bug, and from all of them at
|
|
616
|
-
* once when several people hold the same handoff in different repos.
|
|
617
|
-
*
|
|
618
|
-
* It was tasks only, which left an asymmetry nobody chose: the author of a handoff could mark it
|
|
619
|
-
* done or give it to somebody else, and had no way to simply have it back.
|
|
620
|
-
*/
|
|
492
|
+
/** Take it back from whoever is holding it. */
|
|
621
493
|
export async function release(id) {
|
|
622
494
|
needsSync("taking work back");
|
|
623
|
-
|
|
624
|
-
store.remove(id);
|
|
625
|
-
return r;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* Pull a guide *and* take the handoff: the two halves of starting work, in one call.
|
|
630
|
-
*
|
|
631
|
-
* `pull` says the guide arrived. `ack` says somebody is doing it, which is the thing the sender
|
|
632
|
-
* cannot find out any other way — and the two are deliberately separate, because reading a guide
|
|
633
|
-
* to decide it is not yours is exactly what `pass` is for. Fetching is not committing.
|
|
634
|
-
*
|
|
635
|
-
* For an agent the distinction is real but the ordering never varies: it reads a guide because it
|
|
636
|
-
* is about to follow it. So this is the path an agent takes when it means to do the work, and it
|
|
637
|
-
* is one call rather than two — the mandated thing has to be the cheap thing or it gets skipped.
|
|
638
|
-
* `pull` alone remains the way to read one without answering for it.
|
|
639
|
-
*
|
|
640
|
-
* The ack is best-effort and never costs the caller the markdown, which is what they came for:
|
|
641
|
-
*
|
|
642
|
-
* - the author's own guide is a 403 (`ack` is the reader's answer and an author is not a party
|
|
643
|
-
* to it), and that is a no-op, not a failure — an agent working in its own user's repo hits
|
|
644
|
-
* this constantly;
|
|
645
|
-
* - logged out, there is nobody to tell;
|
|
646
|
-
* - anything else is reported in `ack_error` rather than swallowed, because an ack that quietly
|
|
647
|
-
* did not land leaves the sender in the silence the whole feature exists to end.
|
|
648
|
-
*/
|
|
649
|
-
export async function start(ref, { cwd = process.cwd(), save = false } = {}) {
|
|
650
|
-
const r = await take(await resolveId(ref), { cwd, save });
|
|
651
|
-
if (!r.guide?.markdown) return r;
|
|
652
|
-
return { ...r, markdown: r.guide.markdown };
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* The one line `get_guide` adds to a guide it is handing an agent, or "" when there is nothing to
|
|
657
|
-
* say. Separate from the tool so it can be tested without a server.
|
|
658
|
-
*
|
|
659
|
-
* Only a guide that was addressed to somebody can be taken — one nobody was handed has no handoff
|
|
660
|
-
* to answer for, and nudging about it is noise that teaches an agent to ignore the nudge.
|
|
661
|
-
*
|
|
662
|
-
* It goes after the document, not in front of it. Anything before the opening `---` stops the
|
|
663
|
-
* frontmatter being frontmatter, and an agent that writes what it was handed back out to a file
|
|
664
|
-
* would lose the id along with it.
|
|
665
|
-
*/
|
|
666
|
-
export function handoffNudge(meta = {}) {
|
|
667
|
-
if (!meta.to && !meta.team) return "";
|
|
668
|
-
return (
|
|
669
|
-
"<!-- passalong: this guide was handed to someone. If you are about to do the work, call " +
|
|
670
|
-
"take instead of get_guide — it takes the handoff so the sender stops guessing. " +
|
|
671
|
-
"If you are only reading, or it turns out not to be yours, answer with pass with a reason. -->"
|
|
672
|
-
);
|
|
495
|
+
return api.release(await resolveId(id));
|
|
673
496
|
}
|
|
674
497
|
|
|
675
498
|
/**
|
|
676
|
-
* The image types
|
|
677
|
-
*
|
|
678
|
-
*
|
|
499
|
+
* The image types work can carry. Mirrors SHOT_TYPES in apps/api/src/shots.ts — the server is the
|
|
500
|
+
* authority and refuses anything else; this is what lets a local caller find out before spending an
|
|
501
|
+
* upload on it.
|
|
679
502
|
*/
|
|
680
503
|
export const SHOT_TYPES = {
|
|
681
504
|
".png": "image/png",
|
|
@@ -686,65 +509,42 @@ export const SHOT_TYPES = {
|
|
|
686
509
|
};
|
|
687
510
|
|
|
688
511
|
/**
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
* an image beside it would not travel at all. `claimShots` binds the upload to whichever guide's
|
|
693
|
-
* markdown names it at publish, which is why this returns a line to paste rather than taking a
|
|
694
|
-
* guide id — there is nothing to attach it to until the document says so.
|
|
695
|
-
*
|
|
696
|
-
* Local because it reads a path. An agent in a terminal has one; a hosted assistant does not, and
|
|
697
|
-
* reaches the same route through `attach_screenshot` on the HTTP server instead.
|
|
698
|
-
*/
|
|
699
|
-
/**
|
|
700
|
-
* The same upload, for an agent that has the image and not a path to it.
|
|
701
|
-
*
|
|
702
|
-
* `attach()` takes a path because a screenshot usually lands on disk. An agent driving a browser
|
|
703
|
-
* often has the opposite: the pane hands back an image as a tool result and never writes a file, so
|
|
704
|
-
* the only door into evidence was one it could not open. It did what anyone would — swapped to
|
|
705
|
-
* `grep` and `tsc` checks and put what it saw in `writeup` — and said so itself: "a grep proves the
|
|
706
|
-
* code changed, not that the screen renders right."
|
|
707
|
-
*
|
|
708
|
-
* Base64 in a tool call, which the HTTP server's instructions rightly warn against, because there
|
|
709
|
-
* the alternative is `create_upload` and a curl. Here there is no alternative for bytes held in
|
|
710
|
-
* memory, and this server is a local process talking to the same upload the file path uses.
|
|
512
|
+
* The same upload as `attach()`, for an agent that has the image and not a path to it: a browser
|
|
513
|
+
* pane hands an image back as a tool result and never writes a file. Base64 in a tool call is fine
|
|
514
|
+
* here, where this server is a local process talking to the same upload the file path uses.
|
|
711
515
|
*/
|
|
712
516
|
export async function attachBytes(data, { name = "", type = "image/png" } = {}) {
|
|
713
|
-
|
|
714
|
-
throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
|
|
517
|
+
needsSync("adding a file");
|
|
715
518
|
const allowed = new Set(Object.values(SHOT_TYPES));
|
|
716
519
|
if (!allowed.has(type))
|
|
717
|
-
throw new PassalongError(`
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
// caller's problem to strip.
|
|
722
|
-
bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
|
|
723
|
-
} catch {
|
|
724
|
-
throw new PassalongError("`data` is not base64");
|
|
725
|
-
}
|
|
520
|
+
throw new PassalongError(`pictures must be ${[...allowed].join(", ")} — ${type} is not one`);
|
|
521
|
+
// A data: URL is what several browser tools hand back, so it is accepted rather than made the
|
|
522
|
+
// caller's problem to strip.
|
|
523
|
+
const bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
|
|
726
524
|
if (!bytes.length) throw new PassalongError("`data` decoded to nothing");
|
|
727
|
-
// The server's own ceiling, checked here so a large paste fails before it is sent
|
|
728
|
-
// after. Anything bigger belongs in create_upload, which streams from a file.
|
|
525
|
+
// The server's own ceiling, checked here so a large paste fails before it is sent.
|
|
729
526
|
if (bytes.length > 5 * 1024 * 1024)
|
|
730
527
|
throw new PassalongError(
|
|
731
|
-
`that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB —
|
|
732
|
-
"write it to a file and use create_upload, which streams",
|
|
528
|
+
`that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — write it to a file and pass its path`,
|
|
733
529
|
);
|
|
734
530
|
const label = name || "screenshot";
|
|
735
531
|
const shot = await api.uploadShot(bytes, type, label);
|
|
736
532
|
return { ...shot, markdown: `` };
|
|
737
533
|
}
|
|
738
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Upload a picture from disk, and return the markdown that points at it. It belongs *in* the work's
|
|
537
|
+
* text or a hand-in check: `claimShots` binds the upload to whichever document names it, which is
|
|
538
|
+
* why this returns a line to paste rather than taking an id.
|
|
539
|
+
*/
|
|
739
540
|
export async function attach(file, { name = "" } = {}) {
|
|
740
|
-
|
|
741
|
-
throw new PassalongError("attaching evidence needs sync — run `passalong login` first");
|
|
541
|
+
needsSync("adding a file");
|
|
742
542
|
if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
|
|
743
543
|
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
|
|
744
544
|
const type = SHOT_TYPES[ext];
|
|
745
545
|
if (!type)
|
|
746
546
|
throw new PassalongError(
|
|
747
|
-
`
|
|
547
|
+
`pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")} — ${ext || file} is not one`,
|
|
748
548
|
);
|
|
749
549
|
const shot = await api.uploadShot(readFileSync(file), type, name || basename(file));
|
|
750
550
|
return { ...shot, markdown: `` };
|
|
@@ -752,7 +552,7 @@ export async function attach(file, { name = "" } = {}) {
|
|
|
752
552
|
|
|
753
553
|
/**
|
|
754
554
|
* What a file's extension suggests it is. A hint only: the server reads the bytes and decides, and
|
|
755
|
-
* refuses what it does not keep.
|
|
555
|
+
* refuses what it does not keep.
|
|
756
556
|
*/
|
|
757
557
|
const FILE_HINTS = {
|
|
758
558
|
".pdf": "application/pdf",
|
|
@@ -768,14 +568,11 @@ const FILE_HINTS = {
|
|
|
768
568
|
const FILE_MAX = 10 * 1024 * 1024;
|
|
769
569
|
|
|
770
570
|
/**
|
|
771
|
-
*
|
|
772
|
-
* points at it.
|
|
773
|
-
* show it with. It is private: whoever can read the guide can download it, and nobody else can, so
|
|
774
|
-
* it is not evidence on a page that travels by link.
|
|
571
|
+
* Upload a file that is not a picture — a log, a PDF, a CSV, a zip — and return the markdown line
|
|
572
|
+
* that points at it. It is private: whoever can read the work can download it, and nobody else.
|
|
775
573
|
*/
|
|
776
574
|
export async function attachFile(file, { name = "" } = {}) {
|
|
777
|
-
|
|
778
|
-
throw new PassalongError("attaching a file needs sync — run `passalong login` first");
|
|
575
|
+
needsSync("adding a file");
|
|
779
576
|
if (!existsSync(file)) throw new PassalongError(`no file at ${file}`);
|
|
780
577
|
const bytes = readFileSync(file);
|
|
781
578
|
if (!bytes.length) throw new PassalongError(`${file} is empty`);
|
|
@@ -789,330 +586,149 @@ export async function attachFile(file, { name = "" } = {}) {
|
|
|
789
586
|
return { ...attachment, markdown: `[${label}](${attachment.url})` };
|
|
790
587
|
}
|
|
791
588
|
|
|
589
|
+
/** A picture or any other file from disk, whichever it is, as the line that points at it. */
|
|
590
|
+
export function attachAny(file, opts = {}) {
|
|
591
|
+
const isImage = Object.keys(SHOT_TYPES).some((x) => file.toLowerCase().endsWith(x));
|
|
592
|
+
return (isImage ? attach : attachFile)(file, opts);
|
|
593
|
+
}
|
|
594
|
+
|
|
792
595
|
/**
|
|
793
|
-
* Write to whoever holds a
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
* decides who may write and whether anyone is listening, and says so in words.
|
|
596
|
+
* Write to whoever holds a piece of work, or leave a note on one nobody holds yet. `files` are
|
|
597
|
+
* paths, each uploaded and added as a line of the message. The server decides who may write and
|
|
598
|
+
* whether anyone is listening, and says so in words.
|
|
797
599
|
*/
|
|
798
600
|
export async function reply(ref, text, { files = [] } = {}) {
|
|
799
601
|
needsSync("replying");
|
|
800
602
|
const lines = [];
|
|
801
|
-
for (const file of files)
|
|
802
|
-
const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
|
|
803
|
-
const got = SHOT_TYPES[ext] ? await attach(file) : await attachFile(file);
|
|
804
|
-
lines.push(got.markdown);
|
|
805
|
-
}
|
|
603
|
+
for (const file of files) lines.push((await attachAny(file)).markdown);
|
|
806
604
|
const body = [String(text || "").trim(), ...lines].filter(Boolean).join("\n");
|
|
807
605
|
if (!body) throw new PassalongError("say something, or attach a file");
|
|
808
606
|
return api.reply(await resolveId(ref), body);
|
|
809
607
|
}
|
|
810
608
|
|
|
811
609
|
/**
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* original — a missing detail, a step that needed explaining, what changed since, what someone
|
|
816
|
-
* found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
|
|
817
|
-
* is why context belongs there rather than in a one-line verdict note.
|
|
818
|
-
*
|
|
819
|
-
* What an agent did and found working on the guide is not context for it: that is the hand-in,
|
|
820
|
-
* and the note says so. It used to list "what you found doing it" among the things to publish, on
|
|
821
|
-
* every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
|
|
822
|
-
* it read last, and the author got a second guide to review with the evidence inside it.
|
|
823
|
-
*
|
|
824
|
-
* Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
|
|
825
|
-
* not only in the server's instructions because an agent reads the payload it is working from and
|
|
826
|
-
* skims everything else — the same reason the bug lead is inside the document. For a bug, the
|
|
827
|
-
* context worth adding is usually the fix, when it is worth repeating somewhere else: that is a
|
|
828
|
-
* transfer guide, and the bug is what it adds context to.
|
|
610
|
+
* What an agent is told after the work it opened: where what it learns goes. Knowledge belongs in
|
|
611
|
+
* the project's documents, where the next person finds it; work somebody else should now do is new
|
|
612
|
+
* work. Neither is a second document handed to one person. Mirrors AFTER in apps/api/src/mcp-http.ts.
|
|
829
613
|
*/
|
|
830
|
-
export
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
"more context for this bug: publish it as a transfer guide with publish_guide " +
|
|
836
|
-
`parent=${meta.id}, and whoever opens this bug gets it too. -->`
|
|
837
|
-
);
|
|
838
|
-
return (
|
|
839
|
-
"<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
|
|
840
|
-
"this guide needs more — a missing detail, a step that needed explaining, what changed since " +
|
|
841
|
-
`— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
|
|
842
|
-
"too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
|
|
843
|
-
"`checks` and `writeup`. -->"
|
|
844
|
-
);
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** What heads a guide's follow-ups when they are handed to an agent. Same words on both servers. */
|
|
848
|
-
export const FOLLOW_UPS_LEAD =
|
|
849
|
-
"FOLLOW-UPS — more context added to this guide, oldest first. Read them before acting; where " +
|
|
850
|
-
"one contradicts the original, the follow-up is newer.";
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* A guide's follow-ups as the server lists them, oldest first, or [] when there are none, when
|
|
854
|
-
* sync is off, or when anything goes wrong. Context is never worth failing a pull over: the guide
|
|
855
|
-
* itself is what was asked for, and it is already in hand.
|
|
856
|
-
*
|
|
857
|
-
* Only with sync, because follow-ups live on the server and are scoped to what this account can
|
|
858
|
-
* read — the same condition `resolve()` uses to look a guide up there. Fetching them records no
|
|
859
|
-
* pull on them: reading context for a guide is not opening those guides.
|
|
860
|
-
*
|
|
861
|
-
* `markdown` asks for each one's content too (capped server-side). The api calls are injectable
|
|
862
|
-
* so this can be tested without a network.
|
|
863
|
-
*/
|
|
864
|
-
export async function followUpGuides(
|
|
865
|
-
meta = {},
|
|
866
|
-
{ markdown = false, children = api.children, loggedIn = api.loggedIn } = {},
|
|
867
|
-
) {
|
|
868
|
-
if (!meta.id || !loggedIn()) return [];
|
|
869
|
-
try {
|
|
870
|
-
const { guides = [] } = await children(meta.id, { markdown });
|
|
871
|
-
const listed = (Array.isArray(guides) ? guides : []).filter((g) => g?.id);
|
|
872
|
-
// With content the server already sends oldest first; the plain listing is newest first.
|
|
873
|
-
return markdown ? listed : listed.reverse();
|
|
874
|
-
} catch {
|
|
875
|
-
return [];
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/**
|
|
880
|
-
* A guide's follow-ups with their content, formatted to go after the document for an agent — the
|
|
881
|
-
* same shape the remote server's get_guide returns — or "" when there are none.
|
|
882
|
-
*/
|
|
883
|
-
export async function followUps(meta = {}, deps = {}) {
|
|
884
|
-
const guides = (await followUpGuides(meta, { ...deps, markdown: true })).filter(
|
|
885
|
-
(g) => typeof g.markdown === "string",
|
|
886
|
-
);
|
|
887
|
-
if (!guides.length) return "";
|
|
888
|
-
return [
|
|
889
|
-
FOLLOW_UPS_LEAD,
|
|
890
|
-
...guides.map(
|
|
891
|
-
(g) => `--- follow-up ${g.id}: ${g.title || "untitled"} ---\n${g.markdown.trimEnd()}`,
|
|
892
|
-
),
|
|
893
|
-
].join("\n\n");
|
|
894
|
-
}
|
|
614
|
+
export const AFTER =
|
|
615
|
+
"<!-- passalong: what you learn doing this — how something works, a decision and why — goes " +
|
|
616
|
+
"into the project's documents (find_projects, read_document, save_document), not into a new " +
|
|
617
|
+
"piece of work. Work somebody else should now do is create_work with `parent` set to this id. " +
|
|
618
|
+
"What you did and how you checked it goes on hand_in. -->";
|
|
895
619
|
|
|
896
620
|
/**
|
|
897
|
-
* The
|
|
621
|
+
* The work this one came out of, formatted to go in front of it — or "" when there is none.
|
|
898
622
|
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
904
|
-
* The same shape the hosted server's parentOf() returns. Injectable for testing, and never worth
|
|
905
|
-
* failing the call over: the guide itself is what was asked for.
|
|
623
|
+
* A piece of work read on its own can look like the whole job when it is one part of a bigger one,
|
|
624
|
+
* and where the bigger one has got to is part of the context. Injectable for testing, and never
|
|
625
|
+
* worth failing the call over: the work itself is what was asked for. Mirrors parentOf() in
|
|
626
|
+
* apps/api/src/mcp-http.ts.
|
|
906
627
|
*/
|
|
907
|
-
export async function
|
|
908
|
-
meta = {},
|
|
909
|
-
{ parent = api.parent, loggedIn = api.loggedIn } = {},
|
|
910
|
-
) {
|
|
628
|
+
export async function parentWork(meta = {}, { parent = api.parent, loggedIn = api.loggedIn } = {}) {
|
|
911
629
|
if (!meta.id || !meta.parent || !loggedIn()) return "";
|
|
912
630
|
try {
|
|
913
|
-
const {
|
|
914
|
-
if (!
|
|
915
|
-
const who =
|
|
916
|
-
const state =
|
|
631
|
+
const { work } = await parent(meta.id, { markdown: true });
|
|
632
|
+
if (!work?.id) return "";
|
|
633
|
+
const who = work.by?.name || (work.by?.handle ? `@${work.by.handle}` : "");
|
|
634
|
+
const state = work.state === "held" && who ? `held by ${who}` : work.state || "open";
|
|
917
635
|
return (
|
|
918
|
-
`THIS
|
|
919
|
-
"
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
`--- the guide it follows: ${guide.id} ---\n${String(guide.markdown ?? "").trimEnd()}\n\n`
|
|
636
|
+
`THIS CAME OUT OF ${work.id}: ${work.title || "untitled"} — ${state}. It is part of that ` +
|
|
637
|
+
"work, which is below; read it first. If this depends on " +
|
|
638
|
+
`${work.id} being done and it is not, say so rather than starting.\n\n` +
|
|
639
|
+
`--- the work it came out of: ${work.id} ---\n${String(work.markdown ?? "").trimEnd()}\n\n`
|
|
923
640
|
);
|
|
924
641
|
} catch {
|
|
925
642
|
return "";
|
|
926
643
|
}
|
|
927
644
|
}
|
|
928
645
|
|
|
929
|
-
/**
|
|
646
|
+
/** Work on the server this account can see, newest first, optionally filtered. */
|
|
930
647
|
export async function list(query = "", { scope = "" } = {}) {
|
|
931
|
-
needsSync("
|
|
932
|
-
|
|
933
|
-
return res.guides.map((r) => ({ ...r, where: "synced" }));
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/** Guides handed to you (or your teams) that you have not pulled yet. Needs sync. */
|
|
937
|
-
export async function inbox() {
|
|
938
|
-
const res = await api.inbox();
|
|
939
|
-
return res.guides;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
/**
|
|
943
|
-
* The state of your transfers as four queues: waiting on you, in flight (handed over, untouched),
|
|
944
|
-
* and landed (someone else has it). Needs sync.
|
|
945
|
-
*/
|
|
946
|
-
export const board = () => api.board();
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* What you did, newest first: what you published, what you took delivery of, and every verdict and
|
|
950
|
-
* ack you gave. The other half of `activity` — that one is what other people did to your guides and
|
|
951
|
-
* it clears when you read it; this is your own record and it never clears.
|
|
952
|
-
*
|
|
953
|
-
* It is a record of what you passed along, not of what you worked on. Work that never became a
|
|
954
|
-
* guide has no row, so anything printing this has to say so. Needs sync.
|
|
955
|
-
*/
|
|
956
|
-
export async function log({ repo = "", since = "" } = {}) {
|
|
957
|
-
const res = await api.log({ repo, since });
|
|
958
|
-
return res.log;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/**
|
|
962
|
-
* What happened while you were away: your guides being pulled and shipped, guides handed to you,
|
|
963
|
-
* invites taken up. Each item carries a rendered `text` line so every surface says the same thing.
|
|
964
|
-
*/
|
|
965
|
-
export async function activity({ all = false, limit = 50 } = {}) {
|
|
966
|
-
return api.notifications({ unread: !all, limit });
|
|
648
|
+
needsSync("finding work");
|
|
649
|
+
return (await api.list(query, scope)).work;
|
|
967
650
|
}
|
|
968
651
|
|
|
969
|
-
/**
|
|
970
|
-
* Report whether a guide actually works. This is the reader's answer, not the author's status:
|
|
971
|
-
* `done` says "I implemented it", a verdict says "I tried it and it holds up" — or does not.
|
|
972
|
-
* A failing verdict needs a note; "it doesn't work" without a reason helps nobody.
|
|
973
|
-
*/
|
|
974
|
-
/** Mark notifications seen. No ids means everything unread. */
|
|
975
|
-
export const seen = (ids = []) => api.markRead(ids);
|
|
976
|
-
|
|
977
|
-
export function summary(g) {
|
|
978
|
-
return {
|
|
979
|
-
id: g.meta.id,
|
|
980
|
-
title: g.meta.title,
|
|
981
|
-
status: g.meta.status,
|
|
982
|
-
created: g.meta.created,
|
|
983
|
-
source_context: g.meta.source_context,
|
|
984
|
-
tags: g.meta.tags || [],
|
|
985
|
-
stack_assumptions: g.meta.stack_assumptions || [],
|
|
986
|
-
url: g.meta.url || null,
|
|
987
|
-
team: g.meta.team || "",
|
|
988
|
-
to: g.meta.to || "",
|
|
989
|
-
mine: true,
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/** Archive a guide (`consumed`) or put it back (`published`). The server refuses `promoted`. */
|
|
652
|
+
/** Archive a piece of work (`done`), make it Ready, or put it in Draft. */
|
|
994
653
|
export async function setStatus(id, status) {
|
|
995
654
|
if (!STATUSES.includes(status))
|
|
996
655
|
throw new PassalongError(`status must be one of ${STATUSES.join(", ")}`);
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
// The server first. Saved the other way round, a refusal left the local copy saying what the
|
|
1000
|
-
// server had just refused — a teammate's `ready` on someone else's task read as done here.
|
|
1001
|
-
await api.setStatus(id, status);
|
|
1002
|
-
return next;
|
|
656
|
+
needsSync("changing work");
|
|
657
|
+
return api.setStatus(await resolveId(id), status);
|
|
1003
658
|
}
|
|
1004
659
|
|
|
1005
|
-
/**
|
|
1006
|
-
* Move a guide from Draft to Ready: a person has read it, and whoever it is for may take it now.
|
|
1007
|
-
* Every kind: a handoff or bug is a draft until somebody says so, as a task always was.
|
|
1008
|
-
*/
|
|
660
|
+
/** Move work from Draft to Ready: a person has read it, and whoever it is for may take it now. */
|
|
1009
661
|
export async function ready(id) {
|
|
1010
|
-
const {
|
|
1011
|
-
if (
|
|
1012
|
-
throw new PassalongError(`${
|
|
1013
|
-
|
|
662
|
+
const { work } = await resolve(id, { peek: true });
|
|
663
|
+
if (work.meta.status !== "draft")
|
|
664
|
+
throw new PassalongError(`${work.meta.id} is not in Draft (status: ${work.meta.status})`);
|
|
665
|
+
await setStatus(work.meta.id, "ready");
|
|
666
|
+
return work.meta.id;
|
|
1014
667
|
}
|
|
1015
668
|
|
|
1016
|
-
/**
|
|
1017
|
-
* One goal as several tasks, written as drafts in order. Each step may name the earlier steps it
|
|
1018
|
-
* needs by position (`after: [0, 1]`), and those become its `blocked_by`, so the queue hands them
|
|
1019
|
-
* out in an order that works and runs the independent ones side by side.
|
|
1020
|
-
*
|
|
1021
|
-
* Only earlier steps: a step that waits on itself or on a later one is a plan that never finishes,
|
|
1022
|
-
* and it is refused before anything is written rather than discovered as a queue that never moves.
|
|
1023
|
-
* Every step lands in Draft — a wrong decomposition is several agents building the wrong thing at
|
|
1024
|
-
* once, so a person reads the plan before any of it runs.
|
|
1025
|
-
*/
|
|
1026
|
-
export async function planTasks(steps, { cwd = process.cwd() } = {}) {
|
|
1027
|
-
if (!Array.isArray(steps) || !steps.length)
|
|
1028
|
-
throw new PassalongError("a plan needs at least one step");
|
|
1029
|
-
steps.forEach((step, i) => {
|
|
1030
|
-
for (const j of step.after || [])
|
|
1031
|
-
if (!Number.isInteger(j) || j < 0 || j >= i)
|
|
1032
|
-
throw new PassalongError(
|
|
1033
|
-
`step ${i} ("${step.title}") can only wait on an earlier step, not ${j}`,
|
|
1034
|
-
);
|
|
1035
|
-
});
|
|
1036
|
-
const repo = context(cwd).repo;
|
|
1037
|
-
const ids = [];
|
|
1038
|
-
for (const step of steps) {
|
|
1039
|
-
const section = (name, text) =>
|
|
1040
|
-
String(text || "").trim() ? [`## ${name}`, String(text).trim(), ""] : [];
|
|
1041
|
-
const body = [
|
|
1042
|
-
...section("Goal", step.goal),
|
|
1043
|
-
...section("Context", step.context),
|
|
1044
|
-
...section("Constraints", step.constraints),
|
|
1045
|
-
...section("Acceptance", step.acceptance),
|
|
1046
|
-
...section("Out of scope", step.out_of_scope),
|
|
1047
|
-
].join("\n");
|
|
1048
|
-
// The summary is the step's own, said by whoever planned it: it is what a person reads about the
|
|
1049
|
-
// task, and this builds the document without guessing one (the server refuses a task without).
|
|
1050
|
-
// A plan is the case Draft is for: several tasks at once, which a person reads before any of
|
|
1051
|
-
// them runs. Stated here, by the code that knows it is building one.
|
|
1052
|
-
const meta = {
|
|
1053
|
-
title: step.title,
|
|
1054
|
-
summary: step.summary,
|
|
1055
|
-
kind: "task",
|
|
1056
|
-
status: "draft",
|
|
1057
|
-
target_context: step.target_context ?? repo,
|
|
1058
|
-
};
|
|
1059
|
-
const after = (step.after || []).map((j) => ids[j]);
|
|
1060
|
-
if (after.length) meta.blocked_by = after;
|
|
1061
|
-
ids.push((await share(serialize({ meta, body }), { cwd })).guide.meta.id);
|
|
1062
|
-
}
|
|
1063
|
-
return ids;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/** What an agent started by `work()` is told. The task itself goes in too, so it cannot start blind. */
|
|
669
|
+
/** What an agent started by `work()` is told. The work itself goes in too, so it cannot start blind. */
|
|
1067
670
|
export function workPrompt(t) {
|
|
1068
671
|
return [
|
|
1069
|
-
`You hold passalong
|
|
672
|
+
`You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
|
|
1070
673
|
"",
|
|
1071
674
|
t.markdown.trim(),
|
|
1072
675
|
"",
|
|
1073
|
-
"Do it in this repo
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
"
|
|
1077
|
-
`
|
|
1078
|
-
"
|
|
1079
|
-
|
|
1080
|
-
"
|
|
1081
|
-
"
|
|
1082
|
-
"summary, the commit. Keep that as you go; at the end you would be writing it from memory,",
|
|
1083
|
-
"and it is the one part of the hand-in a reviewer can check. If either call says you no",
|
|
1084
|
-
"longer hold the task, stop.",
|
|
676
|
+
"Do it in this repo: read it, work out what done means, and get there. Call the passalong MCP",
|
|
677
|
+
`tool progress with id ${t.id} and a one-line status at each milestone — 30 minutes of`,
|
|
678
|
+
"silence stalls it. When it is done, commit what you changed as one commit whose message",
|
|
679
|
+
`starts with "${t.id}: " — several pieces of work share this worktree, and a person reads each`,
|
|
680
|
+
`one's change on its own. Then call hand_in with id ${t.id}, \`pr\` set to that commit's hash,`,
|
|
681
|
+
"`note`: one plain sentence saying what you did, and `evidence`: what you ran and what came",
|
|
682
|
+
"back — the commands and the lines that decided it, the test summary, the commit. Keep that",
|
|
683
|
+
"as you go; at the end you would be writing it from memory. What you learned about the",
|
|
684
|
+
"project goes into its documents (save_document). If a call says you no longer hold it, stop.",
|
|
1085
685
|
"",
|
|
1086
686
|
"Nobody reads what you print: this session runs unattended. If something only a person can",
|
|
1087
|
-
"do stands between you and
|
|
687
|
+
"do stands between you and done — a permission, a secret, a decision — call",
|
|
1088
688
|
`progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you`,
|
|
1089
|
-
"need, leave
|
|
689
|
+
"need, leave it unfinished and uncommitted, and stop. The board shows that note.",
|
|
1090
690
|
].join("\n");
|
|
1091
691
|
}
|
|
1092
692
|
|
|
1093
693
|
/**
|
|
1094
|
-
* Work the queue from this worktree: take the next
|
|
1095
|
-
* has finished it take the next, until there is nothing left.
|
|
694
|
+
* Work the queue from this worktree: take the next piece of work, hand it to an agent, and when the
|
|
695
|
+
* agent has finished it take the next, until there is nothing left.
|
|
1096
696
|
*
|
|
1097
697
|
* The agent runs in this worktree, so it is the same agent to the queue — its progress and
|
|
1098
698
|
* hand_in land on the claim this took. `agent` is a command the prompt is appended to;
|
|
1099
699
|
* `claude -p` by default, or whatever PASSALONG_AGENT says.
|
|
1100
700
|
*
|
|
1101
|
-
* An agent that exits without finishing stops the loop, rather than piling up half-done work.
|
|
1102
|
-
*
|
|
701
|
+
* An agent that exits without finishing stops the loop, rather than piling up half-done work. It
|
|
702
|
+
* stays held here, for a person to see on the board and resume (take it) or release.
|
|
703
|
+
*
|
|
704
|
+
* `watch` is the runner (docs/LOOP.md §1a): left running on an always-on machine with the repo
|
|
705
|
+
* checked out, it takes only work a person marked safe to run alone, waits `every` seconds when
|
|
706
|
+
* there is none, and keeps going past work its agent did not finish — that stays held here, the
|
|
707
|
+
* sweep puts it back in the queue once it goes quiet, and a second time a person is told it is
|
|
708
|
+
* stuck. Nobody is watching, so nothing stops for a person.
|
|
1103
709
|
*/
|
|
1104
710
|
export async function work({
|
|
1105
711
|
cwd = process.cwd(),
|
|
1106
712
|
agent = process.env.PASSALONG_AGENT || "claude -p",
|
|
1107
713
|
any = false,
|
|
1108
714
|
once = false,
|
|
715
|
+
watch = false,
|
|
716
|
+
every = 60,
|
|
1109
717
|
onTask = () => {},
|
|
718
|
+
onIdle = () => {},
|
|
719
|
+
onUnfinished = () => {},
|
|
720
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
1110
721
|
} = {}) {
|
|
1111
722
|
needsSync("working the queue");
|
|
1112
723
|
const [command, ...args] = agent.trim().split(/\s+/);
|
|
1113
724
|
const finished = [];
|
|
1114
725
|
for (;;) {
|
|
1115
|
-
const t = await
|
|
726
|
+
const t = await nextWork({ cwd, any, alone: watch });
|
|
727
|
+
if (!t && watch) {
|
|
728
|
+
onIdle();
|
|
729
|
+
await sleep(Math.max(10, every) * 1000);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
1116
732
|
if (!t) return { finished, stopped: "empty" };
|
|
1117
733
|
onTask(t);
|
|
1118
734
|
const run = spawnSync(command, [...args, workPrompt(t)], {
|
|
@@ -1120,9 +736,12 @@ export async function work({
|
|
|
1120
736
|
stdio: "inherit",
|
|
1121
737
|
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
1122
738
|
});
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
739
|
+
const state = (await api.tasks()).tasks.find((x) => x.id === t.id)?.state;
|
|
740
|
+
if (state !== "done" && watch) {
|
|
741
|
+
onUnfinished(t, state, run.status ?? run.error?.message);
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
if (state !== "done")
|
|
1126
745
|
return {
|
|
1127
746
|
finished,
|
|
1128
747
|
stopped: "unfinished",
|
|
@@ -1136,13 +755,13 @@ export async function work({
|
|
|
1136
755
|
}
|
|
1137
756
|
|
|
1138
757
|
/**
|
|
1139
|
-
* Make every
|
|
1140
|
-
* planner has written several and you have read them all. Only your own: sending
|
|
1141
|
-
* author's call.
|
|
758
|
+
* Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
|
|
759
|
+
* moment a planner has written several and you have read them all. Only your own: sending work on
|
|
760
|
+
* is its author's call.
|
|
1142
761
|
*/
|
|
1143
762
|
export async function readyDrafts() {
|
|
1144
763
|
const drafts = (await list("", { scope: "mine" })).filter((g) => g.status === "draft");
|
|
1145
|
-
for (const g of drafts) await
|
|
764
|
+
for (const g of drafts) await setStatus(g.id, "ready");
|
|
1146
765
|
return drafts.map((g) => g.id);
|
|
1147
766
|
}
|
|
1148
767
|
|
|
@@ -1159,8 +778,8 @@ export async function remove(id) {
|
|
|
1159
778
|
}
|
|
1160
779
|
|
|
1161
780
|
/**
|
|
1162
|
-
* Write every
|
|
1163
|
-
* account read, and the local store's own besides. Returns the paths written.
|
|
781
|
+
* Write every piece of work to a directory as plain markdown: signed in, every one the server lets
|
|
782
|
+
* this account read, and the local store's own besides. Returns the paths written.
|
|
1164
783
|
*/
|
|
1165
784
|
export async function exportAll(dir) {
|
|
1166
785
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
@@ -1169,7 +788,7 @@ export async function exportAll(dir) {
|
|
|
1169
788
|
let after = "";
|
|
1170
789
|
do {
|
|
1171
790
|
const page = await api.exportPage(after);
|
|
1172
|
-
for (const g of page.
|
|
791
|
+
for (const g of page.work) paths.set(g.id, g.markdown);
|
|
1173
792
|
after = page.next;
|
|
1174
793
|
} while (after);
|
|
1175
794
|
}
|
|
@@ -1181,26 +800,70 @@ export async function exportAll(dir) {
|
|
|
1181
800
|
});
|
|
1182
801
|
}
|
|
1183
802
|
|
|
803
|
+
// The host this product answered on before passalong.dev. It serves the same data, so a copy
|
|
804
|
+
// whose url names it belongs to the default API.
|
|
805
|
+
const OLD_HOST = "passalong.kreativekorna.com";
|
|
806
|
+
const hostOf = (url) => {
|
|
807
|
+
try {
|
|
808
|
+
const { host, hostname } = new URL(url);
|
|
809
|
+
if (["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return "";
|
|
810
|
+
return host === OLD_HOST ? new URL(api.DEFAULT_API).host : host;
|
|
811
|
+
} catch {
|
|
812
|
+
return "";
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
// Raised whenever tidy learns to remove something it used to keep, so machines that tidied run it once more.
|
|
816
|
+
const TIDIED = 2;
|
|
817
|
+
|
|
1184
818
|
/**
|
|
1185
819
|
* Once per machine, signed in: drop the local copies the server says it holds. They were written
|
|
1186
|
-
* by every share, pull and take before `keep()`, and nothing ever removed one.
|
|
1187
|
-
*
|
|
820
|
+
* by every share, pull and take before `keep()`, and nothing ever removed one. Every login on the
|
|
821
|
+
* machine is asked, since a copy may be any of theirs. A copy whose `url:` is on a login's own host
|
|
822
|
+
* and that the server answers 404 for was since deleted, and goes too. Anything else — no url,
|
|
823
|
+
* localhost, another host, or an answer that was neither 200 nor 404 — is left where it is, and an
|
|
824
|
+
* export that failed leaves the flag unset so the next start tries again.
|
|
1188
825
|
*/
|
|
1189
826
|
export async function tidy() {
|
|
1190
|
-
|
|
1191
|
-
|
|
827
|
+
const logins = api.logins();
|
|
828
|
+
if (!logins.length || store.readConfig().tidied === TIDIED) return 0;
|
|
829
|
+
const local = new Map(store.list().map((g) => [g.meta.id, hostOf(g.meta.url)]));
|
|
1192
830
|
let removed = 0;
|
|
1193
|
-
let
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
831
|
+
let sure = true;
|
|
832
|
+
const drop = (id) => {
|
|
833
|
+
if (!local.delete(id)) return;
|
|
834
|
+
store.remove(id);
|
|
835
|
+
removed++;
|
|
836
|
+
};
|
|
837
|
+
for (const as of logins) {
|
|
838
|
+
try {
|
|
839
|
+
let after = "";
|
|
840
|
+
do {
|
|
841
|
+
const page = await api.exportPage(after, as);
|
|
842
|
+
for (const g of page.work) drop(g.id);
|
|
843
|
+
after = page.next;
|
|
844
|
+
} while (after && local.size);
|
|
845
|
+
} catch {
|
|
846
|
+
sure = false;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const said = new Map();
|
|
850
|
+
for (const as of logins) {
|
|
851
|
+
const host = hostOf(as.api);
|
|
852
|
+
const ids = [...local].filter(([, h]) => h && h === host).map(([id]) => id);
|
|
853
|
+
// Eight at a time: a few hundred copies is a few seconds, not a few hundred open requests.
|
|
854
|
+
for (let i = 0; i < ids.length; i += 8)
|
|
855
|
+
await Promise.all(
|
|
856
|
+
ids.slice(i, i + 8).map(async (id) => {
|
|
857
|
+
const status = await api.get(id, { peek: true, as }).then(
|
|
858
|
+
() => 200,
|
|
859
|
+
(err) => err.status ?? 0,
|
|
860
|
+
);
|
|
861
|
+
said.set(id, [...(said.get(id) ?? []), status]);
|
|
862
|
+
}),
|
|
863
|
+
);
|
|
1203
864
|
}
|
|
1204
|
-
|
|
865
|
+
for (const [id, statuses] of said)
|
|
866
|
+
if (statuses.includes(200) || statuses.every((s) => s === 404)) drop(id);
|
|
867
|
+
if (sure) store.writeConfig({ tidied: TIDIED });
|
|
1205
868
|
return removed;
|
|
1206
869
|
}
|