@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.
Files changed (87) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +55 -3
  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 +492 -146
  7. package/cli/context.mjs +204 -43
  8. package/cli/doctor.mjs +211 -33
  9. package/cli/drills.mjs +32 -7
  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 +123 -37
  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/recipe-output.mjs +91 -0
  29. package/cli/recipe-runner.md +68 -0
  30. package/cli/recipe-runner.mjs +131 -0
  31. package/cli/recipe-set.mjs +12 -3
  32. package/cli/ritual-contexts.mjs +103 -0
  33. package/cli/sessions.mjs +2 -1
  34. package/cli/spend-analysis.mjs +111 -0
  35. package/cli/spend-offer.md +53 -0
  36. package/cli/spend-offer.mjs +93 -0
  37. package/cli/stop-diagnostics.mjs +114 -0
  38. package/cli/stop-gate.mjs +133 -90
  39. package/cli/stop-policy.mjs +14 -0
  40. package/cli/symbols.mjs +55 -8
  41. package/cli/telemetry.mjs +19 -12
  42. package/cli/upgrade.mjs +62 -44
  43. package/cli/vendor.mjs +207 -125
  44. package/core/operating/context.md +368 -60
  45. package/core/operating/evolution.md +3 -2
  46. package/core/operating/loop.md +9 -4
  47. package/core/operating/sessions.md +6 -15
  48. package/core/personas/README.md +52 -10
  49. package/core/personas/implementer.md +3 -0
  50. package/core/personas/librarian.md +3 -0
  51. package/core/personas/reviewer.md +3 -0
  52. package/core/templates/handoff.md +6 -2
  53. package/evals/ab/personas/README.md +111 -0
  54. package/evals/ab/personas/acquisition.json +330 -0
  55. package/evals/ab/personas/bindings/implementer.txt +43 -0
  56. package/evals/ab/personas/bindings/librarian.txt +40 -0
  57. package/evals/ab/personas/bindings/reviewer.txt +41 -0
  58. package/evals/ab/personas/corpus.json +69 -0
  59. package/evals/ab/personas/grader.mjs +171 -0
  60. package/evals/ab/personas/implementer.json +774 -0
  61. package/evals/ab/personas/implementer.md +22 -0
  62. package/evals/ab/personas/librarian.json +758 -0
  63. package/evals/ab/personas/librarian.md +22 -0
  64. package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
  65. package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
  66. package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
  67. package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
  68. package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
  69. package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
  70. package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
  71. package/evals/ab/personas/reviewer.json +758 -0
  72. package/evals/ab/personas/reviewer.md +22 -0
  73. package/evals/releases/0.4.0.json +154 -0
  74. package/evals/releases/0.4.0.md +69 -0
  75. package/package.json +6 -2
  76. package/packs/rituals/checkpoints/README.md +35 -0
  77. package/packs/rituals/checkpoints/pack.json +3 -2
  78. package/packs/tools/local-llm/README.md +46 -6
  79. package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
  80. package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
  81. package/spec/README.md +37 -4
  82. package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
  83. package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
  84. package/spec/migrations/README.md +6 -0
  85. package/spec/pack.schema.json +12 -3
  86. package/spec/slots.md +262 -117
  87. package/spec/workspace.schema.json +67 -11
@@ -0,0 +1,15 @@
1
+ // Optional project import probes.
2
+ import fs from "node:fs";
3
+
4
+ // A denied read or a loop of links is not absence: hiding it could report less than the host loads.
5
+ const ABSENT = new Set(["ENOENT", "ENOTDIR", "ENAMETOOLONG", "ERR_INVALID_ARG_VALUE"]);
6
+ export const isAbsentImportError = (error) => ABSENT.has(error?.code);
7
+
8
+ export function importTargetIsFile(file) {
9
+ try {
10
+ return fs.statSync(file).isFile();
11
+ } catch (error) {
12
+ if (isAbsentImportError(error)) return false;
13
+ throw error;
14
+ }
15
+ }
package/cli/index.mjs CHANGED
@@ -14,6 +14,7 @@ import { pathToFileURL } from "node:url";
14
14
 
15
15
  import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
16
16
  import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
17
+ import { HandoffError, handoffPolicy, handoffOverages, handoffTokens } from "./handoff-policy.mjs";
17
18
 
18
19
  /** Raised when `index` cannot run, or cannot judge honestly. Always exit 2, never 1. */
19
20
  export class IndexError extends Error {
@@ -23,7 +24,7 @@ export class IndexError extends Error {
23
24
  }
24
25
  }
25
26
 
26
- const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13"]);
27
+ const KNOWN_SPECS = new Set(["2.0", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9", "2.10", "2.11", "2.12", "2.13", "2.14", "2.15", "2.16"]);
27
28
 
28
29
  // `doctor` skips the same name, so the two count the same records.
29
30
  const NOT_A_RECORD = new Set(["README.md"]);
@@ -51,12 +52,17 @@ export function headingOf(source) {
51
52
  return first?.match(/^#[ \t]+(.+?)[ \t]*$/)?.[1] ?? null;
52
53
  }
53
54
 
54
- /** A handoff's date, the leading `YYYY-MM-DD` of its filename where that names a real day, else null. */
55
+ /** Valid leading filename date, or null. */
55
56
  export function dateOf(filename) {
56
57
  const m = filename.match(/^(\d{4})-(\d{2})-(\d{2})-/);
57
58
  return m ? realDay(m[1], m[2], m[3]) : null;
58
59
  }
59
60
 
61
+ export function eligibleHandoffDate(filename, today) {
62
+ const date = dateOf(filename);
63
+ return date !== null && date <= nextDay(today) ? date : null;
64
+ }
65
+
60
66
  function realDay(y, mo, d) {
61
67
  const at = new Date(0);
62
68
  // Not `Date.UTC`, which reads a year from 0 to 99 as 1900 to 1999.
@@ -127,8 +133,20 @@ export function readStore(dir, workspace) {
127
133
  return { records, bytes };
128
134
  }
129
135
 
130
- /** A workspace's handoff series, `{ records, bytes }`, newest first; a record's `date` or `heading` is null where it has none. */
131
- export function readHandoffs(dir, workspace) {
136
+ function regularHandoff(seriesDir, slot, file) {
137
+ try {
138
+ const stat = fs.lstatSync(path.join(seriesDir, file));
139
+ if (stat.isSymbolicLink() || !stat.isFile()) {
140
+ throw new IndexError(`${path.join(slot, file)} is not a regular file: a handoff is a file of its own, never a link or a directory`);
141
+ }
142
+ } catch (cause) {
143
+ if (cause instanceof IndexError) throw cause;
144
+ throw new IndexError(`cannot read the handoff ${path.join(slot, file)} — ${cause.code ?? cause.message}`);
145
+ }
146
+ }
147
+
148
+ /** The series' metadata, newest first; bodies stay with the reader that needs them. */
149
+ export function handoffEntries(dir, workspace) {
132
150
  const slot = workspace?.slots?.handoffs;
133
151
  if (!slot) {
134
152
  throw new IndexError(
@@ -141,23 +159,31 @@ export function readHandoffs(dir, workspace) {
141
159
  // `readdirSync` follows a link, so a link on the series path is refused before it is listed.
142
160
  refuseLinks(dir, slot, seriesDir, "a handoff series is read where its path says");
143
161
  const files = listSeries(seriesDir, slot, "handoff series").reverse();
162
+ return files.map(file => {
163
+ regularHandoff(seriesDir, slot, file);
164
+ return { file, date: dateOf(file) };
165
+ });
166
+ }
167
+
168
+ /** A workspace's handoff series, `{ records, bytes }`, newest first; a record's `date` or `heading` is null where it has none. */
169
+ export function readHandoffs(dir, workspace) {
170
+ const entries = handoffEntries(dir, workspace), slot = workspace.slots.handoffs;
171
+ const seriesDir = path.resolve(dir, slot);
144
172
  const records = [];
145
173
  let bytes = 0;
146
174
 
147
- for (const file of files) {
175
+ for (const { file, date } of entries) {
148
176
  let source;
149
177
  try {
150
- const stat = fs.lstatSync(path.join(seriesDir, file));
151
- if (stat.isSymbolicLink() || !stat.isFile()) {
152
- throw new IndexError(`${path.join(slot, file)} is not a regular file: a handoff is a file of its own, never a link or a directory`);
153
- }
178
+ regularHandoff(seriesDir, slot, file);
154
179
  source = fs.readFileSync(path.join(seriesDir, file), "utf8");
155
180
  } catch (cause) {
156
181
  if (cause instanceof IndexError) throw cause;
157
182
  throw new IndexError(`cannot read the handoff ${path.join(slot, file)} — ${cause.code ?? cause.message}`);
158
183
  }
159
- bytes += Buffer.byteLength(source);
160
- records.push({ file, date: dateOf(file), heading: headingOf(source) });
184
+ const recordBytes = Buffer.byteLength(source);
185
+ bytes += recordBytes;
186
+ records.push({ file, date, heading: headingOf(source), bytes: recordBytes });
161
187
  }
162
188
 
163
189
  return { records, bytes };
@@ -358,13 +384,17 @@ export function renderHandoffIndex(workspace, series) {
358
384
  const posix = (p) => p.split(path.sep).join(path.posix.sep);
359
385
  const from = path.posix.dirname(posix(indexPath));
360
386
 
387
+ let policy;
388
+ try { policy = handoffPolicy(workspace); }
389
+ catch (error) { throw new IndexError(error.message); }
361
390
  const header = [
362
391
  `# Handoff index — ${workspace.name}`,
363
392
  "",
364
393
  `> Generated from \`${slot}\` by \`node cli/index.mjs\`. Do not edit by hand: it is regenerated`,
365
394
  `> and byte-compared, so a hand-edit survives exactly until the next run.`,
366
- `> ${series.records.length} handoff(s), newest first. No budget: the series is append-only, so`,
367
- `> the only remedy a budget could ask for is one this project rules out.`,
395
+ ...(policy.window === null ? [
396
+ `> ${series.records.length} handoff(s), newest first. No index budget declared; the full series is listed.`,
397
+ ] : [`> ${series.records.length} handoff(s), newest first. Index budget: ${policy.window} tokens at ${policy.ratio} bytes per token.`]),
368
398
  "",
369
399
  ];
370
400
 
@@ -373,7 +403,17 @@ export function renderHandoffIndex(workspace, series) {
373
403
  return `- ${r.date} · [${r.heading}](${href})`;
374
404
  });
375
405
 
376
- return [...header, ...entries].join("\n") + "\n";
406
+ if (policy.window === null) return [...header, ...entries].join("\n") + "\n";
407
+ const text = (count, budget = policy.window) => [...header.map(line => line.replace(`Index budget: ${policy.window} tokens`, `Index budget: ${budget} tokens`)), ...entries.slice(0, count), "", `> ${entries.length - count} handoff(s) omitted; all remain in \`${slot}\`, filenames ordered by date.`].join("\n") + "\n";
408
+ const overhead = text(0);
409
+ if (handoffTokens(Buffer.byteLength(overhead), policy.ratio) > policy.window) {
410
+ let minimum = 1;
411
+ while (handoffTokens(Buffer.byteLength(text(0, minimum)), policy.ratio) > minimum) minimum = handoffTokens(Buffer.byteLength(text(0, minimum)), policy.ratio);
412
+ throw new IndexError(`handoffs.index.budget.tokens is too small: at least ${minimum} tokens hold the complete header and omission notice`);
413
+ }
414
+ let count = 0;
415
+ while (count < entries.length && handoffTokens(Buffer.byteLength(text(count + 1)), policy.ratio) <= policy.window) count++;
416
+ return text(count);
377
417
  }
378
418
 
379
419
  export function renderScopeIndex(workspace, series) {
@@ -430,7 +470,7 @@ export function inspect(dir, { write = false, packRoots: extraRoots, discoverPac
430
470
  ].join("");
431
471
 
432
472
  const memory = judgeMemory(dir, workspace, { write, fail, note, today, remedyFlags });
433
- const handoffs = judgeHandoffs(dir, workspace, { write, fail, remedyFlags });
473
+ const handoffs = judgeHandoffs(dir, workspace, { write, fail, note, today, remedyFlags });
434
474
  const scopes = judgeScopes(dir, workspace, { write, fail, packRoots: extraRoots, discoverPacks, remedyFlags });
435
475
 
436
476
  return {
@@ -558,26 +598,32 @@ function judgeMemory(dir, workspace, { write, fail, note, today, remedyFlags = "
558
598
  return { declared: true, path: indexPath, expected, budgets };
559
599
  }
560
600
 
561
- function judgeHandoffs(dir, workspace, { write, fail, remedyFlags = "" }) {
601
+ function judgeHandoffs(dir, workspace, { write, fail, note, today, remedyFlags = "" }) {
562
602
  const declaredPath = workspace.handoffs?.index?.path;
563
- if (!declaredPath) return { declared: false, path: null, expected: null };
603
+ let policy;
604
+ try { policy = handoffPolicy(workspace); }
605
+ catch (error) {
606
+ if (!(error instanceof HandoffError)) throw error;
607
+ throw new IndexError(error.message);
608
+ }
609
+ if (!workspace.slots?.handoffs && !workspace.handoffs) return { declared: false, path: null, expected: null };
564
610
 
565
- const indexPath = siteOutside(dir, declaredPath, workspace.slots?.handoffs, "series");
611
+ const indexPath = declaredPath ? siteOutside(dir, declaredPath, workspace.slots?.handoffs, "series") : null;
566
612
  const series = readHandoffs(dir, workspace);
567
613
 
568
614
  let broken = 0;
569
615
  for (const r of series.records) {
570
616
  const where = path.join(workspace.slots.handoffs, r.file);
571
- if (r.date === null) {
617
+ if (eligibleHandoffDate(r.file, today) === null) {
572
618
  broken += 1;
573
619
  fail(
574
620
  "handoffs",
575
621
  "date",
576
- `${where} does not lead with a valid YYYY-MM-DD date, so the index has no date to put on its line. ` +
622
+ `${where} must lead with a valid YYYY-MM-DD date no more than a day after ${today} (UTC). ` +
577
623
  "core/operating/loop.md fixes the form as `YYYY-MM-DD-{slug}.md` — rename the file",
578
624
  );
579
625
  }
580
- if (r.heading === null) {
626
+ if (declaredPath && r.heading === null) {
581
627
  broken += 1;
582
628
  fail(
583
629
  "handoffs",
@@ -587,6 +633,12 @@ function judgeHandoffs(dir, workspace, { write, fail, remedyFlags = "" }) {
587
633
  );
588
634
  }
589
635
  }
636
+ const overages = handoffOverages(series, policy);
637
+ for (const record of overages.bound) fail("handoffs", "budget",
638
+ `${path.join(workspace.slots.handoffs, record.file)} is ${record.tokens} tokens at ${policy.ratio} bytes per token, against a cap of ${policy.cap} — over by ${record.tokens - policy.cap}. ` +
639
+ "Compress it or move facts to their owning records and link them; keep each decision and its why. Never split, retire, raise the cap or move the cutoff to repair this breach");
640
+ if (overages.historical.length) note("handoffs", `${overages.historical.length} handoff(s) on or before cutoff ${policy.cutoff} exceed ${policy.cap} tokens: reported, never railed`);
641
+ if (!declaredPath) return { declared: true, path: null, expected: null, count: series.records.length, kept: false };
590
642
  if (broken) return { declared: true, path: indexPath, expected: null, count: series.records.length };
591
643
 
592
644
  const expected = renderHandoffIndex(workspace, series);
@@ -1052,7 +1104,8 @@ export function run(argv, say = console.log) {
1052
1104
  for (const f of broken) say(` ✗ ${dir}: ${f.message}`);
1053
1105
  if (broken.length && worst < 1) worst = 1;
1054
1106
  else if (!result.series.handoffs.declared) say(` · ${dir}: declares no handoff index`);
1055
- else if (!broken.length) say(result.series.handoffs.expected.trimEnd());
1107
+ else if (!broken.length && result.series.handoffs.expected !== null) say(result.series.handoffs.expected.trimEnd());
1108
+ else say(` · ${dir}: declares no handoff index`);
1056
1109
  continue;
1057
1110
  }
1058
1111
  if (!result.declared) {
@@ -1074,8 +1127,8 @@ export function run(argv, say = console.log) {
1074
1127
  }
1075
1128
  }
1076
1129
  if (result.series.handoffs.declared) {
1077
- const { kept, count } = result.series.handoffs;
1078
- parts.push(kept ? `handoff index ${state}` : `handoff index renders, ${count} handoff(s), none kept on disk`);
1130
+ const { kept, count, path: indexPath } = result.series.handoffs;
1131
+ parts.push(indexPath === null ? `handoff dates checked, ${count} handoff(s); no index declared` : kept ? `handoff index ${state}` : `handoff index renders, ${count} handoff(s), none kept on disk`);
1079
1132
  }
1080
1133
  if (result.series.scopes.declared) parts.push(`scope index ${state}`);
1081
1134
  say(` ok ${dir}: ${parts.join("; ")}`);