@sleepy_panda_srl/portulan 0.2.0 → 0.3.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 +2 -2
- package/cli/README.md +6 -3
- package/cli/compile.mjs +318 -13
- package/cli/doctor.mjs +46 -7
- package/cli/drills.mjs +26 -4
- package/cli/ledger.mjs +110 -6
- package/cli/portulan.mjs +1 -1
- package/cli/review-meter.mjs +14 -9
- package/cli/telemetry.mjs +57 -15
- package/core/operating/context.md +79 -4
- package/core/personas/README.md +33 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/evals/README.md +23 -7
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/package.json +2 -1
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +136 -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 +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +1 -0
- package/spec/slots.md +10 -6
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ workspaces and premium packs delivered as private-marketplace plugins.
|
|
|
20
20
|
|
|
21
21
|
## Status
|
|
22
22
|
|
|
23
|
-
**Current release: `0.
|
|
23
|
+
**Current release: `0.3.0`** — recorded in [`CHANGELOG.md`](CHANGELOG.md), which is also the honest
|
|
24
24
|
account of what the release does *not* contain.
|
|
25
25
|
|
|
26
26
|
This is pre-release software. The leading `0.` is meant literally: nothing here is stable yet, and
|
|
@@ -119,7 +119,7 @@ commit, refusing any packed file that differs from its source.
|
|
|
119
119
|
|---|---|
|
|
120
120
|
| [`init`](cli/init.mjs) | Interview plus codebase scan, producing a drafted workspace |
|
|
121
121
|
| [`doctor`](cli/doctor.mjs) | Validate a workspace against the Workspace Definition |
|
|
122
|
-
| [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement |
|
|
122
|
+
| [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement, and what a pack's persona declares about its context into the agent binding it is given, which Claude Code loads only at a plugin root |
|
|
123
123
|
| [`vendor`](cli/vendor.mjs) | Materialise a workspace where it is needed, in either direction |
|
|
124
124
|
| [`index`](cli/index.mjs) | Rebuild the declared indexes over memory, handoffs and personas; print changelog fragments for a release |
|
|
125
125
|
| [`upgrade`](cli/upgrade.mjs) | Migrate a workspace across Workspace Definition versions |
|
package/cli/README.md
CHANGED
|
@@ -22,7 +22,7 @@ in [`../.portulan/verify/`](../.portulan/verify/), and its operator tooling in
|
|
|
22
22
|
|---|---|
|
|
23
23
|
| [`portulan.mjs`](portulan.mjs) | Portulan — the one entry point the `npx` package exposes. |
|
|
24
24
|
| [`init.mjs`](init.mjs) | `portulan init`: draft a workspace for a repository that has none. |
|
|
25
|
-
| [`doctor.mjs`](doctor.mjs) | `portulan doctor`: validate a workspace
|
|
25
|
+
| [`doctor.mjs`](doctor.mjs) | `portulan doctor`: validate a workspace and report branch-owned host tokens beside local receipts; --static skips usage records. |
|
|
26
26
|
| [`compile.mjs`](compile.mjs) | `portulan compile`: compile a workspace's gate policy into host enforcement. |
|
|
27
27
|
| [`vendor.mjs`](vendor.mjs) | `portulan vendor`: materialise a workspace where it is needed: into a host, or between residences. |
|
|
28
28
|
| [`index.mjs`](index.mjs) | `portulan index`: regenerate the memory, handoff and scope indexes. |
|
|
@@ -63,6 +63,7 @@ Run as `node cli/<file>.mjs`, or imported by the files above.
|
|
|
63
63
|
| [`instructions.mjs`](instructions.mjs) | `instructions` — the sections a team marks in its own instruction file, moved to on-read units and proved. |
|
|
64
64
|
| [`ledger.mjs`](ledger.mjs) | What a change spends, measured from the host's own usage records. |
|
|
65
65
|
| [`librarian.mjs`](librarian.mjs) | `librarian` — the scheduled pass over the curated layer: reindex, staleness, nags, demotion drafts. |
|
|
66
|
+
| [`local-routing.mjs`](local-routing.mjs) | Verify the shipped operational local roles against their immutable acquired A/B record. No model. Not shipped. |
|
|
66
67
|
| [`manifest.mjs`](manifest.mjs) | The two facts every tool here needs out of `package.json`, read once and in a module that imports nothing of ours. |
|
|
67
68
|
| [`mutants.mjs`](mutants.mjs) | Mutation-test this repository's two gate matchers against the corpus that claims to cover them. |
|
|
68
69
|
| [`pack-identity.mjs`](pack-identity.mjs) | Every file `npm pack` emits is byte-identical to the tracked blob it came from. |
|
|
@@ -71,7 +72,7 @@ Run as `node cli/<file>.mjs`, or imported by the files above.
|
|
|
71
72
|
| [`plugin-lint.mjs`](plugin-lint.mjs) | `plugin-lint` — the packaging validator. |
|
|
72
73
|
| [`recipe-set.mjs`](recipe-set.mjs) | The recipe set: the verify recipes a workspace yields, its own plus each composed pack's, namespaced by pack. |
|
|
73
74
|
| [`release-eval.mjs`](release-eval.mjs) | Portulan — the eval result a release carries. |
|
|
74
|
-
| [`review-meter.mjs`](review-meter.mjs) | The review-loop meter: the figures
|
|
75
|
+
| [`review-meter.mjs`](review-meter.mjs) | The review-loop meter: the historical figures of this repository's Copilot review loop, derived from its reviews. |
|
|
75
76
|
| [`roster.mjs`](roster.mjs) | The cli roster: `cli/README.md`, rendered from the files it lists and never edited by hand. Not shipped. |
|
|
76
77
|
| [`rule-carriers.mjs`](rule-carriers.mjs) | `rule-carriers` — the rail that keeps a reduced rule reduced. |
|
|
77
78
|
| [`sessions.mjs`](sessions.mjs) | The session switches' texts: the cache-lifetime offer `init` and `upgrade` print, and the one line `doctor` reports on them. |
|
|
@@ -92,6 +93,7 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
|
|
|
92
93
|
| [`ab-run.test.mjs`](ab-run.test.mjs) | Tests for `ab-run` — the A/B runner: isolation, the matrix, the journal, the register and the checks behind it. |
|
|
93
94
|
| [`ab.test.mjs`](ab.test.mjs) | Tests for `ab` — the A/B arm builder: its disposition table, rule-2 matcher, nonces, isolation and stop probe. |
|
|
94
95
|
| [`advisory.test.mjs`](advisory.test.mjs) | Tests for `advisory` — the restart advisory's one line with a tool result or at the prompt, and its figure in the status line. |
|
|
96
|
+
| [`branch-freshness.test.mjs`](branch-freshness.test.mjs) | Tests for this repository's branch freshness check and trusted workflow. |
|
|
95
97
|
| [`checkout-refs.live.test.mjs`](checkout-refs.live.test.mjs) | A workflow that checks out MORE THAN ONCE names the ref of every checkout after the first. |
|
|
96
98
|
| [`cli-roster.live.test.mjs`](cli-roster.live.test.mjs) | `cli/README.md` is exactly what ./roster.mjs renders from this directory, and the render keeps its shape. |
|
|
97
99
|
| [`collisions.test.mjs`](collisions.test.mjs) | The collision contract: `init`, `new` and `vendor` answer each contracted state alike, and no fourth `collisions` exists. |
|
|
@@ -100,7 +102,7 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
|
|
|
100
102
|
| [`context.test.mjs`](context.test.mjs) | Tests for `context` — what a boot reads and what the host loads into every context, measured. |
|
|
101
103
|
| [`control-chars.test.mjs`](control-chars.test.mjs) | Tests for `control-chars` — the rail on bytes a reader cannot see. |
|
|
102
104
|
| [`discover.test.mjs`](discover.test.mjs) | Tests for host plugin-cache discovery. |
|
|
103
|
-
| [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` —
|
|
105
|
+
| [`doctor.test.mjs`](doctor.test.mjs) | Tests for `doctor` — workspace validation, branch-owned hybrid usage and static-mode isolation. |
|
|
104
106
|
| [`drills.test.mjs`](drills.test.mjs) | Tests for `drills` — the forced-red drill harness: its table, perturbation guards, pair oracle, hook rails and tree choice. |
|
|
105
107
|
| [`eval-bundle.test.mjs`](eval-bundle.test.mjs) | The evaluation-bundle cutter, driven on this repository AND on real fixture repositories. |
|
|
106
108
|
| [`feedback.live.test.mjs`](feedback.live.test.mjs) | The sender's field map, against the real issue forms rather than against a fixture. |
|
|
@@ -121,6 +123,7 @@ Node's own runner: `node --test "cli/**/*.test.mjs"`, which the `tests` recipe r
|
|
|
121
123
|
| [`mutants.test.mjs`](mutants.test.mjs) | Tests for `mutants`, the mutation-census rail: its operator table, its imports, its corpus and its refusals. |
|
|
122
124
|
| [`new.test.mjs`](new.test.mjs) | Tests for `new`: scaffolds that validate, written into the user's own layer and never into `core/`. |
|
|
123
125
|
| [`pack-identity.test.mjs`](pack-identity.test.mjs) | Tests for the byte-identity rail: green, both findings and could-not-run, each exercised. |
|
|
126
|
+
| [`pack-local-llm.test.mjs`](pack-local-llm.test.mjs) | Tests for the `tools/local-llm` pack's runner — its loopback, input-budget and rollback rails, its eval screen, dedup ranking and A/B record. |
|
|
124
127
|
| [`pack-version.test.mjs`](pack-version.test.mjs) | The pack-version rail, driven on REAL git repositories rather than an injected history. |
|
|
125
128
|
| [`payload.test.mjs`](payload.test.mjs) | Tests for the payload-classification rail. |
|
|
126
129
|
| [`pinned-roots.live.test.mjs`](pinned-roots.live.test.mjs) | Required checks name their resolution root, and every suite whose imports reach the host's plugin record empties it. |
|
package/cli/compile.mjs
CHANGED
|
@@ -23,7 +23,7 @@ export class CompileError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** 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"]);
|
|
26
|
+
export const KNOWN_GATE_POLICY_SPECS = new Set(["2.1", "2.2", "2.3"]);
|
|
27
27
|
|
|
28
28
|
const TIERS = new Set(["auto", "propose", "gated", "prohibited"]);
|
|
29
29
|
|
|
@@ -131,11 +131,11 @@ export function parse(policy) {
|
|
|
131
131
|
rules.push({ id, tier: rule.tier, kind, target: action[kind], reason: rule.reason, action });
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
return { rules, floor: parseFloor(policy.floor) };
|
|
134
|
+
return { rules, floor: parseFloor(policy.floor, String(spec)) };
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/** The optional
|
|
138
|
-
function parseFloor(floor) {
|
|
137
|
+
/** The optional platform floor, with strict freshness unless the policy explicitly chooses otherwise. */
|
|
138
|
+
function parseFloor(floor, spec) {
|
|
139
139
|
if (floor === undefined || floor === null) return null;
|
|
140
140
|
if (typeof floor !== "object" || Array.isArray(floor)) {
|
|
141
141
|
throw new CompileError("`floor` is present but is not a JSON object");
|
|
@@ -180,7 +180,11 @@ function parseFloor(floor) {
|
|
|
180
180
|
if (typeof floor.resolve_conversations !== "boolean") {
|
|
181
181
|
throw new CompileError("`floor.resolve_conversations` must be a boolean — omitting it would export a floor weaker than the one in force");
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
if (Object.hasOwn(floor, "strict")) {
|
|
184
|
+
if (spec !== "2.3") throw new CompileError("`floor.strict` requires gate-policy 2.3");
|
|
185
|
+
if (typeof floor.strict !== "boolean") throw new CompileError("`floor.strict` must be a boolean");
|
|
186
|
+
}
|
|
187
|
+
return { branch, checks, reviews: floor.reviews, resolve_conversations: floor.resolve_conversations, strict: floor.strict ?? true };
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
// ---------------------------------------------------------------- what an action means, to the compiler and the gate alike
|
|
@@ -811,8 +815,7 @@ export function githubRuleset(parsed, options = {}) {
|
|
|
811
815
|
rules.push({
|
|
812
816
|
type: "required_status_checks",
|
|
813
817
|
parameters: {
|
|
814
|
-
|
|
815
|
-
strict_required_status_checks_policy: true,
|
|
818
|
+
strict_required_status_checks_policy: floor.strict,
|
|
816
819
|
do_not_enforce_on_create: false,
|
|
817
820
|
required_status_checks: floor.checks,
|
|
818
821
|
},
|
|
@@ -2217,10 +2220,18 @@ export function packContributions(workspaceRoot, workspaceDir = ".portulan", opt
|
|
|
2217
2220
|
`Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
|
|
2218
2221
|
);
|
|
2219
2222
|
}
|
|
2223
|
+
const personas = packManifest?.contributes?.personas;
|
|
2224
|
+
if (personas !== undefined && !Array.isArray(personas)) {
|
|
2225
|
+
throw new CompileError(
|
|
2226
|
+
`the pack manifest for \`${found.name}\` declares \`contributes.personas\` as ${typeof personas} rather than an array. ` +
|
|
2227
|
+
`Refusing to compose it — run \`doctor\` to validate the pack against the Pack Definition.`,
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2220
2230
|
contributions.push({
|
|
2221
2231
|
pack: found.name,
|
|
2222
2232
|
dir: found.dir,
|
|
2223
2233
|
fragments: fragments ?? [],
|
|
2234
|
+
personas: personas ?? [],
|
|
2224
2235
|
origin: recordedOrigin(found.root, plan, workspaceRoot),
|
|
2225
2236
|
version: typeof packManifest?.portulan?.version === "string" ? packManifest.portulan.version : null,
|
|
2226
2237
|
});
|
|
@@ -2328,6 +2339,291 @@ export function render(settings) {
|
|
|
2328
2339
|
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
2329
2340
|
}
|
|
2330
2341
|
|
|
2342
|
+
// ---------------------------------------------------------------- personas: what a role declares about its context
|
|
2343
|
+
|
|
2344
|
+
/** The fields a persona may declare about the context it runs in, in the order a binding carries them. */
|
|
2345
|
+
export const PERSONA_FIELDS = ["model", "effort", "omitClaudeMd", "cacheLifetime"];
|
|
2346
|
+
|
|
2347
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
2348
|
+
|
|
2349
|
+
const MODEL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:@/[\]-]{0,199}$/;
|
|
2350
|
+
|
|
2351
|
+
const readBoolean = (raw) => raw === "true" ? true : raw === "false" ? false : undefined;
|
|
2352
|
+
|
|
2353
|
+
const PERSONA_VALUES = {
|
|
2354
|
+
model: { read: (raw) => (MODEL_NAME.test(raw) ? raw : undefined), takes: "one model name or alias, written as a single token" },
|
|
2355
|
+
effort: { read: (raw) => (EFFORT_LEVELS.includes(raw) ? raw : undefined), takes: `one of ${EFFORT_LEVELS.join(", ")}` },
|
|
2356
|
+
omitClaudeMd: { read: readBoolean, takes: "`true` or `false`, unquoted" },
|
|
2357
|
+
cacheLifetime: { read: (raw) => (CACHE_LIFETIMES.includes(raw) ? raw : undefined), takes: `one of ${CACHE_LIFETIMES.join(", ")}` },
|
|
2358
|
+
};
|
|
2359
|
+
|
|
2360
|
+
/** Claude Code's frontmatter key for each field, a dotted one nested under its first word. */
|
|
2361
|
+
const CLAUDE_CODE_KEYS = { model: "model", effort: "effort", omitClaudeMd: "omitClaudeMd", cacheLifetime: "experimental.cacheTtl" };
|
|
2362
|
+
|
|
2363
|
+
/** `null` is a field the host cannot express; `why` is the sentence the degradation report gives for it. */
|
|
2364
|
+
export const PERSONA_HOSTS = {
|
|
2365
|
+
"claude-code": {
|
|
2366
|
+
label: "Claude Code",
|
|
2367
|
+
...Object.fromEntries(PERSONA_FIELDS.map((field) => [field, `\`${CLAUDE_CODE_KEYS[field]}\``])),
|
|
2368
|
+
},
|
|
2369
|
+
"agents-md": {
|
|
2370
|
+
label: "AGENTS.md, vendored",
|
|
2371
|
+
model: null,
|
|
2372
|
+
effort: null,
|
|
2373
|
+
omitClaudeMd: null,
|
|
2374
|
+
cacheLifetime: null,
|
|
2375
|
+
why: "AGENTS.md defines no agent to carry it, so the role runs with the session's own",
|
|
2376
|
+
},
|
|
2377
|
+
};
|
|
2378
|
+
|
|
2379
|
+
/** Where a Claude Code binding sits in a tree, as `AGENT_DIR` in ./plugin-lint.mjs, which a test holds it to. */
|
|
2380
|
+
export const BINDING_DIR = "agents";
|
|
2381
|
+
|
|
2382
|
+
const PLUGIN_MANIFEST = ".claude-plugin/plugin.json";
|
|
2383
|
+
|
|
2384
|
+
const unquoted = (raw) => (raw.length >= 2 && `"'`.includes(raw[0]) && raw.at(-1) === raw[0] ? raw.slice(1, -1) : raw);
|
|
2385
|
+
|
|
2386
|
+
/** What a persona's top-level frontmatter lines declare: `{ name, declared, problems }`, a malformed value a problem and never a default. */
|
|
2387
|
+
export function personaDeclarations(text) {
|
|
2388
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
2389
|
+
const close = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
|
|
2390
|
+
const declared = {};
|
|
2391
|
+
const problems = [];
|
|
2392
|
+
const seen = new Set();
|
|
2393
|
+
let name = null;
|
|
2394
|
+
for (const line of close === -1 ? [] : lines.slice(1, close)) {
|
|
2395
|
+
const match = /^([A-Za-z_][\w-]*)\s*:(.*)$/.exec(line);
|
|
2396
|
+
if (match?.[1] === "name") name = unquoted(match[2].trim()).trim() || null;
|
|
2397
|
+
if (!match || !Object.hasOwn(PERSONA_VALUES, match[1])) continue;
|
|
2398
|
+
const [, key, rest] = match;
|
|
2399
|
+
if (seen.has(key)) {
|
|
2400
|
+
problems.push(`declares \`${key}\` twice`);
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
seen.add(key);
|
|
2404
|
+
const raw = key === "omitClaudeMd" ? rest.trim() : unquoted(rest.trim());
|
|
2405
|
+
const value = PERSONA_VALUES[key].read(raw);
|
|
2406
|
+
if (value === undefined) problems.push(`sets \`${key}\` to ${JSON.stringify(rest.trim())}, and \`${key}\` takes ${PERSONA_VALUES[key].takes}`);
|
|
2407
|
+
else declared[key] = value;
|
|
2408
|
+
}
|
|
2409
|
+
return { name, declared, problems };
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const PERSONA_MARK = "# compiled by `portulan compile` from ";
|
|
2413
|
+
const PERSONA_MARK_TAIL = "; edit that file, then recompile";
|
|
2414
|
+
const BINDING_KEY = /^([A-Za-z_][\w-]*)\s*:(.*)$/;
|
|
2415
|
+
const topKey = (field) => CLAUDE_CODE_KEYS[field].split(".")[0];
|
|
2416
|
+
|
|
2417
|
+
function bindingLines(declared) {
|
|
2418
|
+
const lines = [];
|
|
2419
|
+
for (const field of PERSONA_FIELDS) {
|
|
2420
|
+
if (declared[field] === undefined) continue;
|
|
2421
|
+
const value = field === "model" ? quoted(declared[field]) : String(declared[field]);
|
|
2422
|
+
const [top, nested] = CLAUDE_CODE_KEYS[field].split(".");
|
|
2423
|
+
lines.push(...(nested ? [`${top}:`, ` ${nested}: ${value}`] : [`${top}: ${value}`]));
|
|
2424
|
+
}
|
|
2425
|
+
return lines;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
/** Read recognized compiled fields strictly; the rewritten lines must still match byte for byte. */
|
|
2429
|
+
function blockFields(lines, where) {
|
|
2430
|
+
const read = {};
|
|
2431
|
+
let under = null;
|
|
2432
|
+
for (const line of lines) {
|
|
2433
|
+
const nested = /^\s+([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
|
|
2434
|
+
const match = nested ? null : BINDING_KEY.exec(line);
|
|
2435
|
+
const key = nested ? `${under}.${nested[1]}` : match?.[1];
|
|
2436
|
+
const raw = (nested?.[2] ?? match?.[2] ?? "").trim();
|
|
2437
|
+
if (!nested) under = key;
|
|
2438
|
+
const field = PERSONA_FIELDS.find((f) => CLAUDE_CODE_KEYS[f] === key);
|
|
2439
|
+
if (!field) continue;
|
|
2440
|
+
let input = raw;
|
|
2441
|
+
if (field === "model") {
|
|
2442
|
+
try { input = JSON.parse(raw); } catch { /* The byte comparison rejects noncanonical quoting. */ }
|
|
2443
|
+
}
|
|
2444
|
+
const value = typeof input === "string" ? PERSONA_VALUES[field].read(input) : undefined;
|
|
2445
|
+
if (value === undefined) throw new CompileError(`${where}: the compiled block holds \`${line}\`, which this compiler did not write — ${PERSONA_VALUES[field].takes}`);
|
|
2446
|
+
read[field] = value;
|
|
2447
|
+
}
|
|
2448
|
+
return read;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/** A Claude Code binding's text with its compiled block rewritten in place from `declared`; every other byte is the binding's own. */
|
|
2452
|
+
export function boundAgent(text, declared, source, where) {
|
|
2453
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
2454
|
+
if (eol === "\r\n" && text.replaceAll("\r\n", "").includes("\n")) {
|
|
2455
|
+
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`);
|
|
2456
|
+
}
|
|
2457
|
+
const lines = text.split(eol);
|
|
2458
|
+
const close = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
|
|
2459
|
+
if (close === -1) {
|
|
2460
|
+
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`);
|
|
2461
|
+
}
|
|
2462
|
+
const owned = new Set(PERSONA_FIELDS.map(topKey));
|
|
2463
|
+
const front = [];
|
|
2464
|
+
let at = null;
|
|
2465
|
+
for (let i = 1; i < close; i += 1) {
|
|
2466
|
+
if (!lines[i].startsWith(PERSONA_MARK)) {
|
|
2467
|
+
front.push(lines[i]);
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
if (at !== null) throw new CompileError(`${where} carries two compiled blocks. Delete one, then compile again`);
|
|
2471
|
+
at = front.length;
|
|
2472
|
+
const block = [];
|
|
2473
|
+
while (i + 1 < close && (/^\s/.test(lines[i + 1]) || owned.has(BINDING_KEY.exec(lines[i + 1])?.[1]))) block.push(lines[(i += 1)]);
|
|
2474
|
+
const written = bindingLines(blockFields(block, where));
|
|
2475
|
+
const stray = block.find((line) => !written.includes(line)) ?? block.find((line, n) => line !== written[n]);
|
|
2476
|
+
if (stray !== undefined) {
|
|
2477
|
+
throw new CompileError(
|
|
2478
|
+
`${where}: the block compiled from ${source} holds \`${stray}\`, which this compiler did not write. Move the line above the ` +
|
|
2479
|
+
"mark or delete it, then compile again: removing it unasked would lose a line somebody wrote",
|
|
2480
|
+
);
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
const writes = new Set(PERSONA_FIELDS.filter((f) => declared[f] !== undefined).map(topKey));
|
|
2484
|
+
for (const line of front) {
|
|
2485
|
+
const key = BINDING_KEY.exec(line)?.[1];
|
|
2486
|
+
if (writes.has(key)) {
|
|
2487
|
+
throw new CompileError(
|
|
2488
|
+
`${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`,
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
if (writes.size && owned.has(key)) {
|
|
2492
|
+
throw new CompileError(
|
|
2493
|
+
`${where} sets \`${key}\` by hand, which ${source} does not declare. Declare it in the persona or remove the line: in a binding ` +
|
|
2494
|
+
"it compiles into, `compile` owns every key a persona may declare",
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
const block = writes.size ? [`${PERSONA_MARK}${source}${PERSONA_MARK_TAIL}`, ...bindingLines(declared)] : [];
|
|
2499
|
+
const place = at ?? front.length;
|
|
2500
|
+
return [lines[0], ...front.slice(0, place), ...block, ...front.slice(place), ...lines.slice(close)].join(eol);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
const listed = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
|
|
2504
|
+
|
|
2505
|
+
/** Each composed persona against its host binding: the bindings to rewrite, the lines to say, and what each host cannot carry. */
|
|
2506
|
+
export function planPersonas(contributions, workspaceRoot, workspaceDir = ".portulan") {
|
|
2507
|
+
const base = path.join(workspaceRoot, workspaceDir);
|
|
2508
|
+
let tree = null;
|
|
2509
|
+
try {
|
|
2510
|
+
tree = JSON.parse(fs.readFileSync(path.join(base, "workspace.json"), "utf8"))?.tree;
|
|
2511
|
+
} catch {
|
|
2512
|
+
// `run` refuses an unreadable manifest before this is reached; an absent one declares no tree.
|
|
2513
|
+
}
|
|
2514
|
+
const treeRoot = typeof tree === "string" && tree.trim() ? path.resolve(base, tree) : null;
|
|
2515
|
+
const files = [];
|
|
2516
|
+
const lines = [];
|
|
2517
|
+
const degraded = [];
|
|
2518
|
+
const owners = new Map();
|
|
2519
|
+
let compared = 0;
|
|
2520
|
+
for (const contribution of contributions) {
|
|
2521
|
+
for (const rel of contribution.personas ?? []) {
|
|
2522
|
+
const where = `\`${contribution.pack}\`'s persona \`${rel}\``;
|
|
2523
|
+
const file = path.resolve(contribution.dir, rel);
|
|
2524
|
+
let text;
|
|
2525
|
+
try {
|
|
2526
|
+
if (!isInside(fs.realpathSync(contribution.dir), fs.realpathSync(file))) throw new CompileError(`${where} resolves outside its pack`);
|
|
2527
|
+
text = fs.readFileSync(file, "utf8");
|
|
2528
|
+
} catch (cause) {
|
|
2529
|
+
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 \`doctor\` to validate the pack`);
|
|
2531
|
+
}
|
|
2532
|
+
const { name: declaredName, declared, problems } = personaDeclarations(text);
|
|
2533
|
+
if (problems.length) {
|
|
2534
|
+
throw new CompileError(`${where} ${problems.join("; it ")}. core/personas/README.md gives the fields a persona may declare`);
|
|
2535
|
+
}
|
|
2536
|
+
const fields = PERSONA_FIELDS.filter((f) => declared[f] !== undefined);
|
|
2537
|
+
const name = declaredName ?? path.basename(rel, ".md");
|
|
2538
|
+
if (fields.length && !SLUG.test(name)) {
|
|
2539
|
+
throw new CompileError(`${where} is named \`${name}\`, which is not a slug, so no binding file can be named for it`);
|
|
2540
|
+
}
|
|
2541
|
+
const source = `${contribution.pack}'s ${rel}`;
|
|
2542
|
+
for (const field of fields) {
|
|
2543
|
+
for (const [id, host] of Object.entries(PERSONA_HOSTS)) {
|
|
2544
|
+
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
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
if (!SLUG.test(name)) continue;
|
|
2548
|
+
const bindingRel = `${BINDING_DIR}/${name}.md`;
|
|
2549
|
+
if (owners.has(bindingRel)) {
|
|
2550
|
+
const sources = [owners.get(bindingRel), source].sort();
|
|
2551
|
+
throw new CompileError(`${bindingRel} has multiple persona owners: ${sources.join(" and ")}. Each binding must have one owner`);
|
|
2552
|
+
}
|
|
2553
|
+
owners.set(bindingRel, source);
|
|
2554
|
+
if (!treeRoot) {
|
|
2555
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and this workspace declares no \`tree\` to hold its binding`);
|
|
2556
|
+
continue;
|
|
2557
|
+
}
|
|
2558
|
+
const binding = path.join(treeRoot, BINDING_DIR, `${name}.md`);
|
|
2559
|
+
const shown = path.relative(workspaceRoot, binding).split(path.sep).join("/");
|
|
2560
|
+
let stat = null;
|
|
2561
|
+
try {
|
|
2562
|
+
stat = fs.lstatSync(binding);
|
|
2563
|
+
} catch (cause) {
|
|
2564
|
+
if (cause.code !== "ENOENT") throw new CompileError(`${shown} could not be examined — ${cause.code ?? cause.message}`);
|
|
2565
|
+
}
|
|
2566
|
+
if (stat === null) {
|
|
2567
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} UNBOUND — declares ${listed(fields)}, and no host binding at \`${bindingRel}\` carries them`);
|
|
2568
|
+
continue;
|
|
2569
|
+
}
|
|
2570
|
+
if (!stat.isFile()) {
|
|
2571
|
+
throw new CompileError(`${shown} binds ${where} and is not a regular file, so nothing is compiled through it`);
|
|
2572
|
+
}
|
|
2573
|
+
try {
|
|
2574
|
+
if (!isInside(fs.realpathSync(treeRoot), fs.realpathSync(binding))) {
|
|
2575
|
+
throw new CompileError(`${shown} resolves outside the declared tree, so nothing is compiled through it`);
|
|
2576
|
+
}
|
|
2577
|
+
} catch (cause) {
|
|
2578
|
+
if (cause instanceof CompileError) throw cause;
|
|
2579
|
+
throw new CompileError(`${shown} could not be resolved against the declared tree — ${cause.code ?? cause.message}`);
|
|
2580
|
+
}
|
|
2581
|
+
const current = fs.readFileSync(binding, "utf8");
|
|
2582
|
+
const next = boundAgent(current, declared, source, shown);
|
|
2583
|
+
if (fields.length) lines.push(`persona ${name.padEnd(22)} ${listed(fields)} → ${shown} (${PERSONA_HOSTS["claude-code"].label})`);
|
|
2584
|
+
if (fields.length && !fs.existsSync(path.join(treeRoot, ...PLUGIN_MANIFEST.split("/")))) {
|
|
2585
|
+
degraded.push({
|
|
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
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
if (fields.length || next !== current) compared += 1;
|
|
2595
|
+
if (next !== current) files.push({ path: shown, file: binding, text: next, source });
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
return { files, lines, degraded, compared };
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
const unresolvedLine = (u) => `pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`;
|
|
2602
|
+
|
|
2603
|
+
function printPersonaReport(say, personas) {
|
|
2604
|
+
if (!personas.lines.length && !personas.degraded.length) return;
|
|
2605
|
+
say();
|
|
2606
|
+
for (const line of [...personas.lines, ...personas.degraded.map((d) => d.line)]) say(` ${line}`);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function emitPersonas(personas, { check, say }) {
|
|
2610
|
+
if (!personas) return 0;
|
|
2611
|
+
for (const line of personas.lines) say(line);
|
|
2612
|
+
for (const d of personas.degraded) say(d.line);
|
|
2613
|
+
let drifted = 0;
|
|
2614
|
+
for (const f of personas.files) {
|
|
2615
|
+
if (check) {
|
|
2616
|
+
say(`RED — ${f.path} has drifted from ${f.source}. Recompile.`);
|
|
2617
|
+
drifted += 1;
|
|
2618
|
+
} else {
|
|
2619
|
+
fs.writeFileSync(f.file, f.text);
|
|
2620
|
+
say(`wrote ${f.file}`);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
if (personas.lines.length || personas.degraded.length || personas.files.length) say();
|
|
2624
|
+
return drifted;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2331
2627
|
// ---------------------------------------------------------------- the command line
|
|
2332
2628
|
|
|
2333
2629
|
function printMatrix(say, parsed, columns, { source }) {
|
|
@@ -2540,14 +2836,19 @@ export function run(argv, options = {}) {
|
|
|
2540
2836
|
);
|
|
2541
2837
|
}
|
|
2542
2838
|
say();
|
|
2839
|
+
const packs = packContributions(workspaceRoot, workspaceDir, packOptions);
|
|
2840
|
+
for (const u of packs.unresolved) say(unresolvedLine(u));
|
|
2841
|
+
if (packs.unresolved.length) say();
|
|
2842
|
+
const personas = planPersonas(packs.contributions, workspaceRoot, workspaceDir);
|
|
2543
2843
|
if (showMatrix) {
|
|
2544
2844
|
printGuidanceMatrix(say, guidance);
|
|
2845
|
+
printPersonaReport(say, personas);
|
|
2545
2846
|
return 0;
|
|
2546
2847
|
}
|
|
2547
|
-
const drifted = emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2848
|
+
const drifted = emitPersonas(personas, { check, say }) + emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2548
2849
|
if (!check) return 0;
|
|
2549
2850
|
if (drifted) return 1;
|
|
2550
|
-
say(
|
|
2851
|
+
say(`GREEN — every compiled guidance file matches its unit${personas.compared ? ", and every persona binding its persona" : ""}`);
|
|
2551
2852
|
return 0;
|
|
2552
2853
|
}
|
|
2553
2854
|
const policy = readJson(policyFile, "the gate policy");
|
|
@@ -2557,6 +2858,7 @@ export function run(argv, options = {}) {
|
|
|
2557
2858
|
discovery: () => discoverPackRoots(),
|
|
2558
2859
|
forced,
|
|
2559
2860
|
});
|
|
2861
|
+
const personas = planPersonas(contributions, workspaceRoot, workspaceDir);
|
|
2560
2862
|
// Only under `--matrix`, as it moves with what is installed; but a union is never silent.
|
|
2561
2863
|
if (plan && (showMatrix || plan.source === "union")) say(`packs: resolution root ${plan.source} — ${plan.why}`);
|
|
2562
2864
|
const composed = composeFragments(policy, contributions);
|
|
@@ -2575,9 +2877,7 @@ export function run(argv, options = {}) {
|
|
|
2575
2877
|
for (const t of composed.tightened) {
|
|
2576
2878
|
say(`pack ${t.pack.padEnd(30)} tightens \`${t.id}\` ${t.from} → ${t.to}`);
|
|
2577
2879
|
}
|
|
2578
|
-
for (const u of unresolved)
|
|
2579
|
-
say(`pack ${u.name.padEnd(30)} UNRESOLVED — ${u.why}; it contributes nothing`);
|
|
2580
|
-
}
|
|
2880
|
+
for (const u of unresolved) say(unresolvedLine(u));
|
|
2581
2881
|
if (composed.added.length || composed.tightened.length || unresolved.length) say();
|
|
2582
2882
|
|
|
2583
2883
|
if (showMatrix) {
|
|
@@ -2586,6 +2886,7 @@ export function run(argv, options = {}) {
|
|
|
2586
2886
|
say();
|
|
2587
2887
|
printGuidanceMatrix(say, guidance);
|
|
2588
2888
|
}
|
|
2889
|
+
printPersonaReport(say, personas);
|
|
2589
2890
|
return 0;
|
|
2590
2891
|
}
|
|
2591
2892
|
|
|
@@ -2642,9 +2943,12 @@ export function run(argv, options = {}) {
|
|
|
2642
2943
|
drifted += 1;
|
|
2643
2944
|
}
|
|
2644
2945
|
}
|
|
2946
|
+
drifted += emitPersonas(personas, { check, say });
|
|
2645
2947
|
drifted += emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2646
2948
|
if (drifted) return 1;
|
|
2647
|
-
|
|
2949
|
+
const bindings = personas.compared ? ", and every persona binding its persona" : "";
|
|
2950
|
+
const guided = bindings ? `, every guidance file its unit${bindings}` : ", and every guidance file its unit";
|
|
2951
|
+
say(`GREEN — every emitted artifact matches the policy${guidance ? guided : bindings}`);
|
|
2648
2952
|
return 0;
|
|
2649
2953
|
}
|
|
2650
2954
|
|
|
@@ -2663,6 +2967,7 @@ export function run(argv, options = {}) {
|
|
|
2663
2967
|
fs.writeFileSync(file, column.artifact.text);
|
|
2664
2968
|
say(`wrote ${file}`);
|
|
2665
2969
|
}
|
|
2970
|
+
emitPersonas(personas, { check, say });
|
|
2666
2971
|
emitGuidance(guidance, guidancePlan, { workspaceRoot, check, say });
|
|
2667
2972
|
return 0;
|
|
2668
2973
|
} catch (error) {
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `doctor` — the Workspace Definition validator.
|
|
3
3
|
//
|
|
4
|
-
// node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
|
|
4
|
+
// node cli/doctor.mjs [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
|
|
5
5
|
//
|
|
6
6
|
// Exit 0 every workspace validates · 1 at least one does not · 2 could not run.
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
12
|
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
composeFragments,
|
|
19
20
|
packDifferences,
|
|
20
21
|
shadowedCopy,
|
|
22
|
+
personaDeclarations,
|
|
21
23
|
} from "./compile.mjs";
|
|
22
24
|
import { isInside, recordType } from "./index.mjs";
|
|
23
25
|
import { parseFrontmatter, AGENT_DIR } from "./plugin-lint.mjs";
|
|
@@ -26,6 +28,7 @@ import { composedId } from "./recipe-set.mjs";
|
|
|
26
28
|
import { alwaysLine } from "./context.mjs";
|
|
27
29
|
import { formLine } from "./form.mjs";
|
|
28
30
|
import { sessionsLine } from "./sessions.mjs";
|
|
31
|
+
import { hostPaths, ledger, print, readSpend, worktrees, linesChanged } from "./ledger.mjs";
|
|
29
32
|
|
|
30
33
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
31
34
|
const DEFAULT_SCHEMA = path.resolve(HERE, "..", "spec", "workspace.schema.json");
|
|
@@ -506,6 +509,9 @@ export function validatePersona(text, { fail, pack, rel }) {
|
|
|
506
509
|
fail("packs", `\`${pack}\`'s persona \`${rel}\` is missing ${part} — the five-part contract core/personas/README.md fixes`);
|
|
507
510
|
}
|
|
508
511
|
}
|
|
512
|
+
for (const problem of personaDeclarations(text).problems) {
|
|
513
|
+
fail("packs", `\`${pack}\`'s persona \`${rel}\` ${problem} — core/personas/README.md gives the fields a persona may declare, and none is defaulted`);
|
|
514
|
+
}
|
|
509
515
|
const reach = text.split(/^##\s+Autonomy reach\b.*$/im)[1]?.split(/^##\s/m)[0];
|
|
510
516
|
if (reach && claimsProhibited(reach)) {
|
|
511
517
|
fail(
|
|
@@ -1499,7 +1505,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1499
1505
|
report(
|
|
1500
1506
|
"bindings",
|
|
1501
1507
|
`${where} has no host binding at \`${rel}\`${keyed} — reported, not failed: a persona without one is unbound rather than wrong, ` +
|
|
1502
|
-
"and this is
|
|
1508
|
+
"and this is where a Claude Code plugin's agents load from — a repository that is not a plugin loads its agents from `.claude/agents/`",
|
|
1503
1509
|
);
|
|
1504
1510
|
} else {
|
|
1505
1511
|
report("bindings", `${where}'s binding at \`${rel}\` could not be read — ${cause.code ?? cause.message}. Unread, not absent`);
|
|
@@ -1850,7 +1856,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1850
1856
|
(stats.unassessed
|
|
1851
1857
|
? `; ${stats.unassessed} unreadable and never assessed`
|
|
1852
1858
|
: "") +
|
|
1853
|
-
". Size and count only: ages
|
|
1859
|
+
". Size and count only: memory ages are not measured here, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
|
|
1854
1860
|
: "no memory records — nothing measured, nothing awaiting retirement",
|
|
1855
1861
|
);
|
|
1856
1862
|
|
|
@@ -1889,6 +1895,32 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1889
1895
|
return { dir, workspace, findings, stats };
|
|
1890
1896
|
}
|
|
1891
1897
|
|
|
1898
|
+
/** Optional usage accounting: a data failure never changes the workspace-validation verdict. */
|
|
1899
|
+
export async function workspaceUsageReport(workspaceDir, workspace, options = {}) {
|
|
1900
|
+
const unmeasured = (why) => [` note spend unmeasured — ${why}`];
|
|
1901
|
+
if (typeof workspace?.tree !== "string" || workspace.tree.trim() === "") return unmeasured("the workspace declares no tree to attribute usage to");
|
|
1902
|
+
try {
|
|
1903
|
+
const repo = path.resolve(workspaceDir, workspace.tree);
|
|
1904
|
+
const roots = worktrees(repo);
|
|
1905
|
+
if (roots === null || roots.length === 0) return unmeasured("the declared tree has no Git worktree ownership");
|
|
1906
|
+
const branch = execFileSync("git", ["-C", repo, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
|
|
1907
|
+
if (!branch || branch === "HEAD") return unmeasured("the declared tree has no current named Git branch");
|
|
1908
|
+
const host = hostPaths(options.env ?? process.env, options.home);
|
|
1909
|
+
if (host.why !== undefined) return unmeasured("the configured usage-record location is ambiguous");
|
|
1910
|
+
const report = ledger({ ...host, roots, branch, lines: linesChanged(repo, branch), spend: readSpend(workspace.spend, "this workspace") });
|
|
1911
|
+
const lines = [];
|
|
1912
|
+
if (report.collected.malformed) lines.push(` note spend partial data — ${report.collected.malformed} malformed usage line(s) were ignored`);
|
|
1913
|
+
if (report.figures.main.requests + report.figures.subagents.requests === 0 && report.local.receipts === 0) {
|
|
1914
|
+
return [...lines, ...unmeasured("no host requests or local receipts on this branch are recorded here")];
|
|
1915
|
+
}
|
|
1916
|
+
print(report, (line) => lines.push(line));
|
|
1917
|
+
return lines;
|
|
1918
|
+
} catch {
|
|
1919
|
+
// Parser messages can quote a private record. Never print them or a raw usage line.
|
|
1920
|
+
return unmeasured("optional local usage data could not be read or parsed; no totals were reported");
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1892
1924
|
// ---------------------------------------------------------------- the command
|
|
1893
1925
|
|
|
1894
1926
|
const ICON = { fail: "FAIL ", report: "note " };
|
|
@@ -1903,8 +1935,9 @@ function usage() {
|
|
|
1903
1935
|
return [
|
|
1904
1936
|
"portulan doctor — validate a workspace against the Workspace Definition",
|
|
1905
1937
|
"",
|
|
1906
|
-
" portulan doctor [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
|
|
1938
|
+
" portulan doctor [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
|
|
1907
1939
|
"",
|
|
1940
|
+
" --static skip local usage records; validation and plugin discovery remain unchanged.",
|
|
1908
1941
|
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
1909
1942
|
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
1910
1943
|
" --repo-root where the repositories this workspace's cards NAME are checked out, so their",
|
|
@@ -1927,6 +1960,7 @@ export async function run(argv, options = {}) {
|
|
|
1927
1960
|
return 0;
|
|
1928
1961
|
}
|
|
1929
1962
|
try {
|
|
1963
|
+
let staticMode = false;
|
|
1930
1964
|
const namedRoots = [];
|
|
1931
1965
|
let discoverPacks = false;
|
|
1932
1966
|
const repoRoots = [];
|
|
@@ -1953,7 +1987,9 @@ export async function run(argv, options = {}) {
|
|
|
1953
1987
|
};
|
|
1954
1988
|
const REPO_ROOT = { unresolvable: "a repository ungoverned", holds: "repositories" };
|
|
1955
1989
|
for (let i = 0; i < argv.length; i += 1) {
|
|
1956
|
-
if (argv[i] === "--
|
|
1990
|
+
if (argv[i] === "--static") {
|
|
1991
|
+
staticMode = true;
|
|
1992
|
+
} else if (argv[i] === "--repo-root") {
|
|
1957
1993
|
repoRoots.push(directoryRoot("--repo-root", argv[i + 1], REPO_ROOT));
|
|
1958
1994
|
i += 1;
|
|
1959
1995
|
} else if (argv[i] === "--pack-root") {
|
|
@@ -1971,7 +2007,7 @@ export async function run(argv, options = {}) {
|
|
|
1971
2007
|
if (bothAsked) throw new DoctorError(bothAsked);
|
|
1972
2008
|
if (dirs.length === 0) {
|
|
1973
2009
|
if (!options.quiet) {
|
|
1974
|
-
process.stderr.write("usage: node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
|
|
2010
|
+
process.stderr.write("usage: node cli/doctor.mjs [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
|
|
1975
2011
|
}
|
|
1976
2012
|
return 2;
|
|
1977
2013
|
}
|
|
@@ -1983,7 +2019,7 @@ export async function run(argv, options = {}) {
|
|
|
1983
2019
|
...(discoverPacks ? { discoverPacks } : {}),
|
|
1984
2020
|
...(repoRoots.length ? { repoRoots } : {}),
|
|
1985
2021
|
};
|
|
1986
|
-
const { findings, stats } = await inspect(dir, { ...options, ...roots });
|
|
2022
|
+
const { findings, stats, workspace } = await inspect(dir, { ...options, ...roots });
|
|
1987
2023
|
const bad = findings.filter((f) => f.severity === "fail");
|
|
1988
2024
|
say(display(dir));
|
|
1989
2025
|
for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(10)} ${f.message}`);
|
|
@@ -1992,6 +2028,9 @@ export async function run(argv, options = {}) {
|
|
|
1992
2028
|
`${findings.length - bad.length} note(s), ${stats.claims} claim(s) checked` +
|
|
1993
2029
|
(stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
|
|
1994
2030
|
);
|
|
2031
|
+
if (!staticMode && !options.quiet && !bad.length && workspace) {
|
|
2032
|
+
for (const line of await workspaceUsageReport(path.resolve(dir), workspace, options)) say(line);
|
|
2033
|
+
}
|
|
1995
2034
|
say();
|
|
1996
2035
|
if (bad.length) failed += 1;
|
|
1997
2036
|
}
|