@sleepy_panda_srl/portulan 0.1.2 → 0.2.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.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +225 -1071
  10. package/cli/drills.mjs +1107 -0
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. package/spec/workspace.schema.json +145 -4
package/cli/feedback.mjs CHANGED
@@ -1,59 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // `portulan feedback` — file an issue from a report you previewed, seam-scanned before it leaves the
3
- // machine, under the Gated tier. Milestone 7's inbound half; proposal
4
- // `../.portulan/proposals/0014-a-feedback-pipe-points-out-of-the-seam.md` is the design.
2
+ // `portulan feedback`: file an issue from a report you previewed, seam-scanned before it leaves the machine.
5
3
  //
6
- // ## The sentence the whole file follows from
7
- //
8
- // **A feedback sender is a pipe from a private workspace into a record it does not control.** An issue
9
- // tracker's history is permanent and its visibility is the *repository's* to change — this one was
10
- // public from 2026-07-27 to 2026-08-03, private after that, and is public again, which is exactly why
11
- // "it is public" is the wrong thing to reason from in either direction: what a sender may safely
12
- // include cannot depend on a setting somebody else can flip, and this one has been flipped three
13
- // times. The workspace it sends *from* may be a company's — repo names, paths,
14
- // product identifiers, gate maps, memory. `docs/plan.md` → Protocol → The seam exists because this
15
- // project treats that leakage as unacceptable in the other direction, and this tool points at the same
16
- // wall pointing out.
17
- //
18
- // Two consequences, and they are the two things that would be hard to add later:
19
- //
20
- // 1. **The payload is assembled from a closed list**, never filtered after the fact. Nothing here reads
21
- // the workspace's name, its repo cards, its gate map, its memory, a git remote, a branch, or any
22
- // path. The one thing it reads out of the workspace is `portulan.spec`, a version number. A filter
23
- // is a list of what to remove and is wrong the day something new arrives; a closed list is a list of
24
- // what to include and is wrong only when somebody widens it on purpose.
25
- // 2. **This product never carries seam terms.** They are client-confidential by definition. The tool is
26
- // *told where a list is* and reads it; it ships none, and a term list has no place in this
27
- // repository.
28
- //
29
- // ## Gated means per action, and the refusal lives in the tool
30
- //
31
- // `send` without `--approve` prints the payload and exits 2. The approval cannot be inherited from an
32
- // earlier `draft` or `preview`, because `core/operating/autonomy.md`'s Gated tier is per action — and a
33
- // queue that flushes itself when the network returns is a silent send with extra steps, which is why
34
- // nothing here auto-sends, retries in the background, or treats an unsent draft as anything but a file.
35
- //
36
- // The refusal is **in the tool** rather than in a host's permission table on purpose. A `Bash(...)`
37
- // matcher in `.portulan/gates.json` would cover one spelling of at least three (`portulan feedback
38
- // send`, `npx portulan feedback send`, `node cli/feedback.mjs send`) and would suggest coverage it does
39
- // not have. A refusal the tool itself makes holds on every host, including the hosts with no hook
40
- // system at all — which is the platform-floor direction this project prefers everywhere else.
41
- //
42
- // ## Exit codes
43
- //
44
- // `0` the verb succeeded · `1` **a verdict was rendered and it was no** — today that is exactly one
45
- // thing, a seam hit · `2` could not run: a report that is not ready, an approval that was not given, a
46
- // term list that was named and could not be read, a missing `gh`. The split matters at the one place it
47
- // is easy to get wrong: an unreadable term list is **not** a clean scan, and a report missing a required
48
- // answer is not a judgement about anybody's workspace.
49
- //
50
- // ## What is deliberately not here
51
- //
52
- // No OAuth device flow — the user's existing `gh` login and nothing else. No relay: proposal `0014`'s
53
- // Q5(b) is a hosted service and `docs/vision.md` permits exactly one by name, so it is constitutional
54
- // and unbuilt. No telemetry, no crash reporter, no attachments, no screenshots, no log bundles — every
55
- // one is an unbounded channel through the wall this file is about. And **no labels**: `0014` rules that
56
- // the repository owns its own labels, so the form's title prefix is what marks the kind.
4
+ // Exit 0 succeeded · 1 a seam hit, or a filed issue its report could not record · 2 could not run.
57
5
 
58
6
  import crypto from "node:crypto";
59
7
  import fs from "node:fs";
@@ -62,34 +10,18 @@ import path from "node:path";
62
10
  import { spawnSync } from "node:child_process";
63
11
  import { pathToFileURL } from "node:url";
64
12
 
65
- // `manifest.mjs` and not `portulan.mjs`: importing the entry point from a subcommand it dispatches to
66
- // is a cycle, and it hung `portulan feedback --help` at exit 13. The header there is the record.
13
+ // Not from `portulan.mjs`: importing the entry point that dispatches here is a cycle, and hangs at exit.
67
14
  import { VERSION, REPOSITORY } from "./manifest.mjs";
68
15
 
69
16
  export class FeedbackError extends Error {}
70
17
 
71
- /**
72
- * The window a second send has to clear. Proposal `0014` asks for "a short cooldown [to catch] a loop"
73
- * beside the re-send no-op: the no-op stops the same report going twice, and this stops a script
74
- * filing twenty different ones. Short enough that a person filing two real reports in a sitting waits
75
- * once and understands why.
76
- */
77
18
  export const COOLDOWN_SECONDS = 60;
78
19
 
79
20
  // ===========================================================================================
80
21
  // The forms
81
22
  // ===========================================================================================
82
- //
83
- // THE ONE CARRIER, and it is a carrier because the alternative does not exist. The forms live at
84
- // `.github/ISSUE_TEMPLATE/*.yml` and `package.json`'s `files` does not ship them, so a published
85
- // package cannot read them; parsing YAML would need a dependency this CLI is ruled not to have, and
86
- // generating this map would need a build step it is ruled not to have either. So the map is written
87
- // here and `feedback.live.test.mjs` reads the real forms and fails when the two disagree — `0020`'s
88
- // repair where a single carrier is impossible: one carrier plus a rail on the pair.
89
- //
90
- // `filled` marks a section this tool answers rather than the user: `version` is the Portulan version,
91
- // `environment` is the structured block. A fact claimed by a `filled` section is left out of the
92
- // environment block, so a payload never states the same thing twice.
23
+
24
+ // Mirrors the forms in `.github/ISSUE_TEMPLATE/`, which do not ship; `feedback.live.test.mjs` fails when they differ.
93
25
  export const FORMS = [
94
26
  {
95
27
  kind: "bug",
@@ -128,7 +60,7 @@ export const FORMS = [
128
60
  label: "Which part of Portulan",
129
61
  required: true,
130
62
  options: [
131
- "doctrine — core/: the loop, autonomy, verification, memory, evolution, safety",
63
+ "doctrine — core/: the loop, autonomy, verification, memory, evolution, safety, context",
132
64
  "mechanism — cli/, spec/, the plugin: the code that checks and enforces",
133
65
  "workspace — .portulan/: the policy layer and the gate map",
134
66
  "packaging — the plugin, the marketplace, install and boot",
@@ -182,7 +114,6 @@ export function form(kind) {
182
114
  return FORMS.find((entry) => entry.kind === kind) ?? null;
183
115
  }
184
116
 
185
- /** The repository issues are filed into — `manifest.mjs` derives it from the `bugs.url` we publish. */
186
117
  export function defaultRepository() {
187
118
  return REPOSITORY;
188
119
  }
@@ -191,10 +122,6 @@ export function defaultRepository() {
191
122
  // The report file
192
123
  // ===========================================================================================
193
124
 
194
- /**
195
- * A filename slug, and it returns "" rather than inventing one. A title of punctuation has no name in
196
- * it, and a report called `2026-08-10-.md` is a file nobody can find again.
197
- */
198
125
  export function slug(title) {
199
126
  return String(title)
200
127
  .toLowerCase()
@@ -202,7 +129,6 @@ export function slug(title) {
202
129
  .replace(/^-+|-+$/g, "");
203
130
  }
204
131
 
205
- /** The scaffolded report — a file the user edits, which is the whole point of the file-first design. */
206
132
  export function scaffold(kind, { title, created, failedRecipe, failedExit } = {}) {
207
133
  const declared = form(kind);
208
134
  if (!declared) throw new FeedbackError(`\`${kind}\` is not a form. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
@@ -221,7 +147,7 @@ export function scaffold(kind, { title, created, failedRecipe, failedExit } = {}
221
147
  ];
222
148
 
223
149
  for (const section of declared.sections) {
224
- if (section.filled) continue; // this tool answers it; a user editing it would be edited over
150
+ if (section.filled) continue;
225
151
  parts.push(`### ${section.label}`);
226
152
  const guidance = section.options
227
153
  ? "<!-- Keep exactly ONE of the lines below and delete the rest. -->\n" + section.options.join("\n")
@@ -235,14 +161,6 @@ export function scaffold(kind, { title, created, failedRecipe, failedExit } = {}
235
161
  return `${parts.join("\n\n")}\n`;
236
162
  }
237
163
 
238
- /**
239
- * Read a report back. Frontmatter through `plugin-lint.mjs`'s reader would drag a 74 KB module in for
240
- * four `key: value` lines, so this reads the block directly — and the block it reads is the block this
241
- * file wrote, which is the case that makes a local reader honest rather than lazy.
242
- *
243
- * @returns {{fields: Record<string,string>, sections: Array<{label: string, text: string}>,
244
- * acknowledgements: Array<{text: string, ticked: boolean}>}}
245
- */
246
164
  export function parseReport(text) {
247
165
  const lines = text.split(/\r?\n/);
248
166
  if (lines[0]?.trim() !== "---") throw new FeedbackError("the report has no frontmatter block");
@@ -290,7 +208,6 @@ export function parseReport(text) {
290
208
  return { fields, sections, acknowledgements };
291
209
  }
292
210
 
293
- /** HTML comments carry this tool's guidance to the author; none of it belongs in a public issue. */
294
211
  function stripComments(text) {
295
212
  return text.replace(/<!--[\s\S]*?-->/g, "");
296
213
  }
@@ -299,16 +216,7 @@ function stripComments(text) {
299
216
  // The payload
300
217
  // ===========================================================================================
301
218
 
302
- /**
303
- * The bytes that would be filed. `preview` and `send` both call this, which is *why* the demonstration
304
- * holds: the previewed body is not compared with the sent body, it **is** the sent body. Two renderers
305
- * that agree today are two carriers of one fact.
306
- *
307
- * `parts` is the same content split by section, so the seam scan runs over exactly what would leave the
308
- * machine rather than over a reconstruction of it.
309
- *
310
- * @returns {{repo: string|null, title: string, body: string, parts: Array<{label: string, text: string}>}}
311
- */
219
+ /** `preview` and `send` both render through this, so the bytes previewed are the bytes sent. */
312
220
  export function payload(report, context = {}) {
313
221
  const declared = form(report.fields.kind);
314
222
  if (!declared) throw new FeedbackError(`\`${report.fields.kind}\` is not a form`);
@@ -343,13 +251,7 @@ export function payload(report, context = {}) {
343
251
  return { repo: context.repo ?? defaultRepository(), title, body, parts };
344
252
  }
345
253
 
346
- /**
347
- * Proposal `0014`'s second part: "Portulan version, spec version, host and version, OS and
348
- * architecture. If the report was started from a failure, the failing recipe's **id and exit code**."
349
- * Not the recipe's *output*, which carries paths and file contents.
350
- *
351
- * `claimed` holds what another field on this form already asks for, so nothing is said twice.
352
- */
254
+ /** A failing recipe's id and exit code, never its output, which carries paths and file contents. */
353
255
  function environmentBlock(report, context, claimed = new Set()) {
354
256
  const facts = context.facts ?? {};
355
257
  const lines = [];
@@ -359,10 +261,6 @@ function environmentBlock(report, context, claimed = new Set()) {
359
261
  `Host: ${context.host ?? "not observable from the command line — re-run with --host to name it"}`,
360
262
  );
361
263
  lines.push(`Runtime: node ${facts.node ?? process.version}`);
362
- // Every fact defaults to the real machine. `?? ""` on the release was a real defect and it was
363
- // invisible in the suite, which injects all four: the shipped tool printed `System: darwin arm64`
364
- // — a line silently one fact short of what this block says it carries. Caught by running the
365
- // demonstration rather than by reading the code, which is where this class keeps being caught.
366
264
  lines.push(`System: ${facts.platform ?? process.platform} ${facts.release ?? os.release()} ${facts.arch ?? process.arch}`);
367
265
  if (report.fields["failed-recipe"]) {
368
266
  lines.push(`Failing recipe: ${report.fields["failed-recipe"]}, exit ${report.fields["failed-exit"] ?? "unrecorded"}`);
@@ -374,16 +272,7 @@ function environmentBlock(report, context, claimed = new Set()) {
374
272
  // The seam
375
273
  // ===========================================================================================
376
274
 
377
- /**
378
- * Find the term list, and be exact about which of the three locations answered — a scan whose source a
379
- * reader cannot name is a scan they cannot check.
380
- *
381
- * **Only `ENOENT` means absent, and only at the convention path.** `existsSync` answers false for
382
- * `EACCES`, which is how #166's layer at mode `0400` skipped every location it declared while reporting
383
- * green. A named list that cannot be read is could-not-run, always.
384
- *
385
- * @returns {{source: string|null, terms: string[]|null}} `source: null` means no list is configured.
386
- */
275
+ /** `source: null` means no list is configured; a list named or present but unreadable throws. */
387
276
  export function locateTerms({ flag, env = {}, workspaceDir } = {}) {
388
277
  if (flag) return { source: `--seam-terms ${flag}`, terms: read(flag, `--seam-terms ${flag}`) };
389
278
 
@@ -408,25 +297,7 @@ export function locateTerms({ flag, env = {}, workspaceDir } = {}) {
408
297
  }
409
298
  }
410
299
 
411
- /**
412
- * `null` when the path is there, `"ENOENT"` when it is genuinely absent, and the errno otherwise. The
413
- * one carrier of *only ENOENT means absent* for path probes in this file — `existsSync` collapses all
414
- * three into `false`, which is how a tool tells a reader the wrong thing about their disk with complete
415
- * confidence.
416
- *
417
- * `lstatSync` rather than `accessSync(R_OK)` or `statSync`, and each difference is a question being
418
- * asked. Both of this file's probes ask *is something already here*, not *may I read it* — a report at
419
- * mode `0000` is a file that exists and must not be written over, while an unsearchable parent is a
420
- * place this tool cannot see into, and that deserves a different sentence.
421
- *
422
- * **And `lstat`, because `stat` follows symlinks.** A dangling symlink at the report path makes `stat`
423
- * throw `ENOENT`, so the probe would answer *absent*, and the write that followed would resolve the link
424
- * and **land outside the workspace** — which is how a scaffold leaves the tree it was meant to stay
425
- * inside, the rule `cli/new.mjs` already refuses on. `lstat` describes the link itself, so a symlink of
426
- * any kind is *something is already here*. Found by review on this pull request, as a sibling of the
427
- * `existsSync` repair one round earlier: the same probe, corrected twice, because the first correction
428
- * answered only the errno half of the question.
429
- */
300
+ /** null when anything is there, a dangling symlink included; else the errno, `ENOENT` meaning absent. */
430
301
  function whyAbsent(at) {
431
302
  try {
432
303
  fs.lstatSync(at);
@@ -436,15 +307,6 @@ function whyAbsent(at) {
436
307
  }
437
308
  }
438
309
 
439
- /**
440
- * Every write this tool makes, through one place, and every failure of one turned into a sentence.
441
- *
442
- * A bare `writeFileSync` on a read-only directory reached the top-level catch, which prints
443
- * `unanticipated failure` and a stack trace. The exit code was already 2, so this is not a fail-open —
444
- * it is the other half of the same discipline: **could-not-run has to say what could not run**, and a
445
- * stack trace is what a tool prints when it has nothing to say. Found by review on this pull request,
446
- * at three sites.
447
- */
448
310
  function writeOrRefuse(at, contents, what) {
449
311
  try {
450
312
  fs.mkdirSync(path.dirname(at), { recursive: true });
@@ -462,35 +324,12 @@ function parseTerms(text) {
462
324
  .map((line) => line.toLowerCase());
463
325
  }
464
326
 
465
- /**
466
- * The digest of a payload, and the reason `preview` is not optional.
467
- *
468
- * Row 7's demonstration is *"a send whose exact payload the user saw first"*, and for one draft of this
469
- * tool that was a property of the **flow** rather than of the mechanism: `send --approve` on a report
470
- * nobody had previewed filed sight-unseen, and a report edited between the preview and the send filed
471
- * bytes that differed from the ones that were read. Both printed the payload in the same breath, which
472
- * is not the same as having been seen — nobody is reading a terminal between two lines of one command.
473
- *
474
- * So `preview` stamps this digest into the report and `send` refuses unless it matches. The approval is
475
- * then bound to bytes rather than to an intention, and *the user saw it first* is structural. It covers
476
- * the machine facts too, since they are in the payload: preview with `--host X` and send without, and
477
- * the digest differs, because the bytes do.
478
- *
479
- * **Only `preview` stamps.** A `send` that refused for want of approval also printed the payload, and
480
- * stamping there would let a refusal manufacture the approval-eligibility it just declined to grant.
481
- */
327
+ /** Only `preview` stamps it: a refused `send` prints the payload too, and stamping there would approve it. */
482
328
  export function digest(built) {
483
329
  return crypto.createHash("sha256").update(`${built.title}\n${built.body}`, "utf8").digest("hex");
484
330
  }
485
331
 
486
- /**
487
- * Case-insensitive substring, over the payload's own parts. Substring rather than word-boundary
488
- * because the terms that matter here are names and identifiers that turn up glued to other text —
489
- * a host, a path fragment, a ticket prefix — and a scan that missed those would be the reassuring kind.
490
- *
491
- * The hit names the term and the section and never quotes the surrounding text: this output goes to a
492
- * terminal and often to a log, and echoing the context would leak the thing the scan just caught.
493
- */
332
+ /** Substring, since terms turn up glued to other text; a hit never quotes its context, which would leak. */
494
333
  export function scan(parts, terms) {
495
334
  const hits = [];
496
335
  for (const part of parts) {
@@ -603,12 +442,6 @@ function draft(rest, flags, { say, warn, now }) {
603
442
  warn("feedback: draft needs a --title. It becomes the issue's title and the report's filename.");
604
443
  return 2;
605
444
  }
606
- // A single line, refused at the door beside the failure pair below and for the same reason. The
607
- // title is written verbatim into `title: …`, and frontmatter is one key per line — so a newline or
608
- // a carriage return would split it, and everything after the break would be read as another key or
609
- // as body. This door already validates the two facts it is handed; the title was the neighbour it
610
- // did not. Found by review on this pull request. The refusal is also the `control-chars` rule this
611
- // repository already rails the tree with, applied to an argument.
612
445
  if (/[\r\n]/.test(title)) {
613
446
  warn("feedback: a title is one line. This one carries a line break, and frontmatter is one key per line —");
614
447
  warn("feedback: everything after the break would be read as another key, or as the body.");
@@ -620,9 +453,6 @@ function draft(rest, flags, { say, warn, now }) {
620
453
  return 2;
621
454
  }
622
455
 
623
- // The failure pair is validated at the door rather than on the way out. A recipe id that is not a
624
- // slug, or an exit code that is not an integer, means somebody is passing something other than the
625
- // two facts `0014` allows — and this is the one place a report picks up anything it was not typed.
626
456
  const failedRecipe = flags["failed-recipe"];
627
457
  const failedExit = flags["failed-exit"];
628
458
  if (failedRecipe !== undefined && !SLUG.test(failedRecipe)) {
@@ -639,12 +469,6 @@ function draft(rest, flags, { say, warn, now }) {
639
469
  }
640
470
 
641
471
  const workspaceDir = path.resolve(flags.into ?? ".portulan");
642
- // ONLY `ENOENT` MEANS ABSENT, here as at the term list. Both of these were `existsSync`, which
643
- // answers false for `EACCES` — so an unreadable workspace was reported as *no workspace.json* and
644
- // an unreadable report as *no report*, each sending the reader to the wrong fix. Found by review on
645
- // this pull request, in the change whose own comments name the class for the seam scan two hundred
646
- // lines up: a rule enforced at one site of an operation and not its neighbour is `0020`'s shape,
647
- // committed inside the change that quotes `0020`.
648
472
  const manifest = path.join(workspaceDir, "workspace.json");
649
473
  const absent = whyAbsent(manifest);
650
474
  if (absent === "ENOENT") {
@@ -695,25 +519,11 @@ function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
695
519
 
696
520
  const parsed = parseReport(text);
697
521
 
698
- // Already filed. Refused before anything else, because the answer is the same whatever the rest of
699
- // the report says and because a duplicate is the failure a user notices in public.
700
522
  if (parsed.fields.issue) {
701
523
  warn(`feedback: this report was already filed — ${parsed.fields.issue}. Draft a new one to say something new.`);
702
524
  return 2;
703
525
  }
704
526
 
705
- // THE WORKSPACE IS DERIVED FROM THE REPORT'S PATH, AND A WRONG DERIVATION IS A SEAM FAIL-OPEN.
706
- //
707
- // `<workspace>/feedback/<report>` is where `draft` writes, so two levels up is the workspace. Move
708
- // the report anywhere else and the derivation still produces *a* directory — one with no
709
- // `seam-terms.txt` in it — and the scan then says **nothing was scanned** while a term list sits in
710
- // the workspace this report actually belongs to. A green that reports its own coverage is only
711
- // honest if the coverage sentence is about the right place, and this is the one path where being
712
- // wrong about it sends unscanned bytes into a public tracker.
713
- //
714
- // So the layout is checked rather than assumed, and a report outside it is could-not-run. Found by
715
- // review on this pull request; it is the seam's own fail-closed rule applied to the step that
716
- // decides *where the seam lives*, which the first cut assumed instead of establishing.
717
527
  const workspaceDir = path.dirname(path.dirname(at));
718
528
  if (path.basename(path.dirname(at)) !== "feedback") {
719
529
  warn(`feedback: ${at} is not inside a workspace's \`feedback/\` directory.`);
@@ -788,8 +598,6 @@ function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
788
598
  return 2;
789
599
  }
790
600
 
791
- // The approval is bound to bytes, not to an intention. See `digest` for why this is a refusal
792
- // rather than a warning: without it, "the exact payload the user saw first" is a habit.
793
601
  if (!parsed.fields.previewed) {
794
602
  warn("feedback: this report has not been previewed, so there are no bytes you have approved.");
795
603
  warn(`feedback: run \`portulan feedback preview ${at}\` and read them, then send.`);
@@ -815,11 +623,7 @@ function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
815
623
  const auth = exec("gh", ["auth", "status"]);
816
624
  if (auth.error || auth.status === null) {
817
625
  warn("feedback: `gh` was not found. This sender files under your own GitHub identity and operates no service of its own.");
818
- // NOT `--body-file <the report>`. That was the first draft's suggestion and it was wrong in the
819
- // one way that matters here: the report carries frontmatter, this tool's guidance comments and
820
- // the raw sections, none of which is the payload — so following it would have published bytes
821
- // nobody previewed, out of the verb whose whole subject is that they cannot be. Caught by
822
- // review on this pull request. The two honest routes are the tool and the form.
626
+ // Never `--body-file <the report>`: the report is not the payload, and would file bytes nobody previewed.
823
627
  warn("feedback: install it and run this again, or paste the body printed above into the form:");
824
628
  warn(`feedback: https://github.com/${built.repo}/issues/new/choose — title: ${built.title}`);
825
629
  warn(`feedback: the report stays at ${at}. Its payload is the bytes between the markers above, and nothing else in it is sent.`);
@@ -845,14 +649,7 @@ function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
845
649
  return 2;
846
650
  }
847
651
 
848
- // THE ONE WRITE THAT CANNOT BE A REFUSAL, because by now the issue exists in the world.
849
- //
850
- // `writeOrRefuse` would throw here and the top-level handler would print `feedback: …` and exit 2 —
851
- // *could not run*, about an operation that ran and filed. Worse, the sentence would send a reader
852
- // back to try again, and the guard that makes a second send a no-op is exactly what just failed to
853
- // land. So the send is reported as what it was, the missing guard is named, and the exit code says
854
- // something is wrong without pretending nothing happened: **1**, a verdict — not 0, which would
855
- // hide it, and not 2, which would deny the send. Found by review on this pull request.
652
+ // Not `writeOrRefuse`: the issue exists now, so a failed stamp exits 1, not 2, which would deny the send.
856
653
  try {
857
654
  fs.writeFileSync(at, stamp(text, { issue: url, sent: now().toISOString() }));
858
655
  } catch (error) {
@@ -870,12 +667,8 @@ function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
870
667
  return 0;
871
668
  }
872
669
 
873
- /** Every reason this report is not ready, in one pass — a user fixing one at a time is a user we lost. */
874
670
  function validate(parsed, declared) {
875
671
  const problems = [];
876
- // `draft` refuses an absent or unsluggable title, but a report is a file a human edits and the
877
- // frontmatter is part of it. Without this, a blanked `title:` files as a bare `[feedback] ` — a
878
- // public issue with no subject, from the one verb where the artifact is permanent.
879
672
  if (!(parsed.fields.title ?? "").trim()) {
880
673
  problems.push("`title:` is empty, and the issue's title is the form's prefix plus that line.");
881
674
  }
@@ -903,11 +696,7 @@ function validate(parsed, declared) {
903
696
  return problems;
904
697
  }
905
698
 
906
- /**
907
- * The workspace's spec version, and the ONE thing this tool reads out of a workspace. Soft rather than
908
- * fail-closed on purpose: it is a line in an environment block, not a gate, and refusing to send a
909
- * report because a manifest was unreadable would put the seam's discipline behind a decoration.
910
- */
699
+ /** The one fact the payload takes from a workspace: "unknown", never a refusal, when it cannot be read. */
911
700
  function readSpec(workspaceDir) {
912
701
  try {
913
702
  return JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8")).portulan?.spec ?? "unknown";
@@ -916,7 +705,6 @@ function readSpec(workspaceDir) {
916
705
  }
917
706
  }
918
707
 
919
- /** The most recent recorded send in this directory, for the cooldown. */
920
708
  function lastSend(workspaceDir, except) {
921
709
  const dir = path.join(workspaceDir, "feedback");
922
710
  let names;
@@ -943,20 +731,11 @@ function lastSend(workspaceDir, except) {
943
731
  return latest;
944
732
  }
945
733
 
946
- /**
947
- * Record something in the report's own frontmatter — the local artifact `0014` asks for.
948
- *
949
- * `replacing` names keys to drop first, so a second `preview` updates its digest rather than stacking
950
- * a second `previewed:` line that `parseReport` would silently resolve to the last one.
951
- */
952
734
  function stamp(text, additions, replacing = []) {
953
735
  const lines = text.split(/\r?\n/);
954
736
  const close = lines.indexOf("---", 1);
955
737
  if (close === -1) throw new FeedbackError("the report's frontmatter block is never closed");
956
738
 
957
- // Scoped to the frontmatter. Filtering the whole file would delete a body line that happened to
958
- // begin `previewed:` — a tool editing prose it was not asked to edit, which is worse than the
959
- // duplicate key it would be preventing.
960
739
  const front = lines.slice(1, close).filter((line) => {
961
740
  const key = /^([A-Za-z_][\w-]*)\s*:/.exec(line)?.[1];
962
741
  return !(key && replacing.includes(key));