@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.
Files changed (76) hide show
  1. package/README.md +6 -5
  2. package/cli/README.md +142 -358
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1322 -1607
  6. package/cli/context.mjs +883 -0
  7. package/cli/control-chars.mjs +12 -301
  8. package/cli/discover.mjs +17 -574
  9. package/cli/doctor.mjs +224 -1074
  10. package/cli/drills.mjs +68 -502
  11. package/cli/feedback.mjs +16 -237
  12. package/cli/finish.mjs +437 -0
  13. package/cli/form.mjs +644 -0
  14. package/cli/fuzz-shell.mjs +20 -305
  15. package/cli/gate.mjs +7 -199
  16. package/cli/goldens.mjs +26 -184
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +243 -566
  19. package/cli/inside.mjs +3 -28
  20. package/cli/instructions.mjs +829 -0
  21. package/cli/ledger.mjs +778 -0
  22. package/cli/librarian.mjs +135 -599
  23. package/cli/manifest.mjs +2 -39
  24. package/cli/mutants.mjs +9 -170
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +5 -74
  27. package/cli/pack-version.mjs +13 -255
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +15 -245
  31. package/cli/release-eval.mjs +28 -348
  32. package/cli/review-meter.mjs +17 -258
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +10 -176
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +83 -514
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +27 -492
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +8 -86
  43. package/core/README.md +1 -1
  44. package/core/engine.md +3 -9
  45. package/core/operating/autonomy.md +1 -1
  46. package/core/operating/context.md +183 -0
  47. package/core/operating/evolution.md +3 -3
  48. package/core/operating/loop.md +13 -20
  49. package/core/operating/memory.md +12 -3
  50. package/core/operating/sessions.md +100 -0
  51. package/core/personas/librarian.md +2 -2
  52. package/core/skills/consolidate/SKILL.md +4 -2
  53. package/core/templates/handoff.md +28 -15
  54. package/core/templates/memory-entry.md +1 -0
  55. package/evals/README.md +255 -845
  56. package/evals/releases/0.2.0.json +146 -0
  57. package/evals/releases/0.2.0.md +67 -0
  58. package/evals/releases/README.md +3 -3
  59. package/package.json +4 -2
  60. package/packs/rituals/checkpoints/README.md +1 -1
  61. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  62. package/spec/README.md +112 -6
  63. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  64. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  65. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  66. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  67. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  68. package/spec/migrations/0006-boot-card.mjs +77 -0
  69. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  70. package/spec/migrations/0008-card-reading.mjs +55 -0
  71. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  72. package/spec/migrations/0010-comment-rail.mjs +57 -0
  73. package/spec/migrations/README.md +45 -10
  74. package/spec/pack.schema.json +1 -1
  75. package/spec/slots.md +333 -21
  76. 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
- // `docs/plan.md` row 7: *"`init` asks where this repository's workspace resides — in the repository,
5
- // or in a workspace that names it — and writes a full workspace or a pointer accordingly."* That verb
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
- // The handoff index is GENERATED, and this is the generator — the same one `index --check` compares
85
- // against. Imported rather than approximated: a draft that wrote its own version of this file would be
86
- // a second renderer of one artifact, and the drafted rail would go red on the adopter's first run
87
- // against a difference this tool invented. `cli/index.mjs` imports nothing from here, so the direction
88
- // is one-way.
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
- /** Every refusal in this file. Carrying a class rather than a string lets `run` map all of them to 2. */
21
+ /** A refusal, thrown only before the first write: `run` exits 2 on it. */
94
22
  export class InitError extends Error {}
95
23
 
96
- // ONE definition of a slug, read from the contract that publishes it rather than written out again.
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
- /** The Workspace Definition this tool writes against. A pointer needs 2.7 — the version that added it. */
36
+ /** A pointer's Workspace Definition: 2.7 is the first with pointers. */
118
37
  const SPEC = "2.7";
119
38
 
120
- /** The gate-policy spec `cli/compile.mjs` reads. Checked by its own suite, not guessed at here. */
121
- // Exported so `cli/new.test.mjs` can assert that the OTHER policy-generating carrier —
122
- // `core/templates/gate-policy.md`, which `new gate-policy` emits — declares the same version.
123
- // #329 was precisely these two disagreeing, with only this one exercised.
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
- // Every key an answers file may carry, and the flag each corresponds to. Declared as data because the
132
- // file reader and the flag parser must agree about what an answer IS — two lists is how a `residnce`
133
- // gets silently dropped and the tool then asks for something the adopter believes they supplied.
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
- // NORMALISED to an array, because the two sources disagree about shape. The flag is
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
- answers.residence === "pointer"
377
- ? "a pointer holds no policy of its own, so it composes no packs"
378
- : "a workspace that lives here has no governor and no feed to be delivered from"
379
- }. Refused rather than ignored: an option accepted and then dropped is one you will believe had an effect.`,
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
- * Reads what the repository actually says about itself. **Observations only.**
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
- // A package.json that does not parse is evidence of node and evidence of nothing else. It
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 five keys `doctor` permits a pointer, and no others. Anything more is not a
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: 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
- // A `repository` workspace must declare `tree`: it is the policy layer of a repository that is
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
- // Sited OUTSIDE the series it indexes: an index living in `handoffs/` would be counted as a
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
- // Written by the generator, not by this file, and written NOW rather than left for the adopter's
628
- // first `index` run — a rail whose subject does not exist yet is a red on day one for a file
629
- // nobody was asked to write. An empty series renders an index with a zero count, which is the
630
- // honest state of a workspace drafted five seconds ago.
631
- files.set(`.portulan/${manifest.handoffs.index.path}`, {
632
- contents: renderHandoffIndex(manifest, { records: [], bytes: 0 }),
633
- });
634
- // A Map of files cannot express an empty directory and git does not track one, so the slot's
635
- // directory is created by a file that says why it is there.
636
- files.set(".portulan/handoffs/.gitkeep", {
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
- // A floor is drafted with no required checks, deliberately. `doctor` FAILS a floor requiring a
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
- // The two rules below are the ones the FLOOR backend can actually compile — a policy
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. It is the first thing an agent reads
715
- after the kernel, and the scan below could only observe so much.
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 the freshness rail is one of them** — a \`handoffs/\`
739
- directory in the \`handoffs\` slot, a generated \`handoffs-index.md\` **written by the same generator
740
- that will check it**, and \`verify/index.sh\` declared beside \`workspace\` to hold the two in
741
- agreement. The index starts at zero handoffs, which is what this repository has, so the rail is green
742
- today rather than red about a file nobody wrote. Edit that index by hand and the rail goes red; add a
743
- handoff without regenerating and it goes red the same way. It is **not** the default recipe: the
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
- - **The session-end gate is wired by \`compile\`, and this draft has not run it.** The runner that
752
- enforces "a handoff dated today exists" before a session ends **does** ship in the package you have —
753
- it is \`cli/stop-gate.mjs\` — and \`portulan compile\` emits a \`Stop\` hook naming it. What this
754
- draft does is bind the ritual and the records conventions; running \`compile\` is the step that turns
755
- them into enforcement, and it is yours to run because compiling writes host settings and that is not
756
- a thing a scaffold should do to your machine unasked. **Until you run it**, treat the session-end
757
- handoff as a practice your team holds rather than a rail — a rule nothing checks is worth exactly
758
- what you can remember about it.
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
- ? "These were read out of files in this repository, not inferred. Correct them if they are stale."
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. This section earns its place the
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 generated \`handoffs-index.md\` against the series it indexes, byte for byte. Finished as drafted — it checks a real thing today. |
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
- **Regenerating is yours, and the rail deliberately does not do it.** A check that repaired what it
951
- found would report green on a repository nobody had corrected, and the index is only worth having
952
- because it is derived — run the index tool yourself and commit the result.
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 generated index is compared against the series it indexes.
819
+ # Records rail — the handoff series is rendered as its index, in memory.
1020
820
  #
1021
- # \`handoffs-index.md\` is GENERATED. This recipe regenerates it in memory and compares byte for byte,
1022
- # so an index edited by hand, or one left behind when a handoff was added, is a RED rather than a file
1023
- # that quietly stopped being true. Nothing here writes: run the index tool yourself to repair it.
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: the index matches the series
1026
- # exit 1 red: it does not — regenerate it
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
- // The path to the manifest is walked with `lstatSync` BEFORE anything is read, and that order is
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
- // Absent — and nothing below an absent directory can exist either, so this path
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. Both are "no answer is coming", and
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
- // The last question, and the one that makes every answer above reversible: everything is echoed
1458
- // and nothing has been written. A confirmation printed AFTER the write would be a receipt.
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
- // **Unasked: an answer, never a refusal.** Carried to the closing advice rather than thrown,
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 files = draft(answers, scan(target));
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
- // Every refusal is ahead of this line, so a run that reaches here has nothing left to
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
- say(`init: drafted ${files.size} file(s) into ${path.join(parsed.target, ".portulan")}`);
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
- // The `?? ""` is not decoration and every sibling tool here carries it: `process.argv[1]` is absent
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
  }