@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.
- package/README.md +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- 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 +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- 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/review-meter.mjs +14 -9
- 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 +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- 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 +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- 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.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- 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 +272 -123
- package/spec/workspace.schema.json +67 -11
package/cli/compile.mjs
CHANGED
|
@@ -6,24 +6,25 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Exit 0 wrote (or, with --check, agrees) · 1 an artifact has drifted · 2 could not run.
|
|
8
8
|
|
|
9
|
+
import { readContextCap, contextCaps, contextCapLines, capOnlyPlan, emitCapOnly, CONTEXT_CAP_ENV } from "./ritual-contexts.mjs";
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
13
|
import { AUTO, discoverPackRoots, namedWithAuto, resolutionRoots } from "./discover.mjs";
|
|
13
14
|
import { isInside } from "./inside.mjs";
|
|
15
|
+
import { importTargetIsFile } from "./import-files.mjs";
|
|
16
|
+
export { importTargetIsFile } from "./import-files.mjs";
|
|
17
|
+
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
14
18
|
import { CannotOutline, sectionOf } from "./symbols.mjs";
|
|
15
19
|
import { HORIZON, LedgerError, readSpend } from "./ledger.mjs";
|
|
20
|
+
import { readStopPolicy, STOP_HOOK_MARGIN } from "./stop-policy.mjs";
|
|
21
|
+
import { assertSourceJournalReady } from "./instructions-journal.mjs";
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
export
|
|
19
|
-
constructor(message) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.name = "CompileError";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
23
|
+
import { CompileError, readOwnedBytes, readOwnedText, sameContent, nestedTarget, nestedMark, validateNestedTargets, workspaceProtection, nestedPlan, applyNestedEdits, preflightNestedEdits, NESTED_REGISTRY, readNestedState, ownedNestedFiles } from "./guidance-files.mjs";
|
|
24
|
+
export { CompileError, readOwnedBytes, readOwnedText, NESTED_REGISTRY, readNestedState, ownedNestedFiles };
|
|
24
25
|
|
|
25
26
|
/** The `portulan.spec` versions of `gates.json`: a train separate from the Workspace Definition's, though the numbers overlap. */
|
|
26
|
-
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2"]);
|
|
27
|
+
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
|
|
27
28
|
|
|
28
29
|
const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
|
|
29
30
|
|
|
@@ -131,11 +132,11 @@ export function parse(policy) {
|
|
|
131
132
|
rules.push({ id, tier: rule.tier, kind, target: action[kind], reason: rule.reason, action });
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
return { rules, floor: parseFloor(policy.floor) };
|
|
135
|
+
return { rules, floor: parseFloor(policy.floor, String(spec)) };
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
/** The optional
|
|
138
|
-
function parseFloor(floor) {
|
|
138
|
+
/** The optional platform floor, with strict freshness unless the policy explicitly chooses otherwise. */
|
|
139
|
+
function parseFloor(floor, spec) {
|
|
139
140
|
if (floor === undefined || floor === null) return null;
|
|
140
141
|
if (typeof floor !== "object" || Array.isArray(floor)) {
|
|
141
142
|
throw new CompileError("`floor` is present but is not a JSON object");
|
|
@@ -180,7 +181,11 @@ function parseFloor(floor) {
|
|
|
180
181
|
if (typeof floor.resolve_conversations !== "boolean") {
|
|
181
182
|
throw new CompileError("`floor.resolve_conversations` must be a boolean — omitting it would export a floor weaker than the one in force");
|
|
182
183
|
}
|
|
183
|
-
|
|
184
|
+
if (Object.hasOwn(floor, "strict")) {
|
|
185
|
+
if (spec !== "2.3") throw new CompileError("`floor.strict` requires gate-policy 2.3");
|
|
186
|
+
if (typeof floor.strict !== "boolean") throw new CompileError("`floor.strict` must be a boolean");
|
|
187
|
+
}
|
|
188
|
+
return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations, strict: floor.strict ?? true };
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
// ---------------------------------------------------------------- what an action means, to the compiler and the gate alike
|
|
@@ -532,6 +537,8 @@ export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs", "advisory.mjs"];
|
|
|
532
537
|
* `ask` for gated, a prompt that blocks headless. Two layers, since on Claude Code 2.1.220 a crashing hook fails open.
|
|
533
538
|
*/
|
|
534
539
|
export function claudeCode(parsed, options = {}) {
|
|
540
|
+
const hasPolicy = parsed !== null;
|
|
541
|
+
const rules = hasPolicy ? parsed.rules : [];
|
|
535
542
|
const source = options.source ?? ".portulan/gates.json";
|
|
536
543
|
const runnerDir = path.dirname(fileURLToPath(import.meta.url));
|
|
537
544
|
const project = options.root ?? process.cwd();
|
|
@@ -545,8 +552,8 @@ export function claudeCode(parsed, options = {}) {
|
|
|
545
552
|
pinned.push({ file, abs });
|
|
546
553
|
return `"${abs}"`;
|
|
547
554
|
};
|
|
548
|
-
const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
|
|
549
|
-
const stopRunner = options.stopRunner ?? spell(HOOK_RUNNERS[1]);
|
|
555
|
+
const runner = options.runner ?? (hasPolicy ? spell(HOOK_RUNNERS[0]) : null);
|
|
556
|
+
const stopRunner = options.stopRunner ?? (hasPolicy ? spell(HOOK_RUNNERS[1]) : null);
|
|
550
557
|
const advisoryRunner = options.advisoryRunner ?? spell(HOOK_RUNNERS[2]);
|
|
551
558
|
|
|
552
559
|
const compiled = [];
|
|
@@ -557,7 +564,7 @@ export function claudeCode(parsed, options = {}) {
|
|
|
557
564
|
const shellWriteGates = [];
|
|
558
565
|
const editCoveredGates = [];
|
|
559
566
|
|
|
560
|
-
for (const rule of
|
|
567
|
+
for (const rule of rules) {
|
|
561
568
|
if (!HOST_GATE_TIERS.has(rule.tier)) {
|
|
562
569
|
refused.push({ id: rule.id, tier: rule.tier, why: HOST_TIER_NOT_A_GATE[rule.tier] });
|
|
563
570
|
continue;
|
|
@@ -605,16 +612,17 @@ export function claudeCode(parsed, options = {}) {
|
|
|
605
612
|
compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
|
|
606
613
|
}
|
|
607
614
|
|
|
608
|
-
if (
|
|
615
|
+
if (rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
|
|
609
616
|
throw new CompileError(
|
|
610
617
|
"every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
|
|
611
618
|
);
|
|
612
619
|
}
|
|
613
620
|
|
|
614
|
-
//
|
|
621
|
+
// Portable provenance excludes absolute roots.
|
|
615
622
|
const packs = (options.packProvenance ?? [])
|
|
616
623
|
.map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
|
|
617
624
|
.sort((a, b) => a.pack.localeCompare(b.pack));
|
|
625
|
+
const contexts = options.contexts ?? contextCaps([]);
|
|
618
626
|
const sessions = options.sessions ?? null;
|
|
619
627
|
const switches = {};
|
|
620
628
|
if (sessions?.git_instructions !== undefined) switches.includeGitInstructions = sessions.git_instructions;
|
|
@@ -624,48 +632,61 @@ export function claudeCode(parsed, options = {}) {
|
|
|
624
632
|
const figures = [];
|
|
625
633
|
if (spend?.multipliers) figures.push("--read", spend.multipliers.read, "--write-5m", spend.multipliers.write["5m"], "--write-1h", spend.multipliers.write["1h"]);
|
|
626
634
|
if (spend?.horizon) figures.push("--horizon", spend.horizon);
|
|
635
|
+
if (spend?.resume != null) figures.push("--resume-tokens", spend.resume);
|
|
627
636
|
const spendFlags = figures.map((f) => ` ${f}`).join("");
|
|
628
637
|
const restartBlock = spend?.restart === "block";
|
|
629
638
|
const spendFrom = figures.length || restartBlock ? spend.manifest : null;
|
|
630
|
-
const
|
|
639
|
+
const figuresIn =
|
|
631
640
|
sessionsFrom && sessionsFrom === spendFrom
|
|
632
641
|
? `\`sessions\` and \`spend\` in ${sessionsFrom}`
|
|
633
642
|
: [sessionsFrom && `\`sessions\` in ${sessionsFrom}`, spendFrom && `\`spend\` in ${spendFrom}`].filter(Boolean).join(", or ");
|
|
643
|
+
const stopPolicy = hasPolicy ? options.stopPolicy ?? null : null;
|
|
644
|
+
if (stopPolicy !== null) {
|
|
645
|
+
try {
|
|
646
|
+
const seconds = typeof stopPolicy === "object" && !Array.isArray(stopPolicy) ? stopPolicy.seconds : null;
|
|
647
|
+
readStopPolicy({ portulan: { spec: "2.14" }, verify: { stopTimeout: seconds ?? null } });
|
|
648
|
+
} catch (error) { throw new CompileError(error.message); }
|
|
649
|
+
}
|
|
650
|
+
const declaredIn = [figuresIn, contexts.limit !== null && "ritual contexts in composed pack manifests", stopPolicy && `\`verify.stopTimeout\` in ${stopPolicy.manifest}`].filter(Boolean).join(", or ");
|
|
634
651
|
const value = {
|
|
635
652
|
$portulan: {
|
|
636
653
|
generated: "cli/compile.mjs",
|
|
637
654
|
source,
|
|
638
655
|
...(sessionsFrom ? { sessions: sessionsFrom } : {}),
|
|
639
656
|
...(spendFrom ? { spend: spendFrom } : {}),
|
|
657
|
+
...(stopPolicy ? { verify: stopPolicy.manifest } : {}),
|
|
640
658
|
...(packs.length ? { packs } : {}),
|
|
659
|
+
...(contexts.limit !== null ? { contexts: contexts.sources } : {}),
|
|
641
660
|
warning: declaredIn
|
|
642
661
|
? `Generated file. Edit ${source}, or ${declaredIn}, and recompile; \`verify/compile.sh\` fails on drift.`
|
|
643
662
|
: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
|
|
644
663
|
},
|
|
645
|
-
permissions: { deny, ask, allow: [] },
|
|
664
|
+
...(hasPolicy ? { permissions: { deny, ask, allow: [] } } : {}),
|
|
646
665
|
hooks: {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
],
|
|
658
|
-
},
|
|
659
|
-
],
|
|
666
|
+
...(hasPolicy ? {
|
|
667
|
+
PreToolUse: [...matchers].sort().map((matcher) => ({
|
|
668
|
+
matcher,
|
|
669
|
+
hooks: [{ type: "command", command: `node ${runner}` }],
|
|
670
|
+
})),
|
|
671
|
+
} : {}),
|
|
672
|
+
...(hasPolicy || restartBlock ? { Stop: [{ hooks: [
|
|
673
|
+
...(hasPolicy ? [{ type: "command", command: `node ${stopRunner}`, ...(stopPolicy ? { timeout: stopPolicy.seconds + STOP_HOOK_MARGIN } : {}) }] : []),
|
|
674
|
+
...(restartBlock ? [{ type: "command", command: `node ${advisoryRunner} stop${spendFlags}` }] : []),
|
|
675
|
+
] }] } : {}),
|
|
660
676
|
// No matcher means every tool; a non-blocking `Stop` hook's output would reach only the host's debug log.
|
|
661
677
|
PostToolUse: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} tool${spendFlags}` }] }],
|
|
662
678
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} prompt${spendFlags}` }] }],
|
|
663
679
|
},
|
|
664
680
|
statusLine: { type: "command", command: `node ${advisoryRunner} status${spendFlags}` },
|
|
665
681
|
...switches,
|
|
682
|
+
...(contexts.limit !== null ? { env: { [CONTEXT_CAP_ENV]: String(contexts.limit) } } : {}),
|
|
666
683
|
};
|
|
667
684
|
|
|
668
|
-
const notes =
|
|
685
|
+
const notes = contextCapLines(contexts);
|
|
686
|
+
if (!hasPolicy) notes.push(
|
|
687
|
+
"Claude Code: prompt/tool restart advisory and status line compile without a gate policy; declared session switches and ritual caps compose here. GitHub repository ruleset and AGENTS guidance: advisory/session settings not compiled by these adapters.",
|
|
688
|
+
`Undeclared prices use general read 0.1× and writes 1.25×/2×; horizon ${spend?.horizon ? `${spend.horizon} declared` : `${HORIZON} general`}; resume expense ${spend?.resume != null ? `${spend.resume} declared` : "unknown"}. Unknown resume expense or a general horizon permits conditional advice, never a Stop hold.`,
|
|
689
|
+
);
|
|
669
690
|
if (pinned.length) {
|
|
670
691
|
notes.push(
|
|
671
692
|
`${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
|
|
@@ -715,11 +736,12 @@ export function claudeCode(parsed, options = {}) {
|
|
|
715
736
|
`commands carry them, so an edit to \`spend\` is drift until recompiled`,
|
|
716
737
|
);
|
|
717
738
|
}
|
|
739
|
+
if (spend?.resume != null) notes.push(`restart resume expense: ${spend.resume} input-equivalent tokens, excluding the fresh-context write and baseline reads`);
|
|
718
740
|
if (restartBlock) {
|
|
719
741
|
notes.push(
|
|
720
|
-
`the restart advisory
|
|
721
|
-
`
|
|
722
|
-
`
|
|
742
|
+
`the restart advisory's Stop command (\`spend.restart\` "block") holds once only with an explicit horizon, an explicit resume expense ` +
|
|
743
|
+
`and finite positive estimated savings; equality never holds. Without both estimates it reports conditionally without holding. ` +
|
|
744
|
+
`Retry protection and once per compaction still apply; nothing ends the session`,
|
|
723
745
|
);
|
|
724
746
|
}
|
|
725
747
|
if (editCoveredGates.length) {
|
|
@@ -811,8 +833,7 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
811
833
|
rules.push({
|
|
812
834
|
type: "required_status_checks",
|
|
813
835
|
parameters: {
|
|
814
|
-
|
|
815
|
-
strict_required_status_checks_policy: true,
|
|
836
|
+
strict_required_status_checks_policy: floor.strict,
|
|
816
837
|
do_not_enforce_on_create: false,
|
|
817
838
|
required_status_checks: floor.checks,
|
|
818
839
|
},
|
|
@@ -988,7 +1009,7 @@ export const GUIDANCE_HOSTS = {
|
|
|
988
1009
|
"claude-code": {
|
|
989
1010
|
label: "Claude Code",
|
|
990
1011
|
always: `an unscoped rule in \`${GUIDANCE_RULES_DIR}/\``,
|
|
991
|
-
"on-path": "a rule
|
|
1012
|
+
"on-path": "a paths-scoped rule or a declared nested CLAUDE.md",
|
|
992
1013
|
"on-invoke": `a project skill in \`${SKILLS_DIR}/\``,
|
|
993
1014
|
"on-read": `a line in \`${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}\``,
|
|
994
1015
|
},
|
|
@@ -1029,11 +1050,148 @@ const STRAY_IMPORT = {
|
|
|
1029
1050
|
"on-read": "an on-read file is opened as it stands, so the import would load nothing",
|
|
1030
1051
|
};
|
|
1031
1052
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1053
|
+
function importCandidate(text) {
|
|
1054
|
+
return [...text.matchAll(/@((?:[^\s\\]|\\ )+)/g)].some(match =>
|
|
1055
|
+
!/[\p{L}\p{N}.+-]/u.test(text[match.index - 1] ?? " ") && importPath(match[1]) !== null);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function importSyntax(text) {
|
|
1059
|
+
const rows = text.split("\n");
|
|
1060
|
+
const starts = [];
|
|
1061
|
+
let offset = 0;
|
|
1062
|
+
for (const row of rows) { starts.push(offset); offset += row.length + 1; }
|
|
1063
|
+
const width = (prefix) => [...prefix].reduce((col, char) => col + (char === "\t" ? 4 - col % 4 : 1), 0);
|
|
1064
|
+
const refuse = (name, region, i) => {
|
|
1065
|
+
if (importCandidate(region)) throw new CompileError(`unsupported import syntax: ${name} at line ${i + 1}; put the import alone in a supported paragraph`);
|
|
1066
|
+
};
|
|
1067
|
+
let fence = null;
|
|
1068
|
+
let comment = false;
|
|
1069
|
+
let quotedList = null;
|
|
1070
|
+
let list = null;
|
|
1071
|
+
let flowing = false;
|
|
1072
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1073
|
+
let line = rows[i].replace(/\r$/, "");
|
|
1074
|
+
let shift = 0;
|
|
1075
|
+
if (fence) {
|
|
1076
|
+
if (new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*$`).test(line)) fence = null;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (comment) {
|
|
1080
|
+
const end = line.indexOf("-->");
|
|
1081
|
+
if (end === -1) continue;
|
|
1082
|
+
comment = false;
|
|
1083
|
+
shift = end + 3;
|
|
1084
|
+
line = line.slice(shift);
|
|
1085
|
+
}
|
|
1086
|
+
const trim = line.trimStart();
|
|
1087
|
+
const indent = width(/^[ \t]*/.exec(line)[0]);
|
|
1088
|
+
const regionThrough = (stop) => [line, ...rows.slice(i + 1, stop)].join("\n");
|
|
1089
|
+
const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
1090
|
+
if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) {
|
|
1091
|
+
fence = opening[1];
|
|
1092
|
+
quotedList = null;
|
|
1093
|
+
flowing = false;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
let quoted = line;
|
|
1097
|
+
let outerItem;
|
|
1098
|
+
while ((outerItem = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/.exec(quoted))) quoted = quoted.slice(outerItem[0].length);
|
|
1099
|
+
let depth = 0;
|
|
1100
|
+
let prefix;
|
|
1101
|
+
while ((prefix = /^ {0,3}>[ \t]?/.exec(quoted))) { depth++; quoted = quoted.slice(prefix[0].length); }
|
|
1102
|
+
const quotedItem = /^ {0,3}(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/.exec(quoted);
|
|
1103
|
+
if (depth && quotedItem) {
|
|
1104
|
+
quotedList = { depth, indent: width(quotedItem[0]), flowing: !opensBlock(quoted.slice(quotedItem[0].length)) };
|
|
1105
|
+
refuse("blockquoted list", quoted, i);
|
|
1106
|
+
} else if (quotedList) {
|
|
1107
|
+
const contained = depth > quotedList.depth || (depth === quotedList.depth && width(/^[ \t]*/.exec(quoted)[0]) >= quotedList.indent);
|
|
1108
|
+
if (quoted.trim() === "") {
|
|
1109
|
+
if (depth < quotedList.depth) quotedList = null;
|
|
1110
|
+
else quotedList.flowing = false;
|
|
1111
|
+
}
|
|
1112
|
+
else if (contained || (quotedList.flowing && !opensBlock(quoted))) {
|
|
1113
|
+
refuse("blockquoted list", quoted, i);
|
|
1114
|
+
quotedList.flowing = !opensBlock(quoted.trimStart());
|
|
1115
|
+
} else quotedList = null;
|
|
1116
|
+
}
|
|
1117
|
+
const item = /^([ \t]*)([-+*]|\d{1,9}[.)])([ \t]+|$)/.exec(line);
|
|
1118
|
+
if (item && (list || indent <= 3)) {
|
|
1119
|
+
if (list && indent >= list.indent) list.reason = "list after nested list";
|
|
1120
|
+
else list = { indent: width(item[0]), reason: null };
|
|
1121
|
+
flowing = true;
|
|
1122
|
+
} else if (line.trim() === "") {
|
|
1123
|
+
flowing = false;
|
|
1124
|
+
} else if (list && (indent >= list.indent || (flowing && !opensBlock(line)))) {
|
|
1125
|
+
flowing = true;
|
|
1126
|
+
} else {
|
|
1127
|
+
list = null;
|
|
1128
|
+
flowing = false;
|
|
1129
|
+
}
|
|
1130
|
+
if (list && flowing) {
|
|
1131
|
+
const content = item ? line.slice(item[0].length) : trim;
|
|
1132
|
+
if (OPENS_HTML_BLOCK.test(content) || /^<(?:\?|!\[CDATA\[|![A-Z])/.test(content)) list.reason = "list after HTML block";
|
|
1133
|
+
else if ((item || indent >= list.indent) && /^<\/?[A-Za-z]/.test(content)) list.reason = "list after tag";
|
|
1134
|
+
else if (!item && indent >= list.indent + 4) list.reason = "list after indented code";
|
|
1135
|
+
if (list.reason && !item) refuse(list.reason, line, i);
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
const deepFence = /^(?: {4,}|[ \t]*\t[ \t]*)(`{3,}|~{3,})/.exec(line);
|
|
1139
|
+
if (deepFence) {
|
|
1140
|
+
let end = i + 1;
|
|
1141
|
+
while (end < rows.length && !new RegExp(`^[ \\t]*${deepFence[1][0]}{${deepFence[1].length},}[ \\t]*$`).test(rows[end])) end++;
|
|
1142
|
+
refuse("indented fence", regionThrough(Math.min(end + 1, rows.length)), i);
|
|
1143
|
+
i = Math.min(end, rows.length - 1);
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (indent >= 4) { refuse("indented code", line, i); continue; }
|
|
1147
|
+
let htmlEnd = null;
|
|
1148
|
+
const raw = /^<(pre|script|style|textarea)(?=[ \t>]|$)/i.exec(trim);
|
|
1149
|
+
if (raw) htmlEnd = new RegExp(`</${raw[1]}[ \\t]*>`, "i");
|
|
1150
|
+
else if (/^<\?/.test(trim)) htmlEnd = /\?>/;
|
|
1151
|
+
else if (/^<!\[CDATA\[/.test(trim)) htmlEnd = /\]\]>/;
|
|
1152
|
+
else if (/^<![A-Z]/.test(trim)) htmlEnd = />/;
|
|
1153
|
+
const html = htmlEnd || OPENS_HTML_BLOCK.test(trim) ||
|
|
1154
|
+
((i === 0 || rows[i - 1].trim() === "") && /^<\/?[A-Za-z][^<>]*>[ \t]*$/.test(trim));
|
|
1155
|
+
if (html) {
|
|
1156
|
+
let end = i;
|
|
1157
|
+
while (end < rows.length && (htmlEnd ? !htmlEnd.test(end === i ? line : rows[end]) : (end === i ? line : rows[end]).trim() !== "")) end++;
|
|
1158
|
+
const stop = htmlEnd ? Math.min(end + 1, rows.length) : end;
|
|
1159
|
+
refuse("HTML block", regionThrough(stop), i);
|
|
1160
|
+
i = Math.max(i, stop - 1);
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
let masked = line;
|
|
1164
|
+
const ticks = [...line.matchAll(/`+/g)];
|
|
1165
|
+
for (let k = 0; k < ticks.length; k++) {
|
|
1166
|
+
const tick = ticks[k];
|
|
1167
|
+
const absolute = starts[i] + shift + tick.index;
|
|
1168
|
+
const rest = text.slice(absolute + tick[0].length).split(/\n[ \t]*\n/, 1)[0];
|
|
1169
|
+
const close = [...rest.matchAll(/`+/g)].find(next => next[0] === tick[0]);
|
|
1170
|
+
if (!close) continue;
|
|
1171
|
+
const region = rest.slice(0, close.index);
|
|
1172
|
+
if (region.includes("\n")) refuse("multiline code span", region, i);
|
|
1173
|
+
else {
|
|
1174
|
+
if (line.includes("<!--")) refuse("comment-opening line", region, i);
|
|
1175
|
+
const end = tick.index + tick[0].length * 2 + close.index;
|
|
1176
|
+
masked = masked.slice(0, tick.index) + " ".repeat(end - tick.index) + masked.slice(end);
|
|
1177
|
+
while (k + 1 < ticks.length && ticks[k + 1].index < end) k++;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
masked = masked.replace(/<!--.*?(?:-->|$)/g, match => " ".repeat(match.length));
|
|
1181
|
+
for (const [name, pattern] of [
|
|
1182
|
+
["emphasis adjacency", /[*_]@((?:[^\s\\]|\\ )+)/g],
|
|
1183
|
+
["escape adjacency", /\\[!-/:-@[-`{-~]@((?:[^\s\\]|\\ )+)/g],
|
|
1184
|
+
["tag adjacency", />@((?:[^\s\\]|\\ )+)/g],
|
|
1185
|
+
]) for (const match of masked.matchAll(pattern)) if (importPath(match[1]) !== null) {
|
|
1186
|
+
throw new CompileError(`unsupported import syntax: ${name} at line ${i + 1}; put the import alone in a supported paragraph`);
|
|
1187
|
+
}
|
|
1188
|
+
if (line.includes("<!--") && !line.slice(line.indexOf("<!--") + 4).includes("-->")) comment = true;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** Proven import forms; ambiguous Markdown regions fail closed before offsets are read. */
|
|
1036
1193
|
export function importSpans(text) {
|
|
1194
|
+
importSyntax(text);
|
|
1037
1195
|
const found = [];
|
|
1038
1196
|
let fence = null;
|
|
1039
1197
|
let comment = false;
|
|
@@ -1060,7 +1218,7 @@ export function importSpans(text) {
|
|
|
1060
1218
|
} else if (item && (listed || /^ {0,3}\S/.test(line))) {
|
|
1061
1219
|
listed = flowing = true;
|
|
1062
1220
|
raw = !opensInItem.test(line.slice(item[0].length));
|
|
1063
|
-
} else if (listed && (/^\s/.test(line) || (flowing && !
|
|
1221
|
+
} else if (listed && (/^\s/.test(line) || (flowing && !opensBlock(line)))) {
|
|
1064
1222
|
flowing = true;
|
|
1065
1223
|
raw = !opensInItem.test(line.trimStart());
|
|
1066
1224
|
} else {
|
|
@@ -1158,8 +1316,13 @@ function checkedImports(text, dir, root, where) {
|
|
|
1158
1316
|
return own;
|
|
1159
1317
|
}
|
|
1160
1318
|
|
|
1161
|
-
/** An unindented line that ends a list, as CommonMark reads it; a block tag such as `<div>` is read as a lazy line. */
|
|
1162
1319
|
const OPENS_BLOCK = /^(?:#{1,6}(?:[ \t]|$)|>|([-*_])(?:[ \t]*\1){2,}[ \t]*$|(?:[-+*]|\d{1,9}[.)])(?:[ \t]|$)|<(?:!--|\?|![A-Za-z]|!\[CDATA\[))/;
|
|
1320
|
+
const OPENS_HTML_BLOCK = /^<(?:(?:pre|script|style|textarea)(?=[ \t>]|$)|\/?(?:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?=[ \t>]|\/>|$))/i;
|
|
1321
|
+
|
|
1322
|
+
/** An unindented line that ends a list, as CommonMark reads it. */
|
|
1323
|
+
function opensBlock(line) {
|
|
1324
|
+
return OPENS_BLOCK.test(line) || OPENS_HTML_BLOCK.test(line);
|
|
1325
|
+
}
|
|
1163
1326
|
|
|
1164
1327
|
/** Each item's first sentence, byte for byte, from the first list in `file` or under its `#fragment` heading. */
|
|
1165
1328
|
function leadsOf(file, where, fragment = null, name = path.basename(file)) {
|
|
@@ -1197,7 +1360,7 @@ export function leadsOfText(source, name, where) {
|
|
|
1197
1360
|
} else if (kind !== null && /^\s+\S/.test(line)) {
|
|
1198
1361
|
items.at(-1).text += ` ${line.trim()}`;
|
|
1199
1362
|
} else if (kind !== null && line.trim() !== "") {
|
|
1200
|
-
if (under && !
|
|
1363
|
+
if (under && !opensBlock(line)) {
|
|
1201
1364
|
throw new CompileError(
|
|
1202
1365
|
`${where}: line ${at + 1} of ${name} follows an item of its first list with no blank line and no indent, ` +
|
|
1203
1366
|
`where CommonMark reads a line of text as more of that item — indent it under the item, or end the list with a blank line`,
|
|
@@ -1249,7 +1412,7 @@ function gatesOf(file, where, packs = []) {
|
|
|
1249
1412
|
}
|
|
1250
1413
|
|
|
1251
1414
|
/** The unit's body with each leads, engine and gates line written out; a leads path may end `#<heading>`. */
|
|
1252
|
-
function expandedBody(unit, dir, root, packs = []) {
|
|
1415
|
+
export function expandedBody(unit, dir, root, packs = [], { originRoot = root } = {}) {
|
|
1253
1416
|
unit.leadSources = [];
|
|
1254
1417
|
unit.written = new Set();
|
|
1255
1418
|
return unit.body
|
|
@@ -1259,11 +1422,13 @@ function expandedBody(unit, dir, root, packs = []) {
|
|
|
1259
1422
|
const engine = leads ? null : ENGINE_LINE.exec(line);
|
|
1260
1423
|
const gates = leads || engine ? null : GATES_LINE.exec(line);
|
|
1261
1424
|
if (!leads && !engine && !gates) return [line];
|
|
1262
|
-
if (engine) return engineLeads(unit, engine[1],
|
|
1425
|
+
if (engine) return engineLeads(unit, engine[1], originRoot);
|
|
1263
1426
|
const [what, spelled] = leads ? ["leads", leads[1]] : ["gates", gates[1]];
|
|
1264
1427
|
const hash = leads ? spelled.indexOf("#") : -1;
|
|
1265
1428
|
const [named, fragment] = hash === -1 ? [spelled, null] : [spelled.slice(0, hash), spelled.slice(hash + 1)];
|
|
1266
|
-
const file = path.
|
|
1429
|
+
const file = path.isAbsolute(named) && originRoot !== root && isInside(originRoot, named)
|
|
1430
|
+
? path.resolve(root, path.relative(originRoot, named))
|
|
1431
|
+
: path.resolve(dir, named);
|
|
1267
1432
|
if (!isInside(root, file)) throw new CompileError(`${unit.source}: the ${what} of ${named} were asked for, and it lies outside the tree compiled here`);
|
|
1268
1433
|
let real;
|
|
1269
1434
|
try {
|
|
@@ -1304,7 +1469,7 @@ function engineLeads(unit, spelled, root) {
|
|
|
1304
1469
|
return leadsOf(real, unit.source, fragment, shown).map((lead) => (own ? lead.replaceAll(PLUGIN_ROOT, "") : lead));
|
|
1305
1470
|
}
|
|
1306
1471
|
|
|
1307
|
-
const UNIT_KEYS = new Set(["tier", "paths", "description"]);
|
|
1472
|
+
const UNIT_KEYS = new Set(["tier", "paths", "description", "target"]);
|
|
1308
1473
|
|
|
1309
1474
|
/** A line break, by any spelling a host might honour, or another control character. */
|
|
1310
1475
|
const CONTROL = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/;
|
|
@@ -1337,7 +1502,7 @@ function unitFrontmatter(lines, where) {
|
|
|
1337
1502
|
if (!match) throw new CompileError(`${where}: frontmatter line ${i + 2} is not \`key: value\` — ${JSON.stringify(line)}`);
|
|
1338
1503
|
const [, key, rest = ""] = match;
|
|
1339
1504
|
if (!UNIT_KEYS.has(key)) {
|
|
1340
|
-
throw new CompileError(`${where}: \`${key}\` is not a key a unit takes — only \`tier\`, \`paths\` and \`description\` (spec/slots.md)`);
|
|
1505
|
+
throw new CompileError(`${where}: \`${key}\` is not a key a unit takes — only \`tier\`, \`paths\`, \`target\` and \`description\` (spec/slots.md)`);
|
|
1341
1506
|
}
|
|
1342
1507
|
if (Object.hasOwn(fields, key)) throw new CompileError(`${where}: \`${key}\` is declared twice`);
|
|
1343
1508
|
if (key !== "paths") {
|
|
@@ -1386,8 +1551,14 @@ export function parseUnit(name, text, source = `${name}.md`) {
|
|
|
1386
1551
|
if (!LOAD_TIERS.includes(tier)) {
|
|
1387
1552
|
throw new CompileError(`${where}: \`tier\` is ${tier === undefined ? "missing" : JSON.stringify(tier)}, and it must be one of ${LOAD_TIERS.map((t) => `\`${t}\``).join(", ")}`);
|
|
1388
1553
|
}
|
|
1389
|
-
let paths = null;
|
|
1390
|
-
if (
|
|
1554
|
+
let paths = null, target = null;
|
|
1555
|
+
if (fields.target !== undefined) {
|
|
1556
|
+
if (tier !== "on-path") throw new CompileError(`${where}: target is valid only on-path`);
|
|
1557
|
+
if (fields.paths !== undefined) throw new CompileError(`${where}: select one of paths or target, never both`);
|
|
1558
|
+
target = nestedTarget(fields.target,where);
|
|
1559
|
+
paths = [`${path.posix.dirname(target)}/**`];
|
|
1560
|
+
}
|
|
1561
|
+
if (tier === "on-path" && target === null) {
|
|
1391
1562
|
if (fields.paths === undefined || fields.paths.length === 0) {
|
|
1392
1563
|
throw new CompileError(`${where}: an on-path unit names the \`paths\` that load it, and this one names none`);
|
|
1393
1564
|
}
|
|
@@ -1400,7 +1571,7 @@ export function parseUnit(name, text, source = `${name}.md`) {
|
|
|
1400
1571
|
}
|
|
1401
1572
|
}
|
|
1402
1573
|
paths = fields.paths;
|
|
1403
|
-
} else if (fields.paths !== undefined) {
|
|
1574
|
+
} else if (tier !== "on-path" && fields.paths !== undefined) {
|
|
1404
1575
|
throw new CompileError(`${where}: \`paths\` scopes an on-path unit, and this one is \`${tier}\` — a key that loads nothing is one its author believes is in force`);
|
|
1405
1576
|
}
|
|
1406
1577
|
let description = null;
|
|
@@ -1433,12 +1604,13 @@ export function parseUnit(name, text, source = `${name}.md`) {
|
|
|
1433
1604
|
} else if (rest[0] === BOOT_CARD_LINE) {
|
|
1434
1605
|
throw new CompileError(`${where}: \`${BOOT_CARD_LINE}\` opens the boot card, and only the unit named \`${BOOT_CARD_UNIT}\` is one — the boot skill would take this unit for the card`);
|
|
1435
1606
|
}
|
|
1607
|
+
if (target !== null && importSpans(rest.join("\n")).some(({target: spelled}) => importPath(spelled) !== null)) throw new CompileError(`${where}: nested instruction imports are not supported; put literal examples in code`);
|
|
1436
1608
|
const written = [["leads", LEADS_LINE], ["engine", ENGINE_LINE], ["gates", GATES_LINE]].find(([, line]) => rest.some((text) => line.test(text)));
|
|
1437
1609
|
if (tier !== "always" && written) {
|
|
1438
1610
|
const [which] = written;
|
|
1439
1611
|
throw new CompileError(`${where}: a \`<!-- ${which}: … -->\` line is written out only in an always unit, and this one is \`${tier}\``);
|
|
1440
1612
|
}
|
|
1441
|
-
return { name, tier, paths, description, body: `${rest.join("\n")}\n`, source, bytes: Buffer.byteLength(text, "utf8") };
|
|
1613
|
+
return { name, tier, paths, ...(target === null ? {} : {target}), description, body: `${rest.join("\n")}\n`, source, bytes: Buffer.byteLength(text, "utf8") };
|
|
1442
1614
|
}
|
|
1443
1615
|
|
|
1444
1616
|
/** The declared `slots.context`, or null where none is declared or there is no manifest; one that will not parse throws. */
|
|
@@ -1453,7 +1625,7 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1453
1625
|
}
|
|
1454
1626
|
let manifest;
|
|
1455
1627
|
try {
|
|
1456
|
-
manifest = JSON.parse(
|
|
1628
|
+
manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
|
|
1457
1629
|
} catch {
|
|
1458
1630
|
return null;
|
|
1459
1631
|
}
|
|
@@ -1474,11 +1646,12 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1474
1646
|
`a unit there would be overwritten by what it compiles to, and \`vendor\` carries neither directory. Keep guidance in a directory of its own, such as \`context/\``,
|
|
1475
1647
|
);
|
|
1476
1648
|
}
|
|
1477
|
-
|
|
1649
|
+
const workspace = path.relative(fs.realpathSync(workspaceRoot),fs.realpathSync(base)).split(path.sep).join("/") || ".";
|
|
1650
|
+
return { dir, rel: `${rel}/`, workspace, packs: Array.isArray(manifest.packs) ? manifest.packs.filter((p) => typeof p === "string") : [] };
|
|
1478
1651
|
}
|
|
1479
1652
|
|
|
1480
1653
|
/** Every unit, read and checked, or null where no `slots.context` is declared. */
|
|
1481
|
-
export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
1654
|
+
export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan", { originRoot = workspaceRoot } = {}) {
|
|
1482
1655
|
const declared = guidanceDeclaration(workspaceRoot, workspaceDir);
|
|
1483
1656
|
if (declared === null) return null;
|
|
1484
1657
|
let entries;
|
|
@@ -1502,6 +1675,7 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1502
1675
|
}
|
|
1503
1676
|
});
|
|
1504
1677
|
const units = [];
|
|
1678
|
+
for(const entry of entries) if(entry.name.endsWith(".md") && !entry.isDirectory() && !entry.isFile() && !entry.isSymbolicLink()) throw new CompileError(`${declared.rel}${entry.name} is not a regular guidance source`);
|
|
1505
1679
|
const names = entries
|
|
1506
1680
|
.filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
|
|
1507
1681
|
.map((e) => e.name)
|
|
@@ -1521,20 +1695,22 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1521
1695
|
}
|
|
1522
1696
|
let text;
|
|
1523
1697
|
try {
|
|
1524
|
-
|
|
1698
|
+
if (!fs.statSync(full).isFile()) throw new CompileError(`${source} is not a regular guidance unit`);
|
|
1699
|
+
text = readOwnedText(full,realBase);
|
|
1525
1700
|
} catch (cause) {
|
|
1526
1701
|
throw new CompileError(`${source} could not be read — ${cause.code ?? cause.message}`);
|
|
1527
1702
|
}
|
|
1528
1703
|
const unit = parseUnit(path.basename(file, ".md"), text, source);
|
|
1704
|
+
unit.ownedSource = path.relative(fs.realpathSync(workspaceRoot),real).split(path.sep).join("/");
|
|
1529
1705
|
if (unit.tier === "always") {
|
|
1530
1706
|
unit.dir = path.dirname(full);
|
|
1531
|
-
unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs);
|
|
1707
|
+
unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs, { originRoot });
|
|
1532
1708
|
unit.imports = checkedImports(unit.text, unit.dir, path.resolve(workspaceRoot), source);
|
|
1533
1709
|
} else {
|
|
1534
1710
|
for (const { target } of importSpans(unit.body)) {
|
|
1535
1711
|
const bare = importPath(target);
|
|
1536
1712
|
if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
|
|
1537
|
-
if (
|
|
1713
|
+
if (importTargetIsFile(path.resolve(path.dirname(full), bare))) {
|
|
1538
1714
|
throw new CompileError(
|
|
1539
1715
|
`${source}: \`@${target}\` names a file, and only an always unit may import one — ${STRAY_IMPORT[unit.tier]}. ` +
|
|
1540
1716
|
`Name the file to open instead, in a code span`,
|
|
@@ -1544,7 +1720,12 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1544
1720
|
}
|
|
1545
1721
|
units.push(unit);
|
|
1546
1722
|
}
|
|
1547
|
-
|
|
1723
|
+
validateNestedTargets(units.filter(unit=>unit.target).map(unit=>unit.target));
|
|
1724
|
+
for (const unit of units.filter(unit=>unit.target)) {
|
|
1725
|
+
const at=path.resolve(workspaceRoot,unit.target);
|
|
1726
|
+
if (units.some(other=>path.resolve(workspaceRoot,other.ownedSource)===at)) throw new CompileError(`${unit.target} would overwrite a guidance source`);
|
|
1727
|
+
}
|
|
1728
|
+
return { source: declared.rel, units, root: path.resolve(workspaceRoot), workspace:declared.workspace, protected:units.some(unit=>unit.target) ? workspaceProtection(workspaceRoot,workspaceDir) : undefined };
|
|
1548
1729
|
}
|
|
1549
1730
|
|
|
1550
1731
|
function rebasedText(unit, root, to) {
|
|
@@ -1573,6 +1754,8 @@ export function claudeCodeGuidance(guidance) {
|
|
|
1573
1754
|
if (unit.tier === "always") {
|
|
1574
1755
|
const at = `${GUIDANCE_RULES_DIR}/${unit.name}.md`;
|
|
1575
1756
|
files.push({ unit, path: at, text: unit.text === undefined ? unit.body : rebasedText(unit, guidance.root, at) });
|
|
1757
|
+
} else if (unit.tier === "on-path" && unit.target) {
|
|
1758
|
+
files.push({unit,path:unit.target,text:`${nestedMark(guidance.workspace ?? ".",unit.ownedSource ?? unit.source,unit.target)}\n${unit.body}`,nested:true});
|
|
1576
1759
|
} else if (unit.tier === "on-path") {
|
|
1577
1760
|
const header = ["---", "paths:", ...unit.paths.map((glob) => ` - ${quoted(glob)}`), "---", ""].join("\n");
|
|
1578
1761
|
files.push({ unit, path: `${GUIDANCE_RULES_DIR}/${unit.name}.md`, text: `${header}\n${unit.body}` });
|
|
@@ -1659,12 +1842,15 @@ function listReal(workspaceRoot, rel) {
|
|
|
1659
1842
|
}
|
|
1660
1843
|
|
|
1661
1844
|
/** Judged before either half writes, so a refusal leaves no gate artifact written beside it. */
|
|
1662
|
-
function planGuidance(guidance, workspaceRoot) {
|
|
1845
|
+
export function planGuidance(guidance, workspaceRoot, workspaceDir = ".portulan") {
|
|
1846
|
+
assertSourceJournalReady(workspaceRoot,workspaceDir);
|
|
1663
1847
|
const files = guidance ? claudeCodeGuidance(guidance).files : [];
|
|
1848
|
+
const nested = nestedPlan(workspaceRoot,guidance,files,guidance?.workspace ?? workspaceDir);
|
|
1664
1849
|
const rules = files.filter((f) => f.path.startsWith(`${GUIDANCE_RULES_DIR}/`));
|
|
1665
1850
|
const owedFiles = rules.length
|
|
1666
1851
|
? [{ unit: null, marker: true, path: `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`, text: markerText(rules.map((f) => path.posix.basename(f.path))) }, ...files]
|
|
1667
1852
|
: files;
|
|
1853
|
+
if (nested.control) owedFiles.push(nested.control);
|
|
1668
1854
|
const owed = new Set(owedFiles.map((f) => f.path));
|
|
1669
1855
|
|
|
1670
1856
|
for (const file of owedFiles) {
|
|
@@ -1706,11 +1892,11 @@ function planGuidance(guidance, workspaceRoot) {
|
|
|
1706
1892
|
// A run stopped before its marker leaves exactly its unit's output; anything else was written by hand.
|
|
1707
1893
|
let current;
|
|
1708
1894
|
try {
|
|
1709
|
-
current = fs.readFileSync(path.join(rulesDir, name)
|
|
1895
|
+
current = fs.readFileSync(path.join(rulesDir, name));
|
|
1710
1896
|
} catch (cause) {
|
|
1711
1897
|
throw new CompileError(`${file.path} could not be read — ${cause.code ?? cause.message}`);
|
|
1712
1898
|
}
|
|
1713
|
-
if (current
|
|
1899
|
+
if (!sameContent(current,file.text)) {
|
|
1714
1900
|
throw new CompileError(`${file.path} exists and was not compiled here — ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`} would replace a rule written by hand. Rename the unit or the rule`);
|
|
1715
1901
|
}
|
|
1716
1902
|
}
|
|
@@ -1739,7 +1925,7 @@ function planGuidance(guidance, workspaceRoot) {
|
|
|
1739
1925
|
if (listed instanceof Set || rules.length) {
|
|
1740
1926
|
for (const entry of entries) {
|
|
1741
1927
|
const rel = `${GUIDANCE_RULES_DIR}/${entry.name}`;
|
|
1742
|
-
if (owed.has(rel)) continue;
|
|
1928
|
+
if (owed.has(rel) || nested.claimed.has(rel)) continue;
|
|
1743
1929
|
const ours = listed instanceof Set && entry.isFile() && (listed.has(entry.name) || entry.name === RULES_MARKER);
|
|
1744
1930
|
stray.push({ path: rel, removable: ours });
|
|
1745
1931
|
}
|
|
@@ -1763,7 +1949,20 @@ function planGuidance(guidance, workspaceRoot) {
|
|
|
1763
1949
|
const kept = listed instanceof Set || rules.length
|
|
1764
1950
|
? rules.map((f) => path.posix.basename(f.path)).filter((name) => listed instanceof Set && listed.has(name) && present.has(name))
|
|
1765
1951
|
: null;
|
|
1766
|
-
|
|
1952
|
+
stray.push(...nested.stale.map(edit=>({path:edit.file,removable:true,nested:true})));
|
|
1953
|
+
return { owedFiles, stray, kept, nested };
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function preflightGuidanceWrites(plan, workspaceRoot) {
|
|
1957
|
+
preflightNestedEdits(workspaceRoot, plan.nested.edits);
|
|
1958
|
+
const outputs = new Set(plan.owedFiles.filter((file) => !file.nested && !file.control).map((file) => file.path));
|
|
1959
|
+
if (plan.kept !== null) outputs.add(`${GUIDANCE_RULES_DIR}/${RULES_MARKER}`);
|
|
1960
|
+
for (const rel of outputs) {
|
|
1961
|
+
const target = path.join(workspaceRoot, ...rel.split("/"));
|
|
1962
|
+
const at = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
1963
|
+
if (at?.isFile() && at.nlink !== 1)
|
|
1964
|
+
throw new CompileError(`${target} is a multiply linked guidance output; refusing publication`);
|
|
1965
|
+
}
|
|
1767
1966
|
}
|
|
1768
1967
|
|
|
1769
1968
|
function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
@@ -1771,7 +1970,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1771
1970
|
if (guidance === null && stray.length === 0) return 0;
|
|
1772
1971
|
|
|
1773
1972
|
if (guidance) {
|
|
1774
|
-
const files = owedFiles.filter((f) => !f.marker);
|
|
1973
|
+
const files = owedFiles.filter((f) => !f.marker && !f.control);
|
|
1775
1974
|
const counts = LOAD_TIERS.map((tier) => `${guidance.units.filter((u) => u.tier === tier).length} ${tier}`).join(", ");
|
|
1776
1975
|
say(`guidance: ${guidance.units.length} unit(s) in ${guidance.source} — ${counts}`);
|
|
1777
1976
|
for (const [id, host] of Object.entries(GUIDANCE_HOSTS)) {
|
|
@@ -1799,17 +1998,19 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1799
1998
|
const target = path.join(workspaceRoot, ...file.path.split("/"));
|
|
1800
1999
|
let current = null;
|
|
1801
2000
|
try {
|
|
1802
|
-
current = fs.readFileSync(target
|
|
2001
|
+
current = fs.readFileSync(target);
|
|
1803
2002
|
} catch {
|
|
1804
2003
|
say(file.marker
|
|
1805
2004
|
? `RED — ${target} does not exist; it is the marker that lists the rules this compiler wrote. Recompile to write it.`
|
|
2005
|
+
: file.control ? `RED — ${target} is missing its nested instruction ownership registry. Recompile to write it.`
|
|
1806
2006
|
: `RED — ${target} does not exist; ${file.unit ? file.unit.source : "an on-read unit"} compiles to it`);
|
|
1807
2007
|
drifted += 1;
|
|
1808
2008
|
continue;
|
|
1809
2009
|
}
|
|
1810
|
-
if (current
|
|
2010
|
+
if (!sameContent(current,file.text)) {
|
|
1811
2011
|
say(file.marker
|
|
1812
2012
|
? `RED — ${target} does not list the rules the units compile to now. Recompile to rewrite it.`
|
|
2013
|
+
: file.control ? `RED — ${target} has drifted from nested instruction ownership. Recompile to rewrite it.`
|
|
1813
2014
|
: file.unit?.leadSources?.length
|
|
1814
2015
|
? `RED — ${target} has drifted from ${file.unit.source}, whose ${[...file.unit.written].sort().join(" and ")} are written from ${file.unit.leadSources.join(" and ")}. Edit the unit or those files, then recompile.`
|
|
1815
2016
|
: `RED — ${target} has drifted from ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`}. Edit the unit, then recompile.`);
|
|
@@ -1826,6 +2027,8 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1826
2027
|
return drifted;
|
|
1827
2028
|
}
|
|
1828
2029
|
|
|
2030
|
+
preflightGuidanceWrites(plan, workspaceRoot);
|
|
2031
|
+
applyNestedEdits(workspaceRoot,plan.nested.edits);
|
|
1829
2032
|
// Listed only once written, unlisted before removal: an interrupted run never lists a file it did not write.
|
|
1830
2033
|
const markerPath = path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/"), RULES_MARKER);
|
|
1831
2034
|
if (kept !== null) {
|
|
@@ -1834,7 +2037,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1834
2037
|
}
|
|
1835
2038
|
for (const s of stray) {
|
|
1836
2039
|
const target = path.join(workspaceRoot, ...s.path.split("/"));
|
|
1837
|
-
if (target === markerPath) continue;
|
|
2040
|
+
if (target === markerPath || s.nested) continue;
|
|
1838
2041
|
if (!s.removable) {
|
|
1839
2042
|
say(`left ${target} — it is not a file this compiler wrote, so it is not this compiler's to remove`);
|
|
1840
2043
|
continue;
|
|
@@ -1850,7 +2053,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1850
2053
|
}
|
|
1851
2054
|
}
|
|
1852
2055
|
for (const file of owedFiles) {
|
|
1853
|
-
if (file.marker) continue;
|
|
2056
|
+
if (file.marker || file.nested || file.control) continue;
|
|
1854
2057
|
const target = path.join(workspaceRoot, ...file.path.split("/"));
|
|
1855
2058
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1856
2059
|
fs.writeFileSync(target, file.text);
|
|
@@ -1875,10 +2078,11 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1875
2078
|
}
|
|
1876
2079
|
|
|
1877
2080
|
/** The guidance half alone: the gate policy is not read, so host settings stay the output of a `compile` a human runs. */
|
|
1878
|
-
export function compileGuidance(named, { check = false, say = () => {} } = {}) {
|
|
2081
|
+
export function compileGuidance(named, { check = false, say = () => {}, originRoot } = {}) {
|
|
1879
2082
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
1880
|
-
const guidance = guidanceUnits(workspaceRoot, workspaceDir);
|
|
1881
|
-
const plan = planGuidance(guidance, workspaceRoot);
|
|
2083
|
+
const guidance = guidanceUnits(workspaceRoot, workspaceDir, { originRoot });
|
|
2084
|
+
const plan = planGuidance(guidance, workspaceRoot, workspaceDir);
|
|
2085
|
+
if (!check) preflightGuidanceWrites(plan, workspaceRoot);
|
|
1882
2086
|
return { declared: guidance !== null, drifted: emitGuidance(guidance, plan, { workspaceRoot, check, say }) };
|
|
1883
2087
|
}
|
|
1884
2088
|
|
|
@@ -1886,25 +2090,37 @@ export function compileGuidance(named, { check = false, say = () => {} } = {}) {
|
|
|
1886
2090
|
export function guidanceEdits(named) {
|
|
1887
2091
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
1888
2092
|
const guidance = guidanceUnits(workspaceRoot, workspaceDir);
|
|
1889
|
-
const { owedFiles, stray, kept } = planGuidance(guidance, workspaceRoot);
|
|
2093
|
+
const { owedFiles, stray, kept, nested } = planGuidance(guidance, workspaceRoot, workspaceDir);
|
|
1890
2094
|
const markerRel = `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`;
|
|
1891
2095
|
const current = (rel) => {
|
|
1892
2096
|
try {
|
|
1893
|
-
return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/"))
|
|
2097
|
+
return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")));
|
|
1894
2098
|
} catch (cause) {
|
|
1895
2099
|
if (cause.code === "ENOENT") return null;
|
|
1896
2100
|
throw new CompileError(`${rel} could not be read — ${cause.code ?? cause.message}`);
|
|
1897
2101
|
}
|
|
1898
2102
|
};
|
|
1899
|
-
const edits = [];
|
|
1900
|
-
for (const s of stray) if (s.removable && s.path !== markerRel) edits.push({ file: s.path, next: null });
|
|
1901
|
-
for (const file of owedFiles) if (!file.marker && current(file.path)
|
|
2103
|
+
const edits = [...nested.edits];
|
|
2104
|
+
for (const s of stray) if (s.removable && !s.nested && s.path !== markerRel) edits.push({ file: s.path, next: null });
|
|
2105
|
+
for (const file of owedFiles) if (!file.marker && !file.nested && !file.control && !sameContent(current(file.path),file.text)) edits.push({ file: file.path, next: file.text });
|
|
1902
2106
|
const marker = owedFiles.find((f) => f.marker);
|
|
1903
|
-
if (marker && current(markerRel)
|
|
2107
|
+
if (marker && !sameContent(current(markerRel),marker.text)) edits.push({ file: markerRel, next: marker.text });
|
|
1904
2108
|
else if (!marker && kept !== null && current(markerRel) !== null) edits.push({ file: markerRel, next: null });
|
|
1905
2109
|
return { root: path.resolve(workspaceRoot), edits, left: stray.filter((s) => !s.removable).map((s) => s.path) };
|
|
1906
2110
|
}
|
|
1907
2111
|
|
|
2112
|
+
export function personaEdits(named, options = {}) {
|
|
2113
|
+
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
2114
|
+
const packs = packContributions(workspaceRoot, workspaceDir, { discovery: () => discoverPackRoots(), ...options });
|
|
2115
|
+
if (packs.unresolved.length) throw new CompileError("persona binding sources are unresolved; refusing a partial refresh");
|
|
2116
|
+
const planned = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
|
|
2117
|
+
return {
|
|
2118
|
+
root: path.resolve(workspaceRoot), compared: planned.compared,
|
|
2119
|
+
edits: planned.files.map(file => ({ file: file.path, next: file.text })),
|
|
2120
|
+
notes: [...planned.lines, ...planned.degraded.map(value => value.line)],
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
|
|
1908
2124
|
// ---------------------------------------------------------------- the manifest's declarations
|
|
1909
2125
|
|
|
1910
2126
|
/** `{ file, why }` for a manifest that is there and will not parse as an object; null when it reads or is absent. */
|
|
@@ -1912,7 +2128,7 @@ export function unreadableManifest(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1912
2128
|
const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1913
2129
|
let raw;
|
|
1914
2130
|
try {
|
|
1915
|
-
raw =
|
|
2131
|
+
raw = readWorkspaceText(workspaceRoot, workspaceDir);
|
|
1916
2132
|
} catch (cause) {
|
|
1917
2133
|
if (cause.code === "ENOENT") return null;
|
|
1918
2134
|
return { file, why: `it could not be read — ${cause.code ?? cause.message}` };
|
|
@@ -1934,7 +2150,7 @@ export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1934
2150
|
const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
|
|
1935
2151
|
let declared;
|
|
1936
2152
|
try {
|
|
1937
|
-
declared = JSON.parse(
|
|
2153
|
+
declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).gates;
|
|
1938
2154
|
} catch {
|
|
1939
2155
|
// An unreadable manifest reaches here only from the hook's reader, which must not stop.
|
|
1940
2156
|
return fallback("no-manifest");
|
|
@@ -1966,7 +2182,7 @@ export function sessionsDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1966
2182
|
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1967
2183
|
let declared;
|
|
1968
2184
|
try {
|
|
1969
|
-
declared = JSON.parse(
|
|
2185
|
+
declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).sessions;
|
|
1970
2186
|
} catch {
|
|
1971
2187
|
return null;
|
|
1972
2188
|
}
|
|
@@ -1993,7 +2209,7 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1993
2209
|
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1994
2210
|
let parsed;
|
|
1995
2211
|
try {
|
|
1996
|
-
parsed = JSON.parse(
|
|
2212
|
+
parsed = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
|
|
1997
2213
|
} catch {
|
|
1998
2214
|
return null;
|
|
1999
2215
|
}
|
|
@@ -2007,16 +2223,43 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
2007
2223
|
if (error instanceof LedgerError) throw new CompileError(error.message);
|
|
2008
2224
|
throw error;
|
|
2009
2225
|
}
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2226
|
+
if (spend.restart !== null) {
|
|
2227
|
+
const spec = parsed.portulan?.spec;
|
|
2228
|
+
const version = typeof spec === "string" ? /^([0-9]+)\.([0-9]+)$/.exec(spec) : null;
|
|
2229
|
+
if (!version || Number(version[1]) !== 2) {
|
|
2230
|
+
throw new CompileError(
|
|
2231
|
+
`\`spend.restart\` in ${where} requires \`portulan.spec\` to be a string MAJOR.MINOR on supported major 2; ` +
|
|
2232
|
+
`got ${JSON.stringify(spec)}. Declare 2.13 or a later minor, or remove the key`,
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
if (Number(version[2]) < 13) {
|
|
2236
|
+
throw new CompileError(
|
|
2237
|
+
`\`spend.restart\` in ${where} is Workspace Definition 2.13's, and this manifest declares ${version[0]}, whose validator ` +
|
|
2238
|
+
"refuses it as an unknown key. Declare 2.13, or remove the key",
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
if (spend.resume !== null) {
|
|
2243
|
+
const version = typeof parsed.portulan?.spec === "string" ? /^2\.([0-9]+)$/.exec(parsed.portulan.spec) : null;
|
|
2244
|
+
const schema = JSON.parse(fs.readFileSync(new URL("../spec/workspace.schema.json", import.meta.url), "utf8"));
|
|
2245
|
+
const supported = /\/spec\/2\.([0-9]+)\//.exec(schema.$id ?? "");
|
|
2246
|
+
if (!version || !supported || Number(version[1]) < 16 || Number(version[1]) > Number(supported[1])) {
|
|
2247
|
+
throw new CompileError(`\`spend.resume\` in ${where} requires Workspace Definition 2.16 through the shipped schema's supported minor`);
|
|
2248
|
+
}
|
|
2016
2249
|
}
|
|
2017
2250
|
return spend;
|
|
2018
2251
|
}
|
|
2019
2252
|
|
|
2253
|
+
function stopDeclaration(workspaceRoot, workspaceDir) {
|
|
2254
|
+
const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
2255
|
+
let manifest;
|
|
2256
|
+
try { manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)); } catch { return null; }
|
|
2257
|
+
try {
|
|
2258
|
+
const policy = readStopPolicy(manifest);
|
|
2259
|
+
return policy.declared ? { ...policy, manifest: path.relative(workspaceRoot, file).split(path.sep).join("/") } : null;
|
|
2260
|
+
} catch (error) { throw new CompileError(error.message); }
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2020
2263
|
/** `guidanceOnly` is true where guidance still compiles, and `"leftover"` where only an earlier run's is removed. */
|
|
2021
2264
|
function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key", guidanceOnly = false) {
|
|
2022
2265
|
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
@@ -2034,8 +2277,14 @@ function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOp
|
|
|
2034
2277
|
: `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
|
|
2035
2278
|
`and there is no \`gates.json\` at ${policyFile}.`;
|
|
2036
2279
|
const outcome =
|
|
2037
|
-
guidanceOnly === "
|
|
2280
|
+
guidanceOnly === "advisory"
|
|
2281
|
+
? "No gate policy is compiled; restart advice, declared session settings, ritual admission settings and guidance/persona bindings compile independently."
|
|
2282
|
+
: guidanceOnly === "contexts"
|
|
2283
|
+
? "No gate policy is compiled; ritual admission settings and any declared guidance/persona bindings compile independently."
|
|
2284
|
+
: guidanceOnly === "leftover"
|
|
2038
2285
|
? "No enforcement is compiled, and no guidance is declared: what an earlier run compiled from guidance is this compiler's to remove, and is named below."
|
|
2286
|
+
: guidanceOnly === "personas"
|
|
2287
|
+
? "No enforcement or guidance is compiled; existing persona bindings still carry their declared fields."
|
|
2039
2288
|
: guidanceOnly
|
|
2040
2289
|
? "No enforcement is compiled; the workspace's guidance still is."
|
|
2041
2290
|
: "Nothing was compiled and nothing was written.";
|
|
@@ -2089,7 +2338,15 @@ export function resolvePack(rawName, roots = []) {
|
|
|
2089
2338
|
for (const root of roots) {
|
|
2090
2339
|
const dir = path.join(root, category, pack);
|
|
2091
2340
|
const manifest = path.join(dir, "pack.json");
|
|
2092
|
-
|
|
2341
|
+
try {
|
|
2342
|
+
const realRoot = fs.realpathSync(root), realDir = fs.realpathSync(dir);
|
|
2343
|
+
if (!isInside(realRoot, realDir)) throw new CompileError(`${dir} resolves outside its resolution root ${root}`);
|
|
2344
|
+
readOwnedText(manifest, dir);
|
|
2345
|
+
return { name, category, pack, dir, manifest, root, why: null };
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
if (error.code === "ENOENT") continue;
|
|
2348
|
+
throw new CompileError(`cannot resolve \`${name}\` under ${root} — ${error.code ?? error.message}`);
|
|
2349
|
+
}
|
|
2093
2350
|
}
|
|
2094
2351
|
return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
|
|
2095
2352
|
}
|
|
@@ -2140,6 +2397,7 @@ export function packDifferences(mine, other) {
|
|
|
2140
2397
|
const differs = [];
|
|
2141
2398
|
if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
|
|
2142
2399
|
if (frag(mine) !== frag(other)) differs.push("gate fragments that differ once parsed");
|
|
2400
|
+
if (JSON.stringify(canonical(mine?.contributes?.contexts ?? null)) !== JSON.stringify(canonical(other?.contributes?.contexts ?? null))) differs.push("context caps that differ once parsed");
|
|
2143
2401
|
return differs;
|
|
2144
2402
|
}
|
|
2145
2403
|
|
|
@@ -2154,9 +2412,10 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2154
2412
|
const base = path.join(workspaceRoot, workspaceDir);
|
|
2155
2413
|
let manifest;
|
|
2156
2414
|
try {
|
|
2157
|
-
manifest = JSON.parse(
|
|
2158
|
-
} catch {
|
|
2159
|
-
return { contributions: [], unresolved: [] };
|
|
2415
|
+
manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
|
|
2416
|
+
} catch (error) {
|
|
2417
|
+
if (error.code === "ENOENT") return { contributions: [], unresolved: [] };
|
|
2418
|
+
throw new CompileError(`the workspace manifest could not be read — ${error.code ?? error.message}`);
|
|
2160
2419
|
}
|
|
2161
2420
|
const declared = manifest?.packs;
|
|
2162
2421
|
if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
|
|
@@ -2180,7 +2439,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2180
2439
|
unresolved.push(found);
|
|
2181
2440
|
continue;
|
|
2182
2441
|
}
|
|
2183
|
-
const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}
|
|
2442
|
+
const packManifest = readJson(found.manifest, `the pack manifest for \`${found.name}\``, found.dir);
|
|
2184
2443
|
if (!(options.forced ?? false) && plan.source !== "named") {
|
|
2185
2444
|
const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
|
|
2186
2445
|
(plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
|
|
@@ -2188,7 +2447,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2188
2447
|
if (behind) {
|
|
2189
2448
|
let other = null;
|
|
2190
2449
|
try {
|
|
2191
|
-
other = JSON.parse(
|
|
2450
|
+
other = JSON.parse(readOwnedText(behind.manifest, behind.dir));
|
|
2192
2451
|
} catch (cause) {
|
|
2193
2452
|
throw new CompileError(
|
|
2194
2453
|
`\`${found.name}\` resolved under the root ${found.root} while the root ` +
|
|
@@ -2217,10 +2476,19 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2217
2476
|
`Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
|
|
2218
2477
|
);
|
|
2219
2478
|
}
|
|
2479
|
+
const personas = packManifest?.contributes?.personas;
|
|
2480
|
+
if (personas !== undefined && !Array.isArray(personas)) {
|
|
2481
|
+
throw new CompileError(
|
|
2482
|
+
`the pack manifest for \`${found.name}\` declares \`contributes.personas\` as ${typeof personas} rather than an array. ` +
|
|
2483
|
+
`Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2220
2486
|
contributions.push({
|
|
2221
2487
|
pack: found.name,
|
|
2222
2488
|
dir: found.dir,
|
|
2489
|
+
contextCap: readContextCap(packManifest, found.name),
|
|
2223
2490
|
fragments: fragments ?? [],
|
|
2491
|
+
personas: personas ?? [],
|
|
2224
2492
|
origin: recordedOrigin(found.root, plan, workspaceRoot),
|
|
2225
2493
|
version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
|
|
2226
2494
|
});
|
|
@@ -2310,10 +2578,18 @@ export function composeFragments(policy, contributions) {
|
|
|
2310
2578
|
return { policy: { ...policy, rules }, added, tightened };
|
|
2311
2579
|
}
|
|
2312
2580
|
|
|
2313
|
-
function
|
|
2581
|
+
function readWorkspaceText(workspaceRoot, workspaceDir) {
|
|
2582
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
2583
|
+
fs.lstatSync(path.join(base, "workspace.json"));
|
|
2584
|
+
const root = fs.realpathSync(workspaceRoot), owner = fs.realpathSync(base);
|
|
2585
|
+
if (!isInside(root, owner)) throw new CompileError(`${base} resolves outside its nominated workspace root`);
|
|
2586
|
+
return readOwnedText(path.join(base, "workspace.json"), base);
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
function readJson(file, what, scope = path.dirname(file)) {
|
|
2314
2590
|
let raw;
|
|
2315
2591
|
try {
|
|
2316
|
-
raw =
|
|
2592
|
+
raw = readOwnedText(file, scope);
|
|
2317
2593
|
} catch (error) {
|
|
2318
2594
|
throw new CompileError(`cannot read ${what} at ${file} — ${error.code ?? error.message}`);
|
|
2319
2595
|
}
|
|
@@ -2328,6 +2604,360 @@ export function render(settings) {
|
|
|
2328
2604
|
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
2329
2605
|
}
|
|
2330
2606
|
|
|
2607
|
+
// ---------------------------------------------------------------- personas: what a role declares about its context
|
|
2608
|
+
|
|
2609
|
+
/** The fields a persona may declare about the context it runs in, in the order a binding carries them. */
|
|
2610
|
+
export const PERSONA_FIELDS = ["model", "effort", "omitClaudeMd", "cacheLifetime"];
|
|
2611
|
+
|
|
2612
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
2613
|
+
|
|
2614
|
+
const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/[\]-]{0,199}$/;
|
|
2615
|
+
|
|
2616
|
+
const readBoolean = (raw) => raw === "true" ? true : raw === "false" ? false : undefined;
|
|
2617
|
+
|
|
2618
|
+
const PERSONA_VALUES = {
|
|
2619
|
+
model: { read: (raw) => (MODEL_NAME.test(raw) ? raw : undefined), takes: "one model name or alias, written as a single token" },
|
|
2620
|
+
effort: { read: (raw) => (EFFORT_LEVELS.includes(raw) ? raw : undefined), takes: `one of ${EFFORT_LEVELS.join(", ")}` },
|
|
2621
|
+
omitClaudeMd: { read: readBoolean, takes: "`true` or `false`, unquoted" },
|
|
2622
|
+
cacheLifetime: { read: (raw) => (CACHE_LIFETIMES.includes(raw) ? raw : undefined), takes: `one of ${CACHE_LIFETIMES.join(", ")}` },
|
|
2623
|
+
};
|
|
2624
|
+
|
|
2625
|
+
/** Claude Code's frontmatter key for each field, a dotted one nested under its first word. */
|
|
2626
|
+
const CLAUDE_CODE_KEYS = { model: "model", effort: "effort", omitClaudeMd: "omitClaudeMd", cacheLifetime: "experimental.cacheTtl" };
|
|
2627
|
+
|
|
2628
|
+
/** `null` is a field the host cannot express; `why` is the sentence the degradation report gives for it. */
|
|
2629
|
+
export const PERSONA_HOSTS = {
|
|
2630
|
+
"claude-code": {
|
|
2631
|
+
label: "Claude Code",
|
|
2632
|
+
...Object.fromEntries(PERSONA_FIELDS.map((field) => [field, `\`${CLAUDE_CODE_KEYS[field]}\``])),
|
|
2633
|
+
},
|
|
2634
|
+
"agents-md": {
|
|
2635
|
+
label: "AGENTS.md, vendored",
|
|
2636
|
+
model: null,
|
|
2637
|
+
effort: null,
|
|
2638
|
+
omitClaudeMd: null,
|
|
2639
|
+
cacheLifetime: null,
|
|
2640
|
+
why: "AGENTS.md defines no agent to carry it, so the role runs with the session's own",
|
|
2641
|
+
},
|
|
2642
|
+
};
|
|
2643
|
+
|
|
2644
|
+
/** The plugin location; ordinary projects use `.claude/agents/`. */
|
|
2645
|
+
export const BINDING_DIR = "agents";
|
|
2646
|
+
|
|
2647
|
+
const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
|
|
2648
|
+
|
|
2649
|
+
export function bindingDirectory(treeRoot) {
|
|
2650
|
+
const unread = (where, cause) => {
|
|
2651
|
+
const error = new CompileError(`${where} could not be examined — ${cause.code ?? cause.message}; binding location is unread, not absent`);
|
|
2652
|
+
error.unread = true;
|
|
2653
|
+
return error;
|
|
2654
|
+
};
|
|
2655
|
+
let realRoot;
|
|
2656
|
+
try { realRoot = fs.realpathSync(treeRoot); }
|
|
2657
|
+
catch (cause) { throw unread(treeRoot, cause); }
|
|
2658
|
+
const directory = rel => {
|
|
2659
|
+
let current = treeRoot;
|
|
2660
|
+
for (const part of rel.split("/")) {
|
|
2661
|
+
current = path.join(current, part);
|
|
2662
|
+
try { fs.lstatSync(current); }
|
|
2663
|
+
catch (cause) { if (cause.code === "ENOENT") return false; throw unread(current, cause); }
|
|
2664
|
+
let real, target;
|
|
2665
|
+
try { real = fs.realpathSync(current); target = fs.statSync(real); }
|
|
2666
|
+
catch (cause) { throw unread(current, cause); }
|
|
2667
|
+
if (!isInside(realRoot, real)) throw new CompileError(`${current} resolves outside the declared tree`);
|
|
2668
|
+
if (!target.isDirectory()) throw new CompileError(`${current} is not a directory`);
|
|
2669
|
+
}
|
|
2670
|
+
return true;
|
|
2671
|
+
};
|
|
2672
|
+
let selected = ".claude/agents";
|
|
2673
|
+
if (directory(".claude-plugin")) {
|
|
2674
|
+
const marker = path.join(treeRoot, PLUGIN_MANIFEST);
|
|
2675
|
+
let stat = null;
|
|
2676
|
+
try { stat = fs.lstatSync(marker); }
|
|
2677
|
+
catch (cause) { if (cause.code !== "ENOENT") throw unread(marker, cause); }
|
|
2678
|
+
if (stat !== null) {
|
|
2679
|
+
if (!stat.isFile()) throw new CompileError(`${marker} is not a regular plugin marker`);
|
|
2680
|
+
let raw;
|
|
2681
|
+
try { raw = fs.readFileSync(marker); }
|
|
2682
|
+
catch (cause) { throw unread(marker, cause); }
|
|
2683
|
+
let value;
|
|
2684
|
+
try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw)); }
|
|
2685
|
+
catch (cause) { throw new CompileError(`${marker} is not a readable UTF-8 JSON plugin marker — ${cause.message}`); }
|
|
2686
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || typeof value.name !== "string" || !SLUG.test(value.name)) throw new CompileError(`${marker} is not a named plugin object`);
|
|
2687
|
+
selected = BINDING_DIR;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
directory(selected);
|
|
2691
|
+
return selected;
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
|
|
2695
|
+
|
|
2696
|
+
/** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
|
|
2697
|
+
export function personaDeclarations(text) {
|
|
2698
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
2699
|
+
const close = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
2700
|
+
if (close === -1) {
|
|
2701
|
+
const why = lines[0]?.trim() === "---" ? "the block is never closed" : "the first line must be `---`";
|
|
2702
|
+
return { name: null, declared: {}, problems: [`has no usable frontmatter — ${why}`] };
|
|
2703
|
+
}
|
|
2704
|
+
const declared = {};
|
|
2705
|
+
const problems = [];
|
|
2706
|
+
const seen = new Set();
|
|
2707
|
+
let name = null;
|
|
2708
|
+
for (const line of lines.slice(1, close)) {
|
|
2709
|
+
const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
|
|
2710
|
+
if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
|
|
2711
|
+
if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
|
|
2712
|
+
const [, key, rest] = match;
|
|
2713
|
+
if (seen.has(key)) {
|
|
2714
|
+
problems.push(`declares \`${key}\` twice`);
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
seen.add(key);
|
|
2718
|
+
const raw = key === "omitClaudeMd" ? rest.trim() : unquoted(rest.trim());
|
|
2719
|
+
const value = PERSONA_VALUES[key].read(raw);
|
|
2720
|
+
if (value === undefined) problems.push(`sets \`${key}\` to ${JSON.stringify(rest.trim())}, and \`${key}\` takes ${PERSONA_VALUES[key].takes}`);
|
|
2721
|
+
else declared[key] = value;
|
|
2722
|
+
}
|
|
2723
|
+
return { name, declared, problems };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
const PERSONA_MARK = "# compiled by `portulan compile` from ";
|
|
2727
|
+
const PERSONA_MARK_TAIL = "; edit that file, then recompile";
|
|
2728
|
+
const BINDING_KEY = /^([A-Za-z_][\w-]*)\s*:(.*)$/;
|
|
2729
|
+
const topKey = (field) => CLAUDE_CODE_KEYS[field].split(".")[0];
|
|
2730
|
+
|
|
2731
|
+
function bindingLines(declared) {
|
|
2732
|
+
const lines = [];
|
|
2733
|
+
for (const field of PERSONA_FIELDS) {
|
|
2734
|
+
if (declared[field] === undefined) continue;
|
|
2735
|
+
const value = field === "model" ? quoted(declared[field]) : String(declared[field]);
|
|
2736
|
+
const [top, nested] = CLAUDE_CODE_KEYS[field].split(".");
|
|
2737
|
+
lines.push(...(nested ? [`${top}:`, ` ${nested}: ${value}`] : [`${top}: ${value}`]));
|
|
2738
|
+
}
|
|
2739
|
+
return lines;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/** Read recognized compiled fields strictly; the rewritten lines must still match byte for byte. */
|
|
2743
|
+
function blockFields(lines, where) {
|
|
2744
|
+
const read = {};
|
|
2745
|
+
let under = null;
|
|
2746
|
+
for (const line of lines) {
|
|
2747
|
+
const nested = /^\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
2748
|
+
const match = nested ? null : BINDING_KEY.exec(line);
|
|
2749
|
+
const key = nested ? `${under}.${nested[1]}` : match?.[1];
|
|
2750
|
+
const raw = (nested?.[2] ?? match?.[2] ?? "").trim();
|
|
2751
|
+
if (!nested) under = key;
|
|
2752
|
+
const field = PERSONA_FIELDS.find((f) => CLAUDE_CODE_KEYS[f] === key);
|
|
2753
|
+
if (!field) continue;
|
|
2754
|
+
let input = raw;
|
|
2755
|
+
if (field === "model") {
|
|
2756
|
+
try { input = JSON.parse(raw); } catch { /* The byte comparison rejects noncanonical quoting. */ }
|
|
2757
|
+
}
|
|
2758
|
+
const value = typeof input === "string" ? PERSONA_VALUES[field].read(input) : undefined;
|
|
2759
|
+
if (value === undefined) throw new CompileError(`${where}: the compiled block holds \`${line}\`, which this compiler did not write — ${PERSONA_VALUES[field].takes}`);
|
|
2760
|
+
read[field] = value;
|
|
2761
|
+
}
|
|
2762
|
+
return read;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/** A Claude Code binding's text with its compiled block rewritten in place from `declared`; every other byte is the binding's own. */
|
|
2766
|
+
export function boundAgent(text, declared, source, where) {
|
|
2767
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
2768
|
+
if (eol === "\r\n" && text.replaceAll("\r\n", "").includes("\n")) {
|
|
2769
|
+
throw new CompileError(`${where} mixes CRLF and LF line endings, so which lines are its own cannot be told apart. Give it one ending, then compile again`);
|
|
2770
|
+
}
|
|
2771
|
+
const lines = text.split(eol);
|
|
2772
|
+
const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
|
|
2773
|
+
if (close === -1) {
|
|
2774
|
+
throw new CompileError(`${where} has no frontmatter block to compile ${source}'s fields into — a host reads an agent's \`name\`, \`description\` and \`tools\` from one`);
|
|
2775
|
+
}
|
|
2776
|
+
const owned = new Set(PERSONA_FIELDS.map(topKey));
|
|
2777
|
+
const front = [];
|
|
2778
|
+
let at = null;
|
|
2779
|
+
for (let i = 1; i < close; i += 1) {
|
|
2780
|
+
if (!lines[i].startsWith(PERSONA_MARK)) {
|
|
2781
|
+
front.push(lines[i]);
|
|
2782
|
+
continue;
|
|
2783
|
+
}
|
|
2784
|
+
if (at !== null) throw new CompileError(`${where} carries two compiled blocks. Delete one, then compile again`);
|
|
2785
|
+
at = front.length;
|
|
2786
|
+
const block = [];
|
|
2787
|
+
while (i + 1 < close && (/^\s/.test(lines[i + 1]) || owned.has(BINDING_KEY.exec(lines[i + 1])?.[1]))) block.push(lines[(i += 1)]);
|
|
2788
|
+
const written = bindingLines(blockFields(block, where));
|
|
2789
|
+
const stray = block.find((line) => !written.includes(line)) ?? block.find((line, n) => line !== written[n]);
|
|
2790
|
+
if (stray !== undefined) {
|
|
2791
|
+
throw new CompileError(
|
|
2792
|
+
`${where}: the block compiled from ${source} holds \`${stray}\`, which this compiler did not write. Move the line above the ` +
|
|
2793
|
+
"mark or delete it, then compile again: removing it unasked would lose a line somebody wrote",
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
const writes = new Set(PERSONA_FIELDS.filter((f) => declared[f] !== undefined).map(topKey));
|
|
2798
|
+
for (const line of front) {
|
|
2799
|
+
const key = BINDING_KEY.exec(line)?.[1];
|
|
2800
|
+
if (writes.has(key)) {
|
|
2801
|
+
throw new CompileError(
|
|
2802
|
+
`${where} sets \`${key}\` by hand, and ${source} declares it. Remove the line from the binding: the persona's value is the one compiled, and two would leave the host to pick`,
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
if (writes.size && owned.has(key)) {
|
|
2806
|
+
throw new CompileError(
|
|
2807
|
+
`${where} sets \`${key}\` by hand, which ${source} does not declare. Declare it in the persona or remove the line: in a binding ` +
|
|
2808
|
+
"it compiles into, `compile` owns every key a persona may declare",
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
const block = writes.size ? [`${PERSONA_MARK}${source}${PERSONA_MARK_TAIL}`, ...bindingLines(declared)] : [];
|
|
2813
|
+
const place = at ?? front.length;
|
|
2814
|
+
return [lines[0], ...front.slice(0, place), ...block, ...front.slice(place), ...lines.slice(close)].join(eol);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
|
|
2818
|
+
|
|
2819
|
+
/** Declared personas against existing bindings, including core sources when compiling this engine's own tree. */
|
|
2820
|
+
export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
|
|
2821
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
2822
|
+
let tree = null;
|
|
2823
|
+
try {
|
|
2824
|
+
tree = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir))?.tree;
|
|
2825
|
+
} catch {
|
|
2826
|
+
// `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
|
|
2827
|
+
}
|
|
2828
|
+
const treeRoot = typeof tree === "string" && tree.trim() ? path.resolve(base, tree) : null;
|
|
2829
|
+
let ownTree = false;
|
|
2830
|
+
if (treeRoot) {
|
|
2831
|
+
try {
|
|
2832
|
+
ownTree = fs.realpathSync(treeRoot) === fs.realpathSync(ENGINE_ROOT);
|
|
2833
|
+
} catch (cause) {
|
|
2834
|
+
if (cause.code !== "ENOENT") throw new CompileError(`the declared tree could not be resolved — ${cause.code ?? cause.message}`);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
const sources = [...contributions];
|
|
2838
|
+
if (ownTree) {
|
|
2839
|
+
let personas;
|
|
2840
|
+
try {
|
|
2841
|
+
personas = fs.readdirSync(path.join(ENGINE_ROOT, "core/personas"))
|
|
2842
|
+
.filter((entry) => entry.endsWith(".md") && entry !== "README.md")
|
|
2843
|
+
.sort().map((entry) => `core/personas/${entry}`);
|
|
2844
|
+
} catch (cause) {
|
|
2845
|
+
throw new CompileError(`core/personas/ could not be read — ${cause.code ?? cause.message}, so its declarations are unknown`);
|
|
2846
|
+
}
|
|
2847
|
+
sources.unshift({ core: true, dir: ENGINE_ROOT, personas });
|
|
2848
|
+
}
|
|
2849
|
+
const files = [];
|
|
2850
|
+
const lines = [];
|
|
2851
|
+
const degraded = [];
|
|
2852
|
+
const owners = new Map();
|
|
2853
|
+
let compared = 0;
|
|
2854
|
+
const bindingDir = treeRoot && sources.some(source => source.personas?.length) ? bindingDirectory(treeRoot) : BINDING_DIR;
|
|
2855
|
+
for (const contribution of sources) {
|
|
2856
|
+
for (const rel of contribution.personas ?? []) {
|
|
2857
|
+
const where = contribution.core ? `core persona \`${rel}\`` : `\`${contribution.pack}\`'s persona \`${rel}\``;
|
|
2858
|
+
const file = path.resolve(contribution.dir, rel);
|
|
2859
|
+
let text;
|
|
2860
|
+
try {
|
|
2861
|
+
if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its ${contribution.core ? "engine" : "pack"}`);
|
|
2862
|
+
if (!fs.statSync(file).isFile()) throw new CompileError(`${where} is not a regular persona file`);
|
|
2863
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(file));
|
|
2864
|
+
} catch (cause) {
|
|
2865
|
+
if (cause instanceof CompileError) throw cause;
|
|
2866
|
+
throw new CompileError(`${where} could not be read — ${cause.code ?? cause.message}, so what it declares is unknown.${contribution.core ? " Restore the core source" : " Run `doctor` to validate the pack"}`);
|
|
2867
|
+
}
|
|
2868
|
+
const { name: declaredName, declared, problems } = personaDeclarations(text);
|
|
2869
|
+
if (problems.length) {
|
|
2870
|
+
throw new CompileError(`${where} ${problems.join("; it ")}. core/personas/README.md gives the fields a persona may declare`);
|
|
2871
|
+
}
|
|
2872
|
+
const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
|
|
2873
|
+
const name = declaredName ?? path.basename(rel, ".md");
|
|
2874
|
+
if (contribution.core && name !== path.basename(rel, ".md")) {
|
|
2875
|
+
throw new CompileError(`${where} declares name \`${name}\`, which differs from its filename and binding identity`);
|
|
2876
|
+
}
|
|
2877
|
+
if (!SLUG.test(name)) {
|
|
2878
|
+
throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
|
|
2879
|
+
}
|
|
2880
|
+
const source = contribution.core ? rel : `${contribution.pack}'s ${rel}`;
|
|
2881
|
+
for (const field of fields) {
|
|
2882
|
+
for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
|
|
2883
|
+
if (host[field] === null) degraded.push({ persona: name, field, host: id, line: `degraded ${name.padEnd(22)} ${field.padEnd(14)} ${id} (${host.label}): ${host.why}` });
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
const bindingRel = `${bindingDir}/${name}.md`;
|
|
2887
|
+
if (owners.has(bindingRel)) {
|
|
2888
|
+
const sources = [owners.get(bindingRel), source].sort();
|
|
2889
|
+
throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
|
|
2890
|
+
}
|
|
2891
|
+
owners.set(bindingRel, source);
|
|
2892
|
+
if (!treeRoot) {
|
|
2893
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
|
|
2894
|
+
continue;
|
|
2895
|
+
}
|
|
2896
|
+
const binding = path.join(treeRoot, bindingDir, `${name}.md`);
|
|
2897
|
+
const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
|
|
2898
|
+
let stat = null;
|
|
2899
|
+
try {
|
|
2900
|
+
stat = fs.lstatSync(binding);
|
|
2901
|
+
} catch (cause) {
|
|
2902
|
+
if (cause.code !== "ENOENT") throw new CompileError(`${shown} could not be examined — ${cause.code ?? cause.message}`);
|
|
2903
|
+
}
|
|
2904
|
+
if (stat === null) {
|
|
2905
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and no host binding at \`${bindingRel}\` carries them`);
|
|
2906
|
+
continue;
|
|
2907
|
+
}
|
|
2908
|
+
if (!stat.isFile()) {
|
|
2909
|
+
throw new CompileError(`${shown} binds ${where} and is not a regular file, so nothing is compiled through it`);
|
|
2910
|
+
}
|
|
2911
|
+
try {
|
|
2912
|
+
if (!isInside(fs.realpathSync(treeRoot), fs.realpathSync(binding))) {
|
|
2913
|
+
throw new CompileError(`${shown} resolves outside the declared tree, so nothing is compiled through it`);
|
|
2914
|
+
}
|
|
2915
|
+
} catch (cause) {
|
|
2916
|
+
if (cause instanceof CompileError) throw cause;
|
|
2917
|
+
throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
|
|
2918
|
+
}
|
|
2919
|
+
let current;
|
|
2920
|
+
try { current = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(binding)); }
|
|
2921
|
+
catch (cause) { throw new CompileError(`${shown} could not be read as UTF-8 — ${cause.code ?? cause.message}`); }
|
|
2922
|
+
const next = boundAgent(current, declared, source, shown);
|
|
2923
|
+
const { fields: bindingFields, error } = parseFrontmatter(current);
|
|
2924
|
+
if (error || bindingFields?.name?.trim() !== name || !bindingFields?.tools?.trim()) {
|
|
2925
|
+
throw new CompileError(`${shown} needs matching name: ${name} and a tools allow-list in usable frontmatter before compilation`);
|
|
2926
|
+
}
|
|
2927
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
|
|
2928
|
+
if (fields.length || next !== current) compared += 1;
|
|
2929
|
+
if (next !== current) files.push({ path: shown, file: binding, text: next, source });
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
return { files, lines, degraded, compared };
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
const unresolvedLine = (u) => `pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`;
|
|
2936
|
+
|
|
2937
|
+
function printPersonaReport(say, personas) {
|
|
2938
|
+
if (!personas.lines.length && !personas.degraded.length) return;
|
|
2939
|
+
say();
|
|
2940
|
+
for (const line of [...personas.lines, ...personas.degraded.map((d) => d.line)]) say(` ${line}`);
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
function emitPersonas(personas, { check, say }) {
|
|
2944
|
+
if (!personas) return 0;
|
|
2945
|
+
for (const line of personas.lines) say(line);
|
|
2946
|
+
for (const d of personas.degraded) say(d.line);
|
|
2947
|
+
let drifted = 0;
|
|
2948
|
+
for (const f of personas.files) {
|
|
2949
|
+
if (check) {
|
|
2950
|
+
say(`RED — ${f.path} has drifted from ${f.source}. Recompile.`);
|
|
2951
|
+
drifted += 1;
|
|
2952
|
+
} else {
|
|
2953
|
+
fs.writeFileSync(f.file, f.text);
|
|
2954
|
+
say(`wrote ${f.file}`);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (personas.lines.length || personas.degraded.length || personas.files.length) say();
|
|
2958
|
+
return drifted;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2331
2961
|
// ---------------------------------------------------------------- the command line
|
|
2332
2962
|
|
|
2333
2963
|
function printMatrix(say, parsed, columns, { source }) {
|
|
@@ -2391,7 +3021,7 @@ export function resolveWorkspace(named) {
|
|
|
2391
3021
|
const manifestPath = path.join(dir, "workspace.json");
|
|
2392
3022
|
let raw;
|
|
2393
3023
|
try {
|
|
2394
|
-
raw =
|
|
3024
|
+
raw = readOwnedText(manifestPath, dir);
|
|
2395
3025
|
} catch (cause) {
|
|
2396
3026
|
// As given, not resolved: `compile` prints paths built on it.
|
|
2397
3027
|
if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
|
|
@@ -2412,6 +3042,7 @@ export function resolveWorkspace(named) {
|
|
|
2412
3042
|
if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
|
|
2413
3043
|
const root = path.resolve(dir, manifest.tree);
|
|
2414
3044
|
const inside = path.relative(root, dir);
|
|
3045
|
+
if(inside === "") return {workspaceRoot:root,workspaceDir:"."};
|
|
2415
3046
|
// A `tree` that does not contain its workspace cannot be trusted, so the answer is the one that changes nothing.
|
|
2416
3047
|
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
|
|
2417
3048
|
return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
|
|
@@ -2423,19 +3054,19 @@ export function resolveWorkspace(named) {
|
|
|
2423
3054
|
|
|
2424
3055
|
function usage() {
|
|
2425
3056
|
return [
|
|
2426
|
-
"portulan compile — compile
|
|
3057
|
+
"portulan compile — compile workspace advice, guidance, bindings and declared gates",
|
|
2427
3058
|
"",
|
|
2428
3059
|
" portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
|
|
2429
3060
|
"",
|
|
2430
|
-
" (no flag) compile
|
|
2431
|
-
" --check write nothing; exit 1 if an artifact
|
|
3061
|
+
" (no flag) compile native advice/settings, guidance, bindings and declared gates",
|
|
3062
|
+
" --check write nothing; exit 1 if an artifact differs from its declarations",
|
|
2432
3063
|
" --matrix print every rule against every backend, the gates neither compiles, and every",
|
|
2433
|
-
" guidance unit against every host",
|
|
3064
|
+
" guidance unit against every host; report native settings without a policy",
|
|
2434
3065
|
" --workspace the workspace directory to compile; defaults to `.portulan`",
|
|
2435
3066
|
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
2436
3067
|
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
2437
3068
|
"",
|
|
2438
|
-
"
|
|
3069
|
+
"Gate compilation emits RESTRICTION only: `auto` and `propose` compile to nothing in the Claude Code",
|
|
2439
3070
|
"backend by design, and the partition inverts in the repository ruleset, which is the floor.",
|
|
2440
3071
|
"",
|
|
2441
3072
|
"Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
|
|
@@ -2512,51 +3143,65 @@ export function run(argv, options = {}) {
|
|
|
2512
3143
|
);
|
|
2513
3144
|
}
|
|
2514
3145
|
const guidance = guidanceUnits(workspaceRoot, workspaceDir);
|
|
2515
|
-
const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot);
|
|
3146
|
+
const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot, workspaceDir);
|
|
3147
|
+
if (!check && guidancePlan) preflightGuidanceWrites(guidancePlan, workspaceRoot);
|
|
2516
3148
|
const sessions = sessionsDeclaration(workspaceRoot, workspaceDir);
|
|
2517
3149
|
const spend = spendDeclaration(workspaceRoot, workspaceDir);
|
|
3150
|
+
const stopPolicy = stopDeclaration(workspaceRoot, workspaceDir);
|
|
2518
3151
|
const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
|
|
2519
3152
|
const packOptions = { named: namedRoots, discovery: () => discoverPackRoots(), forced };
|
|
2520
3153
|
if (policyReason === "refused") {
|
|
2521
3154
|
throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
|
|
2522
3155
|
}
|
|
2523
3156
|
if (!policyDeclared && !fs.existsSync(policyFile)) {
|
|
2524
|
-
// Only
|
|
3157
|
+
// Only an authored manifest clears stray guidance.
|
|
2525
3158
|
const leftover = guidance === null && policyReason === "no-key" && guidancePlan !== null && guidancePlan.stray.length > 0;
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
say(
|
|
2532
|
-
`note \`sessions\` in ${sessions.manifest} compiled nothing: its host switches ride the settings a gate ` +
|
|
2533
|
-
`policy compiles to, and this workspace has none`,
|
|
2534
|
-
);
|
|
3159
|
+
let packs;
|
|
3160
|
+
try { packs = packContributions(workspaceRoot, workspaceDir, packOptions); }
|
|
3161
|
+
catch (cause) {
|
|
3162
|
+
if (guidance === null && !leftover) throw new CompileError(`${undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason)}\n Persona planning was unavailable: ${cause.message}`);
|
|
3163
|
+
throw cause;
|
|
2535
3164
|
}
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
)
|
|
3165
|
+
const contexts = contextCaps(packs.contributions, packs.unresolved);
|
|
3166
|
+
const authored = policyReason === "no-key" && ["repository", "demo", "portfolio"].includes(JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).kind);
|
|
3167
|
+
const advice = authored ? claudeCode(null, {
|
|
3168
|
+
source: path.join(workspaceDir, "workspace.json").split(path.sep).join("/"),
|
|
3169
|
+
root: path.resolve(workspaceRoot), sessions, spend, contexts, packProvenance: packs.contributions,
|
|
3170
|
+
}) : null;
|
|
3171
|
+
const capPlan = capOnlyPlan(workspaceRoot, contexts, advice?.artifact.value ?? null);
|
|
3172
|
+
const personas = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
|
|
3173
|
+
if (guidance === null && !leftover && personas.compared === 0 && !capPlan) {
|
|
3174
|
+
printPersonaReport(say, personas);
|
|
3175
|
+
for (const line of contextCapLines(contexts)) say(`note ${line}`);
|
|
3176
|
+
throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
|
|
2541
3177
|
}
|
|
3178
|
+
say(`note ${undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason, advice ? "advisory" : capPlan ? "contexts" : leftover ? "leftover" : guidance === null ? "personas" : true)}`);
|
|
3179
|
+
if (advice) for (const line of advice.notes) say(`note ${line}`);
|
|
3180
|
+
else if (sessions !== null || spend !== null) say("note session and spend settings require an authored governing workspace or gate policy");
|
|
3181
|
+
if (stopPolicy !== null) say("note verify.stopTimeout compiled no outer hook timeout: this workspace has no gate policy");
|
|
2542
3182
|
say();
|
|
3183
|
+
for (const u of packs.unresolved) say(unresolvedLine(u));
|
|
3184
|
+
if (packs.unresolved.length) say();
|
|
3185
|
+
if (!advice) for (const line of contextCapLines(contexts)) say(`note ${line}`);
|
|
2543
3186
|
if (showMatrix) {
|
|
2544
|
-
printGuidanceMatrix(say, guidance);
|
|
3187
|
+
if (guidance) printGuidanceMatrix(say, guidance);
|
|
3188
|
+
printPersonaReport(say, personas);
|
|
2545
3189
|
return 0;
|
|
2546
3190
|
}
|
|
2547
|
-
const drifted = emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
3191
|
+
const drifted = emitCapOnly(capPlan, { check, say }) + emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2548
3192
|
if (!check) return 0;
|
|
2549
3193
|
if (drifted) return 1;
|
|
2550
|
-
say(
|
|
3194
|
+
say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}${capPlan ? ", and native settings match their declarations" : ""}`);
|
|
2551
3195
|
return 0;
|
|
2552
3196
|
}
|
|
2553
|
-
const policy = readJson(policyFile, "the gate policy");
|
|
3197
|
+
const policy = readJson(policyFile, "the gate policy", path.join(workspaceRoot, workspaceDir));
|
|
2554
3198
|
// A thunk, so the host's plugin record is read only where discovery can win.
|
|
2555
3199
|
const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
|
|
2556
3200
|
named: namedRoots,
|
|
2557
3201
|
discovery: () => discoverPackRoots(),
|
|
2558
3202
|
forced,
|
|
2559
3203
|
});
|
|
3204
|
+
const personas = planPersonas(contributions, workspaceRoot, workspaceDir);
|
|
2560
3205
|
// Only under `--matrix`, as it moves with what is installed; but a union is never silent.
|
|
2561
3206
|
if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
|
|
2562
3207
|
const composed = composeFragments(policy, contributions);
|
|
@@ -2569,23 +3214,25 @@ export function run(argv, options = {}) {
|
|
|
2569
3214
|
packProvenance: contributions,
|
|
2570
3215
|
sessions,
|
|
2571
3216
|
spend,
|
|
3217
|
+
stopPolicy,
|
|
3218
|
+
contexts: contextCaps(contributions, unresolved),
|
|
2572
3219
|
});
|
|
2573
3220
|
|
|
2574
3221
|
for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
|
|
2575
3222
|
for (const t of composed.tightened) {
|
|
2576
3223
|
say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
|
|
2577
3224
|
}
|
|
2578
|
-
for (const u of unresolved)
|
|
2579
|
-
say(`pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`);
|
|
2580
|
-
}
|
|
3225
|
+
for (const u of unresolved) say(unresolvedLine(u));
|
|
2581
3226
|
if (composed.added.length || composed.tightened.length || unresolved.length) say();
|
|
2582
3227
|
|
|
2583
3228
|
if (showMatrix) {
|
|
2584
3229
|
printMatrix(say, parsed, columns, { source });
|
|
3230
|
+
for (const line of contextCapLines(contextCaps(contributions, unresolved))) say(`note ${line}`);
|
|
2585
3231
|
if (guidance) {
|
|
2586
3232
|
say();
|
|
2587
3233
|
printGuidanceMatrix(say, guidance);
|
|
2588
3234
|
}
|
|
3235
|
+
printPersonaReport(say, personas);
|
|
2589
3236
|
return 0;
|
|
2590
3237
|
}
|
|
2591
3238
|
|
|
@@ -2642,9 +3289,12 @@ export function run(argv, options = {}) {
|
|
|
2642
3289
|
drifted += 1;
|
|
2643
3290
|
}
|
|
2644
3291
|
}
|
|
3292
|
+
drifted += emitPersonas(personas, { check, say });
|
|
2645
3293
|
drifted += emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2646
3294
|
if (drifted) return 1;
|
|
2647
|
-
|
|
3295
|
+
const bindings = personas.compared ? ", and every persona binding its persona" : "";
|
|
3296
|
+
const guided = bindings ? `, every guidance file its unit${bindings}` : ", and every guidance file its unit";
|
|
3297
|
+
say(`GREEN — every emitted artifact matches the policy${guidance ? guided : bindings}`);
|
|
2648
3298
|
return 0;
|
|
2649
3299
|
}
|
|
2650
3300
|
|
|
@@ -2663,6 +3313,7 @@ export function run(argv, options = {}) {
|
|
|
2663
3313
|
fs.writeFileSync(file, column.artifact.text);
|
|
2664
3314
|
say(`wrote ${file}`);
|
|
2665
3315
|
}
|
|
3316
|
+
emitPersonas(personas, { check, say });
|
|
2666
3317
|
emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2667
3318
|
return 0;
|
|
2668
3319
|
} catch (error) {
|