@sleepy_panda_srl/portulan 0.2.0 → 0.4.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 (100) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +61 -6
  3. package/cli/advisory.mjs +167 -53
  4. package/cli/cache-risk.mjs +69 -0
  5. package/cli/comments.mjs +8 -5
  6. package/cli/compile.mjs +781 -130
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +248 -31
  9. package/cli/drills.mjs +56 -9
  10. package/cli/finish.md +34 -0
  11. package/cli/finish.mjs +8 -67
  12. package/cli/form.mjs +34 -18
  13. package/cli/frontmatter.mjs +39 -0
  14. package/cli/gate.mjs +14 -12
  15. package/cli/guidance-files.mjs +335 -0
  16. package/cli/handoff-policy.mjs +76 -0
  17. package/cli/html-entities.mjs +2130 -0
  18. package/cli/import-files.mjs +15 -0
  19. package/cli/index.mjs +77 -24
  20. package/cli/init-preview.mjs +553 -0
  21. package/cli/init.mjs +261 -46
  22. package/cli/instructions-journal.mjs +1185 -0
  23. package/cli/instructions.mjs +332 -57
  24. package/cli/ledger.mjs +230 -40
  25. package/cli/librarian.mjs +103 -19
  26. package/cli/persona-routing.mjs +209 -0
  27. package/cli/plugin-lint.mjs +77 -56
  28. package/cli/portulan.mjs +1 -1
  29. package/cli/recipe-output.mjs +91 -0
  30. package/cli/recipe-runner.md +68 -0
  31. package/cli/recipe-runner.mjs +131 -0
  32. package/cli/recipe-set.mjs +12 -3
  33. package/cli/review-meter.mjs +14 -9
  34. package/cli/ritual-contexts.mjs +103 -0
  35. package/cli/sessions.mjs +2 -1
  36. package/cli/spend-analysis.mjs +111 -0
  37. package/cli/spend-offer.md +53 -0
  38. package/cli/spend-offer.mjs +93 -0
  39. package/cli/stop-diagnostics.mjs +114 -0
  40. package/cli/stop-gate.mjs +133 -90
  41. package/cli/stop-policy.mjs +14 -0
  42. package/cli/symbols.mjs +55 -8
  43. package/cli/telemetry.mjs +66 -17
  44. package/cli/upgrade.mjs +62 -44
  45. package/cli/vendor.mjs +207 -125
  46. package/core/operating/context.md +401 -18
  47. package/core/operating/evolution.md +3 -2
  48. package/core/operating/loop.md +9 -4
  49. package/core/operating/sessions.md +6 -15
  50. package/core/personas/README.md +75 -0
  51. package/core/personas/implementer.md +3 -0
  52. package/core/personas/librarian.md +3 -0
  53. package/core/personas/reviewer.md +3 -0
  54. package/core/skills/clarify/SKILL.md +1 -1
  55. package/core/skills/consolidate/SKILL.md +1 -1
  56. package/core/templates/handoff.md +6 -2
  57. package/evals/README.md +23 -7
  58. package/evals/ab/personas/README.md +111 -0
  59. package/evals/ab/personas/acquisition.json +330 -0
  60. package/evals/ab/personas/bindings/implementer.txt +43 -0
  61. package/evals/ab/personas/bindings/librarian.txt +40 -0
  62. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  63. package/evals/ab/personas/corpus.json +69 -0
  64. package/evals/ab/personas/grader.mjs +171 -0
  65. package/evals/ab/personas/implementer.json +774 -0
  66. package/evals/ab/personas/implementer.md +22 -0
  67. package/evals/ab/personas/librarian.json +758 -0
  68. package/evals/ab/personas/librarian.md +22 -0
  69. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  70. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  71. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  72. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  73. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  74. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  75. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  76. package/evals/ab/personas/reviewer.json +758 -0
  77. package/evals/ab/personas/reviewer.md +22 -0
  78. package/evals/releases/0.3.0.json +150 -0
  79. package/evals/releases/0.3.0.md +68 -0
  80. package/evals/releases/0.4.0.json +154 -0
  81. package/evals/releases/0.4.0.md +69 -0
  82. package/package.json +7 -2
  83. package/packs/.claude-plugin/plugin.json +2 -1
  84. package/packs/README.md +7 -4
  85. package/packs/rituals/checkpoints/README.md +35 -0
  86. package/packs/rituals/checkpoints/pack.json +3 -2
  87. package/packs/tools/README.md +8 -8
  88. package/packs/tools/local-llm/README.md +176 -0
  89. package/packs/tools/local-llm/pack.json +32 -0
  90. package/packs/tools/local-llm/personas/local-worker.md +44 -0
  91. package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
  92. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
  93. package/packs/tools/local-llm/verify/README.md +18 -0
  94. package/spec/README.md +37 -3
  95. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  96. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  97. package/spec/migrations/README.md +6 -0
  98. package/spec/pack.schema.json +12 -3
  99. package/spec/slots.md +272 -123
  100. package/spec/workspace.schema.json +67 -11
package/cli/init.mjs CHANGED
@@ -5,21 +5,29 @@
5
5
  // Exit 0 wrote · 2 refused, declined or could not run; never 1, since it renders no verdict on a workspace.
6
6
 
7
7
  import fs from "node:fs";
8
+ import { assertNoSourceJournal } from "./instructions-journal.mjs";
8
9
  import path from "node:path";
10
+ import { isInside } from "./inside.mjs";
11
+ import { outsideTempParents, previewAlways } from "./init-preview.mjs";
9
12
  import { fileURLToPath, pathToFileURL } from "node:url";
10
13
 
11
14
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
12
15
  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 { CACHE_LIFETIMES, compileGuidance, readOwnedText, readOwnedBytes } from "./compile.mjs";
17
+ import { alwaysTier, declaredContext, ESTIMATED_BYTES_PER_TOKEN, OFFER_FLOOR_TOKENS, tokensOf } from "./context.mjs";
18
+ import { cardIgnored, gitIn, changesReadme, claudeRulesUnignore, commentsRecipe, commentsRecipeEntry, COMPILED_CARD, draftCard, handoffIndexIgnore, handoffsReadme, withIgnoreLines } from "./form.mjs";
16
19
  import { offerText, splitOffers } from "./instructions.mjs";
17
20
  import { offerLines } from "./sessions.mjs";
21
+ import { HANDOFF_OFFER, handoffOffer } from "./handoff-policy.mjs";
22
+ import { eligibleHandoffDate, handoffEntries, renderHandoffIndex } from "./index.mjs";
23
+ import { readSpend } from "./ledger.mjs";
24
+ import { spendOffer, spendOfferLines } from "./spend-offer.mjs";
18
25
 
19
26
  const HERE = path.dirname(fileURLToPath(import.meta.url));
20
27
 
21
28
  /** A refusal, thrown only before the first write: `run` exits 2 on it. */
22
29
  export class InitError extends Error {}
30
+ class CutoffError extends InitError {}
23
31
 
24
32
  // The schema's `$defs/slug`; unreadable, it is null for `run` to refuse, since a throw at import leaves only a stack trace.
25
33
  let SCHEMA_ERROR = null;
@@ -48,7 +56,7 @@ const RESIDENCES = new Set(["in-repo", "pointer"]);
48
56
 
49
57
  const DEFAULT_CHECKPOINTS = "rituals/checkpoints";
50
58
 
51
- const ANSWER_KEYS = new Set(["residence", "name", "summary", "governed-by", "feed", "checkpoints", "cycle", "pack-root", "cache-lifetime"]);
59
+ const ANSWER_KEYS = new Set(["residence", "name", "summary", "governed-by", "feed", "checkpoints", "cycle", "pack-root", "cache-lifetime", "multipliers", "horizon", "handoff-budget"]);
52
60
 
53
61
  /** `text` as a slug, or null where nothing survives: `init` asks rather than name a workspace nobody chose. */
54
62
  export function slugify(text) {
@@ -66,7 +74,7 @@ export function parseArgs(argv) {
66
74
  const targets = [];
67
75
  let help = false;
68
76
 
69
- const VALUED = new Set(["--residence", "--name", "--summary", "--governed-by", "--feed", "--checkpoints", "--answers", "--pack-root", "--cache-lifetime"]);
77
+ const VALUED = new Set(["--residence", "--name", "--summary", "--governed-by", "--feed", "--checkpoints", "--answers", "--pack-root", "--cache-lifetime", "--multipliers", "--horizon", "--handoff-budget"]);
70
78
 
71
79
  for (let i = 0; i < argv.length; i++) {
72
80
  const arg = argv[i];
@@ -177,6 +185,9 @@ export function resolveAnswers(flags) {
177
185
  // An answers file may give a string, the repeatable flag an array.
178
186
  packRoots: [merged["pack-root"] ?? []].flat(),
179
187
  cacheLifetime: merged["cache-lifetime"] ?? null,
188
+ handoffBudget: merged["handoff-budget"] ?? null,
189
+ multipliers: merged.multipliers ?? null,
190
+ horizon: merged.horizon ?? null,
180
191
  };
181
192
  }
182
193
 
@@ -205,15 +216,17 @@ export function validateAnswers(answers) {
205
216
  }
206
217
  const misplaced = {
207
218
  "in-repo": ["feed", "governed-by"],
208
- pointer: ["pack-root", "checkpoints", "cycle", "cache-lifetime"],
219
+ pointer: ["pack-root", "checkpoints", "cycle", "cache-lifetime", "multipliers", "horizon", "handoff-budget"],
209
220
  }[answers.residence].filter((key) => answers.given.has(key));
210
221
  if (misplaced.length) {
211
222
  const spelling = (key) => (key === "cycle" ? "`--no-cycle`" : `\`--${key}\``);
212
223
  const why =
213
224
  answers.residence === "pointer"
214
225
  ? [
215
- misplaced.some((key) => key !== "cache-lifetime") && "a pointer holds no policy of its own, so it composes no packs",
226
+ misplaced.some((key) => ["pack-root", "checkpoints", "cycle"].includes(key)) && "a pointer holds no policy of its own, so it composes no packs",
216
227
  misplaced.includes("cache-lifetime") && "a pointer drafts no repository whose settings `compile` writes, so a cache lifetime would reach no session",
228
+ misplaced.includes("handoff-budget") && "a pointer holds no handoff policy of its own",
229
+ misplaced.some((key) => ["multipliers", "horizon"].includes(key)) && "a pointer holds no spend policy of its own",
217
230
  ].filter(Boolean)
218
231
  : ["a workspace that lives here has no governor and no feed to be delivered from"];
219
232
  throw new InitError(
@@ -223,6 +236,18 @@ export function validateAnswers(answers) {
223
236
  );
224
237
  }
225
238
  const lifetime = answers.cacheLifetime ?? null;
239
+ try {
240
+ const handoff = handoffOffer(answers.handoffBudget);
241
+ if (handoff && !SLUG.test(handoff.context.ratio.calibrated_by)) throw new InitError("the calibration reference must be a slug");
242
+ }
243
+ catch (error) { throw new InitError(error.message); }
244
+ if (answers.alwaysBudget) {
245
+ declaredContext({ context: { ratio: answers.alwaysBudget.ratio, always: { budget: { tokens: answers.alwaysBudget.tokens } } } });
246
+ if (!SLUG.test(answers.alwaysBudget.ratio.calibrated_by)) throw new InitError("the calibration reference must be a slug");
247
+ const shared = handoffOffer(answers.handoffBudget)?.context.ratio;
248
+ if (shared && (shared.bytes_per_token !== answers.alwaysBudget.ratio.bytes_per_token || shared.calibrated_by !== answers.alwaysBudget.ratio.calibrated_by)) throw new InitError("always and handoff budgets must share one ratio");
249
+ }
250
+ answers.spend = spendAnswers(answers);
226
251
  if (lifetime !== null && !CACHE_LIFETIMES.includes(lifetime)) {
227
252
  throw new InitError(
228
253
  `\`${lifetime}\` is not a cache lifetime — Claude Code takes ${CACHE_LIFETIMES.map((v) => `\`${v}\``).join(" and ")}, ` +
@@ -258,15 +283,36 @@ export function validateAnswers(answers) {
258
283
  }
259
284
  }
260
285
 
286
+ function spendAnswers(answers) {
287
+ const spend = {};
288
+ try {
289
+ if (answers.multipliers != null) spend.multipliers = JSON.parse(answers.multipliers);
290
+ if (answers.horizon != null) {
291
+ if (!/^[1-9]\d*$/.test(answers.horizon) || !Number.isSafeInteger(Number(answers.horizon))) throw new Error("--horizon needs a positive safe integer in decimal requests");
292
+ spend.horizon = { requests: Number(answers.horizon) };
293
+ }
294
+ readSpend(spend, "init answers");
295
+ } catch (error) { throw new InitError(`invalid spend declaration — ${error.message}`); }
296
+ return Object.keys(spend).length ? spend : null;
297
+ }
298
+
261
299
  // ------------------------------------------------------------------------- the codebase scan
262
300
 
263
301
  /** 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 } = {}) {
302
+ export function scan(dir, { comments = true, env } = {}) {
265
303
  const observed = { stack: [], build: null, test: null, run: null, name: null, vcs: null, evidence: [], commentHistory: null, uncounted: null };
266
- const has = (rel) => fs.existsSync(path.join(dir, rel));
304
+ const owner = fs.realpathSync(dir);
305
+ const has = rel => {
306
+ try {
307
+ const real = fs.realpathSync(path.join(dir, rel));
308
+ if (!isInside(owner, real)) return false;
309
+ const value = fs.statSync(real);
310
+ return value.isFile() || rel === ".git" && value.isDirectory();
311
+ } catch { return false; }
312
+ };
267
313
  const read = (rel) => {
268
314
  try {
269
- return fs.readFileSync(path.join(dir, rel), "utf8");
315
+ return has(rel) ? readOwnedText(path.join(dir, rel), owner) : null;
270
316
  } catch {
271
317
  return null;
272
318
  }
@@ -313,7 +359,7 @@ export function scan(dir, { comments = true } = {}) {
313
359
 
314
360
  if (comments) {
315
361
  try {
316
- observed.commentHistory = historyCount(dir);
362
+ observed.commentHistory = historyCount(dir, { timeout: 1000, env, noFsmonitor: true });
317
363
  } catch (error) {
318
364
  observed.uncounted = error.message;
319
365
  }
@@ -398,9 +444,10 @@ function draftWorkspace(answers, observed) {
398
444
  const files = new Map();
399
445
  const name = answers.name ?? "workspace";
400
446
  const lifetime = answers.cacheLifetime ?? null;
447
+ const offeredHandoffs = handoffOffer(answers.handoffBudget, { newest: answers.handoffNewest, today: answers.handoffToday });
401
448
 
402
449
  const manifest = {
403
- portulan: { spec: lifetime === null ? WORKSPACE_SPEC : SESSIONS_SPEC },
450
+ portulan: { spec: offeredHandoffs ? "2.15" : answers.spend ? "2.12" : lifetime === null ? WORKSPACE_SPEC : SESSIONS_SPEC },
404
451
  name,
405
452
  summary: answers.summary ?? `The ${name} workspace — drafted by \`init\`, and not yet curated.`,
406
453
  kind: "repository",
@@ -438,6 +485,12 @@ function draftWorkspace(answers, observed) {
438
485
  };
439
486
  if (answers.cycle) manifest.packs = [answers.checkpoints];
440
487
  if (lifetime !== null) manifest.sessions = { cache_lifetime: lifetime };
488
+ if (offeredHandoffs) {
489
+ Object.assign(manifest, offeredHandoffs);
490
+ renderHandoffIndex(manifest, { records: [], bytes: 0 });
491
+ }
492
+ if (answers.alwaysBudget) manifest.context = { ratio: answers.alwaysBudget.ratio, always: { budget: { tokens: answers.alwaysBudget.tokens } } };
493
+ if (answers.spend) manifest.spend = answers.spend;
441
494
  const commented = observed.commentHistory !== null;
442
495
 
443
496
  files.set(".portulan/workspace.json", { contents: json(manifest) });
@@ -754,7 +807,7 @@ is a command in a file.
754
807
  | Recipe | What it checks |
755
808
  |---|---|
756
809
  | \`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. |
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 ? `
810
+ | \`index\` | Handoff dates and declared caps are checked; the index renders its declared window, and a kept copy matches byte for byte; none is kept as drafted. Finished as drafted — it checks a real thing today. |${commented ? `
758
811
  | \`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\`. |` : ""}
759
812
 
760
813
  ## The three exit codes, and why the middle one is not enough
@@ -776,7 +829,7 @@ where \`node\` itself is absent, the recipe exits **2** and names what it looked
776
829
  to expect on CI until the CLI is installed there; it is not a red, and it is not a pass either.
777
830
 
778
831
  **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
832
+ would report green on a repository nobody had corrected. An invalid handoff date or required heading needs its
780
833
  title or date fixed; a kept copy that drifted needs the index tool run again, or the copy deleted.
781
834
 
782
835
  **One thing the three locations cannot establish, said rather than left implicit.** The second of them
@@ -818,12 +871,11 @@ function draftIndexRecipe() {
818
871
  return `#!/usr/bin/env bash
819
872
  # Records rail — the handoff series is rendered as its index, in memory.
820
873
  #
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.
874
+ # \`index --handoffs\` prints on demand. Checks dates, caps and the full window.
875
+ # Kept copies are byte-compared; omitted entries keep their source handoffs.
824
876
  #
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
877
+ # exit 0 green: dates, caps and window hold; any kept copy matches
878
+ # exit 1 red: repair the handoff, or regenerate or delete a stale copy
827
879
  # exit 2 could not run: the Portulan CLI is not reachable from here. NEVER a pass.
828
880
 
829
881
  set -uo pipefail
@@ -884,7 +936,7 @@ exit "\$code"
884
936
  }
885
937
 
886
938
  /** 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) {
939
+ function reportCard(target, say, warn, interviewing = false, env) {
888
940
  try {
889
941
  compileGuidance(target);
890
942
  say(`init: compiled the boot card into ${COMPILED_CARD}, which Claude Code loads into every context here`);
@@ -892,7 +944,7 @@ function reportCard(target, say, warn) {
892
944
  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
945
  return;
894
946
  }
895
- if (cardIgnored(target)) {
947
+ if (cardIgnored(target, gitIn(target, { env, timeout: 1000, noFsmonitor: true }))) {
896
948
  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
949
  }
898
950
  let bytes;
@@ -903,16 +955,15 @@ function reportCard(target, say, warn) {
903
955
  return;
904
956
  }
905
957
  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
- );
958
+ say(`init: Claude Code project guidance is ~${tokens.toLocaleString("en-US")} tokens with the compiled card, at the illustrative ${ESTIMATED_BYTES_PER_TOKEN} B/token estimate.`);
959
+ if (!interviewing) say(`init: A budget is yours to declare: use the larger of ${OFFER_FLOOR_TOKENS.toLocaleString("en-US")} tokens and the two project guidance estimates at your explicit context.ratio. Interactive init projects both formats before asking. A provider count is an estimate; actual host usage may differ.`);
911
960
  const offer = offerText(splitOffers(target, { ratio: ESTIMATED_BYTES_PER_TOKEN, floor: OFFER_FLOOR_TOKENS }));
912
961
  if (offer !== null) say(`init: ${offer}`);
913
962
  }
914
963
 
915
964
  function reportLifetime(answers, say) {
965
+ if (answers.handoffBudget && answers.handoffBudget !== "none") say("init: handoff budgets and supplied ratio declared at 2.15; index checks the cap and complete window; ratio provenance is not proof of calibration");
966
+ else if (answers.handoffBudget !== "none") say(`init: ${HANDOFF_OFFER} Use --handoff-budget with JSON record_tokens and/or index_tokens plus ratio {bytes_per_token, calibrated_by}; the record cutoff is set when writing.`);
916
967
  const lifetime = answers.cacheLifetime ?? null;
917
968
  if (lifetime !== null) {
918
969
  say(
@@ -1070,8 +1121,139 @@ function terminal() {
1070
1121
  };
1071
1122
  }
1072
1123
 
1124
+ function prepareHandoffCutoff(target, answers) {
1125
+ const handoffToday = new Date().toISOString().slice(0, 10);
1126
+ let handoffNewest;
1127
+ if (handoffOffer(answers.handoffBudget, { today: handoffToday })?.handoffs.series) {
1128
+ try {
1129
+ let present = true;
1130
+ for (const rel of [".portulan", ".portulan/handoffs"]) {
1131
+ let stat;
1132
+ try { stat = fs.lstatSync(path.join(target, rel)); }
1133
+ catch (error) { if (error.code !== "ENOENT") throw error; present = false; break; }
1134
+ if (!stat.isDirectory()) throw new InitError(`${rel} is not an ordinary owned handoff directory`);
1135
+ }
1136
+ if (present) for (const record of handoffEntries(target, { slots: { handoffs: ".portulan/handoffs" } })) {
1137
+ const date = eligibleHandoffDate(record.file, handoffToday);
1138
+ if (date === null) throw new InitError(`${record.file} must lead with a valid YYYY-MM-DD date no more than a day after ${handoffToday} (UTC); rename the handoff`);
1139
+ if (date > (handoffNewest ?? "")) handoffNewest = date;
1140
+ }
1141
+ } catch (error) { throw new CutoffError(`cannot establish the handoff cutoff: ${error.message}`); }
1142
+ }
1143
+ return { handoffToday, handoffNewest };
1144
+ }
1145
+
1146
+ function prepareDraft(target, answers, beforeConsent = false) {
1147
+ const handoff = prepareHandoffCutoff(target, answers);
1148
+ const parent = outsideTempParents(target)[0];
1149
+ if (!parent && beforeConsent) throw new InitError("no external location for bounded pre-consent preparation");
1150
+ const env = parent ? { ...process.env, TMPDIR: parent, TMP: parent, TEMP: parent } : process.env;
1151
+ const observed = scan(target, { comments: answers.residence !== "pointer", env });
1152
+ return { observed, ...handoff, env, ignoreLines: answers.residence === "pointer" ? [] : claudeRulesUnignore(target, gitIn(target, { env, timeout: 1000, noFsmonitor: true })).lines };
1153
+ }
1154
+
1155
+ function preparedFiles(target, answers, preparation) {
1156
+ const files = draft({ ...answers, handoffToday: preparation.handoffToday, handoffNewest: preparation.handoffNewest }, preparation.observed);
1157
+ if (files.has(".gitignore")) files.get(".gitignore").append.push(...(preparation.ignoreLines.length ? ["", ...preparation.ignoreLines] : []));
1158
+ for (const [rel, file] of files) {
1159
+ if (!file.append) continue;
1160
+ let before = null;
1161
+ try { before = readOwnedBytes(path.join(target, rel), target); }
1162
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1163
+ file.appendBefore = before;
1164
+ file.contents = withIgnoreLines(before, file.append);
1165
+ }
1166
+ return files;
1167
+ }
1168
+
1169
+ function checkAppendBaseline(target, files) {
1170
+ for (const [rel, file] of files) {
1171
+ if (!file.append) continue;
1172
+ let current = null;
1173
+ try { current = readOwnedBytes(path.join(target, rel), target); }
1174
+ catch (error) { if (error.code !== "ENOENT") throw error; }
1175
+ if (current === null ? file.appendBefore !== null : file.appendBefore === null || !current.equals(file.appendBefore)) {
1176
+ throw new InitError(`${rel} changed during confirmation; nothing written — run init again to prepare a fresh draft`);
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ function cutoffAt(answers, preparation) {
1182
+ return handoffOffer(answers.handoffBudget, { today: preparation?.handoffToday, newest: preparation?.handoffNewest })?.handoffs.series?.budget.cutoff ?? null;
1183
+ }
1184
+
1185
+ function checkConfirmedCutoff(answers, preparation) {
1186
+ if (answers.confirmedCutoff !== cutoffAt(answers, preparation)) {
1187
+ throw new InitError("the handoff cutoff changed during confirmation; nothing written — run init again to review the current cutoff");
1188
+ }
1189
+ }
1190
+
1191
+ function checkAcceptedBudget(target, answers, files) {
1192
+ if (!answers.alwaysBudget) return;
1193
+ const { tokens, ratio } = answers.alwaysBudget;
1194
+ let measured;
1195
+ try {
1196
+ measured = previewAlways(target, files);
1197
+ if (measured.after === null) throw new Error("projected guidance could not be measured");
1198
+ } catch (error) {
1199
+ throw new InitError(`the accepted always-tier budget can no longer be checked: ${error.message}; nothing written — run init again`);
1200
+ }
1201
+ const required = Math.max(...[...measured.before, ...measured.after].map(bytes => tokensOf(bytes, ratio.bytes_per_token)));
1202
+ if (required > tokens) {
1203
+ throw new InitError(`project guidance changed: the accepted ${tokens} tokens no longer cover ${required} tokens; nothing written — run init again to review a fresh offer`);
1204
+ }
1205
+ }
1206
+
1207
+ function projectedAlways(target, answers, ratio) {
1208
+ if (!answers.preparation) answers.preparation = prepareDraft(target, answers, true);
1209
+ let tokens = OFFER_FLOOR_TOKENS;
1210
+ for (let pass = 0; pass < 8; pass++) {
1211
+ const files = preparedFiles(target, { ...answers, alwaysBudget: { tokens, ratio } }, answers.preparation);
1212
+ const clash = collisions(target, files);
1213
+ if (clash.length) throw new InitError("drafted paths conflict with existing files");
1214
+ const measured = previewAlways(target, files);
1215
+ const next = Math.max(OFFER_FLOOR_TOKENS, ...[...measured.before, ...measured.after].map(bytes => tokensOf(bytes, ratio.bytes_per_token)));
1216
+ if (next === tokens) return { tokens, files };
1217
+ tokens = next;
1218
+ }
1219
+ throw new InitError("the self-counting budget did not converge");
1220
+ }
1221
+
1222
+ async function alwaysInterview(answers, io, target, ask) {
1223
+ const unavailable = error => {
1224
+ if (error instanceof CutoffError) throw error;
1225
+ io.say(`Always-tier offer unavailable: ${error.message}. No always cap will be declared; you may continue or cancel at confirmation.`);
1226
+ };
1227
+ const shared = handoffOffer(answers.handoffBudget)?.context.ratio;
1228
+ const illustrative = shared ?? { bytes_per_token: ESTIMATED_BYTES_PER_TOKEN, calibrated_by: "illustrative-estimate" };
1229
+ let preview;
1230
+ try { preview = projectedAlways(target, answers, illustrative); }
1231
+ catch (error) { unavailable(error); return; }
1232
+ io.say(`Always-tier projected offer: ${preview.tokens.toLocaleString("en-US")} tokens at ${illustrative.bytes_per_token} B/token${shared ? " (your supplied ratio)" : " (illustrative estimate)"}. Covers the larger of today's and projected Claude Code project guidance and root AGENTS.md, never their sum. Excludes personal/global instructions, system/tools/MCP and later-loaded bodies.`);
1233
+ const decision = await io.ask("Declare an always-tier budget? [y/N]: ");
1234
+ if (decision === null) throw new InitError("no answer — the interview ended before it finished, and nothing was written");
1235
+ if (!/^y(es)?$/i.test(decision.trim())) return;
1236
+ let ratio = shared;
1237
+ if (!ratio) {
1238
+ const raw = await ask("Measured ratio JSON {bytes_per_token, calibrated_by}", { validate: value => {
1239
+ try {
1240
+ const ratio = JSON.parse(value); declaredContext({ context: { ratio } });
1241
+ return SLUG.test(ratio.calibrated_by) ? null : "the calibration reference must be a slug";
1242
+ } catch (error) { return error.message; }
1243
+ } });
1244
+ ratio = JSON.parse(raw);
1245
+ }
1246
+ let offered;
1247
+ try { offered = projectedAlways(target, answers, ratio); }
1248
+ catch (error) { unavailable(error); return; }
1249
+ io.say("A provider count is an estimate with framing and domain limits; the supplied ratio is your assessment, not calibration verified by init.");
1250
+ const accepted = await io.ask(`Accept ${offered.tokens.toLocaleString("en-US")} tokens at ${ratio.bytes_per_token} B/token (${ratio.calibrated_by})? [y/N]: `);
1251
+ if (accepted === null) throw new InitError("no answer — the interview ended before it finished, and nothing was written");
1252
+ if (/^y(es)?$/i.test(accepted.trim())) { answers.alwaysBudget = { tokens: offered.tokens, ratio }; answers.preparedFiles = offered.files; }
1253
+ }
1254
+
1073
1255
  /** Asks for what the flags and the answers file did not give, and fills `answers` in place. */
1074
- export async function interview(answers, { io, target, derivedName }) {
1256
+ export async function interview(answers, { io, target, derivedName, offer }) {
1075
1257
  const ask = async (prompt, { fallback = null, validate = () => null } = {}) => {
1076
1258
  for (;;) {
1077
1259
  const raw = await io.ask(fallback === null ? `${prompt}: ` : `${prompt} [${fallback}]: `);
@@ -1155,7 +1337,31 @@ export async function interview(answers, { io, target, derivedName }) {
1155
1337
  }
1156
1338
  }
1157
1339
 
1340
+ if (answers.residence === "in-repo" && !answers.given.has("handoff-budget")) {
1341
+ io.say(HANDOFF_OFFER);
1342
+ answers.handoffBudget = await ask("Handoff budgets JSON: record_tokens and/or index_tokens, plus ratio {bytes_per_token, calibrated_by}; `none` to decline", {
1343
+ fallback: "none",
1344
+ validate: value => { try { handoffOffer(value); return null; } catch (error) { return error.message; } },
1345
+ });
1346
+ }
1347
+
1348
+ if (answers.residence === "in-repo") {
1349
+ offer = typeof offer === "function" ? offer() : offer ?? spendOffer(target);
1350
+ for (const line of spendOfferLines(offer)) io.say(line);
1351
+ if (!answers.given.has("multipliers")) {
1352
+ const raw = await io.ask("Declare the offered price multipliers? [y/N]: ");
1353
+ if (raw === null) throw new InitError("no answer — the interview ended before it finished, and nothing was written");
1354
+ if (/^y(es)?$/i.test(raw.trim())) answers.multipliers = JSON.stringify(offer.multipliers);
1355
+ }
1356
+ if (!answers.given.has("horizon")) {
1357
+ const value = await ask("Requests still to go (`none` to leave undeclared)", { fallback: "none", validate: v => v === "none" || /^[1-9]\d*$/.test(v) && Number.isSafeInteger(Number(v)) ? null : "give a positive integer or `none`" });
1358
+ if (value !== "none") answers.horizon = value;
1359
+ }
1360
+ answers.spend = spendAnswers(answers);
1361
+ }
1362
+
1158
1363
  io.say("");
1364
+ if (answers.residence === "in-repo") await alwaysInterview(answers, io, target, ask);
1159
1365
  io.say("init: about to draft");
1160
1366
  io.say(` residence ${answers.residence}`);
1161
1367
  io.say(` name ${answers.name}`);
@@ -1165,6 +1371,13 @@ export async function interview(answers, { io, target, derivedName }) {
1165
1371
  io.say(` feed ${answers.feed ?? "(none)"}`);
1166
1372
  } else {
1167
1373
  io.say(` cycle ${answers.cycle ? answers.checkpoints : "(none — composing no packs)"}`);
1374
+ if (answers.preparation) answers.preparedFiles ??= preparedFiles(target, answers, answers.preparation);
1375
+ const handoff = answers.preparation ?? prepareHandoffCutoff(target, answers);
1376
+ const offered = handoffOffer(answers.handoffBudget, { today: handoff.handoffToday, newest: handoff.handoffNewest });
1377
+ answers.confirmedCutoff = offered?.handoffs.series?.budget.cutoff ?? null;
1378
+ io.say(` handoffs ${offered ? JSON.stringify(offered) : "(none — no handoff budgets declared)"}`);
1379
+ io.say(` always ${answers.alwaysBudget ? JSON.stringify(answers.alwaysBudget) : "(none — no always cap declared)"}`);
1380
+ io.say(` spend ${answers.spend ? JSON.stringify(answers.spend) : "(none — undeclared general figures)"}`);
1168
1381
  io.say(
1169
1382
  ` cache ${
1170
1383
  answers.cacheLifetime ? `${answers.cacheLifetime}, as \`sessions.cache_lifetime\` at Workspace Definition ${SESSIONS_SPEC}` : "(none — the host's default lifetime)"
@@ -1203,7 +1416,10 @@ export function usage() {
1203
1416
  " `compile` writes as Claude Code's `promptCacheTtl`. Unset,",
1204
1417
  " the host's default stands and five minutes is offered.",
1205
1418
  " --answers <file> A JSON object of answers. Flags override its keys.",
1419
+ " --handoff-budget <JSON|none> Optional record_tokens/index_tokens plus explicit ratio; cutoff set at write time.",
1206
1420
  " --no-interview Never ask, even at a terminal: refuse instead, naming the flag.",
1421
+ " --multipliers <JSON> In-repo: declare spend.multipliers (read and both write lifetimes).",
1422
+ " --horizon <requests> In-repo: declare spend.horizon.requests, a positive integer.",
1207
1423
  "",
1208
1424
  "Exit codes: 0 it wrote · 2 it wrote nothing. There is no 1: init renders no verdict.",
1209
1425
  "",
@@ -1246,16 +1462,18 @@ export async function run(argv, options = {}) {
1246
1462
  );
1247
1463
  }
1248
1464
  if (!stat.isDirectory()) throw new InitError(`\`${parsed.target}\` is not a directory`);
1465
+ try{assertNoSourceJournal(target);}catch(error){throw new InitError(error.message);}
1249
1466
 
1250
1467
  const answers = resolveAnswers(parsed.flags);
1251
1468
  const derivedName = slugify(path.basename(target));
1252
1469
 
1253
1470
  const io = options.io ?? terminal();
1254
1471
  const interviewing = io.interactive && !parsed.flags.noInterview;
1472
+ let offer = null;
1255
1473
  if (interviewing) {
1256
1474
  // Asked before the questions too, so nobody answers them to be told the repository is already governed.
1257
1475
  refuseIfGoverned(residenceAt(target), parsed.target);
1258
- await interview(answers, { io, target, derivedName });
1476
+ await interview(answers, { io, target, derivedName, offer: () => spendOffer(target, { env: options.env, ...options.spendHost }) });
1259
1477
  }
1260
1478
 
1261
1479
  if (answers.name === null) answers.name = derivedName;
@@ -1268,6 +1486,7 @@ export async function run(argv, options = {}) {
1268
1486
  validateAnswers(answers);
1269
1487
 
1270
1488
  refuseIfGoverned(residenceAt(target), parsed.target);
1489
+ try{assertNoSourceJournal(target);}catch(error){throw new InitError(error.message);}
1271
1490
 
1272
1491
  let packAdvice = null;
1273
1492
  if (answers.residence === "in-repo" && answers.cycle) {
@@ -1295,13 +1514,10 @@ export async function run(argv, options = {}) {
1295
1514
  packAdvice = { resolved, why: expanded.why, inTree: resolvedAt !== null && resolvedAt === path.join(target, "packs") };
1296
1515
  }
1297
1516
 
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
- }
1517
+ if (answers.preparedFiles) checkAppendBaseline(target, answers.preparedFiles);
1518
+ const preparation = prepareDraft(target, answers);
1519
+ const observed = preparation.observed;
1520
+ const files = preparedFiles(target, answers, preparation);
1305
1521
 
1306
1522
  const clash = collisions(target, files);
1307
1523
  if (clash.length) {
@@ -1322,6 +1538,13 @@ export async function run(argv, options = {}) {
1322
1538
  );
1323
1539
  }
1324
1540
 
1541
+ checkAppendBaseline(target, files);
1542
+ if (interviewing && answers.residence === "in-repo") {
1543
+ checkConfirmedCutoff(answers, preparation);
1544
+ checkAcceptedBudget(target, answers, files);
1545
+ checkConfirmedCutoff(answers, { ...preparation, handoffToday: new Date().toISOString().slice(0, 10) });
1546
+ }
1547
+
1325
1548
  // The manifest last: a run failing midway leaves no torso that `residenceAt` would read as a governed repository.
1326
1549
  const manifest = ".portulan/workspace.json";
1327
1550
  const left = [];
@@ -1332,16 +1555,6 @@ export async function run(argv, options = {}) {
1332
1555
  continue;
1333
1556
  }
1334
1557
  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
- }
1345
1558
  fs.writeFileSync(full, file.contents);
1346
1559
  if (file.mode !== undefined) fs.chmodSync(full, file.mode);
1347
1560
  }
@@ -1354,8 +1567,10 @@ export async function run(argv, options = {}) {
1354
1567
  );
1355
1568
  for (const rel of left) say(`init: left the repository's own \`${rel}\` as it is`);
1356
1569
  if (answers.residence !== "pointer") {
1357
- reportCard(target, say, warn);
1570
+ reportCard(target, say, warn, interviewing, preparation.env);
1358
1571
  reportLifetime(answers, say);
1572
+ if (!interviewing) for (const line of spendOfferLines(offer ?? spendOffer(target, { env: options.env, ...options.spendHost }))) say(`init: ${line}`);
1573
+ if (answers.spend) say(`init: declared spend ${JSON.stringify(answers.spend)} (available since spec 2.12); run portulan compile to carry it into advisory commands`);
1359
1574
  }
1360
1575
  if (answers.residence === "pointer") {
1361
1576
  say(`init: this repository is recorded as governed by \`${answers.governedBy}\`. Nothing was fetched.`);