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/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
|
}
|
|
@@ -120,6 +120,19 @@ export function loggedIn() {
|
|
|
120
120
|
return Boolean(token());
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every login this machine holds, as `{ token, api }` for a call's `as`: each kept account and the
|
|
125
|
+
* one in use, once each. For work that belongs to all of them, like `tidy`, rather than to one.
|
|
126
|
+
*/
|
|
127
|
+
export function logins() {
|
|
128
|
+
const all = Object.values(readAccounts())
|
|
129
|
+
.filter((a) => a.token)
|
|
130
|
+
.map((a) => ({ token: a.token, api: a.api || readConfig().api || DEFAULT_API }));
|
|
131
|
+
if (token()) all.push({ token: token(), api: baseUrl() });
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
return all.filter((a) => !seen.has(`${a.token} ${a.api}`) && seen.add(`${a.token} ${a.api}`));
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
/** The team this account works in. It belongs to the account, not the machine. */
|
|
124
137
|
export function currentTeam() {
|
|
125
138
|
const p = picked();
|
|
@@ -149,12 +162,12 @@ export { nameFor };
|
|
|
149
162
|
|
|
150
163
|
async function call(
|
|
151
164
|
path,
|
|
152
|
-
{ method = "GET", body, auth = true, raw = false, uploadName = "" } = {},
|
|
165
|
+
{ method = "GET", body, auth = true, raw = false, uploadName = "", as = null } = {},
|
|
153
166
|
) {
|
|
154
167
|
const headers = { ...CLIENT };
|
|
155
168
|
if (auth) {
|
|
156
|
-
if (needsChoice()) throw new ApiError(409, choiceMessage());
|
|
157
|
-
const t = token();
|
|
169
|
+
if (!as && needsChoice()) throw new ApiError(409, choiceMessage());
|
|
170
|
+
const t = as ? as.token : token();
|
|
158
171
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
159
172
|
headers.authorization = `Bearer ${t}`;
|
|
160
173
|
}
|
|
@@ -166,9 +179,10 @@ async function call(
|
|
|
166
179
|
: "application/json";
|
|
167
180
|
}
|
|
168
181
|
if (uploadName) headers["x-file-name"] = uploadName.replace(/[^\x20-\x7e]/g, "").slice(0, 120);
|
|
182
|
+
const base = as ? as.api.replace(/\/$/, "") : baseUrl();
|
|
169
183
|
let res;
|
|
170
184
|
try {
|
|
171
|
-
res = await fetch(`${
|
|
185
|
+
res = await fetch(`${base}${path}`, {
|
|
172
186
|
method,
|
|
173
187
|
headers,
|
|
174
188
|
body:
|
|
@@ -179,7 +193,7 @@ async function call(
|
|
|
179
193
|
: JSON.stringify(body),
|
|
180
194
|
});
|
|
181
195
|
} catch (err) {
|
|
182
|
-
throw new ApiError(0, `could not reach ${
|
|
196
|
+
throw new ApiError(0, `could not reach ${base} (${err.message})`);
|
|
183
197
|
}
|
|
184
198
|
if (!res.ok) {
|
|
185
199
|
const text = await res.text();
|
|
@@ -201,58 +215,6 @@ const q = (params) => {
|
|
|
201
215
|
return s ? `?${s}` : "";
|
|
202
216
|
};
|
|
203
217
|
|
|
204
|
-
/**
|
|
205
|
-
* Sign in, or make the account, and come back with a token for this machine.
|
|
206
|
-
*
|
|
207
|
-
* Three calls, because the API answers a browser and this is not one: signing in sets a session
|
|
208
|
-
* cookie, and the CLI carries a bearer token instead — one that shows up in the hub's token list
|
|
209
|
-
* with a name on it, and can be revoked there without changing the password. So the cookie is used
|
|
210
|
-
* once, to mint the token, and then dropped.
|
|
211
|
-
*
|
|
212
|
-
* `make` is signup rather than sign-in. It is the caller's decision and not a fallback here: an
|
|
213
|
-
* account created because an address was mistyped is worse than being told there is no such
|
|
214
|
-
* account.
|
|
215
|
-
*/
|
|
216
|
-
export async function signIn(email, password, { make = false, label = "" } = {}) {
|
|
217
|
-
const res = await fetch(`${baseUrl()}${make ? "/v1/auth/signup" : "/v1/auth/login"}`, {
|
|
218
|
-
method: "POST",
|
|
219
|
-
headers: { "content-type": "application/json" },
|
|
220
|
-
body: JSON.stringify({ email, password }),
|
|
221
|
-
}).catch((err) => {
|
|
222
|
-
throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
|
|
223
|
-
});
|
|
224
|
-
if (!res.ok) {
|
|
225
|
-
const said = await res.json().catch(() => ({}));
|
|
226
|
-
throw new ApiError(res.status, said.message || res.statusText);
|
|
227
|
-
}
|
|
228
|
-
const { account } = await res.json();
|
|
229
|
-
// `getSetCookie` keeps the cookies apart; a joined `set-cookie` header cannot be split safely,
|
|
230
|
-
// because an Expires date has a comma in it.
|
|
231
|
-
const cookie = (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; ");
|
|
232
|
-
if (!cookie) throw new ApiError(500, "signed in, but no session came back");
|
|
233
|
-
|
|
234
|
-
const minted = await fetch(`${baseUrl()}/v1/tokens`, {
|
|
235
|
-
method: "POST",
|
|
236
|
-
headers: { "content-type": "application/json", cookie },
|
|
237
|
-
body: JSON.stringify({ name: label || "cli" }),
|
|
238
|
-
});
|
|
239
|
-
if (!minted.ok) {
|
|
240
|
-
const said = await minted.json().catch(() => ({}));
|
|
241
|
-
throw new ApiError(minted.status, said.message || "could not make a token for this machine");
|
|
242
|
-
}
|
|
243
|
-
const { token: made } = await minted.json();
|
|
244
|
-
return { account, token: made };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Mint an account with no email and no password.
|
|
249
|
-
*
|
|
250
|
-
* Nothing in this package calls it any more — `passalong login` asks for an email and a password,
|
|
251
|
-
* because an account nobody can sign in to is one that dies with the file it is stored in, and the
|
|
252
|
-
* CLI used to make one without ever saying so. It stays exported because the route it calls is
|
|
253
|
-
* still live and still right for the invite page, which mints before it claims, and because
|
|
254
|
-
* removing a published export is a breaking change for anyone who imported it.
|
|
255
|
-
*/
|
|
256
218
|
/**
|
|
257
219
|
* Sign this machine in from the browser. `challenge` is the sha256, in hex, of a secret kept here;
|
|
258
220
|
* what comes back is a short code to show, the page to open, and how often to ask whether it was
|
|
@@ -279,7 +241,6 @@ export async function devicePoll(id, verifier) {
|
|
|
279
241
|
return said;
|
|
280
242
|
}
|
|
281
243
|
|
|
282
|
-
export const createAccount = () => call("/v1/accounts", { method: "POST", auth: false });
|
|
283
244
|
export const me = () => call("/v1/me");
|
|
284
245
|
export const updateMe = (patch) => call("/v1/me", { method: "PATCH", body: patch });
|
|
285
246
|
|
|
@@ -300,101 +261,83 @@ export const removeMember = (slug, who) =>
|
|
|
300
261
|
export const join = (code) =>
|
|
301
262
|
call(`/v1/invites/${encodeURIComponent(code)}/accept`, { method: "POST" });
|
|
302
263
|
|
|
303
|
-
export const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
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 = "") {
|
|
316
283
|
const file = await stat(path);
|
|
317
|
-
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.");
|
|
318
285
|
if (file.size > 10 * 1024 * 1024) throw new ApiError(413, "That file is over 10 MB.");
|
|
319
|
-
return call(`/v1/
|
|
286
|
+
return call(`/v1/projects/${encodeURIComponent(projectId)}/files`, {
|
|
320
287
|
method: "POST",
|
|
321
288
|
body: new Uint8Array(await readFile(path)),
|
|
322
289
|
uploadName: name || basename(path),
|
|
323
290
|
});
|
|
324
291
|
}
|
|
325
|
-
export const
|
|
326
|
-
call(`/v1/
|
|
292
|
+
export const createProjectDocument = (projectId, name, body) =>
|
|
293
|
+
call(`/v1/projects/${encodeURIComponent(projectId)}/documents`, {
|
|
327
294
|
method: "POST",
|
|
328
295
|
body: { name, body },
|
|
329
296
|
});
|
|
330
|
-
export const
|
|
331
|
-
call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
|
|
332
|
-
method: "PUT",
|
|
333
|
-
body: { version, body },
|
|
334
|
-
});
|
|
335
|
-
export const renameFolderDocument = (folderId, documentId, name) =>
|
|
336
|
-
call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
|
|
337
|
-
method: "PATCH",
|
|
338
|
-
body: { name },
|
|
339
|
-
});
|
|
340
|
-
export const moveFolderDocument = (folderId, documentId, to, name = "") =>
|
|
341
|
-
call(`/v1/folders/${encodeURIComponent(folderId)}/documents/${encodeURIComponent(documentId)}`, {
|
|
342
|
-
method: "PATCH",
|
|
343
|
-
body: name ? { folder: to, name } : { folder: to },
|
|
344
|
-
});
|
|
345
|
-
export const folderDocumentHistory = (folderId, documentId, version = 0) =>
|
|
297
|
+
export const saveProjectDocument = (projectId, documentId, version, body) =>
|
|
346
298
|
call(
|
|
347
|
-
`/v1/
|
|
299
|
+
`/v1/projects/${encodeURIComponent(projectId)}/documents/${encodeURIComponent(documentId)}`,
|
|
300
|
+
{
|
|
301
|
+
method: "PUT",
|
|
302
|
+
body: { version, body },
|
|
303
|
+
},
|
|
348
304
|
);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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)}`, {
|
|
363
327
|
method: "DELETE",
|
|
364
328
|
});
|
|
365
|
-
|
|
366
|
-
export const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
export const markRead = (ids = []) =>
|
|
372
|
-
call("/v1/notifications/read", { method: "POST", body: { ids } });
|
|
373
|
-
|
|
374
|
-
export const publish = (id, markdown) =>
|
|
375
|
-
call(`/v1/guides/${id}`, { method: "PUT", body: markdown });
|
|
376
|
-
export const list = (query = "", scope = "") => call(`/v1/guides${q({ q: query, scope })}`);
|
|
377
|
-
export const inbox = () => call("/v1/inbox");
|
|
378
|
-
/** Open a report for a set of issues to be filed under. */
|
|
379
|
-
export const createReport = (body) => call("/v1/reports", { method: "POST", body });
|
|
380
|
-
/** One report and the issues filed under it, grouped by product area. */
|
|
381
|
-
export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
|
|
382
|
-
export const get = (id, { peek = false } = {}) =>
|
|
383
|
-
call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true });
|
|
384
|
-
/** One page of every guide this account can read, for `export`. Records no pull. */
|
|
385
|
-
export const exportPage = (after = "") => call(`/v1/export${q({ after })}`);
|
|
386
|
-
/**
|
|
387
|
-
* A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
|
|
388
|
-
* capped server-side; neither form counts as opening them.
|
|
389
|
-
*/
|
|
390
|
-
export const children = (id, { markdown = false } = {}) =>
|
|
391
|
-
call(`/v1/guides/${encodeURIComponent(id)}/children${q({ markdown: markdown ? "1" : "" })}`);
|
|
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 })}`);
|
|
331
|
+
export const get = (id, { peek = false, as = null } = {}) =>
|
|
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. */
|
|
334
|
+
export const exportPage = (after = "", as = null) => call(`/v1/export${q({ after })}`, { as });
|
|
392
335
|
/**
|
|
393
|
-
* The
|
|
394
|
-
*
|
|
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.
|
|
395
338
|
*/
|
|
396
339
|
export const parent = (id, { markdown = false } = {}) =>
|
|
397
|
-
call(`/v1/
|
|
340
|
+
call(`/v1/work/${encodeURIComponent(id)}/parent${q({ markdown: markdown ? "1" : "" })}`);
|
|
398
341
|
/**
|
|
399
342
|
* Giving a plan away, for whoever the deployment names in `ADMIN_ACCOUNTS`. Every other account
|
|
400
343
|
* gets a 404 from these, so the CLI does not have to know who the operator is either.
|
|
@@ -410,63 +353,29 @@ export const listGifts = () => call("/v1/admin/gifts");
|
|
|
410
353
|
export const takeGiftBack = (id) =>
|
|
411
354
|
call(`/v1/admin/gifts/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
412
355
|
export const setStatus = (id, status) =>
|
|
413
|
-
call(`/v1/
|
|
414
|
-
export const remove = (id) => call(`/v1/
|
|
415
|
-
/** Does it actually work? `note` is required when it does not. */
|
|
416
|
-
export const verdict = (id, ok, note = "", detail = "") =>
|
|
417
|
-
call(`/v1/guides/${id}/verdict`, {
|
|
418
|
-
method: "PUT",
|
|
419
|
-
body: { ok, note, ...(detail ? { detail } : {}) },
|
|
420
|
-
});
|
|
421
|
-
|
|
356
|
+
call(`/v1/work/${id}/status`, { method: "PATCH", body: { status } });
|
|
357
|
+
export const remove = (id) => call(`/v1/work/${id}`, { method: "DELETE" });
|
|
422
358
|
export const tasks = () => call("/v1/tasks");
|
|
423
359
|
export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
|
|
424
|
-
export const taskProgress = (id, body) =>
|
|
425
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
|
426
|
-
export const finishTask = (id, body) =>
|
|
427
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/finish`, { method: "POST", body });
|
|
428
|
-
export const approveTask = (id) =>
|
|
429
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/approve`, { method: "POST", body: {} });
|
|
430
|
-
export const rejectTask = (id, why) =>
|
|
431
|
-
call(`/v1/tasks/${encodeURIComponent(id)}/reject`, { method: "POST", body: { why } });
|
|
432
360
|
export const reopen = (id, why) =>
|
|
433
|
-
call(`/v1/
|
|
434
|
-
/**
|
|
435
|
-
export const closeGuide = (id) =>
|
|
436
|
-
call(`/v1/guides/${encodeURIComponent(id)}/close`, { method: "POST", body: {} });
|
|
437
|
-
export const sendBack = (id, why) =>
|
|
438
|
-
call(`/v1/guides/${encodeURIComponent(id)}/send_back`, { method: "POST", body: { why } });
|
|
439
|
-
/** Take work back from whoever holds it, of any kind. `/v1/tasks/…/release` is the old path. */
|
|
361
|
+
call(`/v1/work/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
|
|
362
|
+
/** Take work back from whoever holds it. */
|
|
440
363
|
export const release = (id) =>
|
|
441
|
-
call(`/v1/
|
|
442
|
-
/** The four verbs every guide answers to. See docs/V2.md §11. */
|
|
364
|
+
call(`/v1/work/${encodeURIComponent(id)}/release`, { method: "POST", body: {} });
|
|
443
365
|
export const working = () => call("/v1/working");
|
|
444
|
-
export const handedIn = () => call("/v1/handed_in");
|
|
445
|
-
export const recall = (id) =>
|
|
446
|
-
call(`/v1/guides/${encodeURIComponent(id)}/recall`, { method: "POST", body: {} });
|
|
447
366
|
export const assign = (id, to) =>
|
|
448
|
-
call(`/v1/
|
|
367
|
+
call(`/v1/work/${encodeURIComponent(id)}/assign`, { method: "POST", body: { to } });
|
|
449
368
|
export const take = (body) => call("/v1/take", { method: "POST", body });
|
|
450
369
|
export const progress = (id, body) =>
|
|
451
|
-
call(`/v1/
|
|
370
|
+
call(`/v1/work/${encodeURIComponent(id)}/progress`, { method: "PUT", body });
|
|
452
371
|
export const handIn = (id, body) =>
|
|
453
|
-
call(`/v1/
|
|
372
|
+
call(`/v1/work/${encodeURIComponent(id)}/hand_in`, { method: "POST", body });
|
|
454
373
|
export const ask = (id, body) =>
|
|
455
|
-
call(`/v1/
|
|
374
|
+
call(`/v1/work/${encodeURIComponent(id)}/ask`, { method: "POST", body });
|
|
456
375
|
export const reply = (id, body) =>
|
|
457
|
-
call(`/v1/
|
|
376
|
+
call(`/v1/work/${encodeURIComponent(id)}/reply`, { method: "POST", body: { body } });
|
|
458
377
|
export const pass = (id, body) =>
|
|
459
|
-
call(`/v1/
|
|
460
|
-
export const ack = (id, taken, note = "") =>
|
|
461
|
-
call(`/v1/guides/${id}/ack`, { method: "PUT", body: { taken, note } });
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Upload an image as evidence, and get back the URL a guide points at.
|
|
465
|
-
*
|
|
466
|
-
* Not `call()`: that serialises a body as JSON or markdown, and this route reads bytes and takes
|
|
467
|
-
* the content type as the declaration of what they are. `name` is the label, and it travels in a
|
|
468
|
-
* header rather than the body for the same reason.
|
|
469
|
-
*/
|
|
378
|
+
call(`/v1/work/${encodeURIComponent(id)}/pass`, { method: "POST", body });
|
|
470
379
|
/**
|
|
471
380
|
* Upload a file that is not a picture, and get back what the server made of it. The server decides
|
|
472
381
|
* what the bytes are and refuses what it does not keep; `type` and `name` are hints. The name travels
|
|
@@ -477,6 +386,7 @@ export async function uploadFile(bytes, type, name = "") {
|
|
|
477
386
|
const t = token();
|
|
478
387
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
479
388
|
const headers = {
|
|
389
|
+
...CLIENT,
|
|
480
390
|
authorization: `Bearer ${t}`,
|
|
481
391
|
"content-type": type || "application/octet-stream",
|
|
482
392
|
};
|
|
@@ -498,6 +408,10 @@ export async function uploadFile(bytes, type, name = "") {
|
|
|
498
408
|
return (await res.json()).attachment;
|
|
499
409
|
}
|
|
500
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Upload a picture, and get back the URL work points at. Not `call()`: that serialises a body as
|
|
413
|
+
* JSON or markdown, and this route reads bytes and takes the content type as what they are.
|
|
414
|
+
*/
|
|
501
415
|
export async function uploadShot(bytes, type, name = "") {
|
|
502
416
|
const t = token();
|
|
503
417
|
if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
|
|
@@ -522,7 +436,7 @@ export async function uploadShot(bytes, type, name = "") {
|
|
|
522
436
|
return (await res.json()).shot;
|
|
523
437
|
}
|
|
524
438
|
|
|
525
|
-
/** Fetch
|
|
439
|
+
/** Fetch work by its share link (no account needed). Accepts the web URL or the .md URL. */
|
|
526
440
|
export async function fetchShared(url) {
|
|
527
441
|
const u = new URL(url);
|
|
528
442
|
if (!u.pathname.endsWith(".md")) u.pathname = `${u.pathname.replace(/\/$/, "")}.md`;
|
|
@@ -531,7 +445,3 @@ export async function fetchShared(url) {
|
|
|
531
445
|
throw new ApiError(res.status, `could not fetch ${url}: ${res.status} ${res.statusText}`);
|
|
532
446
|
return res.text();
|
|
533
447
|
}
|
|
534
|
-
|
|
535
|
-
export function shareUrl(id, key) {
|
|
536
|
-
return `${baseUrl()}/g/${id}/${key}`;
|
|
537
|
-
}
|
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.
|
|
@@ -56,7 +52,7 @@ 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}
|
|
55
|
+
`- ${h.id}: ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
|
|
60
56
|
`${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
|
|
61
57
|
);
|
|
62
58
|
lines.push(
|
|
@@ -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);
|
|
@@ -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 = [];
|