@sleepy_panda_srl/portulan 0.3.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.
- package/README.md +15 -4
- package/cli/README.md +55 -3
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- 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) =>
|
|
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
|
|
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
|
|
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\` |
|
|
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.
|
|
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
|
-
#
|
|
822
|
-
#
|
|
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:
|
|
826
|
-
# exit 1 red:
|
|
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
|
-
|
|
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
|
-
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1301
|
-
|
|
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.`);
|