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