passalong 0.21.0 → 1.1.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 +63 -72
- package/bin/passalong +241 -378
- package/package.json +4 -4
- package/skill/SKILL.md +55 -167
- package/src/api.js +86 -188
- package/src/capture.js +15 -51
- package/src/checks.js +5 -5
- package/src/hooks.js +13 -22
- package/src/mcp.js +724 -1483
- package/src/passalong.js +602 -691
- package/src/runner.js +243 -0
- package/src/store.js +18 -28
- package/src/update.js +1 -1
- package/src/work.js +416 -0
- package/src/guide.js +0 -811
package/src/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Client for the hosted
|
|
2
|
-
//
|
|
1
|
+
// Client for the hosted API (apps/api). Work lives there, so nearly every call needs a login, and
|
|
2
|
+
// every function throws an ApiError the CLI turns into a hint.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { readFile, stat } from "node:fs/promises";
|
|
5
5
|
import { basename } from "node:path";
|
|
@@ -27,7 +27,7 @@ export class ApiError extends Error {
|
|
|
27
27
|
// ---- which account -----------------------------------------------------------------------------
|
|
28
28
|
//
|
|
29
29
|
// A machine may be signed in to several (store.js). Who a call is made as comes from, in order:
|
|
30
|
-
// PASSALONG_TOKEN, an account named by PASSALONG_ACCOUNT, `--as` or `
|
|
30
|
+
// PASSALONG_TOKEN, an account named by PASSALONG_ACCOUNT, `--as` or `accounts` `use` (this process
|
|
31
31
|
// only), then the machine's default. With two or more logins and nobody having said which, a
|
|
32
32
|
// person at a terminal gets the default and an agent — a process with no terminal to ask on — is
|
|
33
33
|
// refused with the names, so it asks the person rather than choosing. That refusal is the prompt:
|
|
@@ -102,7 +102,7 @@ export function choiceMessage() {
|
|
|
102
102
|
.join(", ");
|
|
103
103
|
return (
|
|
104
104
|
`More than one Passalong account is signed in on this machine (${list}) and nothing says which ` +
|
|
105
|
-
"this session is for. Ask the person which one to use, then call
|
|
105
|
+
"this session is for. Ask the person which one to use, then call accounts with `use` set to its name " +
|
|
106
106
|
"(or run the command with --as <name>). Do not choose one yourself."
|
|
107
107
|
);
|
|
108
108
|
}
|
|
@@ -168,7 +168,7 @@ async function call(
|
|
|
168
168
|
if (auth) {
|
|
169
169
|
if (!as && needsChoice()) throw new ApiError(409, choiceMessage());
|
|
170
170
|
const t = as ? as.token : token();
|
|
171
|
-
if (!t) throw new ApiError(401, "not logged in
|
|
171
|
+
if (!t) throw new ApiError(401, "not logged in: run `passalong login` to enable sync");
|
|
172
172
|
headers.authorization = `Bearer ${t}`;
|
|
173
173
|
}
|
|
174
174
|
if (body !== undefined) {
|
|
@@ -215,58 +215,6 @@ const q = (params) => {
|
|
|
215
215
|
return s ? `?${s}` : "";
|
|
216
216
|
};
|
|
217
217
|
|
|
218
|
-
/**
|
|
219
|
-
* Sign in, or make the account, and come back with a token for this machine.
|
|
220
|
-
*
|
|
221
|
-
* Three calls, because the API answers a browser and this is not one: signing in sets a session
|
|
222
|
-
* cookie, and the CLI carries a bearer token instead — one that shows up in the hub's token list
|
|
223
|
-
* with a name on it, and can be revoked there without changing the password. So the cookie is used
|
|
224
|
-
* once, to mint the token, and then dropped.
|
|
225
|
-
*
|
|
226
|
-
* `make` is signup rather than sign-in. It is the caller's decision and not a fallback here: an
|
|
227
|
-
* account created because an address was mistyped is worse than being told there is no such
|
|
228
|
-
* account.
|
|
229
|
-
*/
|
|
230
|
-
export async function signIn(email, password, { make = false, label = "" } = {}) {
|
|
231
|
-
const res = await fetch(`${baseUrl()}${make ? "/v1/auth/signup" : "/v1/auth/login"}`, {
|
|
232
|
-
method: "POST",
|
|
233
|
-
headers: { "content-type": "application/json" },
|
|
234
|
-
body: JSON.stringify({ email, password }),
|
|
235
|
-
}).catch((err) => {
|
|
236
|
-
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
237
|
-
});
|
|
238
|
-
if (!res.ok) {
|
|
239
|
-
const said = await res.json().catch(() => ({}));
|
|
240
|
-
throw new ApiError(res.status, said.message || res.statusText);
|
|
241
|
-
}
|
|
242
|
-
const { account } = await res.json();
|
|
243
|
-
// `getSetCookie` keeps the cookies apart; a joined `set-cookie` header cannot be split safely,
|
|
244
|
-
// because an Expires date has a comma in it.
|
|
245
|
-
const cookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
|
|
246
|
-
if (!cookie) throw new ApiError(500, "signed in, but no session came back");
|
|
247
|
-
|
|
248
|
-
const minted = await fetch(`${baseUrl()}/v1/tokens`, {
|
|
249
|
-
method: "POST",
|
|
250
|
-
headers: { "content-type": "application/json", cookie },
|
|
251
|
-
body: JSON.stringify({ name: label || "cli" }),
|
|
252
|
-
});
|
|
253
|
-
if (!minted.ok) {
|
|
254
|
-
const said = await minted.json().catch(() => ({}));
|
|
255
|
-
throw new ApiError(minted.status, said.message || "could not make a token for this machine");
|
|
256
|
-
}
|
|
257
|
-
const { token: made } = await minted.json();
|
|
258
|
-
return { account, token: made };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Mint an account with no email and no password.
|
|
263
|
-
*
|
|
264
|
-
* Nothing in this package calls it any more — `passalong login` asks for an email and a password,
|
|
265
|
-
* because an account nobody can sign in to is one that dies with the file it is stored in, and the
|
|
266
|
-
* CLI used to make one without ever saying so. It stays exported because the route it calls is
|
|
267
|
-
* still live and still right for the invite page, which mints before it claims, and because
|
|
268
|
-
* removing a published export is a breaking change for anyone who imported it.
|
|
269
|
-
*/
|
|
270
218
|
/**
|
|
271
219
|
* Sign this machine in from the browser. `challenge` is the sha256, in hex, of a secret kept here;
|
|
272
220
|
* what comes back is a short code to show, the page to open, and how often to ask whether it was
|
|
@@ -293,7 +241,6 @@ export async function devicePoll(id, verifier) {
|
|
|
293
241
|
return said;
|
|
294
242
|
}
|
|
295
243
|
|
|
296
|
-
export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
|
|
297
244
|
export const me = () => call("/v1/me");
|
|
298
245
|
export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
|
|
299
246
|
|
|
@@ -314,101 +261,83 @@ export const removeMember = (slug, who) =>
|
|
|
314
261
|
export const join = (code) =>
|
|
315
262
|
call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
|
|
316
263
|
|
|
317
|
-
export const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
export
|
|
264
|
+
export const projects = (scope = "all", query = "") =>
|
|
265
|
+
call(`/v1/projects${q({ scope, q: query })}`);
|
|
266
|
+
export const createProject = (
|
|
267
|
+
title,
|
|
268
|
+
description = "",
|
|
269
|
+
team = "",
|
|
270
|
+
parent = "",
|
|
271
|
+
color = "",
|
|
272
|
+
repo = "",
|
|
273
|
+
) =>
|
|
274
|
+
call("/v1/projects", { method: "POST", body: { title, description, team, parent, color, repo } });
|
|
275
|
+
export const project = (id) => call(`/v1/projects/${encodeURIComponent(id)}`);
|
|
276
|
+
export const updateProject = (id, changes) =>
|
|
277
|
+
call(`/v1/projects/${encodeURIComponent(id)}`, { method: "PATCH", body: changes });
|
|
278
|
+
export const projectDocument = (projectId, documentId) =>
|
|
279
|
+
call(`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`);
|
|
280
|
+
export const projectFile = (projectId, fileId) =>
|
|
281
|
+
call(`/v1/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileId)}/agent`);
|
|
282
|
+
export async function addProjectFile(projectId, path, name = "") {
|
|
330
283
|
const file = await stat(path);
|
|
331
|
-
if (!file.isFile()) throw new ApiError(400, "Choose a file to add to the
|
|
284
|
+
if (!file.isFile()) throw new ApiError(400, "Choose a file to add to the project.");
|
|
332
285
|
if (file.size > 10 * 1024 * 1024) throw new ApiError(413, "That file is over 10 MB.");
|
|
333
|
-
return call(`/v1/
|
|
286
|
+
return call(`/v1/projects/${encodeURIComponent(projectId)}/files`, {
|
|
334
287
|
method: "POST",
|
|
335
288
|
body: new Uint8Array(await readFile(path)),
|
|
336
289
|
uploadName: name || basename(path),
|
|
337
290
|
});
|
|
338
291
|
}
|
|
339
|
-
export const
|
|
340
|
-
call(`/v1/
|
|
292
|
+
export const createProjectDocument = (projectId, name, body) =>
|
|
293
|
+
call(`/v1/projects/${encodeURIComponent(projectId)}/documents`, {
|
|
341
294
|
method: "POST",
|
|
342
295
|
body: { name, body },
|
|
343
296
|
});
|
|
344
|
-
export const
|
|
345
|
-
call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
|
|
346
|
-
method: "PUT",
|
|
347
|
-
body: { version, body },
|
|
348
|
-
});
|
|
349
|
-
export const renameFolderDocument = (folderId, documentId, name) =>
|
|
350
|
-
call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
|
|
351
|
-
method: "PATCH",
|
|
352
|
-
body: { name },
|
|
353
|
-
});
|
|
354
|
-
export const moveFolderDocument = (folderId, documentId, to, name = "") =>
|
|
355
|
-
call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
|
|
356
|
-
method: "PATCH",
|
|
357
|
-
body: name ? { folder: to, name } : { folder: to },
|
|
358
|
-
});
|
|
359
|
-
export const folderDocumentHistory = (folderId, documentId, version = 0) =>
|
|
297
|
+
export const saveProjectDocument = (projectId, documentId, version, body) =>
|
|
360
298
|
call(
|
|
361
|
-
`/v1/
|
|
299
|
+
`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
|
|
300
|
+
{
|
|
301
|
+
method: "PUT",
|
|
302
|
+
body: { version, body },
|
|
303
|
+
},
|
|
362
304
|
);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
305
|
+
/** Rename a document, move it to another project (`project`), or both. Its id and history stay. */
|
|
306
|
+
export const patchProjectDocument = (projectId, documentId, changes) =>
|
|
307
|
+
call(
|
|
308
|
+
`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
|
|
309
|
+
{
|
|
310
|
+
method: "PATCH",
|
|
311
|
+
body: changes,
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
export const projectDocumentHistory = (projectId, documentId, version = 0) =>
|
|
315
|
+
call(
|
|
316
|
+
`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}/revisions${version ? `/${version}` : ""}`,
|
|
317
|
+
);
|
|
318
|
+
export const deleteProjectDocument = (projectId, documentId) =>
|
|
319
|
+
call(
|
|
320
|
+
`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
|
|
321
|
+
{
|
|
322
|
+
method: "DELETE",
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
export const deleteProjectFile = (projectId, fileId) =>
|
|
326
|
+
call(`/v1/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileId)}`, {
|
|
377
327
|
method: "DELETE",
|
|
378
328
|
});
|
|
379
|
-
|
|
380
|
-
export const
|
|
381
|
-
call(`/v1/log${q({ repo, since, limit: limit || "" })}`);
|
|
382
|
-
export const notifications = ({ unread = true, limit = 0 } = {}) =>
|
|
383
|
-
call(`/v1/notifications${q({ unread: unread ? "1" : "", limit: limit || "" })}`);
|
|
384
|
-
/** No ids means "everything unread". */
|
|
385
|
-
export const markRead = (ids = []) =>
|
|
386
|
-
call("/v1/notifications/read", { method: "POST", body: { ids } });
|
|
387
|
-
|
|
388
|
-
export const publish = (id, markdown) =>
|
|
389
|
-
call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
|
|
390
|
-
export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
|
|
391
|
-
export const inbox = () => call("/v1/inbox");
|
|
392
|
-
/** Open a report for a set of issues to be filed under. */
|
|
393
|
-
export const createReport = (body) => call("/v1/reports", { method: "POST", body });
|
|
394
|
-
/** One report and the issues filed under it, grouped by product area. */
|
|
395
|
-
export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
|
|
329
|
+
export const publish = (id, markdown) => call(`/v1/work/${id}`, { method: "PUT", body: markdown });
|
|
330
|
+
export const list = (query = "", scope = "") => call(`/v1/work${q({ q: query, scope })}`);
|
|
396
331
|
export const get = (id, { peek = false, as = null } = {}) =>
|
|
397
|
-
call(`/v1/
|
|
398
|
-
/** One page of every
|
|
332
|
+
call(`/v1/work/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true, as });
|
|
333
|
+
/** One page of every piece of work this account can read, for `export`. Records no pull. */
|
|
399
334
|
export const exportPage = (after = "", as = null) => call(`/v1/export${q({ after })}`, { as });
|
|
400
335
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*/
|
|
404
|
-
export const children = (id, { markdown = false } = {}) =>
|
|
405
|
-
call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
|
|
406
|
-
/**
|
|
407
|
-
* The guide this one came out of, with where it has got to. `markdown` adds its content. Like the
|
|
408
|
-
* follow-ups, reading it is not opening it: no pull is recorded on the parent.
|
|
336
|
+
* The work this one came out of, with where it has got to. `markdown` adds its content. Reading it
|
|
337
|
+
* is not opening it: no pull is recorded on the parent.
|
|
409
338
|
*/
|
|
410
339
|
export const parent = (id, { markdown = false } = {}) =>
|
|
411
|
-
call(`/v1/
|
|
340
|
+
call(`/v1/work/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
|
|
412
341
|
/**
|
|
413
342
|
* Giving a plan away, for whoever the deployment names in `ADMIN_ACCOUNTS`. Every other account
|
|
414
343
|
* gets a 404 from these, so the CLI does not have to know who the operator is either.
|
|
@@ -424,63 +353,31 @@ export const listGifts = () => call("/v1/admin/gifts");
|
|
|
424
353
|
export const takeGiftBack = (id) =>
|
|
425
354
|
call(`/v1/admin/gifts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
426
355
|
export const setStatus = (id, status) =>
|
|
427
|
-
call(`/v1/
|
|
428
|
-
export const remove = (id) => call(`/v1/
|
|
429
|
-
/** Does it actually work? `note` is required when it does not. */
|
|
430
|
-
export const verdict = (id, ok, note = "", detail = "") =>
|
|
431
|
-
call(`/v1/guides/${id}/verdict`, {
|
|
432
|
-
method: "PUT",
|
|
433
|
-
body: { ok, note, ...(detail ? { detail } : {}) },
|
|
434
|
-
});
|
|
435
|
-
|
|
356
|
+
call(`/v1/work/${id}/status`, { method: "PATCH", body: { status } });
|
|
357
|
+
export const remove = (id) => call(`/v1/work/${id}`, { method: "DELETE" });
|
|
436
358
|
export const tasks = () => call("/v1/tasks");
|
|
437
359
|
export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
export const finishTask = (id, body) =>
|
|
441
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/finish`, { method: "POST", body });
|
|
442
|
-
export const approveTask = (id) =>
|
|
443
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
|
|
444
|
-
export const rejectTask = (id, why) =>
|
|
445
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
|
|
360
|
+
/** The runner saying how it is, for the hub: what it is on, today's count, or that it stopped. */
|
|
361
|
+
export const runnerStatus = (body) => call("/v1/runner", { method: "PUT", body });
|
|
446
362
|
export const reopen = (id, why) =>
|
|
447
|
-
call(`/v1/
|
|
448
|
-
/**
|
|
449
|
-
export const closeGuide = (id) =>
|
|
450
|
-
call(`/v1/guides/${encodeURIComponent(id)}/close`, { method: "POST", body: {} });
|
|
451
|
-
export const sendBack = (id, why) =>
|
|
452
|
-
call(`/v1/guides/${encodeURIComponent(id)}/send_back`, { method: "POST", body: { why } });
|
|
453
|
-
/** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
|
|
363
|
+
call(`/v1/work/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
|
|
364
|
+
/** Take work back from whoever holds it. */
|
|
454
365
|
export const release = (id) =>
|
|
455
|
-
call(`/v1/
|
|
456
|
-
/** The four verbs every guide answers to. See docs/V2.md §11. */
|
|
366
|
+
call(`/v1/work/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
|
|
457
367
|
export const working = () => call("/v1/working");
|
|
458
|
-
export const handedIn = () => call("/v1/handed_in");
|
|
459
|
-
export const recall = (id) =>
|
|
460
|
-
call(`/v1/guides/${encodeURIComponent(id)}/recall`, { method: "POST", body: {} });
|
|
461
368
|
export const assign = (id, to) =>
|
|
462
|
-
call(`/v1/
|
|
369
|
+
call(`/v1/work/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
|
|
463
370
|
export const take = (body) => call("/v1/take", { method: "POST", body });
|
|
464
371
|
export const progress = (id, body) =>
|
|
465
|
-
call(`/v1/
|
|
372
|
+
call(`/v1/work/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
|
466
373
|
export const handIn = (id, body) =>
|
|
467
|
-
call(`/v1/
|
|
374
|
+
call(`/v1/work/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
|
|
468
375
|
export const ask = (id, body) =>
|
|
469
|
-
call(`/v1/
|
|
376
|
+
call(`/v1/work/${encodeURIComponent(id)}/ask`, { method: "POST", body });
|
|
470
377
|
export const reply = (id, body) =>
|
|
471
|
-
call(`/v1/
|
|
378
|
+
call(`/v1/work/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
|
|
472
379
|
export const pass = (id, body) =>
|
|
473
|
-
call(`/v1/
|
|
474
|
-
export const ack = (id, taken, note = "") =>
|
|
475
|
-
call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Upload an image as evidence, and get back the URL a guide points at.
|
|
479
|
-
*
|
|
480
|
-
* Not `call()`: that serialises a body as JSON or markdown, and this route reads bytes and takes
|
|
481
|
-
* the content type as the declaration of what they are. `name` is the label, and it travels in a
|
|
482
|
-
* header rather than the body for the same reason.
|
|
483
|
-
*/
|
|
380
|
+
call(`/v1/work/${encodeURIComponent(id)}/pass`, { method: "POST", body });
|
|
484
381
|
/**
|
|
485
382
|
* Upload a file that is not a picture, and get back what the server made of it. The server decides
|
|
486
383
|
* what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
|
|
@@ -489,8 +386,9 @@ export const ack = (id, taken, note = "") =>
|
|
|
489
386
|
export async function uploadFile(bytes, type, name = "") {
|
|
490
387
|
if (needsChoice()) throw new ApiError(409, choiceMessage());
|
|
491
388
|
const t = token();
|
|
492
|
-
if (!t) throw new ApiError(401, "not logged in
|
|
389
|
+
if (!t) throw new ApiError(401, "not logged in: run `passalong login` to enable sync");
|
|
493
390
|
const headers = {
|
|
391
|
+
...CLIENT,
|
|
494
392
|
authorization: `Bearer ${t}`,
|
|
495
393
|
"content-type": type || "application/octet-stream",
|
|
496
394
|
};
|
|
@@ -499,7 +397,7 @@ export async function uploadFile(bytes, type, name = "") {
|
|
|
499
397
|
try {
|
|
500
398
|
res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
|
|
501
399
|
} catch (err) {
|
|
502
|
-
throw new ApiError(0, `could not reach ${
|
|
400
|
+
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
503
401
|
}
|
|
504
402
|
if (!res.ok) {
|
|
505
403
|
const body = await res.text();
|
|
@@ -512,9 +410,13 @@ export async function uploadFile(bytes, type, name = "") {
|
|
|
512
410
|
return (await res.json()).attachment;
|
|
513
411
|
}
|
|
514
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Upload a picture, and get back the URL work points at. Not `call()`: that serialises a body as
|
|
415
|
+
* JSON or markdown, and this route reads bytes and takes the content type as what they are.
|
|
416
|
+
*/
|
|
515
417
|
export async function uploadShot(bytes, type, name = "") {
|
|
516
418
|
const t = token();
|
|
517
|
-
if (!t) throw new ApiError(401, "not logged in
|
|
419
|
+
if (!t) throw new ApiError(401, "not logged in: run `passalong login` to enable sync");
|
|
518
420
|
const headers = { ...CLIENT, authorization: `Bearer ${t}`, "content-type": type };
|
|
519
421
|
// A header is latin-1: a filename with an accent in it throws on the way out rather than at the
|
|
520
422
|
// server, and the label is not worth failing an upload over.
|
|
@@ -523,7 +425,7 @@ export async function uploadShot(bytes, type, name = "") {
|
|
|
523
425
|
try {
|
|
524
426
|
res = await fetch(`${baseUrl()}/v1/shots`, { method: "POST", headers, body: bytes });
|
|
525
427
|
} catch (err) {
|
|
526
|
-
throw new ApiError(0, `could not reach ${
|
|
428
|
+
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
527
429
|
}
|
|
528
430
|
if (!res.ok) {
|
|
529
431
|
const body = await res.text();
|
|
@@ -536,7 +438,7 @@ export async function uploadShot(bytes, type, name = "") {
|
|
|
536
438
|
return (await res.json()).shot;
|
|
537
439
|
}
|
|
538
440
|
|
|
539
|
-
/** Fetch
|
|
441
|
+
/** Fetch work by its share link (no account needed). Accepts the web URL or the .md URL. */
|
|
540
442
|
export async function fetchShared(url) {
|
|
541
443
|
const u = new URL(url);
|
|
542
444
|
if (!u.pathname.endsWith(".md")) u.pathname = `${u.pathname.replace(/\/$/, "")}.md`;
|
|
@@ -545,7 +447,3 @@ export async function fetchShared(url) {
|
|
|
545
447
|
throw new ApiError(res.status, `could not fetch ${url}: ${res.status} ${res.statusText}`);
|
|
546
448
|
return res.text();
|
|
547
449
|
}
|
|
548
|
-
|
|
549
|
-
export function shareUrl(id, key) {
|
|
550
|
-
return `${baseUrl()}/g/${id}/${key}`;
|
|
551
|
-
}
|
package/src/capture.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// skills/passalong-capture does the real distillation from a session transcript.
|
|
1
|
+
// What can be learned about where a command runs, without reading any session: the repo, its
|
|
2
|
+
// branch, who is committing. Work written from inside a repo is for that repo.
|
|
4
3
|
import { execFileSync } from "node:child_process";
|
|
5
4
|
import { basename } from "node:path";
|
|
6
|
-
import {
|
|
5
|
+
import { serialize } from "./work.js";
|
|
7
6
|
|
|
8
7
|
function git(cwd, ...args) {
|
|
9
8
|
try {
|
|
@@ -40,59 +39,24 @@ export function context(cwd = process.cwd()) {
|
|
|
40
39
|
const name = basename(root || cwd);
|
|
41
40
|
const branch = root ? git(cwd, "rev-parse", "--abbrev-ref", "HEAD") : "";
|
|
42
41
|
const author = git(cwd, "config", "user.name");
|
|
43
|
-
const commits = root ? git(cwd, "log", "--oneline", "-8").split("\n").filter(Boolean) : [];
|
|
44
|
-
const changed = root
|
|
45
|
-
? git(cwd, "diff", "--stat", "HEAD~5..HEAD").split("\n").filter(Boolean).slice(-1)[0] || ""
|
|
46
|
-
: "";
|
|
47
|
-
const files = root
|
|
48
|
-
? git(cwd, "diff", "--name-only", "HEAD~5..HEAD").split("\n").filter(Boolean)
|
|
49
|
-
: [];
|
|
50
42
|
// The repo as the task queue names it: its remote when it has one, because two worktrees of one
|
|
51
|
-
// repo have two
|
|
43
|
+
// repo have two project names and one remote. The project name when there is no remote.
|
|
52
44
|
const repo = root ? repoKey(git(cwd, "remote", "get-url", "origin") || name) : "";
|
|
53
|
-
return { root, name, repo, branch, author
|
|
45
|
+
return { root, name, repo, branch, author };
|
|
54
46
|
}
|
|
55
47
|
|
|
56
48
|
/**
|
|
57
|
-
* A
|
|
49
|
+
* A piece of work from one sentence: the sentence is its title, what it says to a person, and its
|
|
50
|
+
* text, for whoever wrote it to add to before it goes.
|
|
58
51
|
*
|
|
59
|
-
* `target_context`
|
|
60
|
-
*
|
|
61
|
-
*
|
|
52
|
+
* `target_context` is the repo this runs in, because that is the likeliest repo work written from
|
|
53
|
+
* inside one is for — and it is in the frontmatter, for the author to change before it goes
|
|
54
|
+
* anywhere. Outside a repo it stays empty, which is work for no repo.
|
|
62
55
|
*/
|
|
63
|
-
export function
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// The person typed this sentence, so it is theirs to say to a person as well as to name the task.
|
|
69
|
-
summary: String(sentence).slice(0, 400),
|
|
70
|
-
author: c.author,
|
|
71
|
-
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
72
|
-
target_context: c.repo,
|
|
56
|
+
export function workScaffold(sentence, cwd = process.cwd()) {
|
|
57
|
+
const said = String(sentence).trim();
|
|
58
|
+
return serialize({
|
|
59
|
+
meta: { title: said, summary: said.slice(0, 400), target_context: context(cwd).repo },
|
|
60
|
+
body: `${said}\n`,
|
|
73
61
|
});
|
|
74
|
-
return md.replace(/^## Goal\n/m, `## Goal\n${sentence}\n`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** A draft pre-filled with repo facts. Recent activity is appended as a comment for reference. */
|
|
78
|
-
export function scaffold(cwd = process.cwd()) {
|
|
79
|
-
const c = context(cwd);
|
|
80
|
-
let md = template({
|
|
81
|
-
kind: "transfer",
|
|
82
|
-
title: "",
|
|
83
|
-
author: c.author,
|
|
84
|
-
source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
|
|
85
|
-
});
|
|
86
|
-
if (c.commits.length) {
|
|
87
|
-
md += [
|
|
88
|
-
"",
|
|
89
|
-
"<!-- passalong: recent activity in this repo, for reference while you write. Delete when done.",
|
|
90
|
-
...c.commits.map((l) => ` ${l}`),
|
|
91
|
-
c.changed ? ` ${c.changed}` : "",
|
|
92
|
-
...c.files.slice(0, 30).map((f) => ` ${f}`),
|
|
93
|
-
"-->",
|
|
94
|
-
"",
|
|
95
|
-
].join("\n");
|
|
96
|
-
}
|
|
97
|
-
return md;
|
|
98
62
|
}
|
package/src/checks.js
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* Running an Acceptance check, rather than reading the agent's account of running it.
|
|
3
3
|
*
|
|
4
4
|
* A hand-in used to be the agent's word about its own work: `ran` was a string, and the only rule
|
|
5
|
-
* was that it was longer than a
|
|
5
|
+
* was that it was longer than a yes. "ran the tests, all green" passed. This module is the
|
|
6
6
|
* other half — when a check names the command that proves it, the command is run here and the exit
|
|
7
7
|
* code is recorded from the process, so the agent reports what it did and the machine reports how
|
|
8
8
|
* it went.
|
|
9
9
|
*
|
|
10
|
-
* **The command comes from the agent's own hand_in call, never from
|
|
10
|
+
* **The command comes from the agent's own hand_in call, never from work.** Work is written
|
|
11
11
|
* somewhere else and pulled from a stranger's account; lifting a command out of its Acceptance
|
|
12
|
-
* section and running it would turn every pulled
|
|
13
|
-
* parses
|
|
12
|
+
* section and running it would turn every pulled work into remote code execution. Nothing here
|
|
13
|
+
* parses work markdown, and nothing should.
|
|
14
14
|
*
|
|
15
15
|
* This runs only where there is a shell. `src/mcp.js` is a local stdio server on the agent's own
|
|
16
16
|
* machine, so it can; `apps/api/src/mcp-http.ts` is a Worker, so it cannot, and a hand-in over HTTP
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* What this deliberately does NOT do is parse test output. SWE-bench needs 57 per-framework log
|
|
20
20
|
* parsers to tell a skipped test from a passing one, and a half-parser that guesses is worse than
|
|
21
21
|
* none: it would refuse honest runs in shapes nobody thought of, and pass the ones it misread. The
|
|
22
|
-
* exit code is the whole
|
|
22
|
+
* exit code is the whole answer here, which is also how a CI step decides.
|
|
23
23
|
*/
|
|
24
24
|
import { spawnSync } from "node:child_process";
|
|
25
25
|
import { existsSync } from "node:fs";
|
package/src/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The Claude Code hooks `passalong setup` installs.
|
|
1
|
+
// The Claude Code hooks `passalong setup` installs.
|
|
2
2
|
//
|
|
3
3
|
// Two moments an agent forgets what it holds: when a session starts (a restart, a /clear, a
|
|
4
4
|
// compaction), and when it decides it is done. SessionStart runs `passalong now --hook session`
|
|
@@ -26,14 +26,10 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
|
26
26
|
* agent to the queue. So a session is told what the worktree holds, and to carry on only with what
|
|
27
27
|
* was its own before a restart — never to resume another session's work.
|
|
28
28
|
*/
|
|
29
|
-
export function nowText({ held = [],
|
|
29
|
+
export function nowText({ held = [], ready = 0 }) {
|
|
30
30
|
if (held.length === 1) {
|
|
31
31
|
const [h] = held;
|
|
32
|
-
const lines = [
|
|
33
|
-
// `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
|
|
34
|
-
// both dead and wrong — absent has meant transfer since kinds existed, never task.
|
|
35
|
-
`Passalong: This worktree holds ${h.id} (${h.kind}): ${h.title || h.id}.`,
|
|
36
|
-
];
|
|
32
|
+
const lines = [`Passalong: This worktree holds ${h.id}: ${h.title || h.id}.`];
|
|
37
33
|
if (h.note) lines.push(`Last note: "${h.note}".`);
|
|
38
34
|
// The one thing here that is new information rather than a reminder: the person has written, and
|
|
39
35
|
// nothing else in the session will say so until the agent next checks in.
|
|
@@ -46,8 +42,8 @@ export function nowText({ held = [], waiting = {} }) {
|
|
|
46
42
|
lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
|
|
47
43
|
lines.push(
|
|
48
44
|
`If it was yours before this session restarted, call take ${h.id} to carry on, then ` +
|
|
49
|
-
"progress at each milestone. When it is done, hand_in with evidence
|
|
50
|
-
"what came back
|
|
45
|
+
"progress at each milestone. When it is done, hand_in with evidence (what you ran and " +
|
|
46
|
+
"what came back), so keep it as you go; if it is not yours or you are stuck, pass with " +
|
|
51
47
|
"the reason. If another session here is on it, leave it to that session.",
|
|
52
48
|
);
|
|
53
49
|
return lines.join("\n");
|
|
@@ -56,8 +52,8 @@ export function nowText({ held = [], waiting = {} }) {
|
|
|
56
52
|
const lines = [`Passalong: This worktree holds ${held.length} things:`];
|
|
57
53
|
for (const h of held)
|
|
58
54
|
lines.push(
|
|
59
|
-
`- ${h.id}
|
|
60
|
-
`${h.replies ?
|
|
55
|
+
`- ${h.id}: ${h.title || h.id}${h.state === "stalled" ? " (stalled)" : ""}` +
|
|
56
|
+
`${h.replies ? `, ${plural(h.replies, "message", "messages")} waiting` : ""}`,
|
|
61
57
|
);
|
|
62
58
|
lines.push(
|
|
63
59
|
"Several sessions may be working here. Carry on only with what was yours before this " +
|
|
@@ -65,13 +61,8 @@ export function nowText({ held = [], waiting = {} }) {
|
|
|
65
61
|
);
|
|
66
62
|
return lines.join("\n");
|
|
67
63
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
parts.push(plural(waiting.ready, "ready task for this repo", "ready tasks for this repo"));
|
|
71
|
-
if (waiting.inbox)
|
|
72
|
-
parts.push(plural(waiting.inbox, "guide handed to you", "guides handed to you"));
|
|
73
|
-
if (!parts.length) return "";
|
|
74
|
-
return `Passalong: ${parts.join(" and ")}. Call take with no id to start the next one, when the user wants that.`;
|
|
64
|
+
if (!ready) return "";
|
|
65
|
+
return `Passalong: ${plural(ready, "piece of work is", "pieces of work are")} ready for this repo. Call take with no id to start the next one, when the user wants that.`;
|
|
75
66
|
}
|
|
76
67
|
|
|
77
68
|
/**
|
|
@@ -81,7 +72,7 @@ export function nowText({ held = [], waiting = {} }) {
|
|
|
81
72
|
* would leave it no way to end. The hook cannot tell which session took what, so it names
|
|
82
73
|
* everything still open here and lets the session settle its own.
|
|
83
74
|
*/
|
|
84
|
-
export function
|
|
75
|
+
export function stopDecision({ held = [], input = {} }) {
|
|
85
76
|
// An agent that asked a question was told to stop and wait, so it must be let: blocking it would
|
|
86
77
|
// make the instruction and the hook contradict each other, and the agent would pick one.
|
|
87
78
|
const open = held.filter((h) => !h.asking);
|
|
@@ -93,7 +84,7 @@ export function stopVerdict({ held = [], input = {} }) {
|
|
|
93
84
|
`This worktree still holds ${ids}. For anything there you worked on in this session, do ` +
|
|
94
85
|
"one of before ending: hand_in it with your evidence if it is done; pass it with the reason " +
|
|
95
86
|
"if it is not yours or you are stuck; or, if the user is stopping mid-way, progress it with " +
|
|
96
|
-
"a note saying what is left. Leave what other sessions are on
|
|
87
|
+
"a note saying what is left. Leave what other sessions are on, then end.",
|
|
97
88
|
};
|
|
98
89
|
}
|
|
99
90
|
|
|
@@ -131,8 +122,8 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
|
131
122
|
|
|
132
123
|
/**
|
|
133
124
|
* One line: what this worktree's agent holds, then what needs you and what is ready here.
|
|
134
|
-
* `needs` counts everything waiting on the person:
|
|
135
|
-
*
|
|
125
|
+
* `needs` counts everything waiting on the person: drafts to read, agents asking or stuck on them,
|
|
126
|
+
* and agents gone quiet.
|
|
136
127
|
*/
|
|
137
128
|
export function statusText({ held = [], needs = 0, ready = 0 }) {
|
|
138
129
|
const parts = [];
|