@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.
Files changed (81) hide show
  1. package/README.md +7 -6
  2. package/cli/README.md +142 -291
  3. package/cli/advisory.mjs +354 -0
  4. package/cli/comments.mjs +932 -0
  5. package/cli/compile.mjs +1443 -1210
  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 +225 -1071
  10. package/cli/drills.mjs +1107 -0
  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 +595 -0
  15. package/cli/gate.mjs +7 -198
  16. package/cli/goldens.mjs +311 -0
  17. package/cli/index.mjs +305 -655
  18. package/cli/init.mjs +244 -564
  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 +764 -0
  25. package/cli/new.mjs +12 -243
  26. package/cli/pack-identity.mjs +6 -68
  27. package/cli/pack-version.mjs +13 -248
  28. package/cli/plugin-lint.mjs +42 -658
  29. package/cli/portulan.mjs +5 -124
  30. package/cli/recipe-set.mjs +16 -240
  31. package/cli/release-eval.mjs +748 -0
  32. package/cli/review-meter.mjs +500 -0
  33. package/cli/rule-carriers.mjs +12 -175
  34. package/cli/sessions.mjs +82 -0
  35. package/cli/skill-goldens.mjs +424 -0
  36. package/cli/skills-set.mjs +12 -414
  37. package/cli/stop-gate.mjs +126 -454
  38. package/cli/symbols.mjs +1132 -0
  39. package/cli/telemetry.mjs +582 -0
  40. package/cli/upgrade.mjs +171 -314
  41. package/cli/vendor.mjs +146 -476
  42. package/cli/version-carriers.mjs +9 -74
  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/gate-policy.md +27 -8
  54. package/core/templates/handoff.md +28 -15
  55. package/core/templates/memory-entry.md +1 -0
  56. package/evals/README.md +320 -0
  57. package/evals/releases/0.1.3.json +134 -0
  58. package/evals/releases/0.1.3.md +64 -0
  59. package/evals/releases/0.2.0.json +146 -0
  60. package/evals/releases/0.2.0.md +67 -0
  61. package/evals/releases/README.md +32 -0
  62. package/package.json +9 -2
  63. package/packs/rituals/checkpoints/README.md +9 -2
  64. package/packs/rituals/checkpoints/pack.json +1 -1
  65. package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
  66. package/packs/tools/github/verify/actions-pinned.sh +10 -55
  67. package/spec/README.md +112 -6
  68. package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
  69. package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
  70. package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
  71. package/spec/migrations/0004-changelog-fragments.mjs +53 -0
  72. package/spec/migrations/0005-session-log-retired.mjs +69 -0
  73. package/spec/migrations/0006-boot-card.mjs +77 -0
  74. package/spec/migrations/0007-guidance-compiled.mjs +31 -0
  75. package/spec/migrations/0008-card-reading.mjs +55 -0
  76. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
  77. package/spec/migrations/0010-comment-rail.mjs +57 -0
  78. package/spec/migrations/README.md +45 -10
  79. package/spec/pack.schema.json +1 -1
  80. package/spec/slots.md +359 -23
  81. 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,28 +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
- const GATE_POLICY_SPEC = "2.2";
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
- // Every key an answers file may carry, and the flag each corresponds to. Declared as data because the
129
- // file reader and the flag parser must agree about what an answer IS — two lists is how a `residnce`
130
- // gets silently dropped and the tool then asks for something the adopter believes they supplied.
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
- // NORMALISED to an array, because the two sources disagree about shape. The flag is
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
- answers.residence === "pointer"
374
- ? "a pointer holds no policy of its own, so it composes no packs"
375
- : "a workspace that lives here has no governor and no feed to be delivered from"
376
- }. 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.",
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
- * Reads what the repository actually says about itself. **Observations only.**
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
- // A package.json that does not parse is evidence of node and evidence of nothing else. It
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 five keys `doctor` permits a pointer, and no others. Anything more is not a
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: 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
- // A `repository` workspace must declare `tree`: it is the policy layer of a repository that is
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
- // Sited OUTSIDE the series it indexes: an index living in `handoffs/` would be counted as a
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
- // Written by the generator, not by this file, and written NOW rather than left for the adopter's
625
- // first `index` run — a rail whose subject does not exist yet is a red on day one for a file
626
- // nobody was asked to write. An empty series renders an index with a zero count, which is the
627
- // honest state of a workspace drafted five seconds ago.
628
- files.set(`.portulan/${manifest.handoffs.index.path}`, {
629
- contents: renderHandoffIndex(manifest, { records: [], bytes: 0 }),
630
- });
631
- // A Map of files cannot express an empty directory and git does not track one, so the slot's
632
- // directory is created by a file that says why it is there.
633
- files.set(".portulan/handoffs/.gitkeep", {
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
- // A floor is drafted with no required checks, deliberately. `doctor` FAILS a floor requiring a
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
- // The two rules below are the ones the FLOOR backend can actually compile — a policy
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. It is the first thing an agent reads
712
- 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.
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 the freshness rail is one of them** — a \`handoffs/\`
736
- directory in the \`handoffs\` slot, a generated \`handoffs-index.md\` **written by the same generator
737
- that will check it**, and \`verify/index.sh\` declared beside \`workspace\` to hold the two in
738
- agreement. The index starts at zero handoffs, which is what this repository has, so the rail is green
739
- today rather than red about a file nobody wrote. Edit that index by hand and the rail goes red; add a
740
- 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
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
- - **The session-end gate is wired by \`compile\`, and this draft has not run it.** The runner that
749
- enforces "a handoff dated today exists" before a session ends **does** ship in the package you have —
750
- it is \`cli/stop-gate.mjs\` — and \`portulan compile\` emits a \`Stop\` hook naming it. What this
751
- draft does is bind the ritual and the records conventions; running \`compile\` is the step that turns
752
- them into enforcement, and it is yours to run because compiling writes host settings and that is not
753
- a thing a scaffold should do to your machine unasked. **Until you run it**, treat the session-end
754
- handoff as a practice your team holds rather than a rail — a rule nothing checks is worth exactly
755
- 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.
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
- ? "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."
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. This section earns its place the
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 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\`. |` : ""}
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
- **Regenerating is yours, and the rail deliberately does not do it.** A check that repaired what it
948
- found would report green on a repository nobody had corrected, and the index is only worth having
949
- 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.
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 generated index is compared against the series it indexes.
819
+ # Records rail — the handoff series is rendered as its index, in memory.
1017
820
  #
1018
- # \`handoffs-index.md\` is GENERATED. This recipe regenerates it in memory and compares byte for byte,
1019
- # so an index edited by hand, or one left behind when a handoff was added, is a RED rather than a file
1020
- # 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.
1021
824
  #
1022
- # exit 0 green: the index matches the series
1023
- # 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
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
- // The path to the manifest is walked with `lstatSync` BEFORE anything is read, and that order is
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
- // Absent — and nothing below an absent directory can exist either, so this path
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. Both are "no answer is coming", and
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
- // The last question, and the one that makes every answer above reversible: everything is echoed
1455
- // 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
+
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
- // **Unasked: an answer, never a refusal.** Carried to the closing advice rather than thrown,
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 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
+ }
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
- // Every refusal is ahead of this line, so a run that reaches here has nothing left to
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
- 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
+ }
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
- // The `?? ""` is not decoration and every sibling tool here carries it: `process.argv[1]` is absent
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
  }