@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/init.mjs
CHANGED
|
@@ -1,108 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `init` — the subcommand that drafts a workspace for a repository that has none.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// is the design. This tool has **no default residence**: the one thing the row says it asks is the one
|
|
7
|
-
// thing it may not decide, and the wrong guess is the dual-management shape
|
|
8
|
-
// `../.portulan/proposals/0017-one-repository-one-governing-workspace.md` exists to refuse.
|
|
9
|
-
//
|
|
10
|
-
// ## What it writes, and what it refuses to write
|
|
11
|
-
//
|
|
12
|
-
// It drafts. `docs/vision.md` carries *"No auto-generated curated context. `init` drafts; humans
|
|
13
|
-
// accept"* as a binding non-goal, so every file here is a starting point with the questions left
|
|
14
|
-
// visible in it — never a filled-in workspace that reads as finished. The two hard refusals:
|
|
15
|
-
//
|
|
16
|
-
// **It never overwrites a residence.** A repository already carrying a workspace or a pointer is
|
|
17
|
-
// governed, and replacing that is not onboarding — it is the switch, which `cli/vendor.mjs` carries as
|
|
18
|
-
// of 2026-08-03, when the maintainer widened `vendor`'s gloss and settled the verb he had deliberately
|
|
19
|
-
// left unassigned on 2026-07-31. So `init` stops, says which residence it found, and names the tool
|
|
20
|
-
// whose job the switch is.
|
|
21
|
-
//
|
|
22
|
-
// **It refuses rather than emitting a manifest a validator would misread.** A governor or a name that
|
|
23
|
-
// is not a slug is caught here, at the boundary, because `doctor`'s cross-repository check still
|
|
24
|
-
// mis-reports an empty governor as a *conflicting* one
|
|
25
|
-
// ([#141](https://github.com/sleepy-panda-srl/portulan/issues/141)). That bug is `doctor`'s to fix;
|
|
26
|
-
// what this tool owes is that it can never be the thing that produced the input.
|
|
27
|
-
//
|
|
28
|
-
// ## Two capabilities this draft deliberately does not claim
|
|
29
|
-
//
|
|
30
|
-
// **The session-end gate is drafted as a binding, not as a wire — and the reason has CHANGED, so the
|
|
31
|
-
// paragraph is rewritten rather than left standing.** It used to be that `cli/compile.mjs` emitted a
|
|
32
|
-
// `Stop` hook naming `.portulan/compile/stop.mjs`, which was customer zero's own file and shipped in no
|
|
33
|
-
// published artifact, so a drafted workspace's compiled hook would have pointed at a file the adopter
|
|
34
|
-
// does not have — a gate existing only as a sentence, since a missing hook **fails open** (measured,
|
|
35
|
-
// CLI 2.1.220). That was the state the maintainer's 2026-07-31 ruling waited on: decide the runner's
|
|
36
|
-
// residence when there is a concrete mechanism to point at.
|
|
37
|
-
//
|
|
38
|
-
// **The mechanism now exists.** Both runners live in `cli/`, ship in the published package, and
|
|
39
|
-
// `compile` emits a path that resolves from a checkout and from a project-local install. What `init`
|
|
40
|
-
// still does not do is *run* `compile` over what it drafts — so a drafted workspace has the binding and
|
|
41
|
-
// no compiled hooks until its human runs `compile` themselves, which is the honest remaining gap and is
|
|
42
|
-
// what `cli/init.test.mjs` asserts.
|
|
43
|
-
//
|
|
44
|
-
// **The drafted README said the opposite for one pull request.** It told every adopter the runner "is not
|
|
45
|
-
// shipped in any artifact you have received" and that "nothing here compiles to a working Stop hook" —
|
|
46
|
-
// true before the runners moved, false the moment they did, and it survived a fourteen-carrier sweep
|
|
47
|
-
// because it describes the mechanism without naming the files the sweep was grepping for. The worst
|
|
48
|
-
// shape available: a false claim emitted into somebody else's tree, telling them a rail they now have is
|
|
49
|
-
// one they do not.
|
|
50
|
-
//
|
|
51
|
-
// **The interview asks where somebody is there to answer — milestone 7 session 7.** `docs/vision.md`
|
|
52
|
-
// glosses `init` as *interview + codebase scan*, and until this session the second half shipped and the
|
|
53
|
-
// first did not: the substrate — every question modelled as an answer, with a validator each — was
|
|
54
|
-
// built first precisely because a prompt loop cannot be run by CI, by a test, or by a headless host.
|
|
55
|
-
// It now drives a loop, under two conditions that keep both halves honest: the interview runs only
|
|
56
|
-
// where **stdin and stdout are both TTYs**, so every non-interactive invocation is byte-for-byte what
|
|
57
|
-
// it was before; and it decides nothing the flags path could not decide, so the two are one tool with
|
|
58
|
-
// two front doors rather than two tools. `--no-interview` is the escape for a terminal that wants the
|
|
59
|
-
// refusals. _(This paragraph read "there is no interactive interview yet" and left whether flags
|
|
60
|
-
// satisfy the gloss to the maintainer at milestone 7's close. The question is retired rather than
|
|
61
|
-
// answered: the loop exists, so nobody has to rule on whether its absence was acceptable.)_
|
|
62
|
-
//
|
|
63
|
-
// ## Exit codes
|
|
64
|
-
//
|
|
65
|
-
// `0` it wrote · `2` it wrote nothing. There is deliberately **no 1**: this tool renders no verdict
|
|
66
|
-
// about anybody's workspace, so it has no red to report. `compile` documents the same asymmetry from
|
|
67
|
-
// the other side — writing never returns 1, because a run that rewrites an artifact has nothing to
|
|
68
|
-
// disagree with.
|
|
69
|
-
//
|
|
70
|
-
// _The second code read "it could not run" until the interview arrived, and the interview introduced a
|
|
71
|
-
// way to write nothing that is not a failure at all: a human declining at the confirmation, or ending
|
|
72
|
-
// the input. Widening the sentence is the honest repair — `0` must keep meaning *it wrote*, since
|
|
73
|
-
// callers chain on it, and a decline is nobody's verdict. So the code is the same and the gloss now
|
|
74
|
-
// covers every way of reaching it._
|
|
4
|
+
// Before any write it refuses a residence already there, a file in the way and a link on a drafted path, treating only ENOENT as absent.
|
|
5
|
+
// Exit 0 wrote · 2 refused, declined or could not run; never 1, since it renders no verdict on a workspace.
|
|
75
6
|
|
|
76
7
|
import fs from "node:fs";
|
|
77
8
|
import path from "node:path";
|
|
78
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
79
10
|
|
|
80
|
-
// Host plugin-cache discovery (#123). `init` composes a checkpoints pack by default — row 7 clause (a)
|
|
81
|
-
// — so it is the tool that most needed a root it did not have to be told.
|
|
82
11
|
import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
import { renderHandoffIndex } from "./index.mjs";
|
|
12
|
+
import { historyCount } from "./comments.mjs";
|
|
13
|
+
import { CACHE_LIFETIMES, compileGuidance } from "./compile.mjs";
|
|
14
|
+
import { alwaysTier, ESTIMATED_BYTES_PER_TOKEN, OFFER_FLOOR_TOKENS, tokensOf } from "./context.mjs";
|
|
15
|
+
import { cardIgnored, changesReadme, claudeRulesUnignore, commentsRecipe, commentsRecipeEntry, COMPILED_CARD, draftCard, handoffIndexIgnore, handoffsReadme, withIgnoreLines } from "./form.mjs";
|
|
16
|
+
import { offerText, splitOffers } from "./instructions.mjs";
|
|
17
|
+
import { offerLines } from "./sessions.mjs";
|
|
90
18
|
|
|
91
19
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
92
20
|
|
|
93
|
-
/**
|
|
21
|
+
/** A refusal, thrown only before the first write: `run` exits 2 on it. */
|
|
94
22
|
export class InitError extends Error {}
|
|
95
23
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// A second copy here would be free to drift from `spec/workspace.schema.json`, and this tool's whole
|
|
99
|
-
// job at the boundary is to refuse exactly what that schema refuses. The read works in both layouts —
|
|
100
|
-
// from a checkout `spec/` is one level up from `cli/`, and the published package keeps that shape
|
|
101
|
-
// because `files` ships both under the package root.
|
|
102
|
-
//
|
|
103
|
-
// Unreadable is not fatal at import time, because a module that throws while loading gives the entry
|
|
104
|
-
// point nothing better than a stack trace. It becomes a could-not-run inside `run`, which is where a
|
|
105
|
-
// user can be told what is missing.
|
|
24
|
+
// The schema's `$defs/slug`; unreadable, it is null for `run` to refuse, since a throw at import leaves only a stack trace.
|
|
106
25
|
let SCHEMA_ERROR = null;
|
|
107
26
|
export const SLUG = (() => {
|
|
108
27
|
try {
|
|
@@ -114,31 +33,24 @@ export const SLUG = (() => {
|
|
|
114
33
|
}
|
|
115
34
|
})();
|
|
116
35
|
|
|
117
|
-
/**
|
|
36
|
+
/** A pointer's Workspace Definition: 2.7 is the first with pointers. */
|
|
118
37
|
const SPEC = "2.7";
|
|
119
38
|
|
|
120
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
39
|
+
/** 2.10 is the first with `slots.context`, which holds the drafted boot card. */
|
|
40
|
+
const WORKSPACE_SPEC = "2.10";
|
|
41
|
+
|
|
42
|
+
/** Declared only with a cache lifetime: 2.11 is the first with `sessions`, which `doctor` refuses under an earlier one. */
|
|
43
|
+
const SESSIONS_SPEC = "2.11";
|
|
44
|
+
|
|
124
45
|
export const GATE_POLICY_SPEC = "2.2";
|
|
125
46
|
|
|
126
47
|
const RESIDENCES = new Set(["in-repo", "pointer"]);
|
|
127
48
|
|
|
128
|
-
/** The pack composed by default when the cycle is scaffolded. The workspace names it; core names none. */
|
|
129
49
|
const DEFAULT_CHECKPOINTS = "rituals/checkpoints";
|
|
130
50
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const ANSWER_KEYS = new Set(["residence", "name", "summary", "governed-by", "feed", "checkpoints", "cycle", "pack-root"]);
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* A directory name, or any string, reduced to the schema's slug shape.
|
|
138
|
-
*
|
|
139
|
-
* Returns `null` rather than a fallback when nothing survives: a workspace called `workspace` because
|
|
140
|
-
* the directory was `---` is a name nobody chose, and `init` asks instead.
|
|
141
|
-
*/
|
|
51
|
+
const ANSWER_KEYS = new Set(["residence", "name", "summary", "governed-by", "feed", "checkpoints", "cycle", "pack-root", "cache-lifetime"]);
|
|
52
|
+
|
|
53
|
+
/** `text` as a slug, or null where nothing survives: `init` asks rather than name a workspace nobody chose. */
|
|
142
54
|
export function slugify(text) {
|
|
143
55
|
const slug = String(text ?? "")
|
|
144
56
|
.toLowerCase()
|
|
@@ -149,19 +61,12 @@ export function slugify(text) {
|
|
|
149
61
|
|
|
150
62
|
// ------------------------------------------------------------------------- the command line
|
|
151
63
|
|
|
152
|
-
/**
|
|
153
|
-
* Splits argv into flags and the single target directory.
|
|
154
|
-
*
|
|
155
|
-
* Throws rather than returning a half-parsed shape: a flag whose value is missing must not silently
|
|
156
|
-
* consume the next flag, which is the parsing bug that turns `--name --residence in-repo` into a
|
|
157
|
-
* workspace named `--residence`.
|
|
158
|
-
*/
|
|
159
64
|
export function parseArgs(argv) {
|
|
160
65
|
const flags = {};
|
|
161
66
|
const targets = [];
|
|
162
67
|
let help = false;
|
|
163
68
|
|
|
164
|
-
const VALUED = new Set(["--residence", "--name", "--summary", "--governed-by", "--feed", "--checkpoints", "--answers", "--pack-root"]);
|
|
69
|
+
const VALUED = new Set(["--residence", "--name", "--summary", "--governed-by", "--feed", "--checkpoints", "--answers", "--pack-root", "--cache-lifetime"]);
|
|
165
70
|
|
|
166
71
|
for (let i = 0; i < argv.length; i++) {
|
|
167
72
|
const arg = argv[i];
|
|
@@ -170,20 +75,9 @@ export function parseArgs(argv) {
|
|
|
170
75
|
} else if (arg === "--no-cycle") {
|
|
171
76
|
flags.cycle = false;
|
|
172
77
|
} else if (arg === "--no-interview") {
|
|
173
|
-
// Not an answer, so it is deliberately outside ANSWER_KEYS and cannot arrive in an answers
|
|
174
|
-
// file: it governs how the answers are collected, and a file of answers has already
|
|
175
|
-
// collected them.
|
|
176
78
|
flags.noInterview = true;
|
|
177
79
|
} else if (VALUED.has(arg)) {
|
|
178
80
|
const value = argv[i + 1];
|
|
179
|
-
// ANY leading `-` is a missing value, not `--` only. `cli/doctor.mjs` already guards this
|
|
180
|
-
// way and `init` was the outlier: `init --residence -h <dir>` consumed `-h` as the
|
|
181
|
-
// residence and then complained that `-h` is not one, which blames the user for a token
|
|
182
|
-
// they typed as a flag. A help request is the likeliest thing to land here, and it was
|
|
183
|
-
// the likeliest thing to be eaten. Found by review on the pull request.
|
|
184
|
-
//
|
|
185
|
-
// A value that genuinely begins with `-` is not lost: `--answers` carries it, where the
|
|
186
|
-
// shape is a JSON string and nothing has to guess where a flag ends.
|
|
187
81
|
if (value === undefined || value.startsWith("-")) {
|
|
188
82
|
throw new InitError(
|
|
189
83
|
`\`${arg}\` needs a value and the next argument is \`${value ?? "(nothing)"}\` — refusing to read a ` +
|
|
@@ -191,11 +85,6 @@ export function parseArgs(argv) {
|
|
|
191
85
|
`is a JSON string and nothing has to guess.`,
|
|
192
86
|
);
|
|
193
87
|
}
|
|
194
|
-
// An EMPTY value is given-but-invalid, and it is refused here for every flag rather than
|
|
195
|
-
// per-field. `--summary ""` used to pass straight through `??` into the manifest, where
|
|
196
|
-
// the schema's `minLength: 1` made `doctor` red on a workspace `init` had just reported
|
|
197
|
-
// writing successfully — the one outcome this tool exists to make impossible. No flag
|
|
198
|
-
// here has a meaningful empty value, so one rule covers all of them.
|
|
199
88
|
if (value.trim() === "") {
|
|
200
89
|
throw new InitError(
|
|
201
90
|
`\`${arg}\` was given an empty value. Every answer this tool writes into a manifest has to be ` +
|
|
@@ -207,12 +96,6 @@ export function parseArgs(argv) {
|
|
|
207
96
|
else flags[key] = value;
|
|
208
97
|
i++;
|
|
209
98
|
} else if (arg.startsWith("-")) {
|
|
210
|
-
// #155: this said "run `init --help`", which is not a command anybody can run. The tool is
|
|
211
|
-
// genuinely reachable two ways and the honest string differs per invocation, so both are
|
|
212
|
-
// named rather than one being derived — deriving it would mean the entry point telling the
|
|
213
|
-
// subcommand what name it was invoked under, and that entry point deliberately passes argv
|
|
214
|
-
// and nothing else, so the tool behaves identically either way. A better sentence is not
|
|
215
|
-
// worth trading that property for. The same fix is applied to `usage()` below.
|
|
216
99
|
throw new InitError(
|
|
217
100
|
`unknown option \`${arg}\` — run \`portulan init --help\` for the ones this understands, ` +
|
|
218
101
|
`or \`node cli/init.mjs --help\` from a checkout`,
|
|
@@ -232,12 +115,6 @@ export function parseArgs(argv) {
|
|
|
232
115
|
return { help, flags, target: targets[0] ?? null };
|
|
233
116
|
}
|
|
234
117
|
|
|
235
|
-
/**
|
|
236
|
-
* Merges an `--answers` file under the flags, and refuses anything it does not recognise.
|
|
237
|
-
*
|
|
238
|
-
* The nearer answer wins: a flag typed on the command line overrides the same key in the file, which
|
|
239
|
-
* is the resolution cascade's own direction — more specific beats more general.
|
|
240
|
-
*/
|
|
241
118
|
export function resolveAnswers(flags) {
|
|
242
119
|
let fromFile = {};
|
|
243
120
|
if (flags.answers) {
|
|
@@ -255,9 +132,6 @@ export function resolveAnswers(flags) {
|
|
|
255
132
|
if (!fromFile || typeof fromFile !== "object" || Array.isArray(fromFile)) {
|
|
256
133
|
throw new InitError(`the answers file \`${flags.answers}\` must hold a JSON object of answers`);
|
|
257
134
|
}
|
|
258
|
-
// Refused rather than ignored, on the schema's own `additionalProperties: false` reasoning: the
|
|
259
|
-
// common case is a typo, and a silently-dropped answer leaves the tool asking for something the
|
|
260
|
-
// adopter believes they gave.
|
|
261
135
|
const unknown = Object.keys(fromFile).filter((key) => !ANSWER_KEYS.has(key));
|
|
262
136
|
if (unknown.length) {
|
|
263
137
|
throw new InitError(
|
|
@@ -265,10 +139,6 @@ export function resolveAnswers(flags) {
|
|
|
265
139
|
`(it asks: ${[...ANSWER_KEYS].join(", ")}). A misspelt key is an answer nobody receives.`,
|
|
266
140
|
);
|
|
267
141
|
}
|
|
268
|
-
// The VALUES are checked too, not only the keys. Lecturing about a misspelt key while
|
|
269
|
-
// accepting `"cycle": "false"` — a truthy string that composes the pack the adopter was
|
|
270
|
-
// trying to switch off — checks the half that is easy to check and lets the half that
|
|
271
|
-
// changes behaviour through.
|
|
272
142
|
for (const [key, value] of Object.entries(fromFile)) {
|
|
273
143
|
const expected = key === "cycle" ? "boolean" : "string";
|
|
274
144
|
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
@@ -289,17 +159,10 @@ export function resolveAnswers(flags) {
|
|
|
289
159
|
|
|
290
160
|
const merged = { ...fromFile };
|
|
291
161
|
for (const [key, value] of Object.entries(flags)) {
|
|
292
|
-
// Neither is an answer. `--answers` names where answers come from and `--no-interview` says
|
|
293
|
-
// how they are collected, and letting either into `given` would put a flag nobody answered
|
|
294
|
-
// into the set the misplaced-option refusal reads.
|
|
295
162
|
if (key === "answers" || key === "noInterview") continue;
|
|
296
163
|
merged[key] = value;
|
|
297
164
|
}
|
|
298
165
|
|
|
299
|
-
// WHICH keys were actually supplied, kept apart from what they resolved to. `cycle` and
|
|
300
|
-
// `checkpoints` have defaults, so their values cannot tell a caller who typed them from a caller
|
|
301
|
-
// who did not — and the residence check below must refuse only what somebody asked for. A
|
|
302
|
-
// default that triggered a refusal would be the tool objecting to its own choice.
|
|
303
166
|
const given = new Set(Object.keys(merged));
|
|
304
167
|
|
|
305
168
|
return {
|
|
@@ -311,30 +174,13 @@ export function resolveAnswers(flags) {
|
|
|
311
174
|
feed: merged.feed ?? null,
|
|
312
175
|
checkpoints: merged.checkpoints ?? DEFAULT_CHECKPOINTS,
|
|
313
176
|
cycle: merged.cycle !== false,
|
|
314
|
-
//
|
|
315
|
-
// repeatable and accumulates into one; an answers file may reasonably give a single string,
|
|
316
|
-
// which the value check above accepts as a string like every other key. Everything
|
|
317
|
-
// downstream — `.length`, `.map`, `packResolves`'s `.some` — is array-shaped, so a string
|
|
318
|
-
// reaching here turned a perfectly valid answers file into `roots.some is not a function`:
|
|
319
|
-
// a real answer, refused with a message about somebody else's bug. Found by review on the
|
|
320
|
-
// pull request. Normalising here rather than at each use keeps one shape after this line.
|
|
177
|
+
// An answers file may give a string, the repeatable flag an array.
|
|
321
178
|
packRoots: [merged["pack-root"] ?? []].flat(),
|
|
179
|
+
cacheLifetime: merged["cache-lifetime"] ?? null,
|
|
322
180
|
};
|
|
323
181
|
}
|
|
324
182
|
|
|
325
|
-
/** Everything that must be true before a single byte is written. Each refusal names what to type. */
|
|
326
183
|
export function validateAnswers(answers) {
|
|
327
|
-
// The named+auto pair, refused for EVERY path rather than inside the one branch that resolves a
|
|
328
|
-
// pack. It lived in the `residence === "in-repo" && cycle && packRoots.length` arm until Copilot's
|
|
329
|
-
// round 3, so `--no-cycle` with both flags was accepted and one of them silently ignored —
|
|
330
|
-
// breaking the *refused in all five tools* claim that change made, in the fifth tool. _(Five was the
|
|
331
|
-
// set on #233; it is seven since `recipe-set` and this file joined. Kept as the round's own words
|
|
332
|
-
// rather than re-typed, and dated so it does not read as a present count.)_
|
|
333
|
-
//
|
|
334
|
-
// Third time this session that a correct refusal was placed where something could skip it: below a
|
|
335
|
-
// workspace read in `compile`, below a manifest read in `skills-set`, and inside a conditional
|
|
336
|
-
// here. The rule the three share is that a judgement about the COMMAND LINE belongs where the
|
|
337
|
-
// command line is assembled, not where its subject is used.
|
|
338
184
|
const bothAsked = namedWithAuto(
|
|
339
185
|
(answers.packRoots ?? []).filter((r) => r !== AUTO),
|
|
340
186
|
(answers.packRoots ?? []).includes(AUTO),
|
|
@@ -357,36 +203,34 @@ export function validateAnswers(answers) {
|
|
|
357
203
|
`(${SLUG.source}). A workspace ships as a plugin through a feed, so its name is an identifier rather than a title.`,
|
|
358
204
|
);
|
|
359
205
|
}
|
|
360
|
-
// ACCEPTED-BUT-IGNORED is refused, because this file's header claims it refuses what it cannot
|
|
361
|
-
// act on and that claim has to be true. `--feed` and `--governed-by` mean nothing to a full
|
|
362
|
-
// workspace; `--pack-root`, `--checkpoints` and `--no-cycle` mean nothing to a pointer, which
|
|
363
|
-
// composes nothing. Silently dropping them lets a caller — or an answers file nobody re-reads —
|
|
364
|
-
// believe an option had an effect it never had, which is the same defect as `--summary ""`
|
|
365
|
-
// reaching a manifest: an answer accepted and then not honoured. Found by review on the pull
|
|
366
|
-
// request. Keyed on what was GIVEN, never on the resolved value, so a default never trips it.
|
|
367
206
|
const misplaced = {
|
|
368
207
|
"in-repo": ["feed", "governed-by"],
|
|
369
|
-
pointer: ["pack-root", "checkpoints", "cycle"],
|
|
208
|
+
pointer: ["pack-root", "checkpoints", "cycle", "cache-lifetime"],
|
|
370
209
|
}[answers.residence].filter((key) => answers.given.has(key));
|
|
371
210
|
if (misplaced.length) {
|
|
372
211
|
const spelling = (key) => (key === "cycle" ? "`--no-cycle`" : `\`--${key}\``);
|
|
212
|
+
const why =
|
|
213
|
+
answers.residence === "pointer"
|
|
214
|
+
? [
|
|
215
|
+
misplaced.some((key) => key !== "cache-lifetime") && "a pointer holds no policy of its own, so it composes no packs",
|
|
216
|
+
misplaced.includes("cache-lifetime") && "a pointer drafts no repository whose settings `compile` writes, so a cache lifetime would reach no session",
|
|
217
|
+
].filter(Boolean)
|
|
218
|
+
: ["a workspace that lives here has no governor and no feed to be delivered from"];
|
|
373
219
|
throw new InitError(
|
|
374
220
|
`${misplaced.map(spelling).join(" and ")} ${misplaced.length > 1 ? "do" : "does"} nothing with ` +
|
|
375
|
-
`\`--residence ${answers.residence}\` — ${
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
221
|
+
`\`--residence ${answers.residence}\` — ${why.join(", and ")}. ` +
|
|
222
|
+
"Refused rather than ignored: an option accepted and then dropped is one you will believe had an effect.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const lifetime = answers.cacheLifetime ?? null;
|
|
226
|
+
if (lifetime !== null && !CACHE_LIFETIMES.includes(lifetime)) {
|
|
227
|
+
throw new InitError(
|
|
228
|
+
`\`${lifetime}\` is not a cache lifetime — Claude Code takes ${CACHE_LIFETIMES.map((v) => `\`${v}\``).join(" and ")}, ` +
|
|
229
|
+
"which `compile` writes into .claude/settings.json as `promptCacheTtl`. Leave it out to keep the host's default.",
|
|
380
230
|
);
|
|
381
231
|
}
|
|
382
232
|
|
|
383
233
|
if (answers.residence === "pointer") {
|
|
384
|
-
// Absent and invalid are different answers and take different refusals: telling an adopter
|
|
385
|
-
// who typed a malformed governor to pass the flag they just passed sends them to the wrong
|
|
386
|
-
// place. The EMPTY spelling never reaches here — the command line refuses it earlier, where
|
|
387
|
-
// the clearer sentence lives — so this branch is the omission and the one below is the
|
|
388
|
-
// malformation. Both matter because an empty or non-string governor is exactly the value
|
|
389
|
-
// `doctor` still mis-reports as a *conflicting* one (#141).
|
|
390
234
|
if (answers.governedBy === null) {
|
|
391
235
|
throw new InitError(
|
|
392
236
|
"a pointer must name the workspace that governs this repository — pass `--governed-by <workspace>`. " +
|
|
@@ -416,17 +260,9 @@ export function validateAnswers(answers) {
|
|
|
416
260
|
|
|
417
261
|
// ------------------------------------------------------------------------- the codebase scan
|
|
418
262
|
|
|
419
|
-
/**
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
* The failure an onboarding tool is most likely to commit is the confident default: emitting
|
|
423
|
-
* `make test` because most repositories have one. `doctor` lints repo-card build and test claims
|
|
424
|
-
* against the tree, so an invented claim is a red the adopter did not cause — and, worse, a workspace
|
|
425
|
-
* that lies about them on the day it was created. Every field here is either read out of a file that
|
|
426
|
-
* exists or left `null`, and `null` is written into the draft as *not determined* rather than dropped.
|
|
427
|
-
*/
|
|
428
|
-
export function scan(dir) {
|
|
429
|
-
const observed = { stack: [], build: null, test: null, run: null, name: null, vcs: null, evidence: [] };
|
|
263
|
+
/** What the repository says of itself, observed only: a field nothing states stays null, never a likely default. */
|
|
264
|
+
export function scan(dir, { comments = true } = {}) {
|
|
265
|
+
const observed = { stack: [], build: null, test: null, run: null, name: null, vcs: null, evidence: [], commentHistory: null, uncounted: null };
|
|
430
266
|
const has = (rel) => fs.existsSync(path.join(dir, rel));
|
|
431
267
|
const read = (rel) => {
|
|
432
268
|
try {
|
|
@@ -448,9 +284,7 @@ export function scan(dir) {
|
|
|
448
284
|
try {
|
|
449
285
|
manifest = JSON.parse(read("package.json"));
|
|
450
286
|
} catch {
|
|
451
|
-
//
|
|
452
|
-
// is not this tool's business to fix it, and reading scripts out of a guess would be the
|
|
453
|
-
// invented claim this whole function exists to avoid.
|
|
287
|
+
// Unparsed, it is evidence of node and of nothing else.
|
|
454
288
|
}
|
|
455
289
|
if (manifest && typeof manifest === "object") {
|
|
456
290
|
if (typeof manifest.name === "string") observed.name = manifest.name;
|
|
@@ -465,8 +299,6 @@ export function scan(dir) {
|
|
|
465
299
|
if (makefile !== null) {
|
|
466
300
|
observed.stack.push("make");
|
|
467
301
|
observed.evidence.push("Makefile");
|
|
468
|
-
// A target that exists is an observation; `make test` on a Makefile with no `test:` target is
|
|
469
|
-
// a guess, and would fail for the adopter the first time they ran it.
|
|
470
302
|
for (const [target, key] of [["test", "test"], ["build", "build"], ["run", "run"]]) {
|
|
471
303
|
if (observed[key] === null && new RegExp(`^${target}\\s*:`, "m").test(makefile)) observed[key] = `make ${target}`;
|
|
472
304
|
}
|
|
@@ -479,20 +311,20 @@ export function scan(dir) {
|
|
|
479
311
|
}
|
|
480
312
|
}
|
|
481
313
|
|
|
314
|
+
if (comments) {
|
|
315
|
+
try {
|
|
316
|
+
observed.commentHistory = historyCount(dir);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
observed.uncounted = error.message;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
482
322
|
return observed;
|
|
483
323
|
}
|
|
484
324
|
|
|
485
325
|
// ------------------------------------------------------------------------- the draft
|
|
486
326
|
|
|
487
|
-
/**
|
|
488
|
-
* Decides the whole file set and returns it. **Writes nothing.**
|
|
489
|
-
*
|
|
490
|
-
* The split from writing is what keeps every refusal ahead of the first byte on disk: a tool that
|
|
491
|
-
* decides while writing has no state left in which it can still refuse. It also makes the shape
|
|
492
|
-
* testable without a filesystem, which is why almost every assertion in the suite is cheap.
|
|
493
|
-
*
|
|
494
|
-
* @returns {Map<string, {contents: string, mode?: number}>} keyed by path relative to the target
|
|
495
|
-
*/
|
|
327
|
+
/** Every drafted file, by path from the target: its `contents` and `mode`, `ifAbsent` to leave one already there, or `append` lines. */
|
|
496
328
|
export function draft(answers, observed) {
|
|
497
329
|
return answers.residence === "pointer" ? draftPointer(answers) : draftWorkspace(answers, observed);
|
|
498
330
|
}
|
|
@@ -504,8 +336,7 @@ function json(value) {
|
|
|
504
336
|
function draftPointer(answers) {
|
|
505
337
|
const files = new Map();
|
|
506
338
|
|
|
507
|
-
// Exactly the
|
|
508
|
-
// cosmetic defect here — it is the dual-management refusal, and the adopter's first run is red.
|
|
339
|
+
// Exactly the keys `doctor` permits a pointer: any other is its dual-management refusal.
|
|
509
340
|
const manifest = {
|
|
510
341
|
portulan: { spec: SPEC },
|
|
511
342
|
name: answers.name ?? "workspace",
|
|
@@ -566,16 +397,14 @@ this repository is, in the one line an agent reads before loading anything else.
|
|
|
566
397
|
function draftWorkspace(answers, observed) {
|
|
567
398
|
const files = new Map();
|
|
568
399
|
const name = answers.name ?? "workspace";
|
|
400
|
+
const lifetime = answers.cacheLifetime ?? null;
|
|
569
401
|
|
|
570
402
|
const manifest = {
|
|
571
|
-
portulan: { spec:
|
|
403
|
+
portulan: { spec: lifetime === null ? WORKSPACE_SPEC : SESSIONS_SPEC },
|
|
572
404
|
name,
|
|
573
405
|
summary: answers.summary ?? `The ${name} workspace — drafted by \`init\`, and not yet curated.`,
|
|
574
406
|
kind: "repository",
|
|
575
|
-
//
|
|
576
|
-
// present, so it has an answer, and without one every repo-card and gate-map claim silently
|
|
577
|
-
// degrades from checked to unverifiable. The constraint is `doctor`'s rather than the schema's,
|
|
578
|
-
// which makes it exactly the kind a generator forgets.
|
|
407
|
+
// `doctor`, not the schema, requires `tree` of a `repository` workspace.
|
|
579
408
|
tree: "../",
|
|
580
409
|
gates: "gates.json",
|
|
581
410
|
slots: {
|
|
@@ -584,6 +413,7 @@ function draftWorkspace(answers, observed) {
|
|
|
584
413
|
gates: "gate-map.md",
|
|
585
414
|
dod: "dod.md",
|
|
586
415
|
handoffs: "handoffs/",
|
|
416
|
+
context: "context/",
|
|
587
417
|
},
|
|
588
418
|
verify: {
|
|
589
419
|
default: "workspace",
|
|
@@ -594,48 +424,42 @@ function draftWorkspace(answers, observed) {
|
|
|
594
424
|
requires: ["bash"],
|
|
595
425
|
doc: "verify/README.md",
|
|
596
426
|
},
|
|
597
|
-
// The records rail — row 7 clause (a)'s third record convention, beside the handoffs
|
|
598
|
-
// directory and the session-end binding. A generated index nothing compares is a file
|
|
599
|
-
// that is current until the first person forgets, which is the reminder this project
|
|
600
|
-
// trades for a rail wherever it can. It is NOT the default: the default is what the
|
|
601
|
-
// Stop-gate runs at every session end, and that slot belongs to the recipe saying
|
|
602
|
-
// whether the repository works.
|
|
603
427
|
{
|
|
604
428
|
id: "index",
|
|
605
429
|
run: "./.portulan/verify/index.sh",
|
|
606
430
|
requires: ["bash", "node"],
|
|
607
431
|
doc: "verify/README.md",
|
|
608
432
|
},
|
|
433
|
+
...(observed.commentHistory === null ? [] : [{ ...commentsRecipeEntry(".portulan"), doc: "verify/README.md" }]),
|
|
609
434
|
],
|
|
610
435
|
},
|
|
611
|
-
//
|
|
612
|
-
// handoff by everything that walks the directory.
|
|
436
|
+
// Outside `handoffs/`, where whatever walks the series would count it as a handoff.
|
|
613
437
|
handoffs: { index: { path: "handoffs-index.md" } },
|
|
614
438
|
};
|
|
615
439
|
if (answers.cycle) manifest.packs = [answers.checkpoints];
|
|
440
|
+
if (lifetime !== null) manifest.sessions = { cache_lifetime: lifetime };
|
|
441
|
+
const commented = observed.commentHistory !== null;
|
|
616
442
|
|
|
617
443
|
files.set(".portulan/workspace.json", { contents: json(manifest) });
|
|
618
444
|
files.set(".portulan/gates.json", { contents: json(draftPolicy()) });
|
|
619
|
-
files.set(".portulan/README.md", { contents: draftReadme(answers, observed, name) });
|
|
445
|
+
files.set(".portulan/README.md", { contents: draftReadme(answers, observed, name, commented) });
|
|
620
446
|
files.set(".portulan/identity.md", { contents: draftIdentity(observed, name) });
|
|
621
447
|
files.set(".portulan/principles.md", { contents: draftPrinciples(name) });
|
|
622
448
|
files.set(".portulan/gate-map.md", { contents: draftGateMap() });
|
|
623
449
|
files.set(".portulan/dod.md", { contents: draftDod() });
|
|
624
|
-
files.set(".portulan/verify/README.md", { contents: draftVerifyReadme() });
|
|
450
|
+
files.set(".portulan/verify/README.md", { contents: draftVerifyReadme(commented) });
|
|
625
451
|
files.set(".portulan/verify/workspace.sh", { contents: draftRecipe(), mode: 0o755 });
|
|
626
452
|
files.set(".portulan/verify/index.sh", { contents: draftIndexRecipe(), mode: 0o755 });
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
files.set(
|
|
632
|
-
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
files.set(".portulan/
|
|
637
|
-
contents: "# The handoff series. One dated record per session; filenames lead with YYYY-MM-DD.\n",
|
|
638
|
-
});
|
|
453
|
+
if (commented) {
|
|
454
|
+
const bundle = path.resolve(HERE, "..");
|
|
455
|
+
files.set(".portulan/verify/comments.sh", { contents: commentsRecipe({ bundle, limit: observed.commentHistory, toTree: "../.." }), mode: 0o755 });
|
|
456
|
+
}
|
|
457
|
+
files.set(".portulan/handoffs/README.md", { contents: handoffsReadme() });
|
|
458
|
+
files.set(".gitignore", { append: handoffIndexIgnore(`.portulan/${manifest.handoffs.index.path}`, ".portulan") });
|
|
459
|
+
files.set("changes/README.md", { contents: changesReadme(), ifAbsent: true });
|
|
460
|
+
|
|
461
|
+
const read = (rel) => files.get(`.portulan/${rel}`)?.contents ?? null;
|
|
462
|
+
files.set(".portulan/context/boot.md", { contents: draftCard(manifest, read, { workspace: ".portulan", inTree: (rel) => !path.posix.normalize(rel).startsWith("..") }) });
|
|
639
463
|
|
|
640
464
|
return files;
|
|
641
465
|
}
|
|
@@ -644,10 +468,7 @@ function draftPolicy() {
|
|
|
644
468
|
return {
|
|
645
469
|
portulan: { spec: GATE_POLICY_SPEC },
|
|
646
470
|
why: "gate-map.md",
|
|
647
|
-
//
|
|
648
|
-
// status check no workflow job in the tree reports — so a draft naming the checks this
|
|
649
|
-
// repository has not written yet would put a red in the adopter's first run and teach them the
|
|
650
|
-
// tool guesses. The branch is the answer nearly every repository shares; the checks are theirs.
|
|
471
|
+
// No required checks: `doctor` fails a floor requiring one that no workflow in the tree reports.
|
|
651
472
|
floor: {
|
|
652
473
|
branch: "main",
|
|
653
474
|
checks: [],
|
|
@@ -655,12 +476,7 @@ function draftPolicy() {
|
|
|
655
476
|
resolve_conversations: true,
|
|
656
477
|
},
|
|
657
478
|
rules: [
|
|
658
|
-
//
|
|
659
|
-
// whose floor no rule reaches is refused outright, so a starter policy that declared a
|
|
660
|
-
// floor and gated only `gh pr merge` would put a hard red in the adopter's very first
|
|
661
|
-
// run. Found by running the real validator against a real draft rather than by reading
|
|
662
|
-
// the compiler. They are also the right two to start with: both destroy a ref rather
|
|
663
|
-
// than adding one, which is the property that puts them above the line.
|
|
479
|
+
// Rules the floor backend compiles: `compile` refuses a policy whose floor no rule reaches.
|
|
664
480
|
{
|
|
665
481
|
id: "force-push-without-a-lease",
|
|
666
482
|
tier: "gated",
|
|
@@ -695,7 +511,7 @@ function draftPolicy() {
|
|
|
695
511
|
};
|
|
696
512
|
}
|
|
697
513
|
|
|
698
|
-
function draftReadme(answers, observed, name) {
|
|
514
|
+
function draftReadme(answers, observed, name, commented) {
|
|
699
515
|
const bound = answers.cycle
|
|
700
516
|
? `This workspace composes the \`${answers.checkpoints}\` pack, which carries the three checkpoint
|
|
701
517
|
skills — session-open, pre-commit, milestone-close — and the supervisor persona that staffs them.`
|
|
@@ -711,8 +527,12 @@ replace the placeholders. A workspace you have not curated is a workspace that m
|
|
|
711
527
|
|
|
712
528
|
## Where to start
|
|
713
529
|
|
|
714
|
-
1. **\`identity.md\`** — who you are, your stack, your glossary.
|
|
715
|
-
|
|
530
|
+
1. **\`identity.md\`** — who you are, your stack, your glossary. The boot card imports it whole, so
|
|
531
|
+
every context here carries it: keep it short and keep it true. Where something is not true yet, say
|
|
532
|
+
so rather than describing the intended state, since a file claiming a capability the tree does not
|
|
533
|
+
have is the defect this one exists to prevent. Correct the commands the scan read when they go stale.
|
|
534
|
+
The glossary earns its place the first time an agent misreads one of your words, and the scan below
|
|
535
|
+
could only observe so much.
|
|
716
536
|
2. **\`principles.md\`** — the handful of rules that are yours rather than everyone's. Placeholders
|
|
717
537
|
until you write them.
|
|
718
538
|
3. **\`verify/workspace.sh\`** — **this exits 2 (could not run) until you edit it.** It cannot report
|
|
@@ -735,27 +555,44 @@ is worse than one with fewer of them.
|
|
|
735
555
|
\`doctor --pack-root auto .portulan\`, which finds it in this host's installed plugins;
|
|
736
556
|
\`doctor --pack-root <dir> .portulan\` if you would rather name the location; or \`init --no-cycle\`
|
|
737
557
|
to compose the pack later, once you know where it lives.
|
|
738
|
-
- **The records conventions are drafted, and
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
558
|
+
- **The records conventions are drafted, and a rail holds them from the first day.** A change's why is
|
|
559
|
+
its commit message. A changelog entry is a file of one bullet in \`changes/\` beside this directory,
|
|
560
|
+
which \`changes/README.md\` explains and \`portulan index --changes changes\` prints as a release cut
|
|
561
|
+
pastes it. A handoff, in the \`handoffs\` slot, is written only for work a session leaves open, from
|
|
562
|
+
the template in \`handoffs/README.md\`. The handoff index is printed on demand by \`portulan index
|
|
563
|
+
--handoffs .portulan\` and is not kept: its path is git-ignored, so no copy goes stale on a merge.
|
|
564
|
+
\`verify/index.sh\`, declared beside \`workspace\`, renders the series and goes red when a handoff
|
|
565
|
+
yields no index line, or when a copy someone keeps has drifted. It is **not** the default recipe: the
|
|
744
566
|
default is what runs at every session end, and that slot belongs to the one that says whether this
|
|
745
567
|
repository works.
|
|
746
568
|
- **The rail needs the CLI, and may not find it here.** It looks at \`$PORTULAN_CLI\`, then
|
|
747
569
|
\`portulan\` on your \`PATH\`, then the bundle this workspace was drafted from — an absolute path on
|
|
748
570
|
the machine that ran \`init\`, which git cannot carry to anybody else. Where none answers it exits
|
|
749
571
|
**2 — could not run**, never 0. On CI that is the state to expect until the CLI is installed there,
|
|
750
|
-
and it is said here rather than left to be met as an amber pipeline
|
|
751
|
-
- **
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
572
|
+
and it is said here rather than left to be met as an amber pipeline.${commented ? `
|
|
573
|
+
- **A change's history goes in its commit message, not in a comment**, which is paid for on every read
|
|
574
|
+
of its file. \`verify/comments.sh\` counts the comment lines that record one, by a date, a proposal or
|
|
575
|
+
milestone number, a review round, or a pull request or issue number, and holds them at the
|
|
576
|
+
${observed.commentHistory} \`init\` found: lower its \`LIMIT\` as they leave. It finds the CLI as the records rail does, but takes a
|
|
577
|
+
\`portulan\` on your \`PATH\` only where it holds \`comments.mjs\`.` : ""}
|
|
578
|
+
- **The session-end gate is wired by \`compile\`, and this draft has run only its guidance half.** The
|
|
579
|
+
runner that asks for a dated handoff when a session ends with work not committed and pushed **does**
|
|
580
|
+
ship in the package you have — it is \`cli/stop-gate.mjs\` — and \`portulan compile\` emits a
|
|
581
|
+
\`Stop\` hook naming it into \`.claude/settings.json\`, beside the gate policy's permission rules.
|
|
582
|
+
Running that is yours, because it writes host settings and that is not a thing a scaffold should do
|
|
583
|
+
to your machine unasked. **Until you run it**, treat the session-end handoff as a practice your team
|
|
584
|
+
holds rather than a rail — a rule nothing checks is worth exactly what you can remember about it.
|
|
585
|
+
|
|
586
|
+
## The boot card
|
|
587
|
+
|
|
588
|
+
\`context/boot.md\` is this workspace's boot: an always unit that \`portulan compile\` wrote into
|
|
589
|
+
\`.claude/rules/portulan/boot.md\`, which Claude Code loads into every context here, so a session opens
|
|
590
|
+
no slot to boot. \`init\` drafted it from the files above. It imports \`identity.md\` whole, and
|
|
591
|
+
\`compile\` writes out the lead sentences of \`principles.md\` and \`dod.md\` and the gates of
|
|
592
|
+
\`gates.json\` onto it, so it says what they say. **Run \`portulan compile\` after you edit any of
|
|
593
|
+
them**: until you do, \`portulan compile --check\` reports the card drifted. Commit the compiled rules
|
|
594
|
+
with the files they come from. To boot as before, reading each slot at boot, delete
|
|
595
|
+
\`context/boot.md\` and compile again.
|
|
759
596
|
|
|
760
597
|
## What the scan observed
|
|
761
598
|
|
|
@@ -773,10 +610,6 @@ function draftIdentity(observed, name) {
|
|
|
773
610
|
|
|
774
611
|
return `# Identity — ${name}
|
|
775
612
|
|
|
776
|
-
> Who this team is, what it builds with, and what its words mean. The first thing an agent reads after
|
|
777
|
-
> the kernel, so keep it short and keep it true. \`init\` drafted this from what it could observe; every
|
|
778
|
-
> line marked *not determined* was left blank rather than guessed at.
|
|
779
|
-
|
|
780
613
|
## The team
|
|
781
614
|
|
|
782
615
|
${unknown} — how many people, what they own, how they work.
|
|
@@ -795,19 +628,13 @@ ${row("Run", observed.run)}
|
|
|
795
628
|
|
|
796
629
|
${
|
|
797
630
|
observed.build || observed.test || observed.run
|
|
798
|
-
? "
|
|
631
|
+
? "Read out of files in this repository, not inferred."
|
|
799
632
|
: "The scan found no command written down anywhere it looked. Write them here; anything else would be a guess."
|
|
800
633
|
}
|
|
801
634
|
|
|
802
635
|
## Glossary
|
|
803
636
|
|
|
804
|
-
${unknown} — the words your team uses that a newcomer would misread.
|
|
805
|
-
first time an agent gets one wrong.
|
|
806
|
-
|
|
807
|
-
## Write the limit, not the aspiration
|
|
808
|
-
|
|
809
|
-
Where something is not true yet, say so here rather than describing the intended state. A document that
|
|
810
|
-
claims a capability the tree does not have is the defect this section exists to prevent.
|
|
637
|
+
${unknown} — the words your team uses that a newcomer would misread.
|
|
811
638
|
`;
|
|
812
639
|
}
|
|
813
640
|
|
|
@@ -913,7 +740,7 @@ skimmed. Each condition should be checkable by someone who was not in the room._
|
|
|
913
740
|
`;
|
|
914
741
|
}
|
|
915
742
|
|
|
916
|
-
function draftVerifyReadme() {
|
|
743
|
+
function draftVerifyReadme(commented) {
|
|
917
744
|
return `# verify/
|
|
918
745
|
|
|
919
746
|
The executable half of *done*. \`workspace.json\` declares the recipes and names the default.
|
|
@@ -927,7 +754,8 @@ is a command in a file.
|
|
|
927
754
|
| Recipe | What it checks |
|
|
928
755
|
|---|---|
|
|
929
756
|
| \`workspace\` | **Nothing yet — it exits 2.** Replace it with the command that tells you this repository is healthy. This one is the **default**: it is what runs at a session end. |
|
|
930
|
-
| \`index\` | The
|
|
757
|
+
| \`index\` | The handoff series renders an index line for every handoff, and a copy of the index kept on disk matches it byte for byte; none is kept as drafted. Finished as drafted — it checks a real thing today. |${commented ? `
|
|
758
|
+
| \`comments\` | No more comment lines record a change's history than its \`LIMIT\`, the count \`init\` found. Lower it as they leave. It needs git, and finds the CLI as \`index\` does, but takes a \`portulan\` on \`PATH\` only where it holds \`comments.mjs\`. |` : ""}
|
|
931
759
|
|
|
932
760
|
## The three exit codes, and why the middle one is not enough
|
|
933
761
|
|
|
@@ -947,9 +775,9 @@ ran \`init\`, which git cannot carry to a clone or to a CI runner. Where none of
|
|
|
947
775
|
where \`node\` itself is absent, the recipe exits **2** and names what it looked for. That is the state
|
|
948
776
|
to expect on CI until the CLI is installed there; it is not a red, and it is not a pass either.
|
|
949
777
|
|
|
950
|
-
**
|
|
951
|
-
|
|
952
|
-
|
|
778
|
+
**Repairing is yours, and the rail deliberately does not do it.** A check that repaired what it found
|
|
779
|
+
would report green on a repository nobody had corrected. A handoff that yields no index line needs its
|
|
780
|
+
title or date fixed; a kept copy that drifted needs the index tool run again, or the copy deleted.
|
|
953
781
|
|
|
954
782
|
**One thing the three locations cannot establish, said rather than left implicit.** The second of them
|
|
955
783
|
is *whatever \`portulan\` is on your \`PATH\`* — and nothing here can tell that it is this tool rather
|
|
@@ -984,46 +812,18 @@ exit 2
|
|
|
984
812
|
`;
|
|
985
813
|
}
|
|
986
814
|
|
|
987
|
-
/**
|
|
988
|
-
* The records rail, drafted with its own honest first state.
|
|
989
|
-
*
|
|
990
|
-
* Unlike `workspace.sh` this one is **finished** — it checks a real thing on the day it is written,
|
|
991
|
-
* because the index it holds current is written beside it. What it cannot promise is that the tool is
|
|
992
|
-
* reachable: the package is published, but a drafted workspace does not install it, so on an adopter's
|
|
993
|
-
* CI none of the three locations may answer and the recipe's expected first state there is **exit 2**.
|
|
994
|
-
* Publication widened the ways it CAN be reachable; it did not make it reachable by default. That is the honest code and it is
|
|
995
|
-
* said in `verify/README.md` as well, rather than discovered when a pipeline goes amber.
|
|
996
|
-
*
|
|
997
|
-
* The third location is the bundle this workspace was drafted from, and it is written in as a
|
|
998
|
-
* MACHINE-LOCAL convenience for the person who ran `init` — an absolute path on one machine, which
|
|
999
|
-
* git cannot carry anywhere (`.portulan/memory/a-generated-file-must-not-point-at-what-git-cannot-carry.md`).
|
|
1000
|
-
* The script says so where a reader meets it, so nobody reads it as a portable location.
|
|
1001
|
-
*
|
|
1002
|
-
* **Both lines carrying it are marked `# portulan:bundle-fallback`, and the marker is the point.** A
|
|
1003
|
-
* workspace is not fixed where it was drafted: `vendor --switch` copies these files byte for byte into
|
|
1004
|
-
* another residence — so this path travels to machines it was never true on, and it does so silently,
|
|
1005
|
-
* because a stale absolute path exits 2 rather than failing loudly. A comment asking the next
|
|
1006
|
-
* implementer to notice is the reminder this project trades away wherever it can; a fixed token is
|
|
1007
|
-
* something a rewriter can **find**.
|
|
1008
|
-
*
|
|
1009
|
-
* **The rewriter arrived at milestone 7 session 9**, and this sentence read "`upgrade` *will* rewrite
|
|
1010
|
-
* them" until it did: `spec/migrations/0002-bundle-fallback-path.mjs`, run by `portulan upgrade`,
|
|
1011
|
-
* re-derives every marked line for the bundle it is running from — wherever the line is, since the
|
|
1012
|
-
* marker rather than the filename is the contract. `cli/vendor.mjs` and that step both cite the token
|
|
1013
|
-
* and `init.test.mjs` asserts they do, so the marker cannot be dropped by an edit to this recipe that
|
|
1014
|
-
* forgets why it was there.
|
|
1015
|
-
*/
|
|
815
|
+
/** The records rail; its `portulan:bundle-fallback` lines hold a machine-local path, which `spec/migrations/0002` re-derives. */
|
|
1016
816
|
function draftIndexRecipe() {
|
|
1017
817
|
const bundle = path.resolve(HERE, "..");
|
|
1018
818
|
return `#!/usr/bin/env bash
|
|
1019
|
-
# Records rail — the
|
|
819
|
+
# Records rail — the handoff series is rendered as its index, in memory.
|
|
1020
820
|
#
|
|
1021
|
-
# \`handoffs
|
|
1022
|
-
#
|
|
1023
|
-
#
|
|
821
|
+
# The index is not kept: \`index --handoffs\` prints it. This recipe renders it, so a handoff that yields
|
|
822
|
+
# no index line is a RED, and a copy someone keeps on disk is compared byte for byte, so one edited by
|
|
823
|
+
# hand or left behind when a handoff was added is a RED too. Nothing here writes.
|
|
1024
824
|
#
|
|
1025
|
-
# exit 0 green:
|
|
1026
|
-
# exit 1 red:
|
|
825
|
+
# exit 0 green: every handoff yields a line, and any kept copy matches
|
|
826
|
+
# exit 1 red: one does not — fix the handoff, or regenerate or delete the copy
|
|
1027
827
|
# exit 2 could not run: the Portulan CLI is not reachable from here. NEVER a pass.
|
|
1028
828
|
|
|
1029
829
|
set -uo pipefail
|
|
@@ -1075,9 +875,6 @@ code=\$?
|
|
|
1075
875
|
# codes exist. Left unmapped, either reads downstream as this recipe having RUN and rendered a
|
|
1076
876
|
# verdict about the index, which is the laundering \`need_node\` prevents on the branches it guards.
|
|
1077
877
|
# The index tool itself only ever exits 0, 1 or 2, so neither code is ambiguous here.
|
|
1078
|
-
#
|
|
1079
|
-
# Raised by Copilot, round 1 on #227, naming 127 alone; measured while pinning it, a bad interpreter
|
|
1080
|
-
# is **126** on this platform and the fix would have missed the case that prompted it.
|
|
1081
878
|
if [ "\$code" -eq 126 ] || [ "\$code" -eq 127 ]; then
|
|
1082
879
|
printf 'verify: the index tool could not be executed (%s) — the index was NOT checked.\\n' "\$code" >&2
|
|
1083
880
|
exit 2
|
|
@@ -1086,27 +883,56 @@ exit "\$code"
|
|
|
1086
883
|
`;
|
|
1087
884
|
}
|
|
1088
885
|
|
|
886
|
+
/** After the write, so a failed compile only warns; the guidance half only, since `.claude/settings.json` comes from a `compile` a person runs. */
|
|
887
|
+
function reportCard(target, say, warn) {
|
|
888
|
+
try {
|
|
889
|
+
compileGuidance(target);
|
|
890
|
+
say(`init: compiled the boot card into ${COMPILED_CARD}, which Claude Code loads into every context here`);
|
|
891
|
+
} catch (error) {
|
|
892
|
+
warn(`init: the boot card was drafted and NOT compiled — ${error.message}. Until \`portulan compile\` runs clean, a session here boots through the slots, as before`);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (cardIgnored(target)) {
|
|
896
|
+
warn(`init: git ignores ${COMPILED_CARD}, so the card would reach no review and no fresh checkout — add an exception for it to .gitignore`);
|
|
897
|
+
}
|
|
898
|
+
let bytes;
|
|
899
|
+
try {
|
|
900
|
+
bytes = alwaysTier(target).entries.reduce((n, e) => n + e.bytes, 0);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
warn(`init: the always tier could not be measured — ${error.message}`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const tokens = tokensOf(bytes, ESTIMATED_BYTES_PER_TOKEN);
|
|
906
|
+
say(
|
|
907
|
+
`init: the always tier here is ~${tokens.toLocaleString("en-US")} tokens with the card, at 0036's estimate of ${ESTIMATED_BYTES_PER_TOKEN} bytes a token. ` +
|
|
908
|
+
`A budget is yours to declare: 0036 offers the larger of ${OFFER_FLOOR_TOKENS.toLocaleString("en-US")} tokens and that, ` +
|
|
909
|
+
`${Math.max(OFFER_FLOOR_TOKENS, tokens).toLocaleString("en-US")}, as \`context.always.budget.tokens\`, beside a \`context.ratio\` Claude Code's exact count measured here`,
|
|
910
|
+
);
|
|
911
|
+
const offer = offerText(splitOffers(target, { ratio: ESTIMATED_BYTES_PER_TOKEN, floor: OFFER_FLOOR_TOKENS }));
|
|
912
|
+
if (offer !== null) say(`init: ${offer}`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function reportLifetime(answers, say) {
|
|
916
|
+
const lifetime = answers.cacheLifetime ?? null;
|
|
917
|
+
if (lifetime !== null) {
|
|
918
|
+
say(
|
|
919
|
+
`init: the manifest declares a ${lifetime} cache lifetime, \`sessions.cache_lifetime\` at Workspace Definition ${SESSIONS_SPEC} — ` +
|
|
920
|
+
"run `portulan compile`, which writes it into .claude/settings.json as `promptCacheTtl`",
|
|
921
|
+
);
|
|
922
|
+
} else if (answers.lifetimeDeclined) {
|
|
923
|
+
say(
|
|
924
|
+
`init: no cache lifetime declared, as you answered; \`sessions.cache_lifetime\` declares one, ${CACHE_LIFETIMES.map((v) => `"${v}"`).join(" or ")} ` +
|
|
925
|
+
`at Workspace Definition ${SESSIONS_SPEC}, and \`--cache-lifetime\` drafts it`,
|
|
926
|
+
);
|
|
927
|
+
} else {
|
|
928
|
+
for (const line of offerLines()) say(`init: ${line}`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
1089
932
|
// ------------------------------------------------------------------------- writing
|
|
1090
933
|
|
|
1091
|
-
/**
|
|
1092
|
-
* The residence already present in a target, if any.
|
|
1093
|
-
*
|
|
1094
|
-
* An unreadable manifest returns `unreadable` rather than `none`, and the distinction is the whole
|
|
1095
|
-
* point: reading a parse failure as "no workspace here" would make a corrupt manifest the one case
|
|
1096
|
-
* where this tool overwrites — the case where it knows least and where overwriting costs most.
|
|
1097
|
-
*/
|
|
1098
934
|
export function residenceAt(target) {
|
|
1099
|
-
//
|
|
1100
|
-
// the whole of it. This function used `existsSync`/`readFileSync`, which follow symlinks — so a
|
|
1101
|
-
// repository with a `.portulan` symlink pointing at somebody else's workspace made `init` read a
|
|
1102
|
-
// manifest OUTSIDE the target and announce *"this repository already carries a `repository`
|
|
1103
|
-
// workspace"*, naming a workspace that is not in this repository at all. Two failures in one
|
|
1104
|
-
// sentence: an out-of-repo read, and a refusal that misdescribed what it found.
|
|
1105
|
-
//
|
|
1106
|
-
// It also ran ahead of the symlink-aware collision check, so the containment guarantee that check
|
|
1107
|
-
// exists to give was reachable only when this function happened not to fire first. A guarantee
|
|
1108
|
-
// that depends on which check runs first is not a guarantee. Found by review on the pull request,
|
|
1109
|
-
// as the follow-up to the escape it is the other half of.
|
|
935
|
+
// `lstat` down the path before any read: `readFileSync` follows a `.portulan` link out of the repository.
|
|
1110
936
|
let here = target;
|
|
1111
937
|
for (const segment of [".portulan", "workspace.json"]) {
|
|
1112
938
|
here = path.join(here, segment);
|
|
@@ -1114,16 +940,7 @@ export function residenceAt(target) {
|
|
|
1114
940
|
try {
|
|
1115
941
|
stat = fs.lstatSync(here);
|
|
1116
942
|
} catch (error) {
|
|
1117
|
-
// ONLY `ENOENT` means "nothing here". Every other error — EACCES above all — means the
|
|
1118
|
-
// question could not be answered, and answering "no residence" to a question that could
|
|
1119
|
-
// not be answered is the fail-open that `a-checker-must-refuse-what-it-cannot-check.md` governs:
|
|
1120
|
-
// "nothing looked" reported as "nothing wrong". A permission error here would have let
|
|
1121
|
-
// `init` proceed to write into a directory it could not even stat.
|
|
1122
943
|
if (error.code === "ENOENT") return { state: "none" };
|
|
1123
|
-
// `kind` is carried because the two ways of being unreadable need DIFFERENT sentences.
|
|
1124
|
-
// Telling somebody with a permissions problem to "repair the JSON" is a refusal that
|
|
1125
|
-
// misdescribes what it found — the defect the read-side symlink fix was about, arriving
|
|
1126
|
-
// one round later in the message rather than in the check.
|
|
1127
944
|
return { state: "unreadable", kind: "io", why: error.message };
|
|
1128
945
|
}
|
|
1129
946
|
if (stat.isSymbolicLink()) return { state: "symlink", where: path.relative(target, here) };
|
|
@@ -1137,70 +954,12 @@ export function residenceAt(target) {
|
|
|
1137
954
|
return { state: "present", kind: parsed?.kind ?? "unknown", name: parsed?.name ?? null };
|
|
1138
955
|
}
|
|
1139
956
|
|
|
1140
|
-
/**
|
|
1141
|
-
* The FIRST root that carries the pack, or null.
|
|
1142
|
-
*
|
|
1143
|
-
* It returned a boolean until 2026-08-13, and the root is needed now rather than merely convenient: the
|
|
1144
|
-
* unasked path resolves from **either** the host's plugin cache or the workspace's own `<target>/packs`,
|
|
1145
|
-
* and the closing advice was written claiming the cache for both. A sentence naming the wrong residence
|
|
1146
|
-
* is worse than one naming none — it sends an adopter to uninstall a plugin that had nothing to do with
|
|
1147
|
-
* it. Found by trying to write the test for the degrade, which is the half reading never catches.
|
|
1148
|
-
*
|
|
1149
|
-
* First-match-wins, matching `resolvePack`, so the order `expandRoots` returns is the order that decides.
|
|
1150
|
-
*/
|
|
957
|
+
/** The first of `roots` carrying `packId`, or null: first match wins, as in `resolvePack`. */
|
|
1151
958
|
function packResolvedAt(roots, packId) {
|
|
1152
959
|
return roots.find((root) => fs.existsSync(path.join(root, packId, "pack.json"))) ?? null;
|
|
1153
960
|
}
|
|
1154
961
|
|
|
1155
|
-
/**
|
|
1156
|
-
* The roots `init` checks a composed pack against, resolving `auto` the way the other four tools do.
|
|
1157
|
-
*
|
|
1158
|
-
* `auto` is kept VERBATIM in the answers file rather than expanded into the path it resolved to today:
|
|
1159
|
-
* a persisted answer naming a machine-specific cache path is exactly what an answers file exists to
|
|
1160
|
-
* avoid, and `auto` replays correctly on another machine while a path does not.
|
|
1161
|
-
*
|
|
1162
|
-
* **Two unions, and only one of them is law — do not collapse them.**
|
|
1163
|
-
*
|
|
1164
|
-
* - **Named ∪ discovered is still REFUSED.** The first version of this appended discovered roots
|
|
1165
|
-
* beside named ones, which made `init` the odd tool out with union semantics: the divergence
|
|
1166
|
-
* `../cli/compile.mjs`'s `namedRootsOption` records, re-committed inside the change that cites it.
|
|
1167
|
-
* Since 2026-08-12 asking for both is a refusal rather than a silent drop, which is stricter than
|
|
1168
|
-
* what that incident produced and in the same direction. _(This said "the only tool of **five**",
|
|
1169
|
-
* a figure that was seven by the time `recipe-set` and `init` joined the refusal — see
|
|
1170
|
-
* `NAMED_WITH_AUTO`, which derives nothing and has now been wrong twice. The count is dropped rather
|
|
1171
|
-
* than corrected, because nothing in the sentence needs it.)_
|
|
1172
|
-
* - **Discovered ∪ derived IS law**, as of the same date, in `discover.mjs`'s `resolutionRoots`. A
|
|
1173
|
-
* later reader must not "fix" this arm by citing the incident above: that incident is about the
|
|
1174
|
-
* first pair, and this arm implements the second.
|
|
1175
|
-
*
|
|
1176
|
-
* The derived root here is the one the workspace **being drafted** would have — `<target>/packs` —
|
|
1177
|
-
* because this check exists to predict the `doctor` run that follows the draft. Mirroring the shared
|
|
1178
|
-
* rule is what stops `init` refusing a composition that `doctor` then resolves happily: a false red
|
|
1179
|
-
* against the very tool it is predicting. _(That read "`doctor --pack-root auto`" until 2026-08-13,
|
|
1180
|
-
* when the bare invocation started resolving the same way — which is precisely why this arm had to
|
|
1181
|
-
* gain its own unasked branch below rather than staying a named-roots check.)_
|
|
1182
|
-
*
|
|
1183
|
-
* ## The UNASKED arm, added 2026-08-13 with the disposal, and its one hard rule
|
|
1184
|
-
*
|
|
1185
|
-
* `doctor` consults discovery unasked as of that date, so this check must too or it stops predicting
|
|
1186
|
-
* the run it exists to predict — and the prediction is the whole reason it is here. But `init` **WRITES
|
|
1187
|
-
* FILES**, and that makes one arm of the disposal non-negotiable here:
|
|
1188
|
-
*
|
|
1189
|
-
* **On the unasked path, a pack that does not resolve is ADVICE and never a refusal.** A refusal would
|
|
1190
|
-
* mean `init` drafts a workspace on a host where the pack is installed and refuses to draft one where
|
|
1191
|
-
* it is not — host-dependence deciding whether files exist, which is worse than any verdict moving.
|
|
1192
|
-
* `--pack-root <dir>` and `--pack-root auto` keep their refusals: a caller who said where to look and
|
|
1193
|
-
* was wrong is owed one. Nobody who said nothing is.
|
|
1194
|
-
*
|
|
1195
|
-
* **And the DRAFT ITSELF is byte-identical on every host.** Discovery reaches the advice and the
|
|
1196
|
-
* resolvability answer, never `draft()`. That is `../docs/vision.md`'s *no auto-generated curated
|
|
1197
|
-
* context* at the one tool that could break it, and `init.test.mjs` hashes the drafted files on a host
|
|
1198
|
-
* with a record and on one without to keep it that way.
|
|
1199
|
-
*
|
|
1200
|
-
* Returns `{ roots, why, refusal, asked }`. `why` is non-null only when discovery **could not look**,
|
|
1201
|
-
* which a caller must be able to tell from *looked and found nothing*; `asked` is what lets the caller
|
|
1202
|
-
* choose between a refusal and a sentence for the same answer.
|
|
1203
|
-
*/
|
|
962
|
+
/** The roots a composed pack is checked against, predicting the `doctor` run after the draft; `why` is set only where discovery could not look. */
|
|
1204
963
|
function expandRoots(roots, target, env) {
|
|
1205
964
|
const named = roots.filter((root) => root !== AUTO);
|
|
1206
965
|
const forced = roots.includes(AUTO);
|
|
@@ -1208,41 +967,14 @@ function expandRoots(roots, target, env) {
|
|
|
1208
967
|
const refusal = namedWithAuto(named, forced);
|
|
1209
968
|
if (refusal) return { roots: [], why: null, refusal, asked: true };
|
|
1210
969
|
if (named.length) return { roots: named, why: null, refusal: null, asked: true };
|
|
1211
|
-
// `env` is the injection seam — see `index.mjs`'s `readScopes`.
|
|
1212
970
|
const found = discoverPackRoots({ env });
|
|
1213
|
-
// Could not look. Asked, that is a refusal at the call site; unasked, the derived root carries on
|
|
1214
|
-
// alone and the sentence says why the other half is missing — the same degrade
|
|
1215
|
-
// `../cli/discover.mjs`'s `resolutionRoots` makes, for the same reason.
|
|
1216
971
|
if (!found.ok) return { roots: forced ? [] : [derived], why: found.why, refusal: null, asked: forced };
|
|
1217
972
|
return { roots: [...found.roots, derived], why: null, refusal: null, asked: forced };
|
|
1218
973
|
}
|
|
1219
974
|
|
|
1220
|
-
/**
|
|
1221
|
-
* Every path in the draft that cannot be written without destroying or being blocked by something.
|
|
1222
|
-
*
|
|
1223
|
-
* The residence check above keys on the MANIFEST, which is right for "is this repository governed?"
|
|
1224
|
-
* and wrong for "is it safe to write here?". A `.portulan/` holding a hand-written `gate-map.md` and
|
|
1225
|
-
* no manifest is not a residence — and the first version of this tool silently replaced that file.
|
|
1226
|
-
* The two questions are separate and both have to be asked.
|
|
1227
|
-
*
|
|
1228
|
-
* Blocked ancestors are the same check from the other side: a `verify` that is a *file* makes
|
|
1229
|
-
* `verify/README.md` unwritable, and finding that out halfway through the write loop is how a run
|
|
1230
|
-
* leaves a torso behind.
|
|
1231
|
-
*/
|
|
1232
975
|
export function collisions(target, files) {
|
|
1233
976
|
const found = [];
|
|
1234
977
|
for (const rel of files.keys()) {
|
|
1235
|
-
// Walked from the target DOWN, one segment at a time, with `lstatSync` — never `statSync`,
|
|
1236
|
-
// and never `existsSync`. Both of those FOLLOW symlinks, which is how the first version of
|
|
1237
|
-
// this check let a `.portulan` symlink carry the whole drafted workspace out of the
|
|
1238
|
-
// repository: `init` wrote nine files somewhere else entirely and reported success.
|
|
1239
|
-
// Demonstrated on the pull request. `doctor` and `plugin-lint` already treat a symlink escape
|
|
1240
|
-
// as significant; this is the same rule arriving at the tool that WRITES, where it matters
|
|
1241
|
-
// more than at the tools that read.
|
|
1242
|
-
//
|
|
1243
|
-
// A symlink anywhere on the chain is a collision rather than something to resolve and permit.
|
|
1244
|
-
// Resolving it would mean deciding whether the destination is "really" inside the repository,
|
|
1245
|
-
// which is a containment judgement with a bad failure mode; refusing is a judgement with none.
|
|
1246
978
|
const segments = rel.split("/");
|
|
1247
979
|
let here = target;
|
|
1248
980
|
for (let i = 0; i < segments.length; i++) {
|
|
@@ -1252,14 +984,9 @@ export function collisions(target, files) {
|
|
|
1252
984
|
stat = fs.lstatSync(here);
|
|
1253
985
|
} catch (error) {
|
|
1254
986
|
if (error.code === "ENOENT") {
|
|
1255
|
-
//
|
|
1256
|
-
// is clear and the walk stops.
|
|
987
|
+
// Nothing can exist below an absent path, so the rest of the chain is clear.
|
|
1257
988
|
break;
|
|
1258
989
|
}
|
|
1259
|
-
// Anything else — EACCES above all — means this path's state is UNKNOWN, and an
|
|
1260
|
-
// unknown is not a clear. Declaring it clear would let the write loop start on the
|
|
1261
|
-
// strength of a question nobody could answer, which is precisely the guarantee this
|
|
1262
|
-
// function exists to give. Reported as a collision so the run refuses.
|
|
1263
990
|
found.push({ rel, why: `\`${path.relative(target, here)}\` could not be examined (${error.code})` });
|
|
1264
991
|
break;
|
|
1265
992
|
}
|
|
@@ -1269,7 +996,8 @@ export function collisions(target, files) {
|
|
|
1269
996
|
break;
|
|
1270
997
|
}
|
|
1271
998
|
if (i === segments.length - 1) {
|
|
1272
|
-
found.push({ rel, why: "already exists" });
|
|
999
|
+
if (files.get(rel)?.append === undefined && !files.get(rel)?.ifAbsent) found.push({ rel, why: "already exists" });
|
|
1000
|
+
else if (!stat.isFile()) found.push({ rel, why: `\`${where}\` is in the way and is not a file` });
|
|
1273
1001
|
break;
|
|
1274
1002
|
}
|
|
1275
1003
|
if (!stat.isDirectory()) {
|
|
@@ -1283,18 +1011,8 @@ export function collisions(target, files) {
|
|
|
1283
1011
|
|
|
1284
1012
|
// ------------------------------------------------------------------------- the interview
|
|
1285
1013
|
|
|
1286
|
-
/**
|
|
1287
|
-
* Every refusal owed to a repository that is already governed, in one place.
|
|
1288
|
-
*
|
|
1289
|
-
* Extracted from `run` when the interview arrived, because it is now asked at two moments — before a
|
|
1290
|
-
* human is made to answer five questions, and at the point in the sequence it has always occupied —
|
|
1291
|
-
* and a second spelling of three refusals is three chances for them to drift apart.
|
|
1292
|
-
*/
|
|
1293
1014
|
export function refuseIfGoverned(existing, spelling) {
|
|
1294
1015
|
if (existing.state === "symlink") {
|
|
1295
|
-
// Handled here rather than left to the collision check, so the containment refusal does not
|
|
1296
|
-
// depend on which check happens to run first — and so nothing outside the repository was read
|
|
1297
|
-
// in order to produce it.
|
|
1298
1016
|
throw new InitError(
|
|
1299
1017
|
`\`${existing.where}\` is a symlink, and \`init\` will not follow one out of the repository — not to write ` +
|
|
1300
1018
|
`through it, and not to read a workspace manifest through it either. A manifest reached that way ` +
|
|
@@ -1331,14 +1049,6 @@ export function refuseIfGoverned(existing, spelling) {
|
|
|
1331
1049
|
}
|
|
1332
1050
|
}
|
|
1333
1051
|
|
|
1334
|
-
/**
|
|
1335
|
-
* The default reader: a real terminal, and only where there is one at BOTH ends.
|
|
1336
|
-
*
|
|
1337
|
-
* `interactive` is the whole gate on the interview, and it is two questions rather than one. A pipe
|
|
1338
|
-
* on stdin is a script; a redirected stdout is a log — and prompting into either produces a run that
|
|
1339
|
-
* hangs waiting for an answer nobody is there to give, which on CI is a job that burns its timeout
|
|
1340
|
-
* instead of printing the refusal that names the flag to pass.
|
|
1341
|
-
*/
|
|
1342
1052
|
function terminal() {
|
|
1343
1053
|
return {
|
|
1344
1054
|
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
@@ -1348,8 +1058,7 @@ function terminal() {
|
|
|
1348
1058
|
try {
|
|
1349
1059
|
return await rl.question(question);
|
|
1350
1060
|
} catch {
|
|
1351
|
-
// A closed stdin rejects rather than returning
|
|
1352
|
-
// the caller's EOF handling is where that becomes a refusal.
|
|
1061
|
+
// A closed stdin rejects rather than returning: no answer is coming, which the caller refuses.
|
|
1353
1062
|
return null;
|
|
1354
1063
|
} finally {
|
|
1355
1064
|
rl.close();
|
|
@@ -1361,30 +1070,11 @@ function terminal() {
|
|
|
1361
1070
|
};
|
|
1362
1071
|
}
|
|
1363
1072
|
|
|
1364
|
-
/**
|
|
1365
|
-
* Ask for what the flags did not supply, and fill `answers` in place.
|
|
1366
|
-
*
|
|
1367
|
-
* **What the gloss asked for and what this closes.** `docs/vision.md` glosses `init` as *interview +
|
|
1368
|
-
* codebase scan → drafted workspace, human curates*. The scan has shipped since session 1 and the
|
|
1369
|
-
* substrate — every question modelled as an answer, with one validator each — since then; the prompt
|
|
1370
|
-
* loop was the open half, and this is it. Nothing here decides anything the flags path could not
|
|
1371
|
-
* decide, which is the property that keeps the two paths one tool.
|
|
1372
|
-
*
|
|
1373
|
-
* **A question is re-asked, never fatal.** The flags path refuses a bad value because there is nobody
|
|
1374
|
-
* to ask again; here there is, and aborting an interview on a typo would make the interactive path the
|
|
1375
|
-
* unforgiving one. The validators are the same functions, so the two paths cannot drift into
|
|
1376
|
-
* disagreeing about what a valid answer is.
|
|
1377
|
-
*
|
|
1378
|
-
* Every answer supplied by a flag or an answers file is skipped: being asked to confirm something you
|
|
1379
|
-
* just typed on the command line reads as the tool not having listened.
|
|
1380
|
-
*/
|
|
1073
|
+
/** Asks for what the flags and the answers file did not give, and fills `answers` in place. */
|
|
1381
1074
|
export async function interview(answers, { io, target, derivedName }) {
|
|
1382
1075
|
const ask = async (prompt, { fallback = null, validate = () => null } = {}) => {
|
|
1383
1076
|
for (;;) {
|
|
1384
1077
|
const raw = await io.ask(fallback === null ? `${prompt}: ` : `${prompt} [${fallback}]: `);
|
|
1385
|
-
// EOF — Ctrl-D, or a stream that closed — is not an empty answer. An empty LINE accepts
|
|
1386
|
-
// the offered default; end-of-input means no answer is coming, and the run stops with
|
|
1387
|
-
// nothing written rather than proceeding on defaults nobody confirmed.
|
|
1388
1078
|
if (raw === null) throw new InitError("no answer — the interview ended before it finished, and nothing was written");
|
|
1389
1079
|
const value = raw.trim() === "" ? fallback : raw.trim();
|
|
1390
1080
|
if (value === null || value === "") {
|
|
@@ -1433,8 +1123,6 @@ export async function interview(answers, { io, target, derivedName }) {
|
|
|
1433
1123
|
answers.given.add("governed-by");
|
|
1434
1124
|
}
|
|
1435
1125
|
if (answers.feed === null) {
|
|
1436
|
-
// `none` rather than an empty line, because an empty line means *take the default* at
|
|
1437
|
-
// every other prompt and a second meaning for it here is how an answer gets misread.
|
|
1438
1126
|
const feed = await ask("the feed it ships through", { fallback: "none" });
|
|
1439
1127
|
if (feed !== "none") {
|
|
1440
1128
|
answers.feed = feed;
|
|
@@ -1454,8 +1142,19 @@ export async function interview(answers, { io, target, derivedName }) {
|
|
|
1454
1142
|
}
|
|
1455
1143
|
}
|
|
1456
1144
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1145
|
+
if (answers.residence === "in-repo" && !answers.given.has("cache-lifetime")) {
|
|
1146
|
+
io.say("");
|
|
1147
|
+
for (const line of offerLines({ asking: true })) io.say(line);
|
|
1148
|
+
const lifetime = await io.ask("Five-minute cache writes? [y/N]: ");
|
|
1149
|
+
if (lifetime === null) throw new InitError("no answer — the interview ended before it finished, and nothing was written");
|
|
1150
|
+
if (/^y(es)?$/i.test(lifetime.trim())) {
|
|
1151
|
+
answers.cacheLifetime = "5m";
|
|
1152
|
+
answers.given.add("cache-lifetime");
|
|
1153
|
+
} else {
|
|
1154
|
+
answers.lifetimeDeclined = true;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1459
1158
|
io.say("");
|
|
1460
1159
|
io.say("init: about to draft");
|
|
1461
1160
|
io.say(` residence ${answers.residence}`);
|
|
@@ -1466,12 +1165,15 @@ export async function interview(answers, { io, target, derivedName }) {
|
|
|
1466
1165
|
io.say(` feed ${answers.feed ?? "(none)"}`);
|
|
1467
1166
|
} else {
|
|
1468
1167
|
io.say(` cycle ${answers.cycle ? answers.checkpoints : "(none — composing no packs)"}`);
|
|
1168
|
+
io.say(
|
|
1169
|
+
` cache ${
|
|
1170
|
+
answers.cacheLifetime ? `${answers.cacheLifetime}, as \`sessions.cache_lifetime\` at Workspace Definition ${SESSIONS_SPEC}` : "(none — the host's default lifetime)"
|
|
1171
|
+
}`,
|
|
1172
|
+
);
|
|
1469
1173
|
}
|
|
1470
1174
|
io.say("");
|
|
1471
1175
|
const confirm = await io.ask("Write these files? [y/N]: ");
|
|
1472
1176
|
if (confirm === null || !/^y(es)?$/i.test(confirm.trim())) {
|
|
1473
|
-
// Exit 2, because this file has exactly two codes and 0 means *it wrote*. A decline is not a
|
|
1474
|
-
// verdict about anybody's workspace either, so 1 stays as absent as it has always been.
|
|
1475
1177
|
throw new InitError("nothing written — you declined at the confirmation. Run this again when the answers are right.");
|
|
1476
1178
|
}
|
|
1477
1179
|
}
|
|
@@ -1482,8 +1184,6 @@ export function usage() {
|
|
|
1482
1184
|
return [
|
|
1483
1185
|
"portulan init — draft a workspace for a repository that has none",
|
|
1484
1186
|
"",
|
|
1485
|
-
// Both spellings, for #155's reason: the tool is reachable through the entry point and from a
|
|
1486
|
-
// checkout, and a usage line naming only one of them is wrong for whoever arrived the other way.
|
|
1487
1187
|
" portulan init [options] <repository-directory>",
|
|
1488
1188
|
" node cli/init.mjs [options] <repository-directory> (from a checkout)",
|
|
1489
1189
|
"",
|
|
@@ -1499,6 +1199,9 @@ export function usage() {
|
|
|
1499
1199
|
" --pack-root <dir> Where packs are looked up, so the composed one can be",
|
|
1500
1200
|
" confirmed to exist. Repeatable. `auto` discovers it from",
|
|
1501
1201
|
" the host's plugin cache; `./auto` names a directory.",
|
|
1202
|
+
" --cache-lifetime <5m|1h> In-repo only: declare `sessions.cache_lifetime`, which",
|
|
1203
|
+
" `compile` writes as Claude Code's `promptCacheTtl`. Unset,",
|
|
1204
|
+
" the host's default stands and five minutes is offered.",
|
|
1502
1205
|
" --answers <file> A JSON object of answers. Flags override its keys.",
|
|
1503
1206
|
" --no-interview Never ask, even at a terminal: refuse instead, naming the flag.",
|
|
1504
1207
|
"",
|
|
@@ -1547,18 +1250,10 @@ export async function run(argv, options = {}) {
|
|
|
1547
1250
|
const answers = resolveAnswers(parsed.flags);
|
|
1548
1251
|
const derivedName = slugify(path.basename(target));
|
|
1549
1252
|
|
|
1550
|
-
// The interview, and the two conditions on it. A prompt loop cannot be run by CI, by a test or
|
|
1551
|
-
// by a headless host, so **everything below happens only where somebody is at both ends** —
|
|
1552
|
-
// stdin and stdout are both TTYs — and a non-interactive run is byte-for-byte what it was
|
|
1553
|
-
// before this existed. `--no-interview` is the escape for a TTY that wants the old refusals.
|
|
1554
|
-
//
|
|
1555
|
-
// The governance question is asked FIRST and by the machine, not the human: being made to
|
|
1556
|
-
// answer five questions and then told this repository already has a workspace is the shape of
|
|
1557
|
-
// a tool that asks before it looks. The same refusal stands where it always did, for callers
|
|
1558
|
-
// that never reach this branch — running it twice is two reads and no writes.
|
|
1559
1253
|
const io = options.io ?? terminal();
|
|
1560
1254
|
const interviewing = io.interactive && !parsed.flags.noInterview;
|
|
1561
1255
|
if (interviewing) {
|
|
1256
|
+
// Asked before the questions too, so nobody answers them to be told the repository is already governed.
|
|
1562
1257
|
refuseIfGoverned(residenceAt(target), parsed.target);
|
|
1563
1258
|
await interview(answers, { io, target, derivedName });
|
|
1564
1259
|
}
|
|
@@ -1572,20 +1267,11 @@ export async function run(argv, options = {}) {
|
|
|
1572
1267
|
}
|
|
1573
1268
|
validateAnswers(answers);
|
|
1574
1269
|
|
|
1575
|
-
// Before a byte: is this repository already governed? A repository is governed by exactly one
|
|
1576
|
-
// workspace, so finding one here is a refusal rather than a prompt to replace it.
|
|
1577
1270
|
refuseIfGoverned(residenceAt(target), parsed.target);
|
|
1578
1271
|
|
|
1579
|
-
// **`answers.packRoots.length` was the gate, and dropping it is what makes the unasked arm
|
|
1580
|
-
// live.** With it, `expandRoots`' new branch was unreachable from the command line — the exact
|
|
1581
|
-
// shape of dead plumbing `skills-set` and `recipe-set` were each caught with, a capability that
|
|
1582
|
-
// reads as wired and is none. Every `in-repo` run that composes a pack now asks the question;
|
|
1583
|
-
// what differs is whether the answer can refuse.
|
|
1584
1272
|
let packAdvice = null;
|
|
1585
1273
|
if (answers.residence === "in-repo" && answers.cycle) {
|
|
1586
1274
|
const expanded = expandRoots(answers.packRoots, target, options.env);
|
|
1587
|
-
// Belt and braces: `validateAnswers` has already refused this pair on every path. Kept
|
|
1588
|
-
// because `expandRoots` is reachable on its own and must not answer with an empty set.
|
|
1589
1275
|
if (expanded.refusal) throw new InitError(expanded.refusal);
|
|
1590
1276
|
const resolvedAt = packResolvedAt(expanded.roots, answers.checkpoints);
|
|
1591
1277
|
const resolved = resolvedAt !== null;
|
|
@@ -1605,27 +1291,20 @@ export async function run(argv, options = {}) {
|
|
|
1605
1291
|
);
|
|
1606
1292
|
}
|
|
1607
1293
|
}
|
|
1608
|
-
//
|
|
1609
|
-
// because a run that refuses to draft on one host and drafts on another has made the
|
|
1610
|
-
// existence of files a function of the machine. The three answers are kept apart — resolved,
|
|
1611
|
-
// looked-and-absent, could-not-look — because "the pack is not here" and "I could not tell"
|
|
1612
|
-
// send a reader to different places.
|
|
1613
|
-
// `inTree` is what keeps the advice from naming the wrong residence: the unasked path
|
|
1614
|
-
// searches the host cache AND `<target>/packs`, and only the second is the repository's own.
|
|
1294
|
+
// Unasked, an unresolved pack is advice, never a refusal: whether files are written must not depend on the host.
|
|
1615
1295
|
packAdvice = { resolved, why: expanded.why, inTree: resolvedAt !== null && resolvedAt === path.join(target, "packs") };
|
|
1616
1296
|
}
|
|
1617
1297
|
|
|
1618
|
-
const
|
|
1298
|
+
const observed = scan(target, { comments: answers.residence !== "pointer" });
|
|
1299
|
+
const files = draft(answers, observed);
|
|
1300
|
+
|
|
1301
|
+
if (files.has(".gitignore")) {
|
|
1302
|
+
const { lines } = claudeRulesUnignore(target);
|
|
1303
|
+
if (lines.length) files.get(".gitignore").append.push("", ...lines);
|
|
1304
|
+
}
|
|
1619
1305
|
|
|
1620
|
-
// The second safety question, and it is not the one above. "Is this repository governed?"
|
|
1621
|
-
// keys on the manifest; "may I write here?" keys on every path the draft touches. A
|
|
1622
|
-
// `.portulan/` with a hand-written file and no manifest answers no to the first and must
|
|
1623
|
-
// still stop the second.
|
|
1624
1306
|
const clash = collisions(target, files);
|
|
1625
1307
|
if (clash.length) {
|
|
1626
|
-
// Grouped by CAUSE, not listed per path. One symlinked `.portulan` blocks every drafted
|
|
1627
|
-
// file, and naming it ten times buries the single fact the reader needs under nine
|
|
1628
|
-
// copies of it — a refusal nobody finishes reading is a refusal that failed to explain.
|
|
1629
1308
|
const byCause = new Map();
|
|
1630
1309
|
for (const { rel, why } of clash) (byCause.get(why) ?? byCause.set(why, []).get(why)).push(rel);
|
|
1631
1310
|
const summary = [...byCause]
|
|
@@ -1643,41 +1322,48 @@ export async function run(argv, options = {}) {
|
|
|
1643
1322
|
);
|
|
1644
1323
|
}
|
|
1645
1324
|
|
|
1646
|
-
//
|
|
1647
|
-
// discover — but I/O can still fail for reasons no check can see, and the ORDER decides what
|
|
1648
|
-
// a failure leaves behind. `workspace.json` is written LAST: written first, a half-completed
|
|
1649
|
-
// run leaves a torso that the residence check above then reads as a governed repository,
|
|
1650
|
-
// and the retry is refused with a sentence that is false. Last, the same failure leaves
|
|
1651
|
-
// files and no manifest — which the collision check reports plainly and a person can clear.
|
|
1325
|
+
// The manifest last: a run failing midway leaves no torso that `residenceAt` would read as a governed repository.
|
|
1652
1326
|
const manifest = ".portulan/workspace.json";
|
|
1327
|
+
const left = [];
|
|
1653
1328
|
for (const [rel, file] of [...files].filter(([rel]) => rel !== manifest).concat([[manifest, files.get(manifest)]])) {
|
|
1654
1329
|
const full = path.join(target, rel);
|
|
1330
|
+
if (file.ifAbsent && fs.lstatSync(full, { throwIfNoEntry: false }) !== undefined) {
|
|
1331
|
+
left.push(rel);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1655
1334
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
1335
|
+
if (file.append !== undefined) {
|
|
1336
|
+
let before = null;
|
|
1337
|
+
try {
|
|
1338
|
+
before = fs.readFileSync(full, "utf8");
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
if (error.code !== "ENOENT") throw error;
|
|
1341
|
+
}
|
|
1342
|
+
fs.writeFileSync(full, withIgnoreLines(before, file.append));
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1656
1345
|
fs.writeFileSync(full, file.contents);
|
|
1657
1346
|
if (file.mode !== undefined) fs.chmodSync(full, file.mode);
|
|
1658
1347
|
}
|
|
1659
1348
|
|
|
1660
|
-
|
|
1349
|
+
const inWorkspace = [...files.keys()].filter((rel) => rel.startsWith(".portulan/")).length;
|
|
1350
|
+
const beside = [...files.keys()].filter((rel) => !rel.startsWith(".portulan/") && !left.includes(rel));
|
|
1351
|
+
say(
|
|
1352
|
+
`init: drafted ${inWorkspace} file(s) into ${path.join(parsed.target, ".portulan")}` +
|
|
1353
|
+
(beside.length ? `, and ${beside.map((r) => `\`${r}\``).join(", ")} beside it` : ""),
|
|
1354
|
+
);
|
|
1355
|
+
for (const rel of left) say(`init: left the repository's own \`${rel}\` as it is`);
|
|
1356
|
+
if (answers.residence !== "pointer") {
|
|
1357
|
+
reportCard(target, say, warn);
|
|
1358
|
+
reportLifetime(answers, say);
|
|
1359
|
+
}
|
|
1661
1360
|
if (answers.residence === "pointer") {
|
|
1662
1361
|
say(`init: this repository is recorded as governed by \`${answers.governedBy}\`. Nothing was fetched.`);
|
|
1663
1362
|
} else {
|
|
1664
1363
|
say("init: every file is a DRAFT. Read .portulan/README.md before trusting any of it.");
|
|
1665
1364
|
say("init: the verify recipe exits 2 until you say what green means here — that is deliberate.");
|
|
1365
|
+
if (observed.commentHistory === null) say(`init: drafted no \`comments\` recipe, since its count could not be taken: ${observed.uncounted}; \`portulan upgrade\` offers one once it can be.`);
|
|
1666
1366
|
if (answers.cycle) {
|
|
1667
|
-
// Said at the surface rather than only in a file, because the alternative is that the
|
|
1668
|
-
// adopter's very next command is `doctor`, it is RED on a pack nothing can find, and
|
|
1669
|
-
// the tool that put it there said nothing about it.
|
|
1670
|
-
//
|
|
1671
|
-
// **It used to say that unconditionally**, in the words "nothing resolves a pack for
|
|
1672
|
-
// you … name where it lives" — which stopped being true when `--pack-root auto`
|
|
1673
|
-
// landed at milestone 7 session 4, and read as advice to go and find a cache path by
|
|
1674
|
-
// hand immediately after `auto` had found one. Worse than merely stale: this tool has
|
|
1675
|
-
// ALREADY checked resolvability above, so it knew. Found by running `init` on a real
|
|
1676
|
-
// never-seen repository for D1, which is the half of this class no reading catches.
|
|
1677
|
-
// **The invocation printed is the one that WORKS, and since 2026-08-13 that is usually
|
|
1678
|
-
// the bare one.** `doctor` consults discovery unasked, so a host carrying the pack needs
|
|
1679
|
-
// no flag at all — and printing `--pack-root auto` there would be the same defect this
|
|
1680
|
-
// branch was already fixed for once, advice to type something the tool no longer needs.
|
|
1681
1367
|
const workspaceArg = path.join(parsed.target, ".portulan");
|
|
1682
1368
|
if (answers.packRoots.length) {
|
|
1683
1369
|
const rootArgs = answers.packRoots.map((r) => `--pack-root ${r}`).join(" ");
|
|
@@ -1687,10 +1373,6 @@ export async function run(argv, options = {}) {
|
|
|
1687
1373
|
const where = packAdvice.inTree ? "from `packs/` in this repository" : "from this host's plugin cache";
|
|
1688
1374
|
say(`init: this workspace composes \`${answers.checkpoints}\`, and it resolved ${where} — validate with:`);
|
|
1689
1375
|
say(`init: doctor ${workspaceArg}`);
|
|
1690
|
-
// Said out loud because it is the one thing the green does not certify — and only
|
|
1691
|
-
// where it is TRUE. A pack found on this machine is not in the repository, so a CI
|
|
1692
|
-
// runner with nothing installed derives `<repo>/packs` alone and reports it
|
|
1693
|
-
// unresolved; a pack found in the tree travels with the tree and needs no warning.
|
|
1694
1376
|
if (!packAdvice.inTree) {
|
|
1695
1377
|
say("init: that root is this machine's, not the repository's — pin `--pack-root <dir>` in CI.");
|
|
1696
1378
|
}
|
|
@@ -1722,12 +1404,7 @@ export async function run(argv, options = {}) {
|
|
|
1722
1404
|
}
|
|
1723
1405
|
}
|
|
1724
1406
|
|
|
1725
|
-
//
|
|
1726
|
-
// when this module is imported by something that is not a script — `node -e "import('./init.mjs')"`,
|
|
1727
|
-
// which is how a suite or a REPL reaches it — and `pathToFileURL(undefined)` throws at module load.
|
|
1728
|
-
// This file argued two hundred lines up that an unreadable schema must not throw while loading,
|
|
1729
|
-
// because the entry point can only turn that into a stack trace; it then omitted the guard that makes
|
|
1730
|
-
// the same promise here. Found at the pre-commit checkpoint.
|
|
1407
|
+
// `?? ""`: `process.argv[1]` is absent when a non-script imports this, and `pathToFileURL(undefined)` throws.
|
|
1731
1408
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
1732
1409
|
process.exitCode = await run(process.argv.slice(2));
|
|
1733
1410
|
}
|