@sleepy_panda_srl/portulan 0.1.3 → 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.
- package/README.md +6 -5
- package/cli/README.md +142 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1322 -1607
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +224 -1074
- package/cli/drills.mjs +68 -502
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +17 -258
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +27 -492
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +255 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +3 -3
- package/package.json +4 -2
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +333 -21
- 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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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));
|