passalong 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Hand a finished implementation from one context to another, in a form an AI agent can act on.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/api.js CHANGED
@@ -1,7 +1,18 @@
1
1
  // Client for the hosted sync API (apps/api). Everything here is optional: with no token the
2
2
  // CLI is a purely local tool, and every function throws an ApiError the CLI turns into a hint.
3
+ import { readFileSync } from "node:fs";
3
4
  import { readConfig } from "./store.js";
4
5
 
6
+ /**
7
+ * This build's version, sent on every authenticated call as `x-passalong-version`. The server
8
+ * refuses a CLI below its floor (apps/api/src/clients.ts), because the rules it enforces deploy on
9
+ * merge and the text telling an agent how to follow them only arrives with a reinstall.
10
+ */
11
+ export const VERSION = JSON.parse(
12
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
13
+ ).version;
14
+ const CLIENT = { "x-passalong-version": VERSION };
15
+
5
16
  export const DEFAULT_API = "https://passalong.dev";
6
17
 
7
18
  export class ApiError extends Error {
@@ -24,7 +35,7 @@ export function loggedIn() {
24
35
  }
25
36
 
26
37
  async function call(path, { method = "GET", body, auth = true, raw = false } = {}) {
27
- const headers = {};
38
+ const headers = { ...CLIENT };
28
39
  if (auth) {
29
40
  const t = token();
30
41
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
@@ -220,7 +231,7 @@ export const ack = (id, taken, note = "") =>
220
231
  export async function uploadShot(bytes, type, name = "") {
221
232
  const t = token();
222
233
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
223
- const headers = { authorization: `Bearer ${t}`, "content-type": type };
234
+ const headers = { ...CLIENT, authorization: `Bearer ${t}`, "content-type": type };
224
235
  // A header is latin-1: a filename with an accent in it throws on the way out rather than at the
225
236
  // server, and the label is not worth failing an upload over.
226
237
  if (name) headers["x-shot-name"] = String(name).replace(/[^\x20-\x7e]/g, "");
package/src/guide.js CHANGED
@@ -127,8 +127,22 @@ export function sectionsFor(kind) {
127
127
  }
128
128
 
129
129
  /** The sections it cannot be published without. */
130
+ /**
131
+ * The sections a kind cannot do without, and the one that has none.
132
+ *
133
+ * A transfer is context exchange. Context is whatever the next agent needs to not start cold —
134
+ * a decision and why, a constraint, where the bodies are — and none of that arrives in six fixed
135
+ * headings. Requiring `## Problem` and `## Steps` made every short note into a document: of 56
136
+ * follow-ups in real use, the ones that fill this template average 599 words and ten of sixteen
137
+ * were never opened by anyone, while the ones that ignored it average 137 words and every single
138
+ * one was opened. The template was not raising the floor, it was padding to reach it.
139
+ *
140
+ * A task and a bug keep theirs, because theirs are not prose. `## Acceptance` is the list a
141
+ * hand-in answers line by line, and `## Reproduce` is how a reader sees the defect. Those earn
142
+ * their requirement by being read mechanically; `## Steps` on a piece of context does not.
143
+ */
130
144
  const REQUIRED = {
131
- transfer: ["Problem", "Steps"],
145
+ transfer: [],
132
146
  bug: ["Problem", "Reproduce"],
133
147
  task: ["Goal", "Acceptance"],
134
148
  };
@@ -255,10 +269,21 @@ export function parseFrontmatter(text) {
255
269
  // Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
256
270
  // empty one into every guide anybody re-shares.
257
271
  for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
258
- // What it is, said rather than inferred. An unknown spelling is left alone for `validate()` to
259
- // refuse by name: quietly turning `kind: buggy` into a transfer guide is the same guess this is
260
- // here to remove.
261
- if (!String(meta.kind ?? "").trim()) meta.kind = "transfer";
272
+ // Absent is absent. It is NOT a transfer guide.
273
+ //
274
+ // This line, and the sentence publish_guide tells every agent — "kind: task (the default)" —
275
+ // disagreed for thirteen days. An agent that left `kind:` off, believing what it had been told,
276
+ // got a transfer guide. 162 of 200 guides in real use are labelled transfer; their titles are
277
+ // tasks and bug reports. Everything downstream followed from that: a transfer has no states, so
278
+ // nothing could show as in progress; it demands Problem and Steps, so a two-line correction was
279
+ // padded to six sections; and there is nowhere to report into one, so agents published a second
280
+ // guide to carry what they had found.
281
+ //
282
+ // So it stays empty and `validate()` refuses it by name. The back-compat this default protected
283
+ // is a guide written before kinds existed being re-shared; that guide is re-shared once with an
284
+ // explicit `kind: transfer`, which is a sentence its author can write, unlike thirteen days of
285
+ // mislabelled work nobody could see.
286
+ meta.kind = String(meta.kind ?? "").trim();
262
287
  // Both ends of this module: what it reads and what it writes are in one style, so a guide
263
288
  // written before there was a rule comes back normalised and goes out normalised.
264
289
  meta.tags = tagList(meta.tags);
@@ -305,6 +330,10 @@ export function serializeFrontmatter(meta) {
305
330
  ];
306
331
  const out = [];
307
332
  for (const key of keys) {
333
+ // A kind nobody stated is left unstated. Writing `kind: ""` would put an answer in the
334
+ // document that its author never gave, and the next parse would read it back as a stated
335
+ // empty rather than an absent one — which is the whole distinction validate() turns on.
336
+ if (key === "kind" && !String(meta[key] ?? "").trim()) continue;
308
337
  const v = key === "tags" ? tagList(meta[key]) : meta[key];
309
338
  if (Array.isArray(v)) {
310
339
  out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
@@ -497,8 +526,9 @@ export function bugGuide({
497
526
 
498
527
  /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
499
528
  export function template(meta = {}) {
500
- // A task unless asked otherwise: most guides assign work. The transfer skeleton writes its kind
501
- // out, since an absent kind: still means transfer to every client already installed.
529
+ // A task unless asked otherwise: most guides assign work. Every skeleton writes its kind out —
530
+ // absent is now refused rather than read as transfer, so a template that left it off would
531
+ // produce a document its own author could not publish.
502
532
  if (meta.kind === "bug") return bugTemplate(meta);
503
533
  if (meta.kind !== "transfer") return taskTemplate(meta);
504
534
  const body = [
package/src/mcp.js CHANGED
@@ -261,7 +261,9 @@ export function buildServer() {
261
261
  "Collect it as you work rather than writing it from memory at the end. hand_in without " +
262
262
  "it is refused, because the write-up and the verdict are both your word for your own " +
263
263
  "work and evidence is the part the person reviewing it can check.\n" +
264
- `kind: task (the default) is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
264
+ "SAY WHICH KIND IT IS. There is no default: a guide with no `kind:` line is refused, and so " +
265
+ "is a spelling that is not one of the three. " +
266
+ `kind: task is work nobody has done yet. Sections: ${TASK_SECTIONS.join(", ")}. ` +
265
267
  "It has no Steps: work out how to reach Goal within Constraints, leave Out of scope " +
266
268
  "alone, and treat Acceptance as the definition of done. hand_in with `markdown`: a " +
267
269
  "transfer guide about what you did (guide_template kind transfer shows the shape) — its " +
@@ -381,7 +383,19 @@ export function buildServer() {
381
383
  }
382
384
  }
383
385
 
384
- async function doHandIn({ id, ok, note, evidence, checks, writeup, markdown, report, pr, cwd }) {
386
+ async function doHandIn({
387
+ id,
388
+ ok,
389
+ note,
390
+ evidence,
391
+ checks,
392
+ writeup,
393
+ risk,
394
+ markdown,
395
+ report,
396
+ pr,
397
+ cwd,
398
+ }) {
385
399
  try {
386
400
  // A check that names a command is run here, before anything is recorded, and its exit code
387
401
  // decides it rather than the agent's account of it. A failure is refused with the command's
@@ -402,6 +416,7 @@ export function buildServer() {
402
416
  evidence: evidence || "",
403
417
  checks: done.checks,
404
418
  writeup: writeup || "",
419
+ risk: risk || "",
405
420
  markdown,
406
421
  report,
407
422
  pr: pr || "",
@@ -763,6 +778,15 @@ export function buildServer() {
763
778
  "it out when it worked as written. The next person to open the guide is shown it, " +
764
779
  "which is why it does not need to be a guide of its own",
765
780
  ),
781
+ risk: z
782
+ .string()
783
+ .optional()
784
+ .describe(
785
+ "what this could break, in one line — the part of a PR template's Risk that is " +
786
+ "worth reading: a shared helper changed, a migration that cannot be undone, a " +
787
+ "route other clients call. Optional; leave it out when there is nothing specific, " +
788
+ 'rather than writing "low risk". Its author reads it before accepting the work',
789
+ ),
766
790
  markdown: z
767
791
  .string()
768
792
  .optional()
package/src/passalong.js CHANGED
@@ -358,8 +358,16 @@ export async function finishTask(
358
358
  requireEvidence(evidence);
359
359
  if (!report && markdown) {
360
360
  const g = parse(markdown);
361
- report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
362
- .guide.meta.id;
361
+ // A task's report is a transfer guide, and this code is what is making one — so it says so
362
+ // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
363
+ // agent's hand-in because its write-up left a line off would be this function's omission
364
+ // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
365
+ // author's to leave open, because the caller already knows the answer.
366
+ report = (
367
+ await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
368
+ cwd,
369
+ })
370
+ ).guide.meta.id;
363
371
  }
364
372
  if (!report)
365
373
  throw new PassalongError(
@@ -414,6 +422,7 @@ export async function handIn(
414
422
  evidence = "",
415
423
  checks = [],
416
424
  writeup = "",
425
+ risk = "",
417
426
  markdown,
418
427
  report,
419
428
  pr = "",
@@ -443,8 +452,16 @@ export async function handIn(
443
452
  else requireEvidence(evidence);
444
453
  if (!report && markdown) {
445
454
  const g = parse(markdown);
446
- report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
447
- .guide.meta.id;
455
+ // A task's report is a transfer guide, and this code is what is making one — so it says so
456
+ // rather than hoping the agent's frontmatter did. Absent is refused now, and refusing an
457
+ // agent's hand-in because its write-up left a line off would be this function's omission
458
+ // charged to the agent. Stated, not defaulted: `kind` is the one field here that is not the
459
+ // author's to leave open, because the caller already knows the answer.
460
+ report = (
461
+ await share(serialize({ meta: { ...g.meta, kind: "transfer", parent: id }, body: g.body }), {
462
+ cwd,
463
+ })
464
+ ).guide.meta.id;
448
465
  }
449
466
  const fence = fenceFor(id, cwd);
450
467
  return api.handIn(id, {
@@ -466,6 +483,8 @@ export async function handIn(
466
483
  // Left out when there is nothing to say, rather than sent as "": most hand-ins have nothing
467
484
  // to adapt, and the field is for the ones that do.
468
485
  ...(writeup ? { writeup } : {}),
486
+ // What it could break, for the reviewer. Left out when empty, like the write-up.
487
+ ...(risk ? { risk } : {}),
469
488
  ...(typeof ok === "boolean" ? { ok } : {}),
470
489
  });
471
490
  }
@@ -713,6 +732,11 @@ export async function attach(file, { name = "" } = {}) {
713
732
  * found doing it. Whoever opens the original, person or agent, gets its follow-ups with it, which
714
733
  * is why context belongs there rather than in a one-line verdict note.
715
734
  *
735
+ * What an agent did and found working on the guide is not context for it: that is the hand-in,
736
+ * and the note says so. It used to list "what you found doing it" among the things to publish, on
737
+ * every `take`, while hand_in said a follow-up was not for answering — the agent followed the one
738
+ * it read last, and the author got a second guide to review with the evidence inside it.
739
+ *
716
740
  * Trailing, like the handoff nudge, so it never sits in front of the frontmatter. It is here and
717
741
  * not only in the server's instructions because an agent reads the payload it is working from and
718
742
  * skims everything else — the same reason the bug lead is inside the document. For a bug, the
@@ -729,9 +753,10 @@ export function followUpNote(meta = {}) {
729
753
  );
730
754
  return (
731
755
  "<!-- passalong: a follow-up is more context for this guide, written as its own guide. If " +
732
- "this guide needs more — a missing detail, a step that needed explaining, what changed since, " +
733
- `what you found doing it — publish that with publish_guide parent=${meta.id}, and whoever ` +
734
- "opens this guide gets it too. -->"
756
+ "this guide needs more — a missing detail, a step that needed explaining, what changed since " +
757
+ `— publish that with publish_guide parent=${meta.id}, and whoever opens this guide gets it ` +
758
+ "too. What you did and found working on it is not a follow-up: it goes on hand_in, in " +
759
+ "`checks` and `writeup`. -->"
735
760
  );
736
761
  }
737
762