@north-light/crouter 0.3.314 → 0.3.316

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.
@@ -161,7 +161,8 @@ export type ManifestNode<L extends ManifestLeafBase = ManifestLeaf> = ManifestBr
161
161
  /** One mount point in the manifest's self-contained forest. */
162
162
  export interface ManifestMount<L extends ManifestLeafBase = ManifestLeaf> {
163
163
  /** `[]` mounts `node` as a new top-level command; a non-empty path names a
164
- * branch this SAME manifest already contributes. */
164
+ * branch this manifest already contributes or an extensible core branch.
165
+ * `human` is currently the only extensible core branch. */
165
166
  parent: string[];
166
167
  node: ManifestNode<L>;
167
168
  }
@@ -5,6 +5,7 @@ import { type CoreHookCatalog } from './core/command-hooks/catalog.js';
5
5
  export declare const SUBTREE_NAMES: readonly string[];
6
6
  export declare function coreHookCatalog(): Promise<CoreHookCatalog>;
7
7
  export declare function coreCommandPaths(): Promise<ReadonlySet<string>>;
8
+ export declare function extensibleCoreBranches(): Promise<ReadonlySet<string>>;
8
9
  /** Whether argv targets the daemon-control subtree after the dispatcher's global-token normalization. This only selects a smaller canonical tree; `runCli` still parses, renders help, and reports errors. */
9
10
  export declare function isDaemonControlInvocation(argv: readonly string[]): boolean;
10
11
  /** Assemble the canonical daemon-control definitions without loading settings or plugin discovery. This path exists so `crtr sys daemon restart` can start the new generation that converts pre-SQL user state. */
@@ -1,4 +1,4 @@
1
- import { defineRoot, GLOBAL_TOKENS } from './core/command.js';
1
+ import { defineBranch, defineRoot, GLOBAL_TOKENS } from './core/command.js';
2
2
  import { createCoreHookCatalog } from './core/command-hooks/catalog.js';
3
3
  import { composeCoreHooks } from './core/command-hooks/compose.js';
4
4
  import { mark } from './core/timing.js';
@@ -89,8 +89,7 @@ function hookRegistryLoader() {
89
89
  return registry;
90
90
  };
91
91
  }
92
- export async function coreCommandPaths() {
93
- const core = await loadCoreSubtrees();
92
+ function commandPathsFor(core) {
94
93
  const paths = new Set();
95
94
  const visit = (node, prefix) => {
96
95
  if (node.kind === 'branch' && node.passthrough !== undefined)
@@ -104,6 +103,40 @@ export async function coreCommandPaths() {
104
103
  visit(subtree, subtree.name);
105
104
  return paths;
106
105
  }
106
+ export async function coreCommandPaths() {
107
+ return commandPathsFor(await loadCoreSubtrees());
108
+ }
109
+ export async function extensibleCoreBranches() {
110
+ return new Set((await loadCoreSubtrees()).filter((branch) => branch.extensible === true).map((branch) => branch.name));
111
+ }
112
+ async function composeCorePluginMounts(core) {
113
+ const extensible = new Set(core.filter((branch) => branch.extensible === true).map((branch) => branch.name));
114
+ if (extensible.size === 0)
115
+ return core;
116
+ const [{ buildExternalCommandSnapshot }, { composeCoreBranchMounts }] = await Promise.all([
117
+ import('./core/command-plugins/discovery.js'),
118
+ import('./core/command-plugins/compose.js'),
119
+ ]);
120
+ const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES), undefined, undefined, commandPathsFor(core), extensible);
121
+ return core.map((branch) => {
122
+ const mounted = composeCoreBranchMounts(snapshot.coreMounts, [branch.name]);
123
+ if (mounted.length === 0)
124
+ return branch;
125
+ const { listing: _listing, ...help } = branch.help;
126
+ return defineBranch({
127
+ name: branch.name,
128
+ ...(branch.description !== undefined ? { description: branch.description } : {}),
129
+ ...(branch.whenToUse !== undefined ? { whenToUse: branch.whenToUse } : {}),
130
+ ...(branch.tier !== undefined ? { tier: branch.tier } : {}),
131
+ ...(branch.rootEntry !== undefined ? { rootEntry: branch.rootEntry } : {}),
132
+ ...(branch.extensible === true ? { extensible: true } : {}),
133
+ ...(branch.slash !== undefined ? { slash: branch.slash } : {}),
134
+ ...(branch.passthrough !== undefined ? { passthrough: branch.passthrough } : {}),
135
+ help,
136
+ children: [...branch.children, ...mounted],
137
+ });
138
+ });
139
+ }
107
140
  /** Whether argv targets the daemon-control subtree after the dispatcher's global-token normalization. This only selects a smaller canonical tree; `runCli` still parses, renders help, and reports errors. */
108
141
  export function isDaemonControlInvocation(argv) {
109
142
  const tokens = argv.slice(2).filter((token) => !GLOBAL_TOKENS.has(token));
@@ -123,7 +156,8 @@ export async function resolveDaemonControlRoot() {
123
156
  export async function resolveRoot(first) {
124
157
  const loader = first !== undefined ? SUBTREE_LOADERS[first] : undefined;
125
158
  if (loader !== undefined) {
126
- return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: composeCoreHooks([await loader()], hookRegistryLoader()) });
159
+ const core = [await loader()];
160
+ return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: composeCoreHooks(await composeCorePluginMounts(core), hookRegistryLoader()) });
127
161
  }
128
162
  return buildRoot();
129
163
  }
@@ -156,8 +190,9 @@ export async function buildRoot() {
156
190
  // ONE collision-resolved snapshot over every command plugin transport. Core always wins: any external contribution claiming a core top-level
157
191
  // name is dropped + recorded as an issue (surfaced via pkg plugin show,
158
192
  // sys doctor). Reads only stored manifests — zero network.
159
- const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES));
193
+ const composedCore = await composeCorePluginMounts(core);
194
+ const snapshot = buildExternalCommandSnapshot(new Set(SUBTREE_NAMES), undefined, undefined, commandPathsFor(core), new Set(composedCore.filter((branch) => branch.extensible === true).map((branch) => branch.name)));
160
195
  const external = composeExternalSubtrees(snapshot);
161
196
  // Only core leaves are hookable, so external contributions compose unwrapped.
162
- return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [...composeCoreHooks(core, hookRegistryLoader()), ...external] });
197
+ return defineRoot({ tagline: TAGLINE, globals: GLOBALS, subtrees: [...composeCoreHooks(composedCore, hookRegistryLoader()), ...external] });
163
198
  }
@@ -28,6 +28,7 @@ export function registerHuman() {
28
28
  : `Tickets queue in the human inbox; nothing opens on screen. \`send\` routes completion back when the page carries response-bearing components, and publishes a standalone announcement when it does not. ${durableRequests} \`review\` is for live document review with anchored comments; \`show\` is a passive tmux live-watch rather than a ticket.`;
29
29
  return defineBranch({
30
30
  name: 'human',
31
+ extensible: true,
31
32
  rootEntry: {
32
33
  concept,
33
34
  desc: pageSurface ? 'send, components, feedback, cancel, list' : 'send, review new/comment, show, cancel, list',
@@ -2,7 +2,7 @@
2
2
  // live in actions.ts.
3
3
  import { statSync } from 'node:fs';
4
4
  import { isAbsolute, join } from 'node:path';
5
- import { SUBTREE_NAMES, coreCommandPaths } from '../../../build-root.js';
5
+ import { SUBTREE_NAMES, coreCommandPaths, extensibleCoreBranches } from '../../../build-root.js';
6
6
  import { discoverPluginCommandCandidates, validateEffectiveCommandPlugins, validatePluginCommands } from '../../../core/command-plugins/discovery.js';
7
7
  import { qualifiedTopLevelCommandName } from '../../../core/command-manifests/registry.js';
8
8
  import { listDirs, pathExists, walkFiles } from '../../../core/fs-utils.js';
@@ -391,15 +391,15 @@ export async function loadCapability(entry) {
391
391
  const scope = entry.install.kind === "installed" ? entry.install.scope : "user";
392
392
  const plugin = { name: entry.name, scope, root: entry.root, manifest, enabled: entry.install.kind !== "installed" || entry.install.enabled };
393
393
  const reserved = new Set(SUBTREE_NAMES);
394
- const corePaths = await coreCommandPaths();
395
- let validation = validatePluginCommands(plugin, reserved, corePaths);
394
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
395
+ let validation = validatePluginCommands(plugin, reserved, corePaths, extensibleBranches);
396
396
  const registryIssues = [];
397
397
  // Effective validation resolves collisions for the command that is mounted
398
398
  // today. An uninstalled or disabled plugin is not in that registry, so its
399
399
  // dossier must validate the declared tree and preview the future result.
400
400
  if (entry.install.kind === "installed" && entry.install.enabled) {
401
401
  try {
402
- const effective = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths);
402
+ const effective = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches);
403
403
  validation = effective.find((candidate) => candidate.plugin.root === entry.root) ?? validation;
404
404
  }
405
405
  catch (error) {
@@ -6,7 +6,7 @@ import { listPluginMemoryDocs } from '../../core/memory-resolver.js';
6
6
  import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
7
7
  import { compileHookRegistry, discoverHookRegistry } from '../../core/command-hooks/discovery.js';
8
8
  import { hasDeclaredHooks, hookReport } from '../../core/command-hooks/report.js';
9
- import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
9
+ import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog, extensibleCoreBranches } from '../../build-root.js';
10
10
  import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
11
11
  async function hookInspectionReport(plugin) {
12
12
  if (!hasDeclaredHooks(plugin.manifest))
@@ -106,7 +106,7 @@ export const pluginShow = defineLeaf({
106
106
  { name: 'enabled', type: 'boolean', required: true, constraint: 'Whether the plugin is active.' },
107
107
  { name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
108
108
  { name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
109
- { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted top-level command names), extensibleBranches: string[] (mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
109
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest (manifest.commands). {manifestPath: string, mounts: string[] (accepted command paths), extensibleBranches: string[] (plugin mount names accepting repository fragments), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
110
110
  { name: 'hooks', type: 'object', required: false, constraint: 'Present for every hook declaration, including hook-only plugins. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
111
111
  ],
112
112
  outputKind: 'object',
@@ -135,17 +135,17 @@ export const pluginShow = defineLeaf({
135
135
  let commands;
136
136
  if (typeof found.manifest.commands === 'string' && found.manifest.commands.length > 0) {
137
137
  const reserved = new Set(SUBTREE_NAMES);
138
- const corePaths = await coreCommandPaths();
138
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
139
139
  // Validate against the whole effective set so cross-plugin top-level
140
140
  // name collisions show up in this plugin's issues (a collided command is
141
141
  // NOT mounted). A copy outside the effective set (disabled, or shadowed
142
142
  // by a higher-precedence copy) cannot collide, so the per-plugin
143
143
  // validator is exact for it.
144
- const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths).find((x) => x.plugin.root === found.root) ??
145
- validatePluginCommands(found, reserved, corePaths);
144
+ const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches).find((x) => x.plugin.root === found.root) ??
145
+ validatePluginCommands(found, reserved, corePaths, extensibleBranches);
146
146
  commands = {
147
147
  manifestPath: v.manifestPath,
148
- mounts: v.contributions.map((c) => c.node.name),
148
+ mounts: [...v.contributions.map((c) => c.node.name), ...v.coreMounts.map((mount) => [...mount.parent, mount.node.name].join(' '))],
149
149
  extensibleBranches: v.contributions.filter((c) => c.node.extensible === true).map((c) => c.node.name),
150
150
  issues: v.issues,
151
151
  };
@@ -25,7 +25,7 @@ import { validatePluginBundle } from '../../core/command-plugins/bundle.js';
25
25
  import { compileHookRegistry, discoverCandidateHookRegistry, discoverCandidateHookRegistryForCandidates, discoverHookRegistry } from '../../core/command-hooks/discovery.js';
26
26
  import { hasDeclaredHooks, hookReport } from '../../core/command-hooks/report.js';
27
27
  import { fetchHttpPluginBundle } from '../../core/command-plugins/transport/http-fetch.js';
28
- import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
28
+ import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog, extensibleCoreBranches } from '../../build-root.js';
29
29
  import { isGitUrl, resolvePluginConfigTarget, setPluginEnabled, resolveInstallScope } from './shared.js';
30
30
  /** Validate a just-installed/updated plugin's command manifest, if it declares
31
31
  * one. Returns undefined for plugins without a `commands` pointer. Never
@@ -36,13 +36,13 @@ async function commandReport(name, scope) {
36
36
  return undefined;
37
37
  }
38
38
  const reserved = new Set(SUBTREE_NAMES);
39
- const corePaths = await coreCommandPaths();
39
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
40
40
  // Report from the effective set so a cross-plugin top-level collision is
41
41
  // visible at install time (a collided command is NOT mounted). A copy
42
42
  // outside the effective set (disabled, or shadowed) cannot collide.
43
- const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths).find((x) => x.plugin.root === plugin.root) ??
44
- validatePluginCommands(plugin, reserved, corePaths);
45
- return { mounts: v.contributions.map((c) => c.node.name), issues: v.issues };
43
+ const v = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches).find((x) => x.plugin.root === plugin.root) ??
44
+ validatePluginCommands(plugin, reserved, corePaths, extensibleBranches);
45
+ return { mounts: [...v.contributions.map((c) => c.node.name), ...v.coreMounts.map((mount) => [...mount.parent, mount.node.name].join(' '))], issues: v.issues };
46
46
  }
47
47
  const PLUGIN_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
48
48
  function validateHttpInstall(name, endpoint, authEnv) {
@@ -238,7 +238,8 @@ export async function assertPluginCandidateValid(manifest, root, scope, scopeRoo
238
238
  assertManifestPageComponentsValid(manifest, where);
239
239
  assertManifestBinValid(manifest, where);
240
240
  assertManifestRequiresValid(manifest, where);
241
- const commandValidation = validatePluginCommands({ name: manifest.name, scope, root, manifest, enabled: true }, new Set(SUBTREE_NAMES), await coreCommandPaths());
241
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
242
+ const commandValidation = validatePluginCommands({ name: manifest.name, scope, root, manifest, enabled: true }, new Set(SUBTREE_NAMES), corePaths, extensibleBranches);
242
243
  if (commandValidation.issues.length > 0) {
243
244
  throw invalidBundleError(`plugin command behavior at ${where} is invalid`, commandValidation.issues);
244
245
  }
@@ -296,7 +297,8 @@ function writeStagedBundle(stagingRoot, manifest, bundle) {
296
297
  async function validateStagedBundle(name, scope, root, scopeRootPath, manifest) {
297
298
  const memory = await assertPluginCandidateValid(manifest, root, scope, scopeRootPath, `${root}/.crouter-plugin/plugin.json`);
298
299
  const staged = { name, scope, root, manifest, enabled: true };
299
- const validation = validatePluginCommands(staged, new Set(SUBTREE_NAMES), await coreCommandPaths());
300
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
301
+ const validation = validatePluginCommands(staged, new Set(SUBTREE_NAMES), corePaths, extensibleBranches);
300
302
  if (validation.issues.length > 0) {
301
303
  throw invalidBundleError(`bundle staged package for "${name}" is invalid`, validation.issues);
302
304
  }
@@ -471,7 +473,8 @@ function isInstalledGeneration(name, scope, root, version) {
471
473
  }
472
474
  async function applyBundleArchive(name, scope, target, raw, etag, options, knownVersion) {
473
475
  const { bundle, transport, scopeRootPath, root } = target;
474
- const validated = await validatePluginBundle(raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: await coreCommandPaths() });
476
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
477
+ const validated = await validatePluginBundle(raw, { reservedCoreNames: new Set(SUBTREE_NAMES), coreCommandPaths: corePaths, extensibleCoreBranches: extensibleBranches });
475
478
  if (validated.bundle === undefined)
476
479
  throw invalidBundleError(`HTTP plugin bundle for "${name}" is invalid`, validated.issues);
477
480
  const version = knownVersion ?? bundleVersion(raw);
@@ -881,7 +884,7 @@ export const pluginInstall = defineLeaf({
881
884
  { name: 'docs', type: 'integer', required: false, constraint: 'Memory document count written from an --endpoint archive.' },
882
885
  { name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
883
886
  { name: 'pageComponents', type: 'integer', required: false, constraint: 'Page-component registration count declared by an --endpoint archive\u2019s bundle.json.' },
884
- { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
887
+ { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted command paths now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
885
888
  { name: 'hooks', type: 'object', required: false, constraint: 'Present only when the plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
886
889
  { name: 'warnings', type: 'string[]', required: false, constraint: 'Advisory missing PATH executable requirements declared by the installed plugin. The install still succeeds and leaves the plugin enabled.' },
887
890
  { name: 'memory', type: 'object', required: false, constraint: 'Present only when the plugin ships a memory store. {docs: integer (documents checked), migrated: string[] (store-relative documents rewritten to reach exact canonical identity), linkedSource?: string (the caller-owned directory converged in place, present only when the install keeps that directory as a live link)}.' },
@@ -1064,7 +1067,7 @@ export const pluginUpdate = defineLeaf({
1064
1067
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
1065
1068
  ],
1066
1069
  output: [
1067
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for plugins declaring hooks: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
1070
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[] (accepted command paths), issues: object[]}. hooks is present for plugins declaring hooks: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
1068
1071
  ],
1069
1072
  outputKind: 'object',
1070
1073
  effects: [
@@ -20,7 +20,7 @@ import { resolveBinContributions } from '../../core/runtime/bin-contributions.js
20
20
  import { inspectHumanActions } from '../../core/human-actions.js';
21
21
  import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
22
22
  import { listAllPlugins } from '../../core/resolver.js';
23
- import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
23
+ import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog, extensibleCoreBranches } from '../../build-root.js';
24
24
  import { processGeneration, processLocatedGeneration, runtimeManifest, selectedGeneration } from './shared.js';
25
25
  import { CONFIG_FILE } from '../../types.js';
26
26
  function pass(scope, name, message) {
@@ -125,7 +125,8 @@ export async function runCommandPluginChecks(scopes) {
125
125
  // collisions surface here. A collision no longer costs anyone their command:
126
126
  // each claimant mounts under an origin-qualified name, so it is reported as a
127
127
  // healthy mount rather than a failure demanding the user remove a plugin.
128
- const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, await coreCommandPaths());
128
+ const [corePaths, extensibleBranches] = await Promise.all([coreCommandPaths(), extensibleCoreBranches()]);
129
+ const validations = validateEffectiveCommandPlugins(reserved, undefined, undefined, corePaths, extensibleBranches);
129
130
  for (const v of validations) {
130
131
  const plugin = v.plugin;
131
132
  if (!inScope.has(plugin.scope))
@@ -117,6 +117,33 @@ function commandsJson(topName = 'app') {
117
117
  ],
118
118
  };
119
119
  }
120
+ function humanCommandsJson(parent = ['human'], name = 'pr') {
121
+ return {
122
+ schemaVersion: 1,
123
+ helpAddenda: { human: 'Pull request review commands are provided by this plugin.' },
124
+ mounts: [{
125
+ parent,
126
+ node: {
127
+ kind: 'branch',
128
+ name,
129
+ description: 'pull request review',
130
+ whenToUse: 'a person needs to review a pull request',
131
+ summary: 'review a pull request with a person',
132
+ children: [{
133
+ kind: 'leaf',
134
+ name: 'review',
135
+ description: 'start pull request review',
136
+ whenToUse: 'the pull request is ready for review',
137
+ summary: 'start one pull request review',
138
+ params: [{ kind: 'flag', name: 'app-id', type: 'string', required: true, constraint: 'fixture pull request id' }],
139
+ output: [{ name: 'app_id', type: 'string', required: true, constraint: 'echoed fixture pull request id' }, { name: 'status', type: 'string', required: false, constraint: 'fixture status' }],
140
+ outputKind: 'object',
141
+ effects: ['None. Read-only.'],
142
+ }],
143
+ },
144
+ }],
145
+ };
146
+ }
120
147
  function extensibleDemoCommandsJson(extensible = true) {
121
148
  return {
122
149
  schemaVersion: 1,
@@ -323,6 +350,62 @@ describe('registry composition', () => {
323
350
  assert.match(leafOut, /None\. Read-only\./);
324
351
  });
325
352
  });
353
+ describe('extensible core mounts', () => {
354
+ test('a plugin mounts under human in real help and dispatch receives the full path', async () => {
355
+ installPlugin(userRoot, 'pr-review', { manifest: humanCommandsJson() });
356
+ resetScopeCache();
357
+ const { resolveRoot } = await import('../../../build-root.js');
358
+ const { runCli } = await import('../../command.js');
359
+ const root = await resolveRoot('human');
360
+ const chunks = [];
361
+ const realWrite = process.stdout.write.bind(process.stdout);
362
+ const realCwd = process.cwd();
363
+ const requestLog = join(emptyStart, 'human-mount-request.json');
364
+ process.env['FIXTURE_REQUEST_LOG'] = requestLog;
365
+ process.chdir(emptyStart);
366
+ process.stdout.write = ((chunk) => {
367
+ chunks.push(String(chunk));
368
+ return true;
369
+ });
370
+ try {
371
+ await runCli(root, ['node', 'crtr', 'human', '-h']);
372
+ await runCli(root, ['node', 'crtr', 'human', 'pr', 'review', '--app-id', '42', '--json']);
373
+ }
374
+ finally {
375
+ process.stdout.write = realWrite;
376
+ process.chdir(realCwd);
377
+ delete process.env['FIXTURE_REQUEST_LOG'];
378
+ }
379
+ assert.match(chunks.join(''), /name="pr"[^>]*plugin="pr-review"/);
380
+ assert.match(chunks.join(''), /<plugin-help plugin="pr-review">/);
381
+ assert.deepEqual(JSON.parse(readFileSync(requestLog, 'utf8')).command, ['human', 'pr', 'review']);
382
+ });
383
+ test('plugin installation accepts a human mount', async () => {
384
+ const source = join(mintDir('crtr-human-mount-source-'), 'fixture');
385
+ mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
386
+ mkdirSync(join(source, 'bin'), { recursive: true });
387
+ writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'human-mount-install', version: '0.1.0', description: 'fixture', commands: 'commands.json', transport: { kind: 'exec', executable: 'bin/cmd.js' } }));
388
+ writeFileSync(join(source, 'commands.json'), JSON.stringify(humanCommandsJson()));
389
+ writeFileSync(join(source, 'bin', 'cmd.js'), FIXTURE_EXEC);
390
+ chmodSync(join(source, 'bin', 'cmd.js'), 0o755);
391
+ await pluginInstall.run({ installRef: source, scope: 'user' });
392
+ });
393
+ test('only explicitly extensible core branches accept mounts and core children win collisions', () => {
394
+ const node = listInstalledPluginsInRoot('user', userRoot);
395
+ installPlugin(userRoot, 'wrong-parent', { manifest: humanCommandsJson(['node']) });
396
+ installPlugin(userRoot, 'core-child', { manifest: humanCommandsJson(['human'], 'send') });
397
+ resetScopeCache();
398
+ const plugins = listInstalledPluginsInRoot('user', userRoot);
399
+ const options = { reserved: new Set(['human', 'node']), paths: new Set(['human', 'human send']), extensible: new Set(['human']) };
400
+ const wrongParent = validatePluginCommands(plugins.find((plugin) => plugin.name === 'wrong-parent'), options.reserved, options.paths, options.extensible);
401
+ const coreChild = validatePluginCommands(plugins.find((plugin) => plugin.name === 'core-child'), options.reserved, options.paths, options.extensible);
402
+ assert.equal(wrongParent.contributions.length, 0);
403
+ assert.match(wrongParent.issues[0]?.next ?? '', /extensible core branches: human/);
404
+ assert.equal(coreChild.contributions.length, 0);
405
+ assert.match(coreChild.issues[0]?.message ?? '', /conflicts with a child the extensible core branch already owns/);
406
+ assert.equal(node.length, 0);
407
+ });
408
+ });
326
409
  // 2. Core collision
327
410
  describe('core collision', () => {
328
411
  test('a fixture claiming a core top-level name is skipped + recorded', () => {
@@ -872,6 +955,7 @@ describe('plugin help addenda', () => {
872
955
  const gate = (paths) => ({
873
956
  reservedCoreNames: RESERVED,
874
957
  coreCommandPaths: () => Promise.resolve(new Set(paths)),
958
+ extensibleCoreBranches: () => Promise.resolve(new Set(['human'])),
875
959
  });
876
960
  test('a helpAddenda entry survives validation and is collected attributed to its plugin', async () => {
877
961
  const m = commandsJson();
@@ -1,10 +1,17 @@
1
1
  import type { DeclLeaf, DeclNode, TransportKind, ManifestTimeouts, CommandManifestIssue } from './schema.js';
2
+ export interface ValidatedCoreMount {
3
+ /** The explicitly extensible core branch receiving this plugin node. */
4
+ parent: string[];
5
+ node: DeclNode<DeclLeaf>;
6
+ }
2
7
  export interface ValidatedCommandManifest {
3
8
  schemaVersion: 1;
4
9
  baseUrl?: string;
5
10
  timeouts?: ManifestTimeouts;
6
11
  /** Top-level plugin branches or repository-fragment nodes, ready to mount. */
7
12
  roots: DeclNode<DeclLeaf>[];
13
+ /** Plugin nodes mounted below an explicitly extensible core branch. */
14
+ coreMounts: ValidatedCoreMount[];
8
15
  /** Core command path (space-joined, e.g. "cron add") → attributed addendum
9
16
  * text appended beneath that core command's help. Append-only product
10
17
  * guidance — a plugin can never alter core contract text. */
@@ -36,4 +43,6 @@ export declare function validateCommandManifest(raw: unknown, options: {
36
43
  coreCommandPaths?: ReadonlySet<string>;
37
44
  /** A repository fragment attaches roots below an existing extensible branch. */
38
45
  extensionFragment?: boolean;
46
+ /** Core branch names that accept plugin children. */
47
+ extensibleCoreBranches?: ReadonlySet<string>;
39
48
  }): CommandManifestValidation;
@@ -108,8 +108,20 @@ export function validateCommandManifest(raw, options) {
108
108
  return { issues };
109
109
  validatedMounts.push(m);
110
110
  }
111
- // Materialize the forest: merge inline children with mounted nodes
112
- const roots = materializeForest(validatedMounts, options.reservedCoreNames, issue);
111
+ // Materialize plugin-local trees separately from mounts below an explicitly
112
+ // extensible core branch. Core mounts remain nodes, never fake plugin roots.
113
+ const coreMounts = validatedMounts.filter((mount) => mount.parent.length > 0 && options.extensibleCoreBranches?.has(mount.parent[0]) === true);
114
+ for (const mount of coreMounts) {
115
+ if (mount.parent.length !== 1) {
116
+ issue('command_parent_invalid', 'core mount parent must name an extensible core branch directly', mount.parent.join('.'), `one extensible core branch (one of: ${[...(options.extensibleCoreBranches ?? [])].join(', ')})`, `Mount below one of the extensible core branches: ${[...(options.extensibleCoreBranches ?? [])].join(', ')}.`, mount.parent.join('.'));
117
+ return { issues };
118
+ }
119
+ if (options.coreCommandPaths?.has(`${mount.parent[0]} ${mount.node.name}`) === true) {
120
+ issue('command_collision', `contributed command "${mount.node.name}" conflicts with a child the extensible core branch already owns`, mount.node.name, `a name not already owned by the ${mount.parent[0]} core branch`, 'Rename the contributed command or remove this mount.', `${mount.parent.join('.')}.${mount.node.name}`);
121
+ return { issues };
122
+ }
123
+ }
124
+ const roots = materializeForest(validatedMounts.filter((mount) => !coreMounts.includes(mount)), options.reservedCoreNames, options.extensibleCoreBranches, issue);
113
125
  if (roots === null)
114
126
  return { issues };
115
127
  // If we got here with no issues, return the validated manifest
@@ -121,6 +133,7 @@ export function validateCommandManifest(raw, options) {
121
133
  ...(timeouts !== undefined ? { timeouts } : {}),
122
134
  ...(helpAddenda !== undefined ? { helpAddenda } : {}),
123
135
  roots,
136
+ coreMounts,
124
137
  },
125
138
  issues: [],
126
139
  };
@@ -220,7 +233,7 @@ function validateMount(raw, index, transport, issue, nodeOptions) {
220
233
  * Materializes a flat list of mounts into a forest of top-level branches,
221
234
  * resolving nested mounts whose parent exists in a top-level inline tree regardless of order. Nested mounts that provide another nested mount's parent must appear first. Validates self-containment and no duplicates.
222
235
  */
223
- function materializeForest(mounts, reservedCoreNames, issue) {
236
+ function materializeForest(mounts, reservedCoreNames, extensibleCoreBranches, issue) {
224
237
  // Separate top-level (parent: []) from nested (parent non-empty)
225
238
  const topLevel = mounts.filter((m) => m.parent.length === 0);
226
239
  const nested = mounts.filter((m) => m.parent.length > 0);
@@ -235,6 +248,12 @@ function materializeForest(mounts, reservedCoreNames, issue) {
235
248
  const roots = topLevel.map((m) => m.node.kind === 'branch'
236
249
  ? { ...m.node, children: [...m.node.children] }
237
250
  : { ...m.node });
251
+ for (const mount of nested) {
252
+ if (reservedCoreNames.has(mount.parent[0])) {
253
+ issue('command_parent_invalid', 'parent path starts with a core branch that is not extensible', mount.parent[0], `an extensible core branch (one of: ${[...(extensibleCoreBranches ?? [])].join(', ')})`, `Mount below one of the extensible core branches: ${[...(extensibleCoreBranches ?? [])].join(', ')}.`, mount.parent.join('.'));
254
+ return null;
255
+ }
256
+ }
238
257
  // Attach nested mounts
239
258
  for (const mount of nested) {
240
259
  const resolved = resolveAndAttachMount(roots, mount, issue);
@@ -1,5 +1,5 @@
1
1
  import type { LeafDef } from '../command.js';
2
- import type { DeclLeafBase, DeclBranch, CommandManifestIssue } from './schema.js';
2
+ import type { DeclLeafBase, DeclBranch, DeclNode, CommandManifestIssue } from './schema.js';
3
3
  import type { ExtensionIssueNotice } from '../command-plugins/extensions.js';
4
4
  /** Reference to an external contributor (plugin or HTTP-transport plugin). */
5
5
  export type CommandContributorRef = {
@@ -37,12 +37,22 @@ export interface CommandContribution {
37
37
  export interface ContributorIssue extends CommandManifestIssue {
38
38
  contributor: CommandContributorRef;
39
39
  }
40
+ /** One plugin node accepted below an explicitly extensible core branch. */
41
+ export interface CoreBranchMount {
42
+ parent: string[];
43
+ contributor: CommandContributorRef;
44
+ plugin: string;
45
+ node: DeclNode;
46
+ adaptLeaf: LeafAdapter;
47
+ }
40
48
  /** Invocation-local registry snapshot after collision resolution: the accepted
41
49
  * contributions and every discovered issue. */
42
50
  export interface CommandRegistrySnapshot {
43
51
  /** Accepted top-level contributions, deterministically ordered by
44
52
  * contributor name. */
45
53
  contributions: CommandContribution[];
54
+ /** Accepted plugin nodes below an extensible core branch. */
55
+ coreMounts: CoreBranchMount[];
46
56
  /** Every discovered issue, including collisions. */
47
57
  issues: ContributorIssue[];
48
58
  }
@@ -112,6 +112,7 @@ export function resolveCommandRegistry(candidates, reservedCoreNames) {
112
112
  a.node.name.localeCompare(b.node.name));
113
113
  return {
114
114
  contributions: accepted,
115
+ coreMounts: [],
115
116
  issues,
116
117
  };
117
118
  }
@@ -48,4 +48,5 @@ export interface PluginBundleValidation {
48
48
  export declare function validatePluginBundle(archive: Uint8Array, options: {
49
49
  reservedCoreNames: ReadonlySet<string>;
50
50
  coreCommandPaths?: ReadonlySet<string>;
51
+ extensibleCoreBranches?: ReadonlySet<string>;
51
52
  }): Promise<PluginBundleValidation>;
@@ -187,7 +187,7 @@ function parseBundleMetadata(bytes) {
187
187
  ...(memoryExtensions !== undefined ? { memory_extensions: memoryExtensions } : {}),
188
188
  };
189
189
  }
190
- function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
190
+ function parseCommands(bytes, reservedCoreNames, coreCommandPaths, extensibleCoreBranches = new Set()) {
191
191
  let raw;
192
192
  try {
193
193
  raw = JSON.parse(Buffer.from(bytes).toString('utf8'));
@@ -197,7 +197,7 @@ function parseCommands(bytes, reservedCoreNames, coreCommandPaths) {
197
197
  issues: [bundleInvalid('commands.json is not valid JSON', 'invalid JSON', 'a valid command-manifest JSON object', 'Regenerate commands.json.', 'commands.json')],
198
198
  };
199
199
  }
200
- const validation = validateCommandManifest(raw, { transport: 'http', reservedCoreNames, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
200
+ const validation = validateCommandManifest(raw, { transport: 'http', reservedCoreNames, extensibleCoreBranches, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
201
201
  return {
202
202
  ...(validation.manifest !== undefined ? { commands: validation.manifest } : {}),
203
203
  issues: validation.issues,
@@ -232,7 +232,7 @@ export async function validatePluginBundle(archive, options) {
232
232
  const metadata = parseBundleMetadata(bundle.bytes);
233
233
  if (metadata.issue !== undefined)
234
234
  return { issues: [metadata.issue] };
235
- const commandValidation = parseCommands(commands.bytes, options.reservedCoreNames, options.coreCommandPaths);
235
+ const commandValidation = parseCommands(commands.bytes, options.reservedCoreNames, options.coreCommandPaths, options.extensibleCoreBranches);
236
236
  if (commandValidation.commands === undefined)
237
237
  return { issues: commandValidation.issues };
238
238
  return {
@@ -1,5 +1,7 @@
1
- import type { BranchDef } from '../command.js';
2
- import type { CommandRegistrySnapshot } from '../command-manifests/registry.js';
3
- import type { ValidatedContribution } from './discovery.js';
1
+ import type { BranchDef, LeafDef } from '../command.js';
2
+ import type { CommandRegistrySnapshot, CoreBranchMount } from '../command-manifests/registry.js';
3
+ import type { ValidatedContribution, ValidatedCoreMount } from './discovery.js';
4
4
  export declare function adaptPluginContributions(contributions: ValidatedContribution[]): CommandRegistrySnapshot;
5
+ export declare function adaptPluginCoreMounts(mounts: ValidatedCoreMount[]): CoreBranchMount[];
5
6
  export declare function composeExternalSubtrees(snapshot: CommandRegistrySnapshot): BranchDef[];
7
+ export declare function composeCoreBranchMounts(mounts: readonly CoreBranchMount[], parent: string[]): (BranchDef | LeafDef)[];
@@ -1,47 +1,65 @@
1
1
  import { defineBranch, defineLeaf } from '../command.js';
2
2
  import { executeExternalLeaf } from './transport/exec-invoke.js';
3
3
  import { invokeHttpLeaf } from './transport/http-invoke.js';
4
+ function adaptLeaf(contribution, commandPathFor) {
5
+ return (leaf, commandPath) => {
6
+ const transport = contribution.transport;
7
+ const manifestCommandPath = commandPathFor(commandPath);
8
+ if (transport.kind === 'exec') {
9
+ const executable = transport.executable;
10
+ if (executable === undefined)
11
+ throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
12
+ return {
13
+ outputKind: 'object',
14
+ run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
15
+ };
16
+ }
17
+ const httpLeaf = leaf;
18
+ return {
19
+ outputKind: httpLeaf.rest.streaming === true ? 'jsonl' : 'object',
20
+ run: (input, context) => invokeHttpLeaf({
21
+ registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
22
+ ...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
23
+ ...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
24
+ params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
25
+ }, input, context),
26
+ };
27
+ };
28
+ }
4
29
  export function adaptPluginContributions(contributions) {
5
30
  return {
6
31
  contributions: contributions.map((contribution) => ({
7
32
  contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
8
33
  node: contribution.node,
9
34
  manifestName: contribution.manifestName,
10
- adaptLeaf: (leaf, commandPath) => {
11
- const transport = contribution.transport;
12
- const manifestCommandPath = [contribution.manifestName, ...commandPath.slice(1)];
13
- if (transport.kind === 'exec') {
14
- const executable = transport.executable;
15
- if (executable === undefined)
16
- throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
17
- return {
18
- outputKind: 'object',
19
- run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
20
- };
21
- }
22
- const httpLeaf = leaf;
23
- return {
24
- outputKind: httpLeaf.rest.streaming === true ? 'jsonl' : 'object',
25
- run: (input, context) => invokeHttpLeaf({
26
- registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
27
- ...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
28
- ...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
29
- params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
30
- }, input, context),
31
- };
32
- },
35
+ adaptLeaf: adaptLeaf(contribution, (commandPath) => [contribution.manifestName, ...commandPath.slice(1)]),
33
36
  })),
37
+ coreMounts: [],
34
38
  issues: [],
35
39
  };
36
40
  }
41
+ export function adaptPluginCoreMounts(mounts) {
42
+ return mounts.map((mount) => ({
43
+ parent: mount.parent,
44
+ plugin: mount.plugin.name,
45
+ contributor: { name: mount.plugin.name, scope: mount.plugin.scope, sourceMarketplace: mount.plugin.sourceMarketplace },
46
+ node: mount.node,
47
+ adaptLeaf: adaptLeaf({ ...mount, manifestName: mount.node.name }, (commandPath) => [...commandPath]),
48
+ }));
49
+ }
37
50
  export function composeExternalSubtrees(snapshot) {
38
51
  return snapshot.contributions.map((contribution) => buildBranch(contribution, contribution.node, [contribution.node.name]));
39
52
  }
53
+ export function composeCoreBranchMounts(mounts, parent) {
54
+ return mounts.filter((mount) => mount.parent.join('\u0000') === parent.join('\u0000')).map((mount) => mount.node.kind === 'branch'
55
+ ? buildBranch(mount, mount.node, [...parent, mount.node.name])
56
+ : buildLeaf(mount, mount.node, [...parent, mount.node.name]));
57
+ }
40
58
  function buildBranch(contribution, node, path) {
41
59
  const rootEntry = node.rootEntry === undefined ? undefined : { concept: node.rootEntry.concept, desc: node.rootEntry.description, useWhen: node.rootEntry.whenToUse };
42
- return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}), ...(path.length === 1 && contribution.extensionIssue !== undefined ? { extensionIssue: contribution.extensionIssue } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
60
+ return defineBranch({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(contribution.plugin !== undefined ? { plugin: contribution.plugin } : {}), ...(rootEntry !== undefined ? { rootEntry } : {}), help: { name: path.join(' '), summary: node.summary, ...(node.model !== undefined ? { model: node.model } : {}), ...(path.length === 1 && contribution.extensionIssue !== undefined ? { extensionIssue: contribution.extensionIssue } : {}) }, ...(node.passthrough !== undefined ? { passthrough: node.passthrough } : {}), children: node.children.map((child) => child.kind === 'branch' ? buildBranch(contribution, child, [...path, child.name]) : buildLeaf(contribution, child, [...path, child.name])) });
43
61
  }
44
62
  function buildLeaf(contribution, node, path) {
45
63
  const adapted = contribution.adaptLeaf(node, path);
46
- return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: [...node.effects] }, run: adapted.run });
64
+ return defineLeaf({ name: node.name, description: node.description, whenToUse: node.whenToUse, ...(node.tier !== undefined ? { tier: node.tier } : {}), ...(contribution.plugin !== undefined ? { plugin: contribution.plugin } : {}), help: { name: path.join(' '), summary: node.summary, params: node.params, output: node.output, outputKind: adapted.outputKind, effects: [...node.effects] }, run: adapted.run });
47
65
  }
@@ -16,6 +16,13 @@ export interface ValidatedContribution {
16
16
  manifest: ValidatedCommandManifest;
17
17
  manifestName: string;
18
18
  }
19
+ export interface ValidatedCoreMount {
20
+ plugin: InstalledPlugin;
21
+ transport: PluginTransport;
22
+ parent: string[];
23
+ node: DeclNode;
24
+ manifest: ValidatedCommandManifest;
25
+ }
19
26
  export interface PluginCommandValidation {
20
27
  plugin: InstalledPlugin;
21
28
  manifestPath: string;
@@ -24,14 +31,16 @@ export interface PluginCommandValidation {
24
31
  * Carries `helpAddenda` for the help-render lookup. */
25
32
  manifest?: ValidatedCommandManifest;
26
33
  contributions: ValidatedContribution[];
34
+ coreMounts: ValidatedCoreMount[];
27
35
  issues: CommandDiscoveryIssue[];
28
36
  }
29
37
  export declare function effectiveCommandPlugins(startDir?: string, profileId?: string | null): InstalledPlugin[];
30
- export declare function validatePluginCommands(plugin: InstalledPlugin, reservedNames?: ReadonlySet<string>, coreCommandPaths?: ReadonlySet<string>): PluginCommandValidation;
38
+ export declare function validatePluginCommands(plugin: InstalledPlugin, reservedNames?: ReadonlySet<string>, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): PluginCommandValidation;
31
39
  export declare function discoverPluginCommandCandidates(startDir?: string, profileId?: string | null): PluginCommandValidation[];
32
- export declare function validateEffectiveCommandPlugins(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>): PluginCommandValidation[];
33
- export declare function discoverCommandContributions(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): {
40
+ export declare function validateEffectiveCommandPlugins(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): PluginCommandValidation[];
41
+ export declare function discoverCommandContributions(reservedNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): {
34
42
  contributions: ValidatedContribution[];
43
+ coreMounts: ValidatedCoreMount[];
35
44
  issues: CommandDiscoveryIssue[];
36
45
  };
37
- export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null): CommandRegistrySnapshot;
46
+ export declare function buildExternalCommandSnapshot(reservedCoreNames: ReadonlySet<string>, startDir?: string, profileId?: string | null, coreCommandPaths?: ReadonlySet<string>, extensibleCoreBranches?: ReadonlySet<string>): CommandRegistrySnapshot;
@@ -4,7 +4,7 @@ import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js
4
4
  import { projectScopeRoots } from '../scope.js';
5
5
  import { validateCommandManifest } from '../command-manifests/manifest.js';
6
6
  import { qualifiedTopLevelCommandName, resolveCommandRegistry } from '../command-manifests/registry.js';
7
- import { adaptPluginContributions } from './compose.js';
7
+ import { adaptPluginContributions, adaptPluginCoreMounts } from './compose.js';
8
8
  import { validateHttpPluginTransport } from './endpoint.js';
9
9
  import { applyCommandExtensions } from './extensions.js';
10
10
  export function effectiveCommandPlugins(startDir = process.cwd(), profileId) {
@@ -64,9 +64,12 @@ function transportFor(plugin, issues) {
64
64
  function hasExecutableLeaf(node) {
65
65
  return node.kind === 'leaf' || node.children.some(hasExecutableLeaf);
66
66
  }
67
+ function manifestNodes(manifest) {
68
+ return [...manifest.roots, ...manifest.coreMounts.map((mount) => mount.node)];
69
+ }
67
70
  function validateExecExecutable(plugin, transport, manifest, issues) {
68
71
  if (transport.executable === undefined) {
69
- if (!manifest.roots.some(hasExecutableLeaf))
72
+ if (!manifestNodes(manifest).some(hasExecutableLeaf))
70
73
  return transport;
71
74
  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' });
72
75
  return undefined;
@@ -110,28 +113,29 @@ function resolvePassthroughBinaries(plugin, manifest, issues) {
110
113
  for (const child of node.children)
111
114
  visit(child);
112
115
  };
113
- for (const root of manifest.roots)
116
+ for (const root of manifestNodes(manifest))
114
117
  visit(root);
115
118
  return valid;
116
119
  }
117
- export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths) {
120
+ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCommandPaths, extensibleCoreBranches = new Set()) {
118
121
  const issues = [];
119
122
  const commands = plugin.manifest.commands;
120
123
  const transportRaw = plugin.manifest.transport;
121
124
  let manifestPath = resolve(plugin.root, typeof commands === 'string' ? commands : 'commands.json');
125
+ const empty = (transport) => ({ plugin, manifestPath, ...(transport === undefined ? {} : { transport }), contributions: [], coreMounts: [], issues });
122
126
  if (commands === undefined && transportRaw === undefined)
123
- return { plugin, manifestPath, contributions: [], issues };
127
+ return empty();
124
128
  if (typeof commands !== 'string' || commands.length === 0) {
125
129
  issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'transport requires commands in plugin.json', received: '(missing)', expected: 'a plugin-root-relative commands path', next: 'Add plugin.json.commands or remove transport.', path: 'commands' });
126
- return { plugin, manifestPath, contributions: [], issues };
130
+ return empty();
127
131
  }
128
132
  const transport = transportFor(plugin, issues);
129
133
  if (transport === undefined)
130
- return { plugin, manifestPath, contributions: [], issues };
134
+ return empty();
131
135
  const safe = safePath(plugin.root, commands);
132
136
  if (safe === null) {
133
137
  issues.push({ code: 'command_path_unsafe', plugin: plugin.name, message: 'commands pointer escapes the plugin root or is not a regular file', received: commands, expected: 'a relative path to a regular file inside the plugin root', next: 'Fix plugin.json.commands.', path: 'commands' });
134
- return { plugin, manifestPath, transport, contributions: [], issues };
138
+ return empty(transport);
135
139
  }
136
140
  manifestPath = safe;
137
141
  let bytes;
@@ -143,7 +147,7 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
143
147
  }
144
148
  if (bytes === null) {
145
149
  issues.push({ code: 'command_manifest_unreadable', plugin: plugin.name, message: 'commands.json is missing or unreadable', received: safe, expected: 'a readable JSON file', next: 'Reinstall or update the plugin.' });
146
- return { plugin, manifestPath, transport, contributions: [], issues };
150
+ return empty(transport);
147
151
  }
148
152
  let raw;
149
153
  try {
@@ -151,32 +155,39 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
151
155
  }
152
156
  catch {
153
157
  issues.push({ code: 'command_manifest_invalid', plugin: plugin.name, message: 'commands.json is not valid JSON', received: safe, expected: 'a JSON object', next: 'Regenerate commands.json.' });
154
- return { plugin, manifestPath, transport, contributions: [], issues };
158
+ return empty(transport);
155
159
  }
156
- const validation = validateCommandManifest(raw, { transport: transport.kind, reservedCoreNames: reservedNames, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
160
+ const validation = validateCommandManifest(raw, { transport: transport.kind, reservedCoreNames: reservedNames, extensibleCoreBranches, ...(coreCommandPaths !== undefined ? { coreCommandPaths } : {}) });
157
161
  if (validation.manifest === undefined) {
158
162
  validation.issues.forEach(issueFor(plugin, issues));
159
- return { plugin, manifestPath, transport, contributions: [], issues };
163
+ return empty(transport);
160
164
  }
161
165
  const resolvedTransport = transport.kind === 'exec'
162
166
  ? validateExecExecutable(plugin, transport, validation.manifest, issues)
163
167
  : transport;
164
168
  if (resolvedTransport === undefined)
165
- return { plugin, manifestPath, transport, contributions: [], issues };
166
- if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues)) {
167
- return { plugin, manifestPath, transport: resolvedTransport, contributions: [], issues };
168
- }
169
+ return empty(transport);
170
+ if (resolvedTransport.kind === 'exec' && !resolvePassthroughBinaries(plugin, validation.manifest, issues))
171
+ return empty(resolvedTransport);
169
172
  const roots = validation.manifest.roots;
170
173
  if (!roots.every((node) => node.kind === 'branch')) {
171
174
  throw new Error('plugin manifest validation returned a top-level leaf');
172
175
  }
173
- return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
176
+ return {
177
+ plugin,
178
+ manifestPath,
179
+ transport: resolvedTransport,
180
+ manifest: validation.manifest,
181
+ contributions: roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })),
182
+ coreMounts: validation.manifest.coreMounts.map((mount) => ({ plugin, transport: resolvedTransport, parent: mount.parent, node: mount.node, manifest: validation.manifest })),
183
+ issues,
184
+ };
174
185
  }
175
186
  export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
176
187
  return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
177
188
  }
178
- export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId, coreCommandPaths) {
179
- const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames, coreCommandPaths));
189
+ export function validateEffectiveCommandPlugins(reservedNames, startDir = process.cwd(), profileId, coreCommandPaths, extensibleCoreBranches = new Set()) {
190
+ const validations = effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin, reservedNames, coreCommandPaths, extensibleCoreBranches));
180
191
  const claims = new Map();
181
192
  for (const validation of validations)
182
193
  for (const contribution of validation.contributions) {
@@ -195,19 +206,40 @@ export function validateEffectiveCommandPlugins(reservedNames, startDir = proces
195
206
  validation.contributions = validation.contributions.map((contribution) => contribution === claimant ? { ...contribution, node: { ...contribution.node, name: qualifiedName } } : contribution);
196
207
  validation.issues.push({ code: 'command_collision', plugin: plugin.name, path: name, message: `top-level command "${name}" is claimed by ${claimants.map((entry) => entry.plugin.name).join(', ')} — mounted as "${qualifiedName}"`, received: claimants.map((entry) => entry.plugin.name).join(', '), expected: 'an origin-qualified top-level command for each claimant', next: `Invoke "${qualifiedName}" to use this plugin command.` });
197
208
  }
209
+ const coreClaims = new Map();
210
+ for (const validation of validations)
211
+ for (const mount of validation.coreMounts) {
212
+ const key = [...mount.parent, mount.node.name].join('/');
213
+ const claimants = coreClaims.get(key);
214
+ if (claimants === undefined)
215
+ coreClaims.set(key, [mount]);
216
+ else
217
+ claimants.push(mount);
218
+ }
219
+ for (const [path, claimants] of coreClaims)
220
+ if (claimants.length > 1)
221
+ for (const claimant of claimants) {
222
+ const validation = validations.find((candidate) => candidate.plugin.root === claimant.plugin.root);
223
+ validation.coreMounts = validation.coreMounts.filter((mount) => mount !== claimant);
224
+ validation.issues.push({ code: 'command_collision', plugin: claimant.plugin.name, path: path.replace('/', '.'), message: `contributed command "${claimant.node.name}" conflicts with another plugin child below the extensible core branch`, received: claimants.map((entry) => entry.plugin.name).join(', '), expected: `a unique child name below ${claimant.parent.join('.')}`, next: 'Rename the contributed command or remove this mount.' });
225
+ }
198
226
  return validations;
199
227
  }
200
228
  function contributorIssue(plugin, issue) {
201
229
  return { ...issue, contributor: { name: plugin.name, scope: plugin.scope, sourceMarketplace: plugin.sourceMarketplace } };
202
230
  }
203
- export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId) {
204
- const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId);
205
- return { contributions: validations.flatMap((validation) => validation.contributions), issues: validations.flatMap((validation) => validation.issues) };
231
+ export function discoverCommandContributions(reservedNames, startDir = process.cwd(), profileId, coreCommandPaths, extensibleCoreBranches = new Set()) {
232
+ const validations = validateEffectiveCommandPlugins(reservedNames, startDir, profileId, coreCommandPaths, extensibleCoreBranches);
233
+ return { contributions: validations.flatMap((validation) => validation.contributions), coreMounts: validations.flatMap((validation) => validation.coreMounts), issues: validations.flatMap((validation) => validation.issues) };
206
234
  }
207
- export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId) {
208
- const validations = discoverPluginCommandCandidates(startDir, profileId);
235
+ export function buildExternalCommandSnapshot(reservedCoreNames, startDir = process.cwd(), profileId, coreCommandPaths, extensibleCoreBranches = new Set()) {
236
+ const validations = validateEffectiveCommandPlugins(reservedCoreNames, startDir, profileId, coreCommandPaths, extensibleCoreBranches);
209
237
  const candidates = adaptPluginContributions(validations.flatMap((validation) => validation.contributions)).contributions;
210
238
  const resolved = resolveCommandRegistry(candidates, reservedCoreNames);
211
239
  const extensions = applyCommandExtensions(resolved.contributions, startDir);
212
- return { contributions: extensions.contributions, issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues] };
240
+ return {
241
+ contributions: extensions.contributions,
242
+ coreMounts: adaptPluginCoreMounts(validations.flatMap((validation) => validation.coreMounts)),
243
+ issues: [...validations.flatMap((validation) => validation.issues.map((issue) => contributorIssue(validation.plugin, issue))), ...resolved.issues],
244
+ };
213
245
  }
@@ -18,4 +18,5 @@ export interface HelpAddendum {
18
18
  export declare function collectHelpAddenda(commandPath: string, gate: {
19
19
  reservedCoreNames: ReadonlySet<string>;
20
20
  coreCommandPaths: () => Promise<ReadonlySet<string>>;
21
+ extensibleCoreBranches: () => Promise<ReadonlySet<string>>;
21
22
  }, startDir?: string, profileId?: string | null): Promise<HelpAddendum[]>;
@@ -28,13 +28,14 @@ import { effectiveCommandPlugins, validatePluginCommands } from './discovery.js'
28
28
  export async function collectHelpAddenda(commandPath, gate, startDir, profileId) {
29
29
  if (commandPath.length === 0)
30
30
  return [];
31
- const claimants = effectiveCommandPlugins(startDir, profileId).filter((plugin) => validatePluginCommands(plugin, gate.reservedCoreNames).manifest?.helpAddenda?.[commandPath] !== undefined);
31
+ const extensibleCoreBranches = await gate.extensibleCoreBranches();
32
+ const claimants = effectiveCommandPlugins(startDir, profileId).filter((plugin) => validatePluginCommands(plugin, gate.reservedCoreNames, undefined, extensibleCoreBranches).manifest?.helpAddenda?.[commandPath] !== undefined);
32
33
  if (claimants.length === 0)
33
34
  return [];
34
35
  const coreCommandPaths = await gate.coreCommandPaths();
35
36
  const out = [];
36
37
  for (const plugin of claimants) {
37
- const text = validatePluginCommands(plugin, gate.reservedCoreNames, coreCommandPaths).manifest?.helpAddenda?.[commandPath];
38
+ const text = validatePluginCommands(plugin, gate.reservedCoreNames, coreCommandPaths, extensibleCoreBranches).manifest?.helpAddenda?.[commandPath];
38
39
  if (text !== undefined)
39
40
  out.push({ plugin: plugin.name, text });
40
41
  }
@@ -52,6 +52,8 @@ export interface LeafDef {
52
52
  effectiveHelp?: () => Promise<EffectiveLeafHelp>;
53
53
  /** Opt this conditionally-void leaf out of core command hooks. */
54
54
  hookEligible?: false;
55
+ /** Plugin that contributed this leaf to an extensible core branch. */
56
+ plugin?: string;
55
57
  /** Opt into editor slash-command exposure (see SlashSpec). */
56
58
  slash?: SlashSpec;
57
59
  run: (input: Record<string, unknown>, context?: LeafRunContext) => Promise<Record<string, unknown> | void>;
@@ -77,6 +79,10 @@ export interface BranchDef {
77
79
  * subtrees (assembled into root -h by defineRoot); omitted on nested
78
80
  * branches, whose parent representation is the branch's own children list. */
79
81
  rootEntry?: RootEntry;
82
+ /** Allows command plugins to contribute direct children below this core branch. */
83
+ extensible?: true;
84
+ /** Plugin that contributed this branch to an extensible core branch. */
85
+ plugin?: string;
80
86
  /** Opt into editor slash-command exposure (see SlashSpec). */
81
87
  slash?: SlashSpec;
82
88
  /** Opt this branch out of the tree model entirely: every token after this
@@ -105,6 +111,7 @@ export declare function defineLeaf(opts: {
105
111
  whenToUse?: string;
106
112
  tier?: SubTier;
107
113
  help: LeafHelp;
114
+ plugin?: string;
108
115
  slash?: SlashSpec;
109
116
  /** Opt this conditionally-void leaf out of core command hooks. */
110
117
  hookEligible?: false;
@@ -118,6 +125,8 @@ export declare function defineBranch(opts: {
118
125
  tier?: SubTier;
119
126
  help: BranchHelp;
120
127
  rootEntry?: RootEntry;
128
+ extensible?: true;
129
+ plugin?: string;
121
130
  slash?: SlashSpec;
122
131
  passthrough?: {
123
132
  bin: string;
@@ -34,6 +34,7 @@ export function defineLeaf(opts) {
34
34
  help: freezeCommandMetadata(opts.help),
35
35
  slash: opts.slash === undefined ? undefined : freezeCommandMetadata(opts.slash),
36
36
  hookEligible: opts.hookEligible,
37
+ plugin: opts.plugin,
37
38
  run: opts.run,
38
39
  render: opts.render,
39
40
  });
@@ -65,6 +66,7 @@ export function defineBranch(opts) {
65
66
  description: c.description ?? '',
66
67
  whenToUse: c.whenToUse ?? '',
67
68
  tier: c.tier ?? 'normal',
69
+ ...(c.plugin !== undefined ? { plugin: c.plugin } : {}),
68
70
  ...(subCount > 0 ? { subCount } : {}),
69
71
  };
70
72
  });
@@ -76,6 +78,8 @@ export function defineBranch(opts) {
76
78
  tier: opts.tier,
77
79
  help: opts.help,
78
80
  rootEntry: opts.rootEntry,
81
+ extensible: opts.extensible,
82
+ plugin: opts.plugin,
79
83
  slash: opts.slash,
80
84
  passthrough: opts.passthrough,
81
85
  children: opts.children,
@@ -206,13 +210,14 @@ async function renderNodeWithAddenda(node, path, focusedFlag) {
206
210
  const { hasEffectiveCommandPlugins } = await import('./command-plugins/presence.js');
207
211
  if (!hasEffectiveCommandPlugins())
208
212
  return body;
209
- const [{ collectHelpAddenda }, { SUBTREE_NAMES, coreCommandPaths }] = await Promise.all([
213
+ const [{ collectHelpAddenda }, { SUBTREE_NAMES, coreCommandPaths, extensibleCoreBranches }] = await Promise.all([
210
214
  import('./command-plugins/help-addenda.js'),
211
215
  import('../build-root.js'),
212
216
  ]);
213
217
  const addenda = await collectHelpAddenda(path.join(' '), {
214
218
  reservedCoreNames: new Set(SUBTREE_NAMES),
215
219
  coreCommandPaths,
220
+ extensibleCoreBranches,
216
221
  });
217
222
  return body + addenda.map((a) => `\n\n<plugin-help plugin="${a.plugin}">\n${a.text}\n</plugin-help>`).join('');
218
223
  }
@@ -121,6 +121,8 @@ export type SubTier = 'hidden' | 'normal' | 'common' | 'important';
121
121
  * representation one level up). */
122
122
  export interface ListingChild {
123
123
  name: string;
124
+ /** Plugin that contributed this child to an extensible core branch. */
125
+ plugin?: string;
124
126
  /** Short description for this child's <subcommand> row. */
125
127
  description: string;
126
128
  /** Selection rubric — plainly states when to reach for this command. Expansive
package/dist/core/help.js CHANGED
@@ -152,6 +152,7 @@ export function renderBranch(h) {
152
152
  for (const c of h.listing ?? []) {
153
153
  if (c.tier === 'hidden')
154
154
  continue;
155
+ const plugin = c.plugin === undefined ? '' : ` plugin="${attr(c.plugin)}"`;
155
156
  const subs = c.subCount !== undefined && c.subCount > 0 ? ` subcommands="${c.subCount}"` : '';
156
157
  // whenToUse plainly states when to reach for this child, rendered verbatim —
157
158
  // expansive with examples for judgment-heavy commands, concise for
@@ -159,7 +160,7 @@ export function renderBranch(h) {
159
160
  // capability-discovery rule in the root footer already teaches that.
160
161
  if (lines.length > 1)
161
162
  lines.push('');
162
- lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${subs}/>`);
163
+ lines.push(`<subcommand name="${c.name}" description="${attr(c.description)}" whenToUse="${attr(c.whenToUse)}"${plugin}${subs}/>`);
163
164
  }
164
165
  if (h.extensionIssue !== undefined) {
165
166
  lines.push(`<extension-issue fragment="${attr(h.extensionIssue.fragment)}">This repository's contributed commands were rejected: ${h.extensionIssue.message}. Run \`crtr sys doctor\` for the full report.</extension-issue>`);
@@ -1,3 +1,5 @@
1
1
  import type { PageAnswer } from './answer.js';
2
- /** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent. */
3
- export declare function renderAnswerText(answer: PageAnswer, responsePath: string): string;
2
+ /** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent.
3
+ * The person sees this same prose in their own chat surface, so it carries the answer
4
+ * itself and nothing addressed only to the agent. */
5
+ export declare function renderAnswerText(answer: PageAnswer): string;
@@ -80,19 +80,21 @@ function slotLines(slot, totalSlots) {
80
80
  lines.push(...commentLines(comment));
81
81
  return lines;
82
82
  }
83
- /** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent. */
84
- export function renderAnswerText(answer, responsePath) {
83
+ /** Render a PageAnswer as the deterministic Markdown report delivered to its asking agent.
84
+ * The person sees this same prose in their own chat surface, so it carries the answer
85
+ * itself and nothing addressed only to the agent. */
86
+ export function renderAnswerText(answer) {
85
87
  if (answer.kind === 'acknowledgement') {
86
88
  return [
87
89
  `The user saw **${answer.title}** and dismissed it. The page asked for nothing, so the acknowledgement is the whole answer.`,
88
90
  '',
89
- `Acknowledged ${answer.completedAt}. Response record: ${responsePath}`,
91
+ `Acknowledged ${answer.completedAt}.`,
90
92
  ].join('\n');
91
93
  }
92
94
  const answered = answer.slots.filter((slot) => slot.state === 'answered');
93
95
  const lines = [`The user answered **${answer.title}**.`];
94
96
  for (const slot of answered)
95
97
  lines.push('', ...slotLines(slot, answer.slots.length));
96
- lines.push('', `Answered ${answer.completedAt}. Typed responses: ${responsePath}`);
98
+ lines.push('', `Answered ${answer.completedAt}.`);
97
99
  return lines.join('\n');
98
100
  }
@@ -2,6 +2,11 @@ import { getRequest, getRequestByInboxTicket } from '../../core/human/requests.j
2
2
  import { buildPageSummary } from '../../core/human/summary.js';
3
3
  import { validateFinalPageResponses } from '../../core/human/tickets.js';
4
4
  import { composeHumanCompletion } from '../../core/human/completion.js';
5
+ import { describePageAnswer } from '../../core/human/answer.js';
6
+ import { renderAnswerText } from '../../core/human/answer-text.js';
7
+ import { readPageFeedback } from '../../core/human/feedback.js';
8
+ import { pageDocumentSha256 } from '../../core/human/feedback-companion.js';
9
+ import { pagePath } from '../../core/human/convention.js';
5
10
  import { retireCompanion } from '../companion-retire.js';
6
11
  import { settleHumanRequest } from './settle.js';
7
12
  function requestForTicket(ticketId) {
@@ -38,10 +43,73 @@ function completionAction(request, result) {
38
43
  throw new Error(`corrupt frozen completion action: ${request.request_id}`, { cause: error });
39
44
  }
40
45
  }
41
- function notification(request, disposition, bodyJson) {
46
+ function firstLine(text, maxLen = 80) {
47
+ const line = text.split('\n')[0] ?? '';
48
+ return line.length > maxLen ? `${line.slice(0, maxLen - 1)}…` : line;
49
+ }
50
+ function pageTitle(request) {
51
+ try {
52
+ return manifest(request).title;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /** The settle-time feedback addendum — answer and cancellation alike: a change
59
+ * notice when the final page differs from the version first commented on, and
60
+ * each still-open item's quote and note. Silent when the page is unchanged and
61
+ * nothing remains open; never a manufactured prose diff. */
62
+ function feedbackAddendum(request) {
63
+ let sections;
64
+ try {
65
+ const feedback = readPageFeedback(request.request_dir);
66
+ if (feedback.companionNodeId === undefined)
67
+ return '';
68
+ sections = [];
69
+ if (feedback.firstCommentedSha256 !== undefined && pageDocumentSha256(request.request_dir) !== feedback.firstCommentedSha256) {
70
+ sections.push(`The page changed during feedback: the person commented on it and its companion conversation (node ${feedback.companionNodeId}) revised it in place. ` +
71
+ `Reread the final page at ${pagePath(request.request_dir)} before interpreting this outcome.`);
72
+ }
73
+ const open = feedback.comments.filter((comment) => comment.status === 'open');
74
+ if (open.length > 0) {
75
+ const items = open.map((comment) => [
76
+ ...comment.quote.split('\n').map((line) => `> ${line}`),
77
+ comment.note,
78
+ ].join('\n'));
79
+ sections.push([
80
+ `Feedback left unresolved at settlement — the companion conversation (node ${feedback.companionNodeId}) had not dealt with these items:`,
81
+ ...items,
82
+ ].join('\n\n'));
83
+ }
84
+ }
85
+ catch {
86
+ // The outcome itself is the deliverable; a broken feedback record adds nothing.
87
+ return '';
88
+ }
89
+ return sections.length === 0 ? '' : `\n\n${sections.join('\n\n')}`;
90
+ }
91
+ /** The person's answer as prose the asking agent reads directly. Short bodies
92
+ * ride inline; the mail layer spills a long one to a file and leaves a preview
93
+ * plus its path. */
94
+ function answerBody(request, result) {
95
+ const answer = describePageAnswer(manifest(request), result.responses, { completedAt: result.completedAt });
96
+ return renderAnswerText(answer) + feedbackAddendum(request);
97
+ }
98
+ /** A dismissal is a rejection, not a routing event: say so plainly so the agent
99
+ * acts on the "no" instead of re-raising the same ask. */
100
+ function cancelBody(request, dismissed, reason) {
101
+ const title = pageTitle(request);
102
+ const named = title === undefined ? `the human interaction ${request.request_id}` : `“${title}”`;
103
+ const why = reason !== undefined && reason !== '' ? reason : 'no reason was given';
104
+ const body = dismissed
105
+ ? `The user dismissed ${named} without answering — a deliberate “no, I'm not answering this.” Treat it as a rejection: no response will arrive, no partial answer exists, and the same ask must not be raised again.`
106
+ : `${title === undefined ? `The human interaction ${request.request_id}` : `The page “${title}”`} was canceled — ${why}. No response will arrive, and any partial answers the user had entered were discarded unsent.`;
107
+ return body + feedbackAddendum(request);
108
+ }
109
+ function notification(request, disposition, body, result) {
42
110
  if (request.requester_node_id === null)
43
111
  return undefined;
44
- return { label: disposition === 'human-answer' ? 'human request answered' : 'human request canceled', disposition, body_json: bodyJson };
112
+ return { label: firstLine(body), disposition, body_json: { body, result } };
45
113
  }
46
114
  function retire(request) {
47
115
  return request.companion_node_id === null ? undefined : { retire: async (nodeId, outcome) => { await retireCompanion(nodeId, outcome); } };
@@ -60,7 +128,9 @@ export async function resolvePageTicket(ticketId, responses, opts = {}) {
60
128
  request_id: request.request_id,
61
129
  outcome: 'answered',
62
130
  result_json: JSON.stringify(result),
63
- ...(notification(request, 'human-answer', result) === undefined ? {} : { notification: notification(request, 'human-answer', result) }),
131
+ ...(notification(request, 'human-answer', answerBody(request, result), result) === undefined
132
+ ? {}
133
+ : { notification: notification(request, 'human-answer', answerBody(request, result), result) }),
64
134
  ...(completionAction(request, result) === undefined ? {} : { completion_action: completionAction(request, result) }),
65
135
  ...(retire(request) === undefined ? {} : { post_commit: retire(request) }),
66
136
  });
@@ -84,7 +154,9 @@ export async function cancelHumanTicket(ticketId, opts) {
84
154
  request_id: request.request_id,
85
155
  outcome: opts.disposition === 'dismissed' ? 'dismissed' : 'canceled',
86
156
  result_json: JSON.stringify(result),
87
- ...(notification(request, 'human-canceled', result) === undefined ? {} : { notification: notification(request, 'human-canceled', result) }),
157
+ ...(notification(request, 'human-canceled', cancelBody(request, opts.disposition === 'dismissed', opts.reason), result) === undefined
158
+ ? {}
159
+ : { notification: notification(request, 'human-canceled', cancelBody(request, opts.disposition === 'dismissed', opts.reason), result) }),
88
160
  ...(completionAction(request, result) === undefined ? {} : { completion_action: completionAction(request, result) }),
89
161
  ...(retire(request) === undefined ? {} : { post_commit: retire(request) }),
90
162
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.314",
3
+ "version": "0.3.316",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.314",
3
+ "version": "0.3.316",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.314",
9
+ "version": "0.3.316",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.314",
5209
+ "version": "0.3.316",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.314",
5214
+ "version": "0.3.316",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.314",
5219
+ "version": "0.3.316",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"