@sleepy_panda_srl/portulan 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/cli/README.md +55 -3
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +492 -146
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +211 -33
- package/cli/drills.mjs +32 -7
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +123 -37
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +19 -12
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +368 -60
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +52 -10
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/templates/handoff.md +6 -2
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +6 -2
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/local-llm/README.md +46 -6
- package/packs/tools/local-llm/runner/local-llm.mjs +127 -13
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +26 -2
- package/spec/README.md +37 -4
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +262 -117
- package/spec/workspace.schema.json +67 -11
package/cli/compile.mjs
CHANGED
|
@@ -6,21 +6,22 @@
|
|
|
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
27
|
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
|
|
@@ -536,6 +537,8 @@ export const HOOK_RUNNERS = ["gate.mjs", "stop-gate.mjs", "advisory.mjs"];
|
|
|
536
537
|
* `ask` for gated, a prompt that blocks headless. Two layers, since on Claude Code 2.1.220 a crashing hook fails open.
|
|
537
538
|
*/
|
|
538
539
|
export function claudeCode(parsed, options = {}) {
|
|
540
|
+
const hasPolicy = parsed !== null;
|
|
541
|
+
const rules = hasPolicy ? parsed.rules : [];
|
|
539
542
|
const source = options.source ?? ".portulan/gates.json";
|
|
540
543
|
const runnerDir = path.dirname(fileURLToPath(import.meta.url));
|
|
541
544
|
const project = options.root ?? process.cwd();
|
|
@@ -549,8 +552,8 @@ export function claudeCode(parsed, options = {}) {
|
|
|
549
552
|
pinned.push({ file, abs });
|
|
550
553
|
return `"${abs}"`;
|
|
551
554
|
};
|
|
552
|
-
const runner = options.runner ?? spell(HOOK_RUNNERS[0]);
|
|
553
|
-
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);
|
|
554
557
|
const advisoryRunner = options.advisoryRunner ?? spell(HOOK_RUNNERS[2]);
|
|
555
558
|
|
|
556
559
|
const compiled = [];
|
|
@@ -561,7 +564,7 @@ export function claudeCode(parsed, options = {}) {
|
|
|
561
564
|
const shellWriteGates = [];
|
|
562
565
|
const editCoveredGates = [];
|
|
563
566
|
|
|
564
|
-
for (const rule of
|
|
567
|
+
for (const rule of rules) {
|
|
565
568
|
if (!HOST_GATE_TIERS.has(rule.tier)) {
|
|
566
569
|
refused.push({ id: rule.id, tier: rule.tier, why: HOST_TIER_NOT_A_GATE[rule.tier] });
|
|
567
570
|
continue;
|
|
@@ -609,16 +612,17 @@ export function claudeCode(parsed, options = {}) {
|
|
|
609
612
|
compiled.push({ id: rule.id, tier: rule.tier, surface: [...emitted, ...hookOnly].join(" · ") });
|
|
610
613
|
}
|
|
611
614
|
|
|
612
|
-
if (
|
|
615
|
+
if (rules.some((r) => HOST_GATE_TIERS.has(r.tier)) && compiled.length === 0) {
|
|
613
616
|
throw new CompileError(
|
|
614
617
|
"every gate in this policy refused to compile — refusing to write an artifact that enforces nothing while the policy claims gates",
|
|
615
618
|
);
|
|
616
619
|
}
|
|
617
620
|
|
|
618
|
-
//
|
|
621
|
+
// Portable provenance excludes absolute roots.
|
|
619
622
|
const packs = (options.packProvenance ?? [])
|
|
620
623
|
.map((c) => ({ pack: c.pack, origin: c.origin, version: c.version ?? null }))
|
|
621
624
|
.sort((a, b) => a.pack.localeCompare(b.pack));
|
|
625
|
+
const contexts = options.contexts ?? contextCaps([]);
|
|
622
626
|
const sessions = options.sessions ?? null;
|
|
623
627
|
const switches = {};
|
|
624
628
|
if (sessions?.git_instructions !== undefined) switches.includeGitInstructions = sessions.git_instructions;
|
|
@@ -628,48 +632,61 @@ export function claudeCode(parsed, options = {}) {
|
|
|
628
632
|
const figures = [];
|
|
629
633
|
if (spend?.multipliers) figures.push("--read", spend.multipliers.read, "--write-5m", spend.multipliers.write["5m"], "--write-1h", spend.multipliers.write["1h"]);
|
|
630
634
|
if (spend?.horizon) figures.push("--horizon", spend.horizon);
|
|
635
|
+
if (spend?.resume != null) figures.push("--resume-tokens", spend.resume);
|
|
631
636
|
const spendFlags = figures.map((f) => ` ${f}`).join("");
|
|
632
637
|
const restartBlock = spend?.restart === "block";
|
|
633
638
|
const spendFrom = figures.length || restartBlock ? spend.manifest : null;
|
|
634
|
-
const
|
|
639
|
+
const figuresIn =
|
|
635
640
|
sessionsFrom && sessionsFrom === spendFrom
|
|
636
641
|
? `\`sessions\` and \`spend\` in ${sessionsFrom}`
|
|
637
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 ");
|
|
638
651
|
const value = {
|
|
639
652
|
$portulan: {
|
|
640
653
|
generated: "cli/compile.mjs",
|
|
641
654
|
source,
|
|
642
655
|
...(sessionsFrom ? { sessions: sessionsFrom } : {}),
|
|
643
656
|
...(spendFrom ? { spend: spendFrom } : {}),
|
|
657
|
+
...(stopPolicy ? { verify: stopPolicy.manifest } : {}),
|
|
644
658
|
...(packs.length ? { packs } : {}),
|
|
659
|
+
...(contexts.limit !== null ? { contexts: contexts.sources } : {}),
|
|
645
660
|
warning: declaredIn
|
|
646
661
|
? `Generated file. Edit ${source}, or ${declaredIn}, and recompile; \`verify/compile.sh\` fails on drift.`
|
|
647
662
|
: `Generated file. Edit ${source} and recompile; \`verify/compile.sh\` fails on drift.`,
|
|
648
663
|
},
|
|
649
|
-
permissions: { deny, ask, allow: [] },
|
|
664
|
+
...(hasPolicy ? { permissions: { deny, ask, allow: [] } } : {}),
|
|
650
665
|
hooks: {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
],
|
|
662
|
-
},
|
|
663
|
-
],
|
|
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
|
+
] }] } : {}),
|
|
664
676
|
// No matcher means every tool; a non-blocking `Stop` hook's output would reach only the host's debug log.
|
|
665
677
|
PostToolUse: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} tool${spendFlags}` }] }],
|
|
666
678
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: `node ${advisoryRunner} prompt${spendFlags}` }] }],
|
|
667
679
|
},
|
|
668
680
|
statusLine: { type: "command", command: `node ${advisoryRunner} status${spendFlags}` },
|
|
669
681
|
...switches,
|
|
682
|
+
...(contexts.limit !== null ? { env: { [CONTEXT_CAP_ENV]: String(contexts.limit) } } : {}),
|
|
670
683
|
};
|
|
671
684
|
|
|
672
|
-
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
|
+
);
|
|
673
690
|
if (pinned.length) {
|
|
674
691
|
notes.push(
|
|
675
692
|
`${pinned.length} hook(s) are pinned to an ABSOLUTE path on this machine — ` +
|
|
@@ -719,11 +736,12 @@ export function claudeCode(parsed, options = {}) {
|
|
|
719
736
|
`commands carry them, so an edit to \`spend\` is drift until recompiled`,
|
|
720
737
|
);
|
|
721
738
|
}
|
|
739
|
+
if (spend?.resume != null) notes.push(`restart resume expense: ${spend.resume} input-equivalent tokens, excluding the fresh-context write and baseline reads`);
|
|
722
740
|
if (restartBlock) {
|
|
723
741
|
notes.push(
|
|
724
|
-
`the restart advisory
|
|
725
|
-
`
|
|
726
|
-
`
|
|
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`,
|
|
727
745
|
);
|
|
728
746
|
}
|
|
729
747
|
if (editCoveredGates.length) {
|
|
@@ -991,7 +1009,7 @@ export const GUIDANCE_HOSTS = {
|
|
|
991
1009
|
"claude-code": {
|
|
992
1010
|
label: "Claude Code",
|
|
993
1011
|
always: `an unscoped rule in \`${GUIDANCE_RULES_DIR}/\``,
|
|
994
|
-
"on-path": "a rule
|
|
1012
|
+
"on-path": "a paths-scoped rule or a declared nested CLAUDE.md",
|
|
995
1013
|
"on-invoke": `a project skill in \`${SKILLS_DIR}/\``,
|
|
996
1014
|
"on-read": `a line in \`${GUIDANCE_RULES_DIR}/${ON_READ_INDEX}\``,
|
|
997
1015
|
},
|
|
@@ -1032,11 +1050,148 @@ const STRAY_IMPORT = {
|
|
|
1032
1050
|
"on-read": "an on-read file is opened as it stands, so the import would load nothing",
|
|
1033
1051
|
};
|
|
1034
1052
|
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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. */
|
|
1039
1193
|
export function importSpans(text) {
|
|
1194
|
+
importSyntax(text);
|
|
1040
1195
|
const found = [];
|
|
1041
1196
|
let fence = null;
|
|
1042
1197
|
let comment = false;
|
|
@@ -1063,7 +1218,7 @@ export function importSpans(text) {
|
|
|
1063
1218
|
} else if (item && (listed || /^ {0,3}\S/.test(line))) {
|
|
1064
1219
|
listed = flowing = true;
|
|
1065
1220
|
raw = !opensInItem.test(line.slice(item[0].length));
|
|
1066
|
-
} else if (listed && (/^\s/.test(line) || (flowing && !
|
|
1221
|
+
} else if (listed && (/^\s/.test(line) || (flowing && !opensBlock(line)))) {
|
|
1067
1222
|
flowing = true;
|
|
1068
1223
|
raw = !opensInItem.test(line.trimStart());
|
|
1069
1224
|
} else {
|
|
@@ -1161,8 +1316,13 @@ function checkedImports(text, dir, root, where) {
|
|
|
1161
1316
|
return own;
|
|
1162
1317
|
}
|
|
1163
1318
|
|
|
1164
|
-
/** An unindented line that ends a list, as CommonMark reads it; a block tag such as `<div>` is read as a lazy line. */
|
|
1165
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
|
+
}
|
|
1166
1326
|
|
|
1167
1327
|
/** Each item's first sentence, byte for byte, from the first list in `file` or under its `#fragment` heading. */
|
|
1168
1328
|
function leadsOf(file, where, fragment = null, name = path.basename(file)) {
|
|
@@ -1200,7 +1360,7 @@ export function leadsOfText(source, name, where) {
|
|
|
1200
1360
|
} else if (kind !== null && /^\s+\S/.test(line)) {
|
|
1201
1361
|
items.at(-1).text += ` ${line.trim()}`;
|
|
1202
1362
|
} else if (kind !== null && line.trim() !== "") {
|
|
1203
|
-
if (under && !
|
|
1363
|
+
if (under && !opensBlock(line)) {
|
|
1204
1364
|
throw new CompileError(
|
|
1205
1365
|
`${where}: line ${at + 1} of ${name} follows an item of its first list with no blank line and no indent, ` +
|
|
1206
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`,
|
|
@@ -1252,7 +1412,7 @@ function gatesOf(file, where, packs = []) {
|
|
|
1252
1412
|
}
|
|
1253
1413
|
|
|
1254
1414
|
/** The unit's body with each leads, engine and gates line written out; a leads path may end `#<heading>`. */
|
|
1255
|
-
function expandedBody(unit, dir, root, packs = []) {
|
|
1415
|
+
export function expandedBody(unit, dir, root, packs = [], { originRoot = root } = {}) {
|
|
1256
1416
|
unit.leadSources = [];
|
|
1257
1417
|
unit.written = new Set();
|
|
1258
1418
|
return unit.body
|
|
@@ -1262,11 +1422,13 @@ function expandedBody(unit, dir, root, packs = []) {
|
|
|
1262
1422
|
const engine = leads ? null : ENGINE_LINE.exec(line);
|
|
1263
1423
|
const gates = leads || engine ? null : GATES_LINE.exec(line);
|
|
1264
1424
|
if (!leads && !engine && !gates) return [line];
|
|
1265
|
-
if (engine) return engineLeads(unit, engine[1],
|
|
1425
|
+
if (engine) return engineLeads(unit, engine[1], originRoot);
|
|
1266
1426
|
const [what, spelled] = leads ? ["leads", leads[1]] : ["gates", gates[1]];
|
|
1267
1427
|
const hash = leads ? spelled.indexOf("#") : -1;
|
|
1268
1428
|
const [named, fragment] = hash === -1 ? [spelled, null] : [spelled.slice(0, hash), spelled.slice(hash + 1)];
|
|
1269
|
-
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);
|
|
1270
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`);
|
|
1271
1433
|
let real;
|
|
1272
1434
|
try {
|
|
@@ -1307,7 +1469,7 @@ function engineLeads(unit, spelled, root) {
|
|
|
1307
1469
|
return leadsOf(real, unit.source, fragment, shown).map((lead) => (own ? lead.replaceAll(PLUGIN_ROOT, "") : lead));
|
|
1308
1470
|
}
|
|
1309
1471
|
|
|
1310
|
-
const UNIT_KEYS = new Set(["tier", "paths", "description"]);
|
|
1472
|
+
const UNIT_KEYS = new Set(["tier", "paths", "description", "target"]);
|
|
1311
1473
|
|
|
1312
1474
|
/** A line break, by any spelling a host might honour, or another control character. */
|
|
1313
1475
|
const CONTROL = /[\u0000-\u001f\u007f\u0085\u2028\u2029]/;
|
|
@@ -1340,7 +1502,7 @@ function unitFrontmatter(lines, where) {
|
|
|
1340
1502
|
if (!match) throw new CompileError(`${where}: frontmatter line ${i + 2} is not \`key: value\` — ${JSON.stringify(line)}`);
|
|
1341
1503
|
const [, key, rest = ""] = match;
|
|
1342
1504
|
if (!UNIT_KEYS.has(key)) {
|
|
1343
|
-
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)`);
|
|
1344
1506
|
}
|
|
1345
1507
|
if (Object.hasOwn(fields, key)) throw new CompileError(`${where}: \`${key}\` is declared twice`);
|
|
1346
1508
|
if (key !== "paths") {
|
|
@@ -1389,8 +1551,14 @@ export function parseUnit(name, text, source = `${name}.md`) {
|
|
|
1389
1551
|
if (!LOAD_TIERS.includes(tier)) {
|
|
1390
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(", ")}`);
|
|
1391
1553
|
}
|
|
1392
|
-
let paths = null;
|
|
1393
|
-
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) {
|
|
1394
1562
|
if (fields.paths === undefined || fields.paths.length === 0) {
|
|
1395
1563
|
throw new CompileError(`${where}: an on-path unit names the \`paths\` that load it, and this one names none`);
|
|
1396
1564
|
}
|
|
@@ -1403,7 +1571,7 @@ export function parseUnit(name, text, source = `${name}.md`) {
|
|
|
1403
1571
|
}
|
|
1404
1572
|
}
|
|
1405
1573
|
paths = fields.paths;
|
|
1406
|
-
} else if (fields.paths !== undefined) {
|
|
1574
|
+
} else if (tier !== "on-path" && fields.paths !== undefined) {
|
|
1407
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`);
|
|
1408
1576
|
}
|
|
1409
1577
|
let description = null;
|
|
@@ -1436,12 +1604,13 @@ export function parseUnit(name, text, source = `${name}.md`) {
|
|
|
1436
1604
|
} else if (rest[0] === BOOT_CARD_LINE) {
|
|
1437
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`);
|
|
1438
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`);
|
|
1439
1608
|
const written = [["leads", LEADS_LINE], ["engine", ENGINE_LINE], ["gates", GATES_LINE]].find(([, line]) => rest.some((text) => line.test(text)));
|
|
1440
1609
|
if (tier !== "always" && written) {
|
|
1441
1610
|
const [which] = written;
|
|
1442
1611
|
throw new CompileError(`${where}: a \`<!-- ${which}: … -->\` line is written out only in an always unit, and this one is \`${tier}\``);
|
|
1443
1612
|
}
|
|
1444
|
-
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") };
|
|
1445
1614
|
}
|
|
1446
1615
|
|
|
1447
1616
|
/** The declared `slots.context`, or null where none is declared or there is no manifest; one that will not parse throws. */
|
|
@@ -1456,7 +1625,7 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1456
1625
|
}
|
|
1457
1626
|
let manifest;
|
|
1458
1627
|
try {
|
|
1459
|
-
manifest = JSON.parse(
|
|
1628
|
+
manifest = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
|
|
1460
1629
|
} catch {
|
|
1461
1630
|
return null;
|
|
1462
1631
|
}
|
|
@@ -1477,11 +1646,12 @@ export function guidanceDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1477
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/\``,
|
|
1478
1647
|
);
|
|
1479
1648
|
}
|
|
1480
|
-
|
|
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") : [] };
|
|
1481
1651
|
}
|
|
1482
1652
|
|
|
1483
1653
|
/** Every unit, read and checked, or null where no `slots.context` is declared. */
|
|
1484
|
-
export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
1654
|
+
export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan", { originRoot = workspaceRoot } = {}) {
|
|
1485
1655
|
const declared = guidanceDeclaration(workspaceRoot, workspaceDir);
|
|
1486
1656
|
if (declared === null) return null;
|
|
1487
1657
|
let entries;
|
|
@@ -1505,6 +1675,7 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1505
1675
|
}
|
|
1506
1676
|
});
|
|
1507
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`);
|
|
1508
1679
|
const names = entries
|
|
1509
1680
|
.filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
|
|
1510
1681
|
.map((e) => e.name)
|
|
@@ -1524,20 +1695,22 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1524
1695
|
}
|
|
1525
1696
|
let text;
|
|
1526
1697
|
try {
|
|
1527
|
-
|
|
1698
|
+
if (!fs.statSync(full).isFile()) throw new CompileError(`${source} is not a regular guidance unit`);
|
|
1699
|
+
text = readOwnedText(full,realBase);
|
|
1528
1700
|
} catch (cause) {
|
|
1529
1701
|
throw new CompileError(`${source} could not be read — ${cause.code ?? cause.message}`);
|
|
1530
1702
|
}
|
|
1531
1703
|
const unit = parseUnit(path.basename(file, ".md"), text, source);
|
|
1704
|
+
unit.ownedSource = path.relative(fs.realpathSync(workspaceRoot),real).split(path.sep).join("/");
|
|
1532
1705
|
if (unit.tier === "always") {
|
|
1533
1706
|
unit.dir = path.dirname(full);
|
|
1534
|
-
unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs);
|
|
1707
|
+
unit.text = expandedBody(unit, unit.dir, path.resolve(workspaceRoot), declared.packs, { originRoot });
|
|
1535
1708
|
unit.imports = checkedImports(unit.text, unit.dir, path.resolve(workspaceRoot), source);
|
|
1536
1709
|
} else {
|
|
1537
1710
|
for (const { target } of importSpans(unit.body)) {
|
|
1538
1711
|
const bare = importPath(target);
|
|
1539
1712
|
if (bare === null || bare.startsWith("~") || path.isAbsolute(bare)) continue;
|
|
1540
|
-
if (
|
|
1713
|
+
if (importTargetIsFile(path.resolve(path.dirname(full), bare))) {
|
|
1541
1714
|
throw new CompileError(
|
|
1542
1715
|
`${source}: \`@${target}\` names a file, and only an always unit may import one — ${STRAY_IMPORT[unit.tier]}. ` +
|
|
1543
1716
|
`Name the file to open instead, in a code span`,
|
|
@@ -1547,7 +1720,12 @@ export function guidanceUnits(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1547
1720
|
}
|
|
1548
1721
|
units.push(unit);
|
|
1549
1722
|
}
|
|
1550
|
-
|
|
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 };
|
|
1551
1729
|
}
|
|
1552
1730
|
|
|
1553
1731
|
function rebasedText(unit, root, to) {
|
|
@@ -1576,6 +1754,8 @@ export function claudeCodeGuidance(guidance) {
|
|
|
1576
1754
|
if (unit.tier === "always") {
|
|
1577
1755
|
const at = `${GUIDANCE_RULES_DIR}/${unit.name}.md`;
|
|
1578
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});
|
|
1579
1759
|
} else if (unit.tier === "on-path") {
|
|
1580
1760
|
const header = ["---", "paths:", ...unit.paths.map((glob) => ` - ${quoted(glob)}`), "---", ""].join("\n");
|
|
1581
1761
|
files.push({ unit, path: `${GUIDANCE_RULES_DIR}/${unit.name}.md`, text: `${header}\n${unit.body}` });
|
|
@@ -1662,12 +1842,15 @@ function listReal(workspaceRoot, rel) {
|
|
|
1662
1842
|
}
|
|
1663
1843
|
|
|
1664
1844
|
/** Judged before either half writes, so a refusal leaves no gate artifact written beside it. */
|
|
1665
|
-
function planGuidance(guidance, workspaceRoot) {
|
|
1845
|
+
export function planGuidance(guidance, workspaceRoot, workspaceDir = ".portulan") {
|
|
1846
|
+
assertSourceJournalReady(workspaceRoot,workspaceDir);
|
|
1666
1847
|
const files = guidance ? claudeCodeGuidance(guidance).files : [];
|
|
1848
|
+
const nested = nestedPlan(workspaceRoot,guidance,files,guidance?.workspace ?? workspaceDir);
|
|
1667
1849
|
const rules = files.filter((f) => f.path.startsWith(`${GUIDANCE_RULES_DIR}/`));
|
|
1668
1850
|
const owedFiles = rules.length
|
|
1669
1851
|
? [{ unit: null, marker: true, path: `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`, text: markerText(rules.map((f) => path.posix.basename(f.path))) }, ...files]
|
|
1670
1852
|
: files;
|
|
1853
|
+
if (nested.control) owedFiles.push(nested.control);
|
|
1671
1854
|
const owed = new Set(owedFiles.map((f) => f.path));
|
|
1672
1855
|
|
|
1673
1856
|
for (const file of owedFiles) {
|
|
@@ -1709,11 +1892,11 @@ function planGuidance(guidance, workspaceRoot) {
|
|
|
1709
1892
|
// A run stopped before its marker leaves exactly its unit's output; anything else was written by hand.
|
|
1710
1893
|
let current;
|
|
1711
1894
|
try {
|
|
1712
|
-
current = fs.readFileSync(path.join(rulesDir, name)
|
|
1895
|
+
current = fs.readFileSync(path.join(rulesDir, name));
|
|
1713
1896
|
} catch (cause) {
|
|
1714
1897
|
throw new CompileError(`${file.path} could not be read — ${cause.code ?? cause.message}`);
|
|
1715
1898
|
}
|
|
1716
|
-
if (current
|
|
1899
|
+
if (!sameContent(current,file.text)) {
|
|
1717
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`);
|
|
1718
1901
|
}
|
|
1719
1902
|
}
|
|
@@ -1742,7 +1925,7 @@ function planGuidance(guidance, workspaceRoot) {
|
|
|
1742
1925
|
if (listed instanceof Set || rules.length) {
|
|
1743
1926
|
for (const entry of entries) {
|
|
1744
1927
|
const rel = `${GUIDANCE_RULES_DIR}/${entry.name}`;
|
|
1745
|
-
if (owed.has(rel)) continue;
|
|
1928
|
+
if (owed.has(rel) || nested.claimed.has(rel)) continue;
|
|
1746
1929
|
const ours = listed instanceof Set && entry.isFile() && (listed.has(entry.name) || entry.name === RULES_MARKER);
|
|
1747
1930
|
stray.push({ path: rel, removable: ours });
|
|
1748
1931
|
}
|
|
@@ -1766,7 +1949,20 @@ function planGuidance(guidance, workspaceRoot) {
|
|
|
1766
1949
|
const kept = listed instanceof Set || rules.length
|
|
1767
1950
|
? rules.map((f) => path.posix.basename(f.path)).filter((name) => listed instanceof Set && listed.has(name) && present.has(name))
|
|
1768
1951
|
: null;
|
|
1769
|
-
|
|
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
|
+
}
|
|
1770
1966
|
}
|
|
1771
1967
|
|
|
1772
1968
|
function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
@@ -1774,7 +1970,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1774
1970
|
if (guidance === null && stray.length === 0) return 0;
|
|
1775
1971
|
|
|
1776
1972
|
if (guidance) {
|
|
1777
|
-
const files = owedFiles.filter((f) => !f.marker);
|
|
1973
|
+
const files = owedFiles.filter((f) => !f.marker && !f.control);
|
|
1778
1974
|
const counts = LOAD_TIERS.map((tier) => `${guidance.units.filter((u) => u.tier === tier).length} ${tier}`).join(", ");
|
|
1779
1975
|
say(`guidance: ${guidance.units.length} unit(s) in ${guidance.source} — ${counts}`);
|
|
1780
1976
|
for (const [id, host] of Object.entries(GUIDANCE_HOSTS)) {
|
|
@@ -1802,17 +1998,19 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1802
1998
|
const target = path.join(workspaceRoot, ...file.path.split("/"));
|
|
1803
1999
|
let current = null;
|
|
1804
2000
|
try {
|
|
1805
|
-
current = fs.readFileSync(target
|
|
2001
|
+
current = fs.readFileSync(target);
|
|
1806
2002
|
} catch {
|
|
1807
2003
|
say(file.marker
|
|
1808
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.`
|
|
1809
2006
|
: `RED — ${target} does not exist; ${file.unit ? file.unit.source : "an on-read unit"} compiles to it`);
|
|
1810
2007
|
drifted += 1;
|
|
1811
2008
|
continue;
|
|
1812
2009
|
}
|
|
1813
|
-
if (current
|
|
2010
|
+
if (!sameContent(current,file.text)) {
|
|
1814
2011
|
say(file.marker
|
|
1815
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.`
|
|
1816
2014
|
: file.unit?.leadSources?.length
|
|
1817
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.`
|
|
1818
2016
|
: `RED — ${target} has drifted from ${file.unit ? file.unit.source : `the on-read units in ${guidance.source}`}. Edit the unit, then recompile.`);
|
|
@@ -1829,6 +2027,8 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1829
2027
|
return drifted;
|
|
1830
2028
|
}
|
|
1831
2029
|
|
|
2030
|
+
preflightGuidanceWrites(plan, workspaceRoot);
|
|
2031
|
+
applyNestedEdits(workspaceRoot,plan.nested.edits);
|
|
1832
2032
|
// Listed only once written, unlisted before removal: an interrupted run never lists a file it did not write.
|
|
1833
2033
|
const markerPath = path.join(workspaceRoot, ...GUIDANCE_RULES_DIR.split("/"), RULES_MARKER);
|
|
1834
2034
|
if (kept !== null) {
|
|
@@ -1837,7 +2037,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1837
2037
|
}
|
|
1838
2038
|
for (const s of stray) {
|
|
1839
2039
|
const target = path.join(workspaceRoot, ...s.path.split("/"));
|
|
1840
|
-
if (target === markerPath) continue;
|
|
2040
|
+
if (target === markerPath || s.nested) continue;
|
|
1841
2041
|
if (!s.removable) {
|
|
1842
2042
|
say(`left ${target} — it is not a file this compiler wrote, so it is not this compiler's to remove`);
|
|
1843
2043
|
continue;
|
|
@@ -1853,7 +2053,7 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1853
2053
|
}
|
|
1854
2054
|
}
|
|
1855
2055
|
for (const file of owedFiles) {
|
|
1856
|
-
if (file.marker) continue;
|
|
2056
|
+
if (file.marker || file.nested || file.control) continue;
|
|
1857
2057
|
const target = path.join(workspaceRoot, ...file.path.split("/"));
|
|
1858
2058
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
1859
2059
|
fs.writeFileSync(target, file.text);
|
|
@@ -1878,10 +2078,11 @@ function emitGuidance(guidance, plan, { workspaceRoot, check, say }) {
|
|
|
1878
2078
|
}
|
|
1879
2079
|
|
|
1880
2080
|
/** The guidance half alone: the gate policy is not read, so host settings stay the output of a `compile` a human runs. */
|
|
1881
|
-
export function compileGuidance(named, { check = false, say = () => {} } = {}) {
|
|
2081
|
+
export function compileGuidance(named, { check = false, say = () => {}, originRoot } = {}) {
|
|
1882
2082
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
1883
|
-
const guidance = guidanceUnits(workspaceRoot, workspaceDir);
|
|
1884
|
-
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);
|
|
1885
2086
|
return { declared: guidance !== null, drifted: emitGuidance(guidance, plan, { workspaceRoot, check, say }) };
|
|
1886
2087
|
}
|
|
1887
2088
|
|
|
@@ -1889,25 +2090,37 @@ export function compileGuidance(named, { check = false, say = () => {} } = {}) {
|
|
|
1889
2090
|
export function guidanceEdits(named) {
|
|
1890
2091
|
const { workspaceRoot, workspaceDir } = resolveWorkspace(named);
|
|
1891
2092
|
const guidance = guidanceUnits(workspaceRoot, workspaceDir);
|
|
1892
|
-
const { owedFiles, stray, kept } = planGuidance(guidance, workspaceRoot);
|
|
2093
|
+
const { owedFiles, stray, kept, nested } = planGuidance(guidance, workspaceRoot, workspaceDir);
|
|
1893
2094
|
const markerRel = `${GUIDANCE_RULES_DIR}/${RULES_MARKER}`;
|
|
1894
2095
|
const current = (rel) => {
|
|
1895
2096
|
try {
|
|
1896
|
-
return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/"))
|
|
2097
|
+
return fs.readFileSync(path.join(workspaceRoot, ...rel.split("/")));
|
|
1897
2098
|
} catch (cause) {
|
|
1898
2099
|
if (cause.code === "ENOENT") return null;
|
|
1899
2100
|
throw new CompileError(`${rel} could not be read — ${cause.code ?? cause.message}`);
|
|
1900
2101
|
}
|
|
1901
2102
|
};
|
|
1902
|
-
const edits = [];
|
|
1903
|
-
for (const s of stray) if (s.removable && s.path !== markerRel) edits.push({ file: s.path, next: null });
|
|
1904
|
-
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 });
|
|
1905
2106
|
const marker = owedFiles.find((f) => f.marker);
|
|
1906
|
-
if (marker && current(markerRel)
|
|
2107
|
+
if (marker && !sameContent(current(markerRel),marker.text)) edits.push({ file: markerRel, next: marker.text });
|
|
1907
2108
|
else if (!marker && kept !== null && current(markerRel) !== null) edits.push({ file: markerRel, next: null });
|
|
1908
2109
|
return { root: path.resolve(workspaceRoot), edits, left: stray.filter((s) => !s.removable).map((s) => s.path) };
|
|
1909
2110
|
}
|
|
1910
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
|
+
|
|
1911
2124
|
// ---------------------------------------------------------------- the manifest's declarations
|
|
1912
2125
|
|
|
1913
2126
|
/** `{ file, why }` for a manifest that is there and will not parse as an object; null when it reads or is absent. */
|
|
@@ -1915,7 +2128,7 @@ export function unreadableManifest(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1915
2128
|
const file = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1916
2129
|
let raw;
|
|
1917
2130
|
try {
|
|
1918
|
-
raw =
|
|
2131
|
+
raw = readWorkspaceText(workspaceRoot, workspaceDir);
|
|
1919
2132
|
} catch (cause) {
|
|
1920
2133
|
if (cause.code === "ENOENT") return null;
|
|
1921
2134
|
return { file, why: `it could not be read — ${cause.code ?? cause.message}` };
|
|
@@ -1937,7 +2150,7 @@ export function policyDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1937
2150
|
const fallback = (reason) => ({ file: path.join(workspaceRoot, workspaceDir, "gates.json"), declared: false, reason });
|
|
1938
2151
|
let declared;
|
|
1939
2152
|
try {
|
|
1940
|
-
declared = JSON.parse(
|
|
2153
|
+
declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).gates;
|
|
1941
2154
|
} catch {
|
|
1942
2155
|
// An unreadable manifest reaches here only from the hook's reader, which must not stop.
|
|
1943
2156
|
return fallback("no-manifest");
|
|
@@ -1969,7 +2182,7 @@ export function sessionsDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1969
2182
|
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1970
2183
|
let declared;
|
|
1971
2184
|
try {
|
|
1972
|
-
declared = JSON.parse(
|
|
2185
|
+
declared = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir)).sessions;
|
|
1973
2186
|
} catch {
|
|
1974
2187
|
return null;
|
|
1975
2188
|
}
|
|
@@ -1996,7 +2209,7 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
1996
2209
|
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
1997
2210
|
let parsed;
|
|
1998
2211
|
try {
|
|
1999
|
-
parsed = JSON.parse(
|
|
2212
|
+
parsed = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir));
|
|
2000
2213
|
} catch {
|
|
2001
2214
|
return null;
|
|
2002
2215
|
}
|
|
@@ -2010,16 +2223,43 @@ export function spendDeclaration(workspaceRoot, workspaceDir = ".portulan") {
|
|
|
2010
2223
|
if (error instanceof LedgerError) throw new CompileError(error.message);
|
|
2011
2224
|
throw error;
|
|
2012
2225
|
}
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
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
|
+
}
|
|
2019
2249
|
}
|
|
2020
2250
|
return spend;
|
|
2021
2251
|
}
|
|
2022
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
|
+
|
|
2023
2263
|
/** `guidanceOnly` is true where guidance still compiles, and `"leftover"` where only an earlier run's is removed. */
|
|
2024
2264
|
function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, reason = "no-key", guidanceOnly = false) {
|
|
2025
2265
|
const manifest = path.join(workspaceRoot, workspaceDir, "workspace.json");
|
|
@@ -2037,8 +2277,14 @@ function undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOp
|
|
|
2037
2277
|
: `this workspace declares no gate policy — \`workspace.json\` has no top-level \`gates\` key, ` +
|
|
2038
2278
|
`and there is no \`gates.json\` at ${policyFile}.`;
|
|
2039
2279
|
const outcome =
|
|
2040
|
-
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"
|
|
2041
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."
|
|
2042
2288
|
: guidanceOnly
|
|
2043
2289
|
? "No enforcement is compiled; the workspace's guidance still is."
|
|
2044
2290
|
: "Nothing was compiled and nothing was written.";
|
|
@@ -2092,7 +2338,15 @@ export function resolvePack(rawName, roots = []) {
|
|
|
2092
2338
|
for (const root of roots) {
|
|
2093
2339
|
const dir = path.join(root, category, pack);
|
|
2094
2340
|
const manifest = path.join(dir, "pack.json");
|
|
2095
|
-
|
|
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
|
+
}
|
|
2096
2350
|
}
|
|
2097
2351
|
return { name, category, pack, dir: null, manifest: null, root: null, why: "no pack.json under any resolution root" };
|
|
2098
2352
|
}
|
|
@@ -2143,6 +2397,7 @@ export function packDifferences(mine, other) {
|
|
|
2143
2397
|
const differs = [];
|
|
2144
2398
|
if (mineV !== otherV) differs.push(`version ${mineV} against the tree's ${otherV}`);
|
|
2145
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");
|
|
2146
2401
|
return differs;
|
|
2147
2402
|
}
|
|
2148
2403
|
|
|
@@ -2157,9 +2412,10 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2157
2412
|
const base = path.join(workspaceRoot, workspaceDir);
|
|
2158
2413
|
let manifest;
|
|
2159
2414
|
try {
|
|
2160
|
-
manifest = JSON.parse(
|
|
2161
|
-
} catch {
|
|
2162
|
-
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}`);
|
|
2163
2419
|
}
|
|
2164
2420
|
const declared = manifest?.packs;
|
|
2165
2421
|
if (!Array.isArray(declared) || declared.length === 0) return { contributions: [], unresolved: [] };
|
|
@@ -2183,7 +2439,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2183
2439
|
unresolved.push(found);
|
|
2184
2440
|
continue;
|
|
2185
2441
|
}
|
|
2186
|
-
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);
|
|
2187
2443
|
if (!(options.forced ?? false) && plan.source !== "named") {
|
|
2188
2444
|
const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, workspaceRoot), roots, (r) =>
|
|
2189
2445
|
(plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin,
|
|
@@ -2191,7 +2447,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2191
2447
|
if (behind) {
|
|
2192
2448
|
let other = null;
|
|
2193
2449
|
try {
|
|
2194
|
-
other = JSON.parse(
|
|
2450
|
+
other = JSON.parse(readOwnedText(behind.manifest, behind.dir));
|
|
2195
2451
|
} catch (cause) {
|
|
2196
2452
|
throw new CompileError(
|
|
2197
2453
|
`\`${found.name}\` resolved under the root ${found.root} while the root ` +
|
|
@@ -2230,6 +2486,7 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2230
2486
|
contributions.push({
|
|
2231
2487
|
pack: found.name,
|
|
2232
2488
|
dir: found.dir,
|
|
2489
|
+
contextCap: readContextCap(packManifest, found.name),
|
|
2233
2490
|
fragments: fragments ?? [],
|
|
2234
2491
|
personas: personas ?? [],
|
|
2235
2492
|
origin: recordedOrigin(found.root, plan, workspaceRoot),
|
|
@@ -2321,10 +2578,18 @@ export function composeFragments(policy, contributions) {
|
|
|
2321
2578
|
return { policy: { ...policy, rules }, added, tightened };
|
|
2322
2579
|
}
|
|
2323
2580
|
|
|
2324
|
-
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)) {
|
|
2325
2590
|
let raw;
|
|
2326
2591
|
try {
|
|
2327
|
-
raw =
|
|
2592
|
+
raw = readOwnedText(file, scope);
|
|
2328
2593
|
} catch (error) {
|
|
2329
2594
|
throw new CompileError(`cannot read ${what} at ${file} — ${error.code ?? error.message}`);
|
|
2330
2595
|
}
|
|
@@ -2376,22 +2641,71 @@ export const PERSONA_HOSTS = {
|
|
|
2376
2641
|
},
|
|
2377
2642
|
};
|
|
2378
2643
|
|
|
2379
|
-
/**
|
|
2644
|
+
/** The plugin location; ordinary projects use `.claude/agents/`. */
|
|
2380
2645
|
export const BINDING_DIR = "agents";
|
|
2381
2646
|
|
|
2382
2647
|
const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
|
|
2383
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
|
+
|
|
2384
2694
|
const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
|
|
2385
2695
|
|
|
2386
2696
|
/** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
|
|
2387
2697
|
export function personaDeclarations(text) {
|
|
2388
2698
|
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
2389
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
|
+
}
|
|
2390
2704
|
const declared = {};
|
|
2391
2705
|
const problems = [];
|
|
2392
2706
|
const seen = new Set();
|
|
2393
2707
|
let name = null;
|
|
2394
|
-
for (const line of
|
|
2708
|
+
for (const line of lines.slice(1, close)) {
|
|
2395
2709
|
const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
|
|
2396
2710
|
if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
|
|
2397
2711
|
if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
|
|
@@ -2502,32 +2816,54 @@ export function boundAgent(text, declared, source, where) {
|
|
|
2502
2816
|
|
|
2503
2817
|
const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
|
|
2504
2818
|
|
|
2505
|
-
/**
|
|
2819
|
+
/** Declared personas against existing bindings, including core sources when compiling this engine's own tree. */
|
|
2506
2820
|
export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
|
|
2507
2821
|
const base = path.join(workspaceRoot, workspaceDir);
|
|
2508
2822
|
let tree = null;
|
|
2509
2823
|
try {
|
|
2510
|
-
tree = JSON.parse(
|
|
2824
|
+
tree = JSON.parse(readWorkspaceText(workspaceRoot, workspaceDir))?.tree;
|
|
2511
2825
|
} catch {
|
|
2512
2826
|
// `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
|
|
2513
2827
|
}
|
|
2514
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
|
+
}
|
|
2515
2849
|
const files = [];
|
|
2516
2850
|
const lines = [];
|
|
2517
2851
|
const degraded = [];
|
|
2518
2852
|
const owners = new Map();
|
|
2519
2853
|
let compared = 0;
|
|
2520
|
-
|
|
2854
|
+
const bindingDir = treeRoot && sources.some(source => source.personas?.length) ? bindingDirectory(treeRoot) : BINDING_DIR;
|
|
2855
|
+
for (const contribution of sources) {
|
|
2521
2856
|
for (const rel of contribution.personas ?? []) {
|
|
2522
|
-
const where = `\`${contribution.pack}\`'s persona \`${rel}\``;
|
|
2857
|
+
const where = contribution.core ? `core persona \`${rel}\`` : `\`${contribution.pack}\`'s persona \`${rel}\``;
|
|
2523
2858
|
const file = path.resolve(contribution.dir, rel);
|
|
2524
2859
|
let text;
|
|
2525
2860
|
try {
|
|
2526
|
-
if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its pack`);
|
|
2527
|
-
|
|
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));
|
|
2528
2864
|
} catch (cause) {
|
|
2529
2865
|
if (cause instanceof CompileError) throw cause;
|
|
2530
|
-
throw new CompileError(`${where} could not be read — ${cause.code ?? cause.message}, so what it declares is unknown. Run
|
|
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"}`);
|
|
2531
2867
|
}
|
|
2532
2868
|
const { name: declaredName, declared, problems } = personaDeclarations(text);
|
|
2533
2869
|
if (problems.length) {
|
|
@@ -2535,17 +2871,19 @@ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".port
|
|
|
2535
2871
|
}
|
|
2536
2872
|
const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
|
|
2537
2873
|
const name = declaredName ?? path.basename(rel, ".md");
|
|
2538
|
-
if (
|
|
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)) {
|
|
2539
2878
|
throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
|
|
2540
2879
|
}
|
|
2541
|
-
const source = `${contribution.pack}'s ${rel}`;
|
|
2880
|
+
const source = contribution.core ? rel : `${contribution.pack}'s ${rel}`;
|
|
2542
2881
|
for (const field of fields) {
|
|
2543
2882
|
for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
|
|
2544
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}` });
|
|
2545
2884
|
}
|
|
2546
2885
|
}
|
|
2547
|
-
|
|
2548
|
-
const bindingRel = `${BINDING_DIR}/${name}.md`;
|
|
2886
|
+
const bindingRel = `${bindingDir}/${name}.md`;
|
|
2549
2887
|
if (owners.has(bindingRel)) {
|
|
2550
2888
|
const sources = [owners.get(bindingRel), source].sort();
|
|
2551
2889
|
throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
|
|
@@ -2555,7 +2893,7 @@ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".port
|
|
|
2555
2893
|
if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
|
|
2556
2894
|
continue;
|
|
2557
2895
|
}
|
|
2558
|
-
const binding = path.join(treeRoot,
|
|
2896
|
+
const binding = path.join(treeRoot, bindingDir, `${name}.md`);
|
|
2559
2897
|
const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
|
|
2560
2898
|
let stat = null;
|
|
2561
2899
|
try {
|
|
@@ -2578,19 +2916,15 @@ export function planPersonas(contributions, workspaceRoot, workspaceDir = ".port
|
|
|
2578
2916
|
if (cause instanceof CompileError) throw cause;
|
|
2579
2917
|
throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
|
|
2580
2918
|
}
|
|
2581
|
-
|
|
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}`); }
|
|
2582
2922
|
const next = boundAgent(current, declared, source, shown);
|
|
2583
|
-
|
|
2584
|
-
if (
|
|
2585
|
-
|
|
2586
|
-
persona: name,
|
|
2587
|
-
field: null,
|
|
2588
|
-
host: "claude-code",
|
|
2589
|
-
line:
|
|
2590
|
-
`degraded ${name.padEnd(22)} ${listed(fields)} claude-code (Claude Code): ${shown} loads only from a plugin root, and this tree ` +
|
|
2591
|
-
`has no ${PLUGIN_MANIFEST}; compile writes no .claude/agents/, so nothing here loads the compiled fields`,
|
|
2592
|
-
});
|
|
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`);
|
|
2593
2926
|
}
|
|
2927
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
|
|
2594
2928
|
if (fields.length || next !== current) compared += 1;
|
|
2595
2929
|
if (next !== current) files.push({ path: shown, file: binding, text: next, source });
|
|
2596
2930
|
}
|
|
@@ -2687,7 +3021,7 @@ export function resolveWorkspace(named) {
|
|
|
2687
3021
|
const manifestPath = path.join(dir, "workspace.json");
|
|
2688
3022
|
let raw;
|
|
2689
3023
|
try {
|
|
2690
|
-
raw =
|
|
3024
|
+
raw = readOwnedText(manifestPath, dir);
|
|
2691
3025
|
} catch (cause) {
|
|
2692
3026
|
// As given, not resolved: `compile` prints paths built on it.
|
|
2693
3027
|
if (cause.code === "ENOENT") return { workspaceRoot: named, workspaceDir: ".portulan" };
|
|
@@ -2708,6 +3042,7 @@ export function resolveWorkspace(named) {
|
|
|
2708
3042
|
if (typeof manifest?.tree === "string" && manifest.tree.trim()) {
|
|
2709
3043
|
const root = path.resolve(dir, manifest.tree);
|
|
2710
3044
|
const inside = path.relative(root, dir);
|
|
3045
|
+
if(inside === "") return {workspaceRoot:root,workspaceDir:"."};
|
|
2711
3046
|
// A `tree` that does not contain its workspace cannot be trusted, so the answer is the one that changes nothing.
|
|
2712
3047
|
if (inside && !inside.startsWith("..") && !path.isAbsolute(inside)) {
|
|
2713
3048
|
return { workspaceRoot: root, workspaceDir: inside.split(path.sep).join("/") };
|
|
@@ -2719,19 +3054,19 @@ export function resolveWorkspace(named) {
|
|
|
2719
3054
|
|
|
2720
3055
|
function usage() {
|
|
2721
3056
|
return [
|
|
2722
|
-
"portulan compile — compile
|
|
3057
|
+
"portulan compile — compile workspace advice, guidance, bindings and declared gates",
|
|
2723
3058
|
"",
|
|
2724
3059
|
" portulan compile [--check] [--matrix] [--workspace <dir>] [--pack-root <dir>|auto]...",
|
|
2725
3060
|
"",
|
|
2726
|
-
" (no flag) compile
|
|
2727
|
-
" --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",
|
|
2728
3063
|
" --matrix print every rule against every backend, the gates neither compiles, and every",
|
|
2729
|
-
" guidance unit against every host",
|
|
3064
|
+
" guidance unit against every host; report native settings without a policy",
|
|
2730
3065
|
" --workspace the workspace directory to compile; defaults to `.portulan`",
|
|
2731
3066
|
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
2732
3067
|
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
2733
3068
|
"",
|
|
2734
|
-
"
|
|
3069
|
+
"Gate compilation emits RESTRICTION only: `auto` and `propose` compile to nothing in the Claude Code",
|
|
2735
3070
|
"backend by design, and the partition inverts in the repository ruleset, which is the floor.",
|
|
2736
3071
|
"",
|
|
2737
3072
|
"Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
|
|
@@ -2808,50 +3143,58 @@ export function run(argv, options = {}) {
|
|
|
2808
3143
|
);
|
|
2809
3144
|
}
|
|
2810
3145
|
const guidance = guidanceUnits(workspaceRoot, workspaceDir);
|
|
2811
|
-
const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot);
|
|
3146
|
+
const guidancePlan = showMatrix ? null : planGuidance(guidance, workspaceRoot, workspaceDir);
|
|
3147
|
+
if (!check && guidancePlan) preflightGuidanceWrites(guidancePlan, workspaceRoot);
|
|
2812
3148
|
const sessions = sessionsDeclaration(workspaceRoot, workspaceDir);
|
|
2813
3149
|
const spend = spendDeclaration(workspaceRoot, workspaceDir);
|
|
3150
|
+
const stopPolicy = stopDeclaration(workspaceRoot, workspaceDir);
|
|
2814
3151
|
const { file: policyFile, declared: policyDeclared, reason: policyReason } = policyDeclaration(workspaceRoot, workspaceDir);
|
|
2815
3152
|
const packOptions = { named: namedRoots, discovery: () => discoverPackRoots(), forced };
|
|
2816
3153
|
if (policyReason === "refused") {
|
|
2817
3154
|
throw new CompileError(undeclaredPolicyMessage(policyFile, workspaceRoot, workspaceDir, packOptions, policyReason));
|
|
2818
3155
|
}
|
|
2819
3156
|
if (!policyDeclared && !fs.existsSync(policyFile)) {
|
|
2820
|
-
// Only
|
|
3157
|
+
// Only an authored manifest clears stray guidance.
|
|
2821
3158
|
const leftover = guidance === null && policyReason === "no-key" && guidancePlan !== null && guidancePlan.stray.length > 0;
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
say(
|
|
2828
|
-
`note \`sessions\` in ${sessions.manifest} compiled nothing: its host switches ride the settings a gate ` +
|
|
2829
|
-
`policy compiles to, and this workspace has none`,
|
|
2830
|
-
);
|
|
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;
|
|
2831
3164
|
}
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
)
|
|
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));
|
|
2837
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");
|
|
2838
3182
|
say();
|
|
2839
|
-
const packs = packContributions(workspaceRoot, workspaceDir, packOptions);
|
|
2840
3183
|
for (const u of packs.unresolved) say(unresolvedLine(u));
|
|
2841
3184
|
if (packs.unresolved.length) say();
|
|
2842
|
-
|
|
3185
|
+
if (!advice) for (const line of contextCapLines(contexts)) say(`note ${line}`);
|
|
2843
3186
|
if (showMatrix) {
|
|
2844
|
-
printGuidanceMatrix(say, guidance);
|
|
3187
|
+
if (guidance) printGuidanceMatrix(say, guidance);
|
|
2845
3188
|
printPersonaReport(say, personas);
|
|
2846
3189
|
return 0;
|
|
2847
3190
|
}
|
|
2848
|
-
const drifted = emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
3191
|
+
const drifted = emitCapOnly(capPlan, { check, say }) + emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2849
3192
|
if (!check) return 0;
|
|
2850
3193
|
if (drifted) return 1;
|
|
2851
|
-
say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}`);
|
|
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" : ""}`);
|
|
2852
3195
|
return 0;
|
|
2853
3196
|
}
|
|
2854
|
-
const policy = readJson(policyFile, "the gate policy");
|
|
3197
|
+
const policy = readJson(policyFile, "the gate policy", path.join(workspaceRoot, workspaceDir));
|
|
2855
3198
|
// A thunk, so the host's plugin record is read only where discovery can win.
|
|
2856
3199
|
const { contributions, unresolved, plan } = packContributions(workspaceRoot, workspaceDir, {
|
|
2857
3200
|
named: namedRoots,
|
|
@@ -2871,6 +3214,8 @@ export function run(argv, options = {}) {
|
|
|
2871
3214
|
packProvenance: contributions,
|
|
2872
3215
|
sessions,
|
|
2873
3216
|
spend,
|
|
3217
|
+
stopPolicy,
|
|
3218
|
+
contexts: contextCaps(contributions, unresolved),
|
|
2874
3219
|
});
|
|
2875
3220
|
|
|
2876
3221
|
for (const a of composed.added) say(`pack ${a.pack.padEnd(30)} adds \`${a.id}\` (${a.tier})`);
|
|
@@ -2882,6 +3227,7 @@ export function run(argv, options = {}) {
|
|
|
2882
3227
|
|
|
2883
3228
|
if (showMatrix) {
|
|
2884
3229
|
printMatrix(say, parsed, columns, { source });
|
|
3230
|
+
for (const line of contextCapLines(contextCaps(contributions, unresolved))) say(`note ${line}`);
|
|
2885
3231
|
if (guidance) {
|
|
2886
3232
|
say();
|
|
2887
3233
|
printGuidanceMatrix(say, guidance);
|