@north-light/crouter 0.3.165 → 0.3.167

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.
@@ -1,8 +1,9 @@
1
1
  // `crtr node inspect context` — browse this node's context and reports dirs and those of
2
2
  // every node it subscribes to, in one read-only nvim file explorer.
3
3
  //
4
- // crtr makes a STRONG CLAIM that nvim is installed (see crouter/CLAUDE.md): the
5
- // file explorer + file viewer are nvim, not a hand-rolled view-contract surface.
4
+ // crtr makes a STRONG CLAIM that nvim is installed (see
5
+ // .crouter/memory/INDEX.md): the file explorer + file viewer are nvim, not a
6
+ // hand-rolled view-contract surface.
6
7
  // We synthesize a temp dir of LABELLED NODE DIRS — `self` first, then one per
7
8
  // subscription — and place `context` / `reports` symlinks inside each label dir
8
9
  // for the node's existing on-disk dirs. We then open `nvim -R` on the temp tree
@@ -110,6 +110,28 @@ function commandsJson(topName = 'app') {
110
110
  ],
111
111
  };
112
112
  }
113
+ function passthroughCommandsJson() {
114
+ return {
115
+ schemaVersion: 1,
116
+ mounts: [{
117
+ parent: [],
118
+ node: {
119
+ kind: 'branch',
120
+ name: 'capture',
121
+ description: 'browser automation',
122
+ whenToUse: 'driving a browser through the Capture CLI',
123
+ rootEntry: {
124
+ concept: 'browser automation',
125
+ description: 'browser automation through Capture',
126
+ whenToUse: 'driving a browser through the Capture CLI',
127
+ },
128
+ summary: 'browser automation through Capture',
129
+ passthrough: { bin: 'capture', installHint: 'Install @crouton-kit/capture globally.' },
130
+ children: [],
131
+ },
132
+ }],
133
+ };
134
+ }
113
135
  /** Write a plugin into `scopeRoot/plugins/<name>/` with a command manifest +
114
136
  * executable. `manifest` is the commands.json object (default fixture). */
115
137
  function installPlugin(scopeRoot, name, opts = {}) {
@@ -122,7 +144,7 @@ function installPlugin(scopeRoot, name, opts = {}) {
122
144
  version: '0.1.0',
123
145
  description: 'fixture',
124
146
  commands: pointer,
125
- transport: { kind: 'exec', executable: opts.executable ?? 'bin/cmd.js' },
147
+ transport: { kind: 'exec', ...(opts.executable === null ? {} : { executable: opts.executable ?? 'bin/cmd.js' }) },
126
148
  }));
127
149
  writeFileSync(join(root, 'commands.json'), JSON.stringify(opts.manifest ?? commandsJson()));
128
150
  const execPath = join(root, 'bin', 'cmd.js');
@@ -446,6 +468,23 @@ describe('typed validation issues', () => {
446
468
  const v = validate(commandsJson(), { execBit: false });
447
469
  assert.ok(v.issues.some((i) => i.code === 'command_not_executable'));
448
470
  });
471
+ test('exec transport omits executable only for a passthrough-only manifest', () => {
472
+ const passthrough = validate(passthroughCommandsJson(), { executable: null });
473
+ assert.equal(passthrough.issues.length, 0, JSON.stringify(passthrough.issues));
474
+ assert.equal(passthrough.contributions.length, 1);
475
+ assert.equal(passthrough.contributions[0].node.passthrough?.bin, 'capture');
476
+ assert.equal(composeExternalSubtrees(adaptPluginContributions(passthrough.contributions))[0].passthrough?.bin, 'capture');
477
+ const nestedPassthrough = passthroughCommandsJson();
478
+ nestedPassthrough.mounts.push({
479
+ parent: ['capture'],
480
+ node: { kind: 'branch', name: 'child', description: 'nested child', whenToUse: 'testing invalid composition', summary: 'nested child', children: [] },
481
+ });
482
+ const rejectedNestedMount = validate(nestedPassthrough, { executable: null });
483
+ assert.ok(rejectedNestedMount.issues.some((issue) => issue.code === 'command_parent_invalid' && /passthrough branch/.test(issue.message)));
484
+ const executableLeaf = validate(commandsJson(), { executable: null });
485
+ assert.equal(executableLeaf.contributions.length, 0);
486
+ assert.ok(executableLeaf.issues.some((i) => i.path === 'transport.executable' && /requires executable/.test(i.message)));
487
+ });
449
488
  test('executable escaping the plugin root → command_path_unsafe', () => {
450
489
  const v = validate(commandsJson(), { executable: '../../../../bin/sh' });
451
490
  assert.ok(v.issues.some((i) => i.code === 'command_path_unsafe'));
@@ -6,11 +6,12 @@
6
6
  // sender (`from`) is visible in both the read body and the artifact title.
7
7
  import { test, before, after, beforeEach } from 'node:test';
8
8
  import assert from 'node:assert/strict';
9
- import { mkdtempSync, rmSync } from 'node:fs';
9
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { createNode } from '../canvas/canvas.js';
13
13
  import { closeDb } from '../canvas/db.js';
14
+ import { contextDir } from '../canvas/paths.js';
14
15
  import { appendInbox } from '../feed/inbox.js';
15
16
  import { buildCorpus, resolveRef } from '../canvas/history.js';
16
17
  let home;
@@ -39,6 +40,15 @@ after(() => {
39
40
  rmSync(home, { recursive: true, force: true });
40
41
  delete process.env['CRTR_HOME'];
41
42
  });
43
+ test('history corpus skips malformed frontmatter in another cwd', () => {
44
+ const broken = createNode(node('broken-frontmatter', '/tmp/hist-broken'));
45
+ const valid = createNode(node('valid-history', '/tmp/hist-valid'));
46
+ writeFileSync(join(contextDir(broken.node_id), 'copied-source.md'), '---\ndesc: "unterminated\n---\nsource text\n');
47
+ writeFileSync(join(contextDir(valid.node_id), 'result.md'), '---\ntitle: result\n---\n0a0afd613\n');
48
+ const corpus = buildCorpus({ allCwds: true }, { types: ['report', 'doc', 'roadmap'] });
49
+ assert.equal(corpus.some((a) => a.ref === `${broken.node_id}:context/copied-source.md`), false, 'invalid artifact is skipped');
50
+ assert.ok(corpus.some((a) => a.loadBody().includes('0a0afd613')), 'valid artifacts remain searchable');
51
+ });
42
52
  test('default history excludes inbox; --type inbox includes it, with full sender-attributed bodies', () => {
43
53
  const cwd = '/tmp/hist-work';
44
54
  createNode(node('sender-1', cwd));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import test from 'node:test';
8
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
9
+ const SELF_UPDATE = join(ROOT, 'scripts', 'self-update.mjs');
10
+ test('self-update installs the downloaded release without replacing the stable global shim', () => {
11
+ const directory = mkdtempSync(join(tmpdir(), 'crtr-self-update-test-'));
12
+ try {
13
+ const release = join(directory, 'release');
14
+ const archive = join(directory, 'release.tgz');
15
+ const bin = join(directory, 'bin');
16
+ const stableShim = join(bin, 'crtr');
17
+ const marker = join(directory, 'selected');
18
+ const calls = join(directory, 'npm-calls');
19
+ const fakeBin = join(directory, 'fake-bin');
20
+ mkdirSync(join(release, 'scripts'), { recursive: true });
21
+ mkdirSync(bin);
22
+ mkdirSync(fakeBin);
23
+ writeFileSync(join(release, 'scripts', 'install-runtime.mjs'), `import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.CRTR_SELF_UPDATE_MARKER, 'selected');\n`);
24
+ execFileSync('tar', ['-czf', archive, '-C', release, '.']);
25
+ symlinkSync('../stable-crtr', stableShim);
26
+ writeFileSync(join(fakeBin, 'npm'), `#!/usr/bin/env node\nimport { copyFileSync, appendFileSync } from 'node:fs';\nimport { basename, join } from 'node:path';\nconst args = process.argv.slice(2);\nappendFileSync(process.env.CRTR_SELF_UPDATE_CALLS, args.join(' ') + '\\n');\nif (args[0] !== 'pack') process.exit(1);\nconst destination = args[args.indexOf('--pack-destination') + 1];\ncopyFileSync(process.env.CRTR_SELF_UPDATE_ARCHIVE, join(destination, basename(process.env.CRTR_SELF_UPDATE_ARCHIVE)));\nprocess.stdout.write(JSON.stringify([{ filename: basename(process.env.CRTR_SELF_UPDATE_ARCHIVE) }]));\n`);
27
+ chmodSync(join(fakeBin, 'npm'), 0o755);
28
+ const result = spawnSync(process.execPath, [SELF_UPDATE], {
29
+ encoding: 'utf8',
30
+ env: {
31
+ ...process.env,
32
+ PATH: `${fakeBin}:${process.env.PATH}`,
33
+ CRTR_SELF_UPDATE_ARCHIVE: archive,
34
+ CRTR_SELF_UPDATE_CALLS: calls,
35
+ CRTR_SELF_UPDATE_MARKER: marker,
36
+ },
37
+ });
38
+ assert.equal(result.status, 0, result.stderr);
39
+ assert.equal(readFileSync(marker, 'utf8'), 'selected');
40
+ assert.equal(readlinkSync(stableShim), '../stable-crtr');
41
+ assert.match(readFileSync(calls, 'utf8'), /^pack @north-light\/crouter@latest --json --pack-destination .+\/crtr-self-update-\S+\n$/);
42
+ }
43
+ finally {
44
+ rmSync(directory, { recursive: true, force: true });
45
+ }
46
+ });
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { readConfig, readState, updateState } from './config.js';
3
3
  import { nowIso } from './fs-utils.js';
4
4
  import { info } from './output.js';
5
- import { selfCheck, contentCheck } from './self-update.js';
5
+ import { SELF_UPDATE_SCRIPT, selfCheck, contentCheck } from './self-update.js';
6
6
  const HOUR_MS = 60 * 60 * 1000;
7
7
  const SKIP_SUBCOMMANDS = new Set([
8
8
  'update',
@@ -27,7 +27,7 @@ function withinInterval(lastIso, intervalHours) {
27
27
  return Date.now() - last < intervalHours * HOUR_MS;
28
28
  }
29
29
  function spawnDetachedSelfUpdate() {
30
- const child = spawn('npm', ['i', '-g', '@north-light/crouter@latest'], {
30
+ const child = spawn(process.execPath, [SELF_UPDATE_SCRIPT], {
31
31
  detached: true,
32
32
  stdio: 'ignore',
33
33
  });
@@ -15,6 +15,7 @@ import { getNode, listNodes, view } from './canvas.js';
15
15
  import { contextDir, reportsDir, nodeDir, inboxPath } from './paths.js';
16
16
  import { readTextIfExists, walkFiles, pathExists } from '../fs-utils.js';
17
17
  import { parseFrontmatterGeneric } from '../frontmatter.js';
18
+ import { warn } from '../output.js';
18
19
  /** The cwd the caller means by default: the calling node's cwd (resolved from
19
20
  * CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
20
21
  * when not run as a node. */
@@ -242,6 +243,11 @@ function listDocFiles(nodeId) {
242
243
  return [];
243
244
  }
244
245
  }
246
+ /** Keep one malformed artifact from breaking a cross-node corpus scan. */
247
+ function warnInvalidFrontmatter(path, error) {
248
+ const message = (error instanceof Error ? error.message : String(error)).split('\n')[0];
249
+ warn(`invalid frontmatter in ${path}: ${message}`);
250
+ }
245
251
  /** The node desc lives in meta.json, not the row — read it once per survivor. */
246
252
  function nodeDescOf(nodeId) {
247
253
  const n = getNode(nodeId);
@@ -281,7 +287,15 @@ export function buildCorpus(scope, corpus) {
281
287
  out.push(metaArtifact(row, desc));
282
288
  if (want.has('report')) {
283
289
  for (const f of listReportFiles(row.node_id)) {
284
- const a = reportArtifact(row, desc, f);
290
+ const abs = join(reportsDir(row.node_id), f);
291
+ let a;
292
+ try {
293
+ a = reportArtifact(row, desc, f);
294
+ }
295
+ catch (e) {
296
+ warnInvalidFrontmatter(abs, e);
297
+ continue;
298
+ }
285
299
  if (a === null)
286
300
  continue;
287
301
  if (wantReportKind !== undefined && a.reportKind !== wantReportKind)
@@ -291,7 +305,14 @@ export function buildCorpus(scope, corpus) {
291
305
  }
292
306
  if (want.has('doc') || want.has('roadmap')) {
293
307
  for (const abs of listDocFiles(row.node_id)) {
294
- const a = docArtifact(row, desc, abs);
308
+ let a;
309
+ try {
310
+ a = docArtifact(row, desc, abs);
311
+ }
312
+ catch (e) {
313
+ warnInvalidFrontmatter(abs, e);
314
+ continue;
315
+ }
295
316
  if (a.source === 'roadmap' && !want.has('roadmap'))
296
317
  continue;
297
318
  if (a.source === 'doc' && !want.has('doc'))
@@ -217,6 +217,7 @@ function materializeForest(mounts, reservedCoreNames, issue) {
217
217
  ...(m.node.rootEntry !== undefined ? { rootEntry: m.node.rootEntry } : {}),
218
218
  summary: m.node.summary,
219
219
  ...(m.node.model !== undefined ? { model: m.node.model } : {}),
220
+ ...(m.node.passthrough !== undefined ? { passthrough: m.node.passthrough } : {}),
220
221
  children: [...m.node.children],
221
222
  }));
222
223
  // Attach nested mounts
@@ -270,6 +271,12 @@ function resolveAndAttachMount(roots, mount, issue) {
270
271
  }
271
272
  current = child;
272
273
  }
274
+ // A passthrough owns every token below its path, so nested mounts would make
275
+ // the materialized branch invalid even though its inline children are empty.
276
+ if (current.passthrough !== undefined) {
277
+ issue('command_parent_invalid', 'parent path resolves to a passthrough branch', mount.parent.join('.'), 'a branch without passthrough', 'Remove the nested mount or remove passthrough from its parent branch.', mount.parent.join('.'));
278
+ return false;
279
+ }
273
280
  // Attach the node
274
281
  if (current.children.some((c) => c.name === mount.node.name)) {
275
282
  issue('command_node_invalid', `duplicate child name`, mount.node.name, 'unique child names within a branch', 'Rename the node or remove the duplicate.', `${mount.parent.join('.')}.${mount.node.name}`);
@@ -8,11 +8,15 @@ export function adaptPluginContributions(contributions) {
8
8
  node: contribution.node,
9
9
  adaptLeaf: (leaf, commandPath) => {
10
10
  const transport = contribution.transport;
11
- if (transport.kind === 'exec')
11
+ if (transport.kind === 'exec') {
12
+ const executable = transport.executable;
13
+ if (executable === undefined)
14
+ throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
12
15
  return {
13
16
  outputKind: 'object',
14
- run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable: transport.executable, commandPath: [...commandPath], output: leaf.output }, input),
17
+ run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: [...commandPath], output: leaf.output }, input),
15
18
  };
19
+ }
16
20
  const httpLeaf = leaf;
17
21
  return {
18
22
  outputKind: httpLeaf.rest.streaming === true ? 'jsonl' : 'object',
@@ -56,17 +56,29 @@ function transportFor(plugin, issues) {
56
56
  return undefined;
57
57
  }
58
58
  const unknown = Object.keys(value).filter((key) => !['kind', 'executable'].includes(key));
59
- if (value.kind !== 'exec' || unknown.length || typeof value.executable !== 'string' || value.executable.length === 0) {
60
- issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: unknown.length ? `transport has unknown keys: ${unknown.join(', ')}` : 'exec transport requires executable', received: JSON.stringify(raw), expected: '{ kind: "exec", executable: "plugin-relative path" }', next: 'Fix plugin.json.transport.', path: 'transport' });
59
+ if (value.kind !== 'exec' || unknown.length || (value.executable !== undefined && (typeof value.executable !== 'string' || value.executable.length === 0))) {
60
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: unknown.length ? `transport has unknown keys: ${unknown.join(', ')}` : 'exec transport requires a non-empty executable when declared', received: JSON.stringify(raw), expected: '{ kind: "exec", executable?: "plugin-relative path" }', next: 'Fix plugin.json.transport.', path: 'transport' });
61
61
  return undefined;
62
62
  }
63
- const executable = safePath(plugin.root, value.executable);
63
+ return { kind: 'exec', ...(value.executable !== undefined ? { executable: value.executable } : {}) };
64
+ }
65
+ function hasExecutableLeaf(node) {
66
+ return node.kind === 'leaf' || node.children.some(hasExecutableLeaf);
67
+ }
68
+ function validateExecExecutable(plugin, transport, manifest, issues) {
69
+ if (transport.executable === undefined) {
70
+ if (!manifest.roots.some(hasExecutableLeaf))
71
+ return transport;
72
+ issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'exec transport requires executable when commands.json declares executable leaves', received: '(missing)', expected: '{ kind: "exec", executable: "plugin-relative path" }', next: 'Add plugin.json.transport.executable.', path: 'transport.executable' });
73
+ return undefined;
74
+ }
75
+ const executable = safePath(plugin.root, transport.executable);
64
76
  if (executable === null) {
65
- issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'executable escapes the plugin root or is not a regular file', received: value.executable, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix plugin.json.transport.executable.', path: 'transport.executable' });
77
+ issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'executable escapes the plugin root or is not a regular file', received: transport.executable, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix plugin.json.transport.executable.', path: 'transport.executable' });
66
78
  return undefined;
67
79
  }
68
80
  if ((statSync(executable).mode & 0o111) === 0) {
69
- issues.push({ code: 'command_not_executable', plugin: plugin.name, message: 'executable lacks the POSIX exec bit', received: value.executable, expected: 'a file with an executable permission bit', next: 'chmod +x the plugin executable, then update the plugin.', path: 'transport.executable' });
81
+ issues.push({ code: 'command_not_executable', plugin: plugin.name, message: 'executable lacks the POSIX exec bit', received: transport.executable, expected: 'a file with an executable permission bit', next: 'chmod +x the plugin executable, then update the plugin.', path: 'transport.executable' });
70
82
  return undefined;
71
83
  }
72
84
  return { kind: 'exec', executable };
@@ -122,7 +134,12 @@ export function validatePluginCommands(plugin, reservedNames = new Set()) {
122
134
  validation.issues.forEach(issueFor(plugin, issues));
123
135
  return { plugin, manifestPath, transport, contributions: [], issues };
124
136
  }
125
- return { plugin, manifestPath, transport, contributions: validation.manifest.roots.map((node) => ({ plugin, transport, node, manifest: validation.manifest })), issues };
137
+ const resolvedTransport = transport.kind === 'exec'
138
+ ? validateExecExecutable(plugin, transport, validation.manifest, issues)
139
+ : transport;
140
+ if (resolvedTransport === undefined)
141
+ return { plugin, manifestPath, transport, contributions: [], issues };
142
+ return { plugin, manifestPath, transport: resolvedTransport, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest })), issues };
126
143
  }
127
144
  export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
128
145
  return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
@@ -475,8 +475,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
475
475
  });
476
476
  };
477
477
  // Defensive: any `crtr` child the engine/extensions spawn must inherit the
478
- // front-door recursion guard (runtime/CLAUDE.md fork-bomb invariant). The host
479
- // (T6) also sets this; we set it again so the broker is self-sufficient.
478
+ // front-door recursion guard (see
479
+ // src/core/runtime/.crouter/memory/INDEX.md). The host (T6) also sets this;
480
+ // setting it here keeps the broker self-sufficient.
480
481
  process.env[FRONT_DOOR_ENV] = '1';
481
482
  // A successful in-turn crtr command stamps this process id into its stop
482
483
  // signal, so a crash-revived broker cannot consume a prior broker's outcome.
@@ -33,8 +33,8 @@
33
33
  // Layering note: lifecycle.ts is runtime, but it is the canvas write surface's
34
34
  // `transition` verb (the only writer of status+intent), so it owns its atomic
35
35
  // row UPDATE directly via openDb — the one sanctioned exception to "only
36
- // canvas.ts touches the db" (see canvas/CLAUDE.md), exactly as db.ts's backfill
37
- // is the sanctioned exception for a data migration.
36
+ // canvas.ts touches the db" (see src/core/canvas/.crouter/memory/INDEX.md),
37
+ // exactly as db.ts's backfill is the sanctioned exception for a data migration.
38
38
  import { openDb, getNode, cancelCronsOnWake } from '../canvas/index.js';
39
39
  const ANY = '*';
40
40
  /** The supervised statuses — a live node the daemon watches. */
@@ -1,3 +1,4 @@
1
+ export declare const SELF_UPDATE_SCRIPT: string;
1
2
  export declare function currentVersion(): string;
2
3
  export declare function selfUpdate(): void;
3
4
  /** Check whether a newer crtr version is available on npm.
@@ -15,16 +15,18 @@ const __dirname = dirname(__filename);
15
15
  // src/core/self-update.ts → up to src/ → up to pkg root
16
16
  const PKG_ROOT = join(__dirname, '..', '..');
17
17
  const PACKAGE_JSON_PATH = join(PKG_ROOT, 'package.json');
18
+ export const SELF_UPDATE_SCRIPT = join(PKG_ROOT, 'scripts', 'self-update.mjs');
18
19
  export function currentVersion() {
19
20
  const raw = readFileSync(PACKAGE_JSON_PATH, 'utf8');
20
21
  const parsed = JSON.parse(raw);
21
22
  return parsed.version;
22
23
  }
23
24
  export function selfUpdate() {
24
- const res = spawnSync('npm', ['i', '-g', '@north-light/crouter@latest'], { stdio: 'inherit' });
25
- if (res.status !== 0) {
26
- throw general('npm install failed');
27
- }
25
+ const res = spawnSync(process.execPath, [SELF_UPDATE_SCRIPT], { stdio: 'inherit' });
26
+ if (res.error)
27
+ throw general(`self-update failed: ${res.error.message}`);
28
+ if (res.status !== 0)
29
+ throw general(`self-update failed (${res.status ?? res.signal ?? 'unknown'})`);
28
30
  }
29
31
  /** Check whether a newer crtr version is available on npm.
30
32
  * Warns to stderr if network unavailable; returns {current, latest} or null if unreachable. */
@@ -48,8 +48,10 @@ seams to fall between — skip it for self-contained specs.
48
48
 
49
49
  For larger specs touching established patterns, optionally spawn a Task
50
50
  agent (\`general-purpose\`, \`sonnet\`) to cross-check the spec's design
51
- against \`CLAUDE.md\` / \`.claude/rules/*.md\` and the files it references,
52
- looking for contradictions with project conventions. Skip for small specs.
51
+ against the applicable project memory documents (discover them with
52
+ \`crtr memory list\` and load them with \`crtr memory read\`) and the files they
53
+ reference, looking for contradictions with project conventions. Skip for small
54
+ specs.
53
55
 
54
56
  ## Calibration
55
57
 
package/dist/types.d.ts CHANGED
@@ -14,8 +14,8 @@ export interface OwnerRef {
14
14
  email?: string;
15
15
  }
16
16
  export type PluginTransport = {
17
- kind: 'exec';
18
- executable: string;
17
+ kind: 'exec'; /** Required when the validated command manifest has executable leaves. */
18
+ executable?: string;
19
19
  } | {
20
20
  kind: 'http';
21
21
  endpoint: string;