@sleepy_panda_srl/portulan 0.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.
Files changed (73) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +5 -0
  3. package/README.md +87 -0
  4. package/cli/README.md +337 -0
  5. package/cli/compile.mjs +2209 -0
  6. package/cli/control-chars.mjs +570 -0
  7. package/cli/discover.mjs +985 -0
  8. package/cli/doctor.mjs +2803 -0
  9. package/cli/feedback.mjs +970 -0
  10. package/cli/gate.mjs +271 -0
  11. package/cli/index.mjs +1390 -0
  12. package/cli/init.mjs +1729 -0
  13. package/cli/librarian.mjs +1384 -0
  14. package/cli/manifest.mjs +59 -0
  15. package/cli/new.mjs +803 -0
  16. package/cli/pack-version.mjs +543 -0
  17. package/cli/plugin-lint.mjs +1566 -0
  18. package/cli/portulan.mjs +266 -0
  19. package/cli/recipe-set.mjs +425 -0
  20. package/cli/rule-carriers.mjs +489 -0
  21. package/cli/skills-set.mjs +715 -0
  22. package/cli/stop-gate.mjs +434 -0
  23. package/cli/upgrade.mjs +897 -0
  24. package/cli/vendor.mjs +1504 -0
  25. package/core/README.md +46 -0
  26. package/core/engine.md +47 -0
  27. package/core/operating/autonomy.md +100 -0
  28. package/core/operating/evolution.md +84 -0
  29. package/core/operating/loop.md +83 -0
  30. package/core/operating/memory.md +161 -0
  31. package/core/operating/safety.md +42 -0
  32. package/core/operating/verification.md +76 -0
  33. package/core/personas/README.md +40 -0
  34. package/core/personas/implementer.md +51 -0
  35. package/core/personas/librarian.md +73 -0
  36. package/core/personas/reviewer.md +52 -0
  37. package/core/skills/README.md +52 -0
  38. package/core/skills/clarify/SKILL.md +42 -0
  39. package/core/skills/codify/SKILL.md +65 -0
  40. package/core/skills/consolidate/SKILL.md +121 -0
  41. package/core/templates/gate-policy.md +76 -0
  42. package/core/templates/handoff.md +29 -0
  43. package/core/templates/memory-entry.md +44 -0
  44. package/core/templates/pack.md +94 -0
  45. package/core/templates/persona.md +69 -0
  46. package/core/templates/proposal.md +27 -0
  47. package/core/templates/repo-card.md +28 -0
  48. package/core/templates/skill.md +57 -0
  49. package/core/templates/task.md +26 -0
  50. package/core/templates/workspace.md +81 -0
  51. package/package.json +41 -0
  52. package/packs/.claude-plugin/plugin.json +22 -0
  53. package/packs/README.md +61 -0
  54. package/packs/rituals/README.md +13 -0
  55. package/packs/rituals/checkpoints/README.md +105 -0
  56. package/packs/rituals/checkpoints/pack.json +36 -0
  57. package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
  58. package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
  59. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
  60. package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
  61. package/packs/stacks/README.md +8 -0
  62. package/packs/tools/README.md +17 -0
  63. package/packs/tools/github/README.md +65 -0
  64. package/packs/tools/github/pack.json +29 -0
  65. package/packs/tools/github/verify/README.md +51 -0
  66. package/packs/tools/github/verify/actions-pinned.sh +132 -0
  67. package/spec/README.md +399 -0
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
  70. package/spec/migrations/README.md +95 -0
  71. package/spec/pack.schema.json +201 -0
  72. package/spec/slots.md +796 -0
  73. package/spec/workspace.schema.json +438 -0
@@ -0,0 +1,970 @@
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.
5
+ //
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.
57
+
58
+ import crypto from "node:crypto";
59
+ import fs from "node:fs";
60
+ import os from "node:os";
61
+ import path from "node:path";
62
+ import { spawnSync } from "node:child_process";
63
+ import { pathToFileURL } from "node:url";
64
+
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.
67
+ import { VERSION, REPOSITORY } from "./manifest.mjs";
68
+
69
+ export class FeedbackError extends Error {}
70
+
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
+ export const COOLDOWN_SECONDS = 60;
78
+
79
+ // ===========================================================================================
80
+ // The forms
81
+ // ===========================================================================================
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.
93
+ export const FORMS = [
94
+ {
95
+ kind: "bug",
96
+ file: "bug.yml",
97
+ titlePrefix: "[bug] ",
98
+ acknowledgementsLabel: "Before you file",
99
+ sections: [
100
+ { id: "what", label: "What happened", required: true },
101
+ { id: "expected", label: "What you expected, and where Portulan says so", required: true },
102
+ { id: "reproduce", label: "How to reproduce", required: false, render: "shell" },
103
+ { id: "version", label: "Version or commit", required: false, filled: "version" },
104
+ { id: "environment", label: "Environment", required: false, filled: "environment" },
105
+ ],
106
+ acknowledgements: [
107
+ { text: "I searched existing issues and this is not already reported.", required: true },
108
+ {
109
+ text:
110
+ "I understand that Portulan does not accept outside pull requests, and that this report is " +
111
+ "the contribution — not a lesser path to one.",
112
+ required: true,
113
+ },
114
+ ],
115
+ },
116
+ {
117
+ kind: "improvement",
118
+ file: "improvement.yml",
119
+ titlePrefix: "[improvement] ",
120
+ acknowledgementsLabel: "Before you file",
121
+ sections: [
122
+ { id: "problem", label: "The problem", required: true },
123
+ { id: "proposal", label: "What you propose", required: true },
124
+ { id: "enforcement", label: "How it would earn its place", required: true },
125
+ { id: "cost", label: "What it costs, and what it would break", required: false },
126
+ {
127
+ id: "area",
128
+ label: "Which part of Portulan",
129
+ required: true,
130
+ options: [
131
+ "doctrine — core/: the loop, autonomy, verification, memory, evolution, safety",
132
+ "mechanism — cli/, spec/, the plugin: the code that checks and enforces",
133
+ "workspace — .portulan/: the policy layer and the gate map",
134
+ "packaging — the plugin, the marketplace, install and boot",
135
+ "documentation — README, the plan, the examples",
136
+ "not sure",
137
+ ],
138
+ },
139
+ { id: "environment", label: "Environment", required: false, filled: "environment" },
140
+ ],
141
+ acknowledgements: [
142
+ { text: "I searched existing issues and proposals, and this is not already open.", required: true },
143
+ { text: "I have read `docs/vision.md` and this does not contradict a stated non-goal.", required: false },
144
+ ],
145
+ },
146
+ {
147
+ kind: "feedback",
148
+ file: "feedback.yml",
149
+ titlePrefix: "[feedback] ",
150
+ acknowledgementsLabel: "Before you file",
151
+ sections: [
152
+ {
153
+ id: "kind",
154
+ label: "What kind of feedback",
155
+ required: true,
156
+ options: [
157
+ "Something was confusing or hard to follow",
158
+ "Something did not survive contact with my repository",
159
+ "I expected to find something and did not",
160
+ "A disagreement with a decision or a non-goal",
161
+ "Something worked well and is worth keeping",
162
+ "Other",
163
+ ],
164
+ },
165
+ { id: "feedback", label: "The feedback", required: true },
166
+ { id: "context", label: "What you were trying to do", required: false },
167
+ { id: "environment", label: "Environment", required: false, filled: "environment" },
168
+ ],
169
+ acknowledgements: [
170
+ {
171
+ text:
172
+ "I understand this issue is a permanent record that everyone who can read this repository " +
173
+ "sees, and I have not included anything confidential — my own, my employer's, or anyone " +
174
+ "else's.",
175
+ required: true,
176
+ },
177
+ ],
178
+ },
179
+ ];
180
+
181
+ export function form(kind) {
182
+ return FORMS.find((entry) => entry.kind === kind) ?? null;
183
+ }
184
+
185
+ /** The repository issues are filed into — `manifest.mjs` derives it from the `bugs.url` we publish. */
186
+ export function defaultRepository() {
187
+ return REPOSITORY;
188
+ }
189
+
190
+ // ===========================================================================================
191
+ // The report file
192
+ // ===========================================================================================
193
+
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
+ export function slug(title) {
199
+ return String(title)
200
+ .toLowerCase()
201
+ .replace(/[^a-z0-9]+/g, "-")
202
+ .replace(/^-+|-+$/g, "");
203
+ }
204
+
205
+ /** The scaffolded report — a file the user edits, which is the whole point of the file-first design. */
206
+ export function scaffold(kind, { title, created, failedRecipe, failedExit } = {}) {
207
+ const declared = form(kind);
208
+ if (!declared) throw new FeedbackError(`\`${kind}\` is not a form. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
209
+
210
+ const front = ["---", `kind: ${kind}`, `title: ${title}`, `created: ${created}`];
211
+ if (failedRecipe !== undefined) front.push(`failed-recipe: ${failedRecipe}`);
212
+ if (failedExit !== undefined) front.push(`failed-exit: ${failedExit}`);
213
+ front.push("---");
214
+
215
+ const parts = [
216
+ front.join("\n"),
217
+ "<!-- Fill the sections below, tick the acknowledgements, then:\n" +
218
+ " portulan feedback preview <this file>\n" +
219
+ " portulan feedback send <this file> --approve\n" +
220
+ " Everything inside an HTML comment is stripped before anything is sent. -->",
221
+ ];
222
+
223
+ for (const section of declared.sections) {
224
+ if (section.filled) continue; // this tool answers it; a user editing it would be edited over
225
+ parts.push(`### ${section.label}`);
226
+ const guidance = section.options
227
+ ? "<!-- Keep exactly ONE of the lines below and delete the rest. -->\n" + section.options.join("\n")
228
+ : `<!-- ${section.required ? "Required." : "Optional — leave it empty and it files as _No response_."} -->`;
229
+ parts.push(guidance);
230
+ }
231
+
232
+ parts.push(`### ${declared.acknowledgementsLabel}`);
233
+ parts.push(declared.acknowledgements.map((ack) => `- [ ] ${ack.text}`).join("\n"));
234
+
235
+ return `${parts.join("\n\n")}\n`;
236
+ }
237
+
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
+ export function parseReport(text) {
247
+ const lines = text.split(/\r?\n/);
248
+ if (lines[0]?.trim() !== "---") throw new FeedbackError("the report has no frontmatter block");
249
+ const close = lines.indexOf("---", 1);
250
+ if (close === -1) throw new FeedbackError("the report's frontmatter block is never closed");
251
+
252
+ const fields = {};
253
+ for (const line of lines.slice(1, close)) {
254
+ const match = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
255
+ if (match) fields[match[1]] = match[2].trim();
256
+ }
257
+
258
+ const declared = form(fields.kind);
259
+ if (!declared) {
260
+ throw new FeedbackError(
261
+ `the report's \`kind\` is \`${fields.kind ?? "(absent)"}\`, which is not a form. The three are: ` +
262
+ FORMS.map((f) => f.kind).join(" · "),
263
+ );
264
+ }
265
+
266
+ const sections = [];
267
+ const acknowledgements = [];
268
+ let current = null;
269
+ for (const line of lines.slice(close + 1)) {
270
+ const heading = /^###\s+(.*)$/.exec(line);
271
+ if (heading) {
272
+ current = { label: heading[1].trim(), lines: [] };
273
+ if (current.label === declared.acknowledgementsLabel) current.acks = true;
274
+ else sections.push(current);
275
+ continue;
276
+ }
277
+ if (!current) continue;
278
+ if (current.acks) {
279
+ const box = /^-\s+\[([ xX])\]\s+(.*)$/.exec(line);
280
+ if (box) acknowledgements.push({ text: box[2].trim(), ticked: box[1] !== " " });
281
+ continue;
282
+ }
283
+ current.lines.push(line);
284
+ }
285
+
286
+ for (const section of sections) {
287
+ section.text = stripComments(section.lines.join("\n")).trim();
288
+ delete section.lines;
289
+ }
290
+ return { fields, sections, acknowledgements };
291
+ }
292
+
293
+ /** HTML comments carry this tool's guidance to the author; none of it belongs in a public issue. */
294
+ function stripComments(text) {
295
+ return text.replace(/<!--[\s\S]*?-->/g, "");
296
+ }
297
+
298
+ // ===========================================================================================
299
+ // The payload
300
+ // ===========================================================================================
301
+
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
+ */
312
+ export function payload(report, context = {}) {
313
+ const declared = form(report.fields.kind);
314
+ if (!declared) throw new FeedbackError(`\`${report.fields.kind}\` is not a form`);
315
+
316
+ const title = `${declared.titlePrefix}${report.fields.title ?? ""}`;
317
+ const claimed = new Set(declared.sections.filter((s) => s.filled).map((s) => s.filled));
318
+ const parts = [{ label: "the title", text: title }];
319
+
320
+ for (const section of declared.sections) {
321
+ let value;
322
+ if (section.filled === "version") value = VERSION;
323
+ else if (section.filled === "environment") value = environmentBlock(report, context, claimed);
324
+ else value = report.sections.find((s) => s.label === section.label)?.text ?? "";
325
+
326
+ if (value && section.render === "shell") value = `\`\`\`shell\n${value}\n\`\`\``;
327
+ parts.push({ label: section.label, text: value || "_No response_" });
328
+ }
329
+
330
+ const ticks = declared.acknowledgements
331
+ .map((ack) => {
332
+ const said = report.acknowledgements.find((a) => a.text === ack.text);
333
+ return `- [${said?.ticked ? "X" : " "}] ${ack.text}`;
334
+ })
335
+ .join("\n");
336
+ parts.push({ label: declared.acknowledgementsLabel, text: ticks });
337
+
338
+ const body = parts
339
+ .filter((part) => part.label !== "the title")
340
+ .map((part) => `### ${part.label}\n\n${part.text}`)
341
+ .join("\n\n");
342
+
343
+ return { repo: context.repo ?? defaultRepository(), title, body, parts };
344
+ }
345
+
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
+ */
353
+ function environmentBlock(report, context, claimed = new Set()) {
354
+ const facts = context.facts ?? {};
355
+ const lines = [];
356
+ if (!claimed.has("version")) lines.push(`Portulan: ${VERSION}`);
357
+ lines.push(`Workspace Definition: ${context.spec ?? "unknown"}`);
358
+ lines.push(
359
+ `Host: ${context.host ?? "not observable from the command line — re-run with --host to name it"}`,
360
+ );
361
+ 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
+ lines.push(`System: ${facts.platform ?? process.platform} ${facts.release ?? os.release()} ${facts.arch ?? process.arch}`);
367
+ if (report.fields["failed-recipe"]) {
368
+ lines.push(`Failing recipe: ${report.fields["failed-recipe"]}, exit ${report.fields["failed-exit"] ?? "unrecorded"}`);
369
+ }
370
+ return lines.join("\n");
371
+ }
372
+
373
+ // ===========================================================================================
374
+ // The seam
375
+ // ===========================================================================================
376
+
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
+ */
387
+ export function locateTerms({ flag, env = {}, workspaceDir } = {}) {
388
+ if (flag) return { source: `--seam-terms ${flag}`, terms: read(flag, `--seam-terms ${flag}`) };
389
+
390
+ const named = env.PORTULAN_SEAM_TERMS;
391
+ if (named) return { source: `PORTULAN_SEAM_TERMS (${named})`, terms: read(named, `PORTULAN_SEAM_TERMS (${named})`) };
392
+
393
+ if (!workspaceDir) return { source: null, terms: null };
394
+ const at = path.join(workspaceDir, "seam-terms.txt");
395
+ try {
396
+ return { source: at, terms: parseTerms(fs.readFileSync(at, "utf8")) };
397
+ } catch (error) {
398
+ if (error.code === "ENOENT") return { source: null, terms: null };
399
+ throw new FeedbackError(`the term list at ${at} could not be read — ${error.code ?? error.message}`);
400
+ }
401
+
402
+ function read(at, described) {
403
+ try {
404
+ return parseTerms(fs.readFileSync(at, "utf8"));
405
+ } catch (error) {
406
+ throw new FeedbackError(`the term list named by ${described} could not be read — ${error.code ?? error.message}`);
407
+ }
408
+ }
409
+ }
410
+
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
+ */
430
+ function whyAbsent(at) {
431
+ try {
432
+ fs.lstatSync(at);
433
+ return null;
434
+ } catch (error) {
435
+ return error.code ?? "unreadable";
436
+ }
437
+ }
438
+
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
+ function writeOrRefuse(at, contents, what) {
449
+ try {
450
+ fs.mkdirSync(path.dirname(at), { recursive: true });
451
+ fs.writeFileSync(at, contents);
452
+ } catch (error) {
453
+ throw new FeedbackError(`${what} could not be written to ${at} — ${error.code ?? error.message}`);
454
+ }
455
+ }
456
+
457
+ function parseTerms(text) {
458
+ return text
459
+ .split(/\r?\n/)
460
+ .map((line) => line.trim())
461
+ .filter((line) => line && !line.startsWith("#"))
462
+ .map((line) => line.toLowerCase());
463
+ }
464
+
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
+ */
482
+ export function digest(built) {
483
+ return crypto.createHash("sha256").update(`${built.title}\n${built.body}`, "utf8").digest("hex");
484
+ }
485
+
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
+ */
494
+ export function scan(parts, terms) {
495
+ const hits = [];
496
+ for (const part of parts) {
497
+ const haystack = String(part.text).toLowerCase();
498
+ for (const term of terms) {
499
+ if (haystack.includes(term)) hits.push({ term, section: part.label });
500
+ }
501
+ }
502
+ return hits;
503
+ }
504
+
505
+ // ===========================================================================================
506
+ // The verbs
507
+ // ===========================================================================================
508
+
509
+ const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
510
+
511
+ export function usage() {
512
+ return [
513
+ "portulan feedback — file an issue from a report you previewed",
514
+ "",
515
+ " portulan feedback draft <kind> --title <text> [--into <workspace-dir>]",
516
+ " [--failed-recipe <id> --failed-exit <n>]",
517
+ " portulan feedback preview <report> [--seam-terms <file>] [--host <name>]",
518
+ " portulan feedback send <report> --approve [--seam-terms <file>] [--host <name>] [--repo <owner/name>]",
519
+ "",
520
+ ` kinds: ${FORMS.map((f) => f.kind).join(" · ")}`,
521
+ "",
522
+ "The report is a file before it is a request: `draft` writes one into the workspace's",
523
+ "`feedback/` directory, you edit it, `preview` shows the exact bytes, and `send` files them",
524
+ "through your own `gh` login. Approval is per send and is never inherited — and `send` refuses",
525
+ "any report whose payload does not match the one `preview` last showed you, so the bytes you",
526
+ "approved are the bytes that leave.",
527
+ "",
528
+ "The seam term list is yours and this tool ships none. It is looked for at --seam-terms, then",
529
+ "$PORTULAN_SEAM_TERMS, then <workspace-dir>/seam-terms.txt. A hit refuses the send (exit 1); a",
530
+ "list that is named and unreadable refuses it too (exit 2); no list at all is reported in the",
531
+ "preview rather than passed over in silence.",
532
+ "",
533
+ "Exit codes: 0 succeeded · 1 the scan refused it · 2 could not run.",
534
+ ].join("\n");
535
+ }
536
+
537
+ export function parseArgs(argv) {
538
+ const parsed = { rest: [], flags: {} };
539
+ for (let i = 0; i < argv.length; i += 1) {
540
+ const arg = argv[i];
541
+ if (arg === "--help" || arg === "-h") parsed.flags.help = true;
542
+ else if (arg === "--approve") parsed.flags.approve = true;
543
+ else if (arg.startsWith("--")) {
544
+ const key = arg.slice(2);
545
+ const value = argv[i + 1];
546
+ if (value === undefined || value.startsWith("--")) throw new FeedbackError(`\`--${key}\` needs a value`);
547
+ parsed.flags[key] = value;
548
+ i += 1;
549
+ } else parsed.rest.push(arg);
550
+ }
551
+ return parsed;
552
+ }
553
+
554
+ export function run(argv, options = {}) {
555
+ const say = options.say ?? ((line = "") => process.stdout.write(`${line}\n`));
556
+ const warn = options.warn ?? ((line = "") => process.stderr.write(`${line}\n`));
557
+ const now = options.now ?? (() => new Date());
558
+ const env = options.env ?? process.env;
559
+ const exec =
560
+ options.exec ??
561
+ ((cmd, args, spawn = {}) => {
562
+ const result = spawnSync(cmd, args, { input: spawn.input, encoding: "utf8" });
563
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", error: result.error };
564
+ });
565
+
566
+ try {
567
+ const parsed = parseArgs(argv);
568
+ const [verb, ...rest] = parsed.rest;
569
+ if (parsed.flags.help) {
570
+ say(usage());
571
+ return 0;
572
+ }
573
+ if (!verb) {
574
+ warn("feedback: no verb. Run `portulan feedback --help`.");
575
+ return 2;
576
+ }
577
+
578
+ if (verb === "draft") return draft(rest, parsed.flags, { say, warn, now });
579
+ if (verb === "preview" || verb === "send") {
580
+ return report(verb, rest, parsed.flags, { say, warn, now, env, exec, facts: options.facts });
581
+ }
582
+
583
+ warn(`feedback: \`${verb}\` is not a verb. The three are: draft · preview · send.`);
584
+ return 2;
585
+ } catch (error) {
586
+ warn(`feedback: ${error instanceof FeedbackError ? error.message : `unanticipated failure — ${error.stack ?? error}`}`);
587
+ return 2;
588
+ }
589
+ }
590
+
591
+ function draft(rest, flags, { say, warn, now }) {
592
+ const [kind] = rest;
593
+ if (!kind) {
594
+ warn(`feedback: draft needs a kind. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
595
+ return 2;
596
+ }
597
+ if (!form(kind)) {
598
+ warn(`feedback: \`${kind}\` is not a form. The three are: ${FORMS.map((f) => f.kind).join(" · ")}`);
599
+ return 2;
600
+ }
601
+ const title = flags.title;
602
+ if (!title) {
603
+ warn("feedback: draft needs a --title. It becomes the issue's title and the report's filename.");
604
+ return 2;
605
+ }
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
+ if (/[\r\n]/.test(title)) {
613
+ warn("feedback: a title is one line. This one carries a line break, and frontmatter is one key per line —");
614
+ warn("feedback: everything after the break would be read as another key, or as the body.");
615
+ return 2;
616
+ }
617
+ const name = slug(title);
618
+ if (!name) {
619
+ warn(`feedback: \`${title}\` yields no slug — a title needs letters or digits, since it names the file too.`);
620
+ return 2;
621
+ }
622
+
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
+ const failedRecipe = flags["failed-recipe"];
627
+ const failedExit = flags["failed-exit"];
628
+ if (failedRecipe !== undefined && !SLUG.test(failedRecipe)) {
629
+ warn(`feedback: \`${failedRecipe}\` is not a recipe id. Ids here are lowercase, digits and single hyphens.`);
630
+ return 2;
631
+ }
632
+ if (failedExit !== undefined && !/^-?\d+$/.test(failedExit)) {
633
+ warn(`feedback: \`${failedExit}\` is not an exit code. Only the recipe's id and its integer code travel — never its output.`);
634
+ return 2;
635
+ }
636
+ if ((failedRecipe === undefined) !== (failedExit === undefined)) {
637
+ warn("feedback: --failed-recipe and --failed-exit are one fact and travel together.");
638
+ return 2;
639
+ }
640
+
641
+ 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
+ const manifest = path.join(workspaceDir, "workspace.json");
649
+ const absent = whyAbsent(manifest);
650
+ if (absent === "ENOENT") {
651
+ warn(`feedback: ${workspaceDir} does not look like a workspace — no workspace.json. Point --into at one.`);
652
+ return 2;
653
+ }
654
+ if (absent) {
655
+ warn(`feedback: ${manifest} could not be read — ${absent}. That is not the same as absent, so this refuses rather than drafting elsewhere.`);
656
+ return 2;
657
+ }
658
+
659
+ const created = now().toISOString().slice(0, 10);
660
+ const at = path.join(workspaceDir, "feedback", `${created}-${name}.md`);
661
+ const free = whyAbsent(at);
662
+ if (!free) {
663
+ warn(`feedback: ${at} already exists. A report is a file you edit — open that one, or pick another title.`);
664
+ return 2;
665
+ }
666
+ if (free !== "ENOENT") {
667
+ warn(`feedback: ${at} could not be read — ${free}. Refusing to write over a file this cannot see.`);
668
+ return 2;
669
+ }
670
+
671
+ writeOrRefuse(at, scaffold(kind, { title, created, failedRecipe, failedExit }), "the report");
672
+
673
+ say(`feedback: wrote ${at}`);
674
+ say("");
675
+ say("Edit it, tick the acknowledgements, then:");
676
+ say(` portulan feedback preview ${at}`);
677
+ say(` portulan feedback send ${at} --approve`);
678
+ return 0;
679
+ }
680
+
681
+ function report(verb, rest, flags, { say, warn, now, env, exec, facts }) {
682
+ const [target] = rest;
683
+ if (!target) {
684
+ warn(`feedback: ${verb} needs a report file — the one \`draft\` wrote.`);
685
+ return 2;
686
+ }
687
+ const at = path.resolve(target);
688
+ let text;
689
+ try {
690
+ text = fs.readFileSync(at, "utf8");
691
+ } catch (error) {
692
+ warn(`feedback: ${at} could not be read — ${error.code ?? error.message}`);
693
+ return 2;
694
+ }
695
+
696
+ const parsed = parseReport(text);
697
+
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
+ if (parsed.fields.issue) {
701
+ warn(`feedback: this report was already filed — ${parsed.fields.issue}. Draft a new one to say something new.`);
702
+ return 2;
703
+ }
704
+
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
+ const workspaceDir = path.dirname(path.dirname(at));
718
+ if (path.basename(path.dirname(at)) !== "feedback") {
719
+ warn(`feedback: ${at} is not inside a workspace's \`feedback/\` directory.`);
720
+ warn("feedback: this tool locates the workspace — and therefore the seam term list — from the report's path,");
721
+ warn("feedback: so a report kept elsewhere would be scanned against the wrong directory, or against nothing.");
722
+ return 2;
723
+ }
724
+ const governing = whyAbsent(path.join(workspaceDir, "workspace.json"));
725
+ if (governing) {
726
+ warn(`feedback: ${path.join(workspaceDir, "workspace.json")} — ${governing}.`);
727
+ warn("feedback: the report's parent workspace has to be there before this can say which seam applies.");
728
+ return 2;
729
+ }
730
+
731
+ const context = {
732
+ spec: readSpec(workspaceDir),
733
+ host: flags.host,
734
+ repo: flags.repo ?? defaultRepository(),
735
+ facts,
736
+ };
737
+
738
+ const declared = form(parsed.fields.kind);
739
+ const problems = validate(parsed, declared);
740
+ if (problems.length) {
741
+ for (const problem of problems) warn(`feedback: ${problem}`);
742
+ return 2;
743
+ }
744
+
745
+ const built = payload(parsed, context);
746
+ if (!built.repo) {
747
+ warn("feedback: no repository to file into — package.json carries no `bugs.url`. Pass --repo <owner/name>.");
748
+ return 2;
749
+ }
750
+
751
+ const found = locateTerms({ flag: flags["seam-terms"], env, workspaceDir });
752
+ let coverage;
753
+ if (found.terms === null) {
754
+ coverage =
755
+ "seam: no term list configured — NOTHING WAS SCANNED. Looked at --seam-terms, " +
756
+ "$PORTULAN_SEAM_TERMS, and " + path.join(workspaceDir, "seam-terms.txt") + ".";
757
+ } else {
758
+ const hits = scan(built.parts, found.terms);
759
+ if (hits.length) {
760
+ warn(`feedback: the seam scan refused this send — ${found.source}`);
761
+ for (const hit of hits) warn(`feedback: \`${hit.term}\` appears in ${hit.section}`);
762
+ warn("feedback: nothing was sent. Edit the report and run it again.");
763
+ return 1;
764
+ }
765
+ coverage = `seam: clean against ${found.terms.length} term(s) from ${found.source}.`;
766
+ }
767
+
768
+ say(`portulan feedback — the payload, exactly as it would be filed`);
769
+ say(`repository: ${built.repo}`);
770
+ say(`title: ${built.title}`);
771
+ say("labels: none — the repository owns its own labels (proposal 0014); the title prefix marks the kind");
772
+ say(`--- body (${Buffer.byteLength(built.body, "utf8")} bytes) ---`);
773
+ say(built.body);
774
+ say("--- end of body ---");
775
+ say(coverage);
776
+
777
+ const seen = digest(built);
778
+ if (verb === "preview") {
779
+ writeOrRefuse(at, stamp(text, { previewed: seen }, ["previewed"]), "the preview stamp");
780
+ say(`previewed: ${seen}`);
781
+ return 0;
782
+ }
783
+
784
+ if (!flags.approve) {
785
+ say("");
786
+ warn("feedback: nothing was sent. Sending is Gated — per action, never inherited from a draft or a preview.");
787
+ warn(`feedback: re-run with --approve when the bytes above are the bytes you mean to publish.`);
788
+ return 2;
789
+ }
790
+
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
+ if (!parsed.fields.previewed) {
794
+ warn("feedback: this report has not been previewed, so there are no bytes you have approved.");
795
+ warn(`feedback: run \`portulan feedback preview ${at}\` and read them, then send.`);
796
+ return 2;
797
+ }
798
+ if (parsed.fields.previewed !== seen) {
799
+ warn("feedback: the payload has changed since it was previewed — the report, or a fact about this machine.");
800
+ warn(`feedback: previewed ${parsed.fields.previewed}`);
801
+ warn(`feedback: now ${seen}`);
802
+ warn("feedback: nothing was sent. Preview again and read what changed.");
803
+ return 2;
804
+ }
805
+
806
+ const recent = lastSend(workspaceDir, at);
807
+ if (recent && (now().getTime() - recent.at) / 1000 < COOLDOWN_SECONDS) {
808
+ warn(
809
+ `feedback: a report was filed from this directory ${Math.round((now().getTime() - recent.at) / 1000)}s ago ` +
810
+ `(${recent.issue}). Wait out the ${COOLDOWN_SECONDS}s cooldown — it catches a loop, not a person.`,
811
+ );
812
+ return 2;
813
+ }
814
+
815
+ const auth = exec("gh", ["auth", "status"]);
816
+ if (auth.error || auth.status === null) {
817
+ 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.
823
+ warn("feedback: install it and run this again, or paste the body printed above into the form:");
824
+ warn(`feedback: https://github.com/${built.repo}/issues/new/choose — title: ${built.title}`);
825
+ warn(`feedback: the report stays at ${at}. Its payload is the bytes between the markers above, and nothing else in it is sent.`);
826
+ return 2;
827
+ }
828
+ if (auth.status !== 0) {
829
+ warn("feedback: `gh` is installed but not logged in. Run `gh auth login`, then run this again.");
830
+ return 2;
831
+ }
832
+
833
+ const created = exec("gh", ["issue", "create", "--repo", built.repo, "--title", built.title, "--body-file", "-"], {
834
+ input: built.body,
835
+ });
836
+ if (created.error || created.status !== 0) {
837
+ warn(`feedback: \`gh issue create\` failed — ${(created.stderr || created.error?.message || "").trim() || `exit ${created.status}`}`);
838
+ warn(`feedback: nothing was recorded. The report is still at ${at}.`);
839
+ return 2;
840
+ }
841
+
842
+ const url = (created.stdout.match(/https:\/\/\S+/g) ?? []).pop();
843
+ if (!url) {
844
+ warn("feedback: `gh` succeeded and printed no issue URL — refusing to record a send this cannot name.");
845
+ return 2;
846
+ }
847
+
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.
856
+ try {
857
+ fs.writeFileSync(at, stamp(text, { issue: url, sent: now().toISOString() }));
858
+ } catch (error) {
859
+ say("");
860
+ say(`feedback: FILED ${url} — the issue exists.`);
861
+ warn(`feedback: but ${at} could not be updated — ${error.code ?? error.message}.`);
862
+ warn("feedback: nothing now stops a second send of this report from filing a duplicate.");
863
+ warn(`feedback: add this line to its frontmatter by hand, and the guard is back: issue: ${url}`);
864
+ return 1;
865
+ }
866
+
867
+ say("");
868
+ say(`feedback: filed ${url}`);
869
+ say(`feedback: recorded in ${at} — a second send of this report is refused.`);
870
+ return 0;
871
+ }
872
+
873
+ /** Every reason this report is not ready, in one pass — a user fixing one at a time is a user we lost. */
874
+ function validate(parsed, declared) {
875
+ 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
+ if (!(parsed.fields.title ?? "").trim()) {
880
+ problems.push("`title:` is empty, and the issue's title is the form's prefix plus that line.");
881
+ }
882
+ for (const section of declared.sections) {
883
+ if (section.filled) continue;
884
+ const text = parsed.sections.find((s) => s.label === section.label)?.text ?? "";
885
+ if (section.options) {
886
+ const chosen = text.split("\n").map((line) => line.trim()).filter(Boolean);
887
+ if (chosen.length !== 1 || !section.options.includes(chosen[0])) {
888
+ problems.push(
889
+ `\`${section.label}\` must hold exactly one of these lines and nothing else:\n` +
890
+ section.options.map((option) => ` ${option}`).join("\n"),
891
+ );
892
+ }
893
+ continue;
894
+ }
895
+ if (section.required && !text) problems.push(`\`${section.label}\` is required and is empty.`);
896
+ }
897
+ for (const ack of declared.acknowledgements) {
898
+ if (!ack.required) continue;
899
+ const said = parsed.acknowledgements.find((a) => a.text === ack.text);
900
+ if (!said) problems.push(`the acknowledgement "${ack.text}" is missing from the report.`);
901
+ else if (!said.ticked) problems.push(`the acknowledgement "${ack.text}" is not ticked.`);
902
+ }
903
+ return problems;
904
+ }
905
+
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
+ */
911
+ function readSpec(workspaceDir) {
912
+ try {
913
+ return JSON.parse(fs.readFileSync(path.join(workspaceDir, "workspace.json"), "utf8")).portulan?.spec ?? "unknown";
914
+ } catch {
915
+ return "unknown";
916
+ }
917
+ }
918
+
919
+ /** The most recent recorded send in this directory, for the cooldown. */
920
+ function lastSend(workspaceDir, except) {
921
+ const dir = path.join(workspaceDir, "feedback");
922
+ let names;
923
+ try {
924
+ names = fs.readdirSync(dir);
925
+ } catch {
926
+ return null;
927
+ }
928
+ let latest = null;
929
+ for (const name of names) {
930
+ const at = path.join(dir, name);
931
+ if (at === except || !name.endsWith(".md")) continue;
932
+ let fields;
933
+ try {
934
+ fields = parseReport(fs.readFileSync(at, "utf8")).fields;
935
+ } catch {
936
+ continue;
937
+ }
938
+ if (!fields.sent) continue;
939
+ const when = Date.parse(fields.sent);
940
+ if (Number.isNaN(when)) continue;
941
+ if (!latest || when > latest.at) latest = { at: when, issue: fields.issue ?? at };
942
+ }
943
+ return latest;
944
+ }
945
+
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
+ function stamp(text, additions, replacing = []) {
953
+ const lines = text.split(/\r?\n/);
954
+ const close = lines.indexOf("---", 1);
955
+ if (close === -1) throw new FeedbackError("the report's frontmatter block is never closed");
956
+
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
+ const front = lines.slice(1, close).filter((line) => {
961
+ const key = /^([A-Za-z_][\w-]*)\s*:/.exec(line)?.[1];
962
+ return !(key && replacing.includes(key));
963
+ });
964
+ const added = Object.entries(additions).map(([key, value]) => `${key}: ${value}`);
965
+ return [lines[0], ...front, ...added, ...lines.slice(close)].join("\n");
966
+ }
967
+
968
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
969
+ process.exitCode = run(process.argv.slice(2));
970
+ }