@north-light/crouter 0.3.219 → 0.3.221

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.
Files changed (155) hide show
  1. package/dist/api/client.d.ts +9 -0
  2. package/dist/api/client.js +10 -0
  3. package/dist/api/dto/chat-inventory.d.ts +43 -0
  4. package/dist/api/dto/chat-inventory.js +11 -0
  5. package/dist/api/dto/profiles.d.ts +19 -5
  6. package/dist/api/dto/profiles.js +2 -1
  7. package/dist/api/index.d.ts +1 -0
  8. package/dist/api/index.js +1 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/build-root.d.ts +2 -6
  12. package/dist/build-root.js +51 -4
  13. package/dist/builtin-memory/internal/agent-shaping.md +3 -1
  14. package/dist/builtin-memory/internal/memory-loading.md +4 -0
  15. package/dist/builtin-memory/plan/roadmap.md +7 -1
  16. package/dist/builtin-memory/spec/guide.md +7 -1
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +1 -1
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -1
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +7 -2
  20. package/dist/clients/attach/__tests__/ref-autocomplete.test.js +1 -1
  21. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +1 -1
  22. package/dist/clients/attach/overlays/file-review.js +2 -2
  23. package/dist/clients/attach/session/keys.d.ts +1 -1
  24. package/dist/clients/attach/session/profile-files.js +1 -1
  25. package/dist/clients/attach/viewer.js +690 -690
  26. package/dist/clients/inbox/review/launch.d.ts +8 -4
  27. package/dist/clients/inbox/review/launch.js +55 -5
  28. package/dist/clients/inbox/review/review-client.d.ts +1 -0
  29. package/dist/clients/inbox/review/review-client.js +4 -0
  30. package/dist/clients/inbox/review-adapter.d.ts +1 -8
  31. package/dist/clients/inbox/review-adapter.js +4 -52
  32. package/dist/commands/memory/lint.js +2 -1
  33. package/dist/commands/memory/read.js +1 -0
  34. package/dist/commands/memory.js +1 -1
  35. package/dist/commands/pkg/market-manage.js +165 -75
  36. package/dist/commands/pkg/plugin-inspect.js +19 -2
  37. package/dist/commands/pkg/plugin-manage.d.ts +8 -3
  38. package/dist/commands/pkg/plugin-manage.js +72 -24
  39. package/dist/commands/profile/default.js +6 -10
  40. package/dist/commands/profile/list.js +5 -3
  41. package/dist/commands/profile/new.js +21 -8
  42. package/dist/commands/profile/project.js +25 -19
  43. package/dist/commands/profile/show.js +3 -3
  44. package/dist/commands/surface-inbox.js +1 -0
  45. package/dist/commands/sys/__tests__/migrate.test.js +16 -5
  46. package/dist/commands/sys/doctor.js +35 -5
  47. package/dist/commands/sys/migrate.js +38 -19
  48. package/dist/commands/sys/panels/broker-limits-panel.js +3 -3
  49. package/dist/commands/sys/setup-core.js +1 -1
  50. package/dist/commands/sys/sync-project-guidance.js +1 -1
  51. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +1 -1
  52. package/dist/core/__tests__/fixtures/c5-command-boundary-ext.js +24 -0
  53. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -18
  54. package/dist/core/__tests__/fixtures/fake-engine.js +8 -1
  55. package/dist/core/__tests__/helpers/broker-clients.d.ts +1 -0
  56. package/dist/core/__tests__/helpers/broker-clients.js +1 -0
  57. package/dist/core/__tests__/inline-memory-refs.test.js +36 -2
  58. package/dist/core/__tests__/profile-project-memory-delivery.test.js +217 -0
  59. package/dist/core/__tests__/seam/dormancy-release.test.js +37 -1
  60. package/dist/core/__tests__/serial/broker-sdk-wiring.test.js +102 -2
  61. package/dist/core/bootstrap.js +6 -0
  62. package/dist/core/canvas/browse/app.js +5 -2
  63. package/dist/core/canvas/browse/model.d.ts +25 -15
  64. package/dist/core/canvas/browse/model.js +86 -65
  65. package/dist/core/canvas/render-source.d.ts +6 -0
  66. package/dist/core/canvas/render-source.js +7 -1
  67. package/dist/core/canvas/render.js +10 -2
  68. package/dist/core/command-hooks/artifact.d.ts +10 -0
  69. package/dist/core/command-hooks/artifact.js +129 -0
  70. package/dist/core/command-hooks/catalog.d.ts +14 -0
  71. package/dist/core/command-hooks/catalog.js +38 -0
  72. package/dist/core/command-hooks/compose.d.ts +15 -0
  73. package/dist/core/command-hooks/compose.js +99 -0
  74. package/dist/core/command-hooks/discovery.d.ts +87 -0
  75. package/dist/core/command-hooks/discovery.js +174 -0
  76. package/dist/core/command-hooks/help.d.ts +5 -0
  77. package/dist/core/command-hooks/help.js +18 -0
  78. package/dist/core/command-hooks/index.d.ts +6 -0
  79. package/dist/core/command-hooks/index.js +6 -0
  80. package/dist/core/command-hooks/report.d.ts +23 -0
  81. package/dist/core/command-hooks/report.js +19 -0
  82. package/dist/core/command-hooks/schema.d.ts +27 -0
  83. package/dist/core/command-hooks/schema.js +68 -0
  84. package/dist/core/command-hooks/transport/exec-invoke.d.ts +22 -0
  85. package/dist/core/command-hooks/transport/exec-invoke.js +274 -0
  86. package/dist/core/command-plugins/presence.d.ts +2 -0
  87. package/dist/core/command-plugins/presence.js +17 -0
  88. package/dist/core/command-plugins/transport/exec-invoke.d.ts +5 -0
  89. package/dist/core/command-plugins/transport/exec-invoke.js +58 -5
  90. package/dist/core/command.d.ts +8 -1
  91. package/dist/core/command.js +12 -10
  92. package/dist/core/help.d.ts +7 -1
  93. package/dist/core/io.d.ts +9 -1
  94. package/dist/core/io.js +44 -2
  95. package/dist/core/memory/inline-ref-inventory.d.ts +2 -1
  96. package/dist/core/memory/inline-ref-inventory.js +15 -8
  97. package/dist/core/memory-resolver.d.ts +13 -1
  98. package/dist/core/memory-resolver.js +25 -19
  99. package/dist/core/profiles/manifest.d.ts +13 -2
  100. package/dist/core/profiles/manifest.js +84 -18
  101. package/dist/core/profiles/select.js +9 -9
  102. package/dist/core/render.js +11 -0
  103. package/dist/core/runtime/advertised-command-invocation.d.ts +20 -0
  104. package/dist/core/runtime/advertised-command-invocation.js +233 -0
  105. package/dist/core/runtime/bearings.js +1 -1
  106. package/dist/core/runtime/broker/client-registry.d.ts +6 -3
  107. package/dist/core/runtime/broker/client-registry.js +6 -4
  108. package/dist/core/runtime/broker/event-projection.js +7 -0
  109. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  110. package/dist/core/runtime/broker/frame-dispatch.js +16 -10
  111. package/dist/core/runtime/broker/read-ops.d.ts +4 -0
  112. package/dist/core/runtime/broker/read-ops.js +6 -2
  113. package/dist/core/runtime/broker-extension-render.js +1 -1
  114. package/dist/core/runtime/broker-inventory.d.ts +5 -0
  115. package/dist/core/runtime/broker-inventory.js +191 -0
  116. package/dist/core/runtime/broker-protocol.d.ts +13 -2
  117. package/dist/core/runtime/broker.js +10 -1
  118. package/dist/core/runtime/command-surface.d.ts +33 -0
  119. package/dist/core/runtime/command-surface.js +81 -0
  120. package/dist/core/runtime/node-read.js +5 -0
  121. package/dist/core/scope.d.ts +26 -1
  122. package/dist/core/scope.js +52 -12
  123. package/dist/core/substrate/on-read.d.ts +7 -1
  124. package/dist/core/substrate/on-read.js +13 -4
  125. package/dist/core/substrate/render.js +14 -5
  126. package/dist/core/substrate/schema.d.ts +11 -1
  127. package/dist/core/substrate/schema.js +11 -2
  128. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +4 -4
  129. package/dist/daemon/api/handlers/chat-inventory.d.ts +2 -0
  130. package/dist/daemon/api/handlers/chat-inventory.js +25 -0
  131. package/dist/daemon/api/handlers/profiles.js +7 -1
  132. package/dist/daemon/api/map.d.ts +2 -1
  133. package/dist/daemon/api/map.js +3 -2
  134. package/dist/daemon/api/server.js +2 -0
  135. package/dist/hook-authoring.d.ts +75 -0
  136. package/dist/hook-authoring.js +358 -0
  137. package/dist/hook-process.d.ts +7 -0
  138. package/dist/hook-process.js +34 -0
  139. package/dist/index.d.ts +2 -0
  140. package/dist/index.js +2 -0
  141. package/dist/migrations/002-profile-project-memory.d.ts +2 -0
  142. package/dist/migrations/002-profile-project-memory.js +71 -0
  143. package/dist/migrations/profile-manifests.d.ts +30 -0
  144. package/dist/migrations/profile-manifests.js +70 -0
  145. package/dist/migrations/registry.js +10 -5
  146. package/dist/migrations/types.d.ts +28 -1
  147. package/dist/migrations/types.js +15 -9
  148. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +21 -4
  149. package/dist/pi-extensions/canvas-structured-output.js +85 -2
  150. package/dist/types.d.ts +15 -6
  151. package/dist/types.js +5 -1
  152. package/package.json +1 -1
  153. package/runtime.lock.json +2 -2
  154. package/dist/clients/attach/__tests__/file-review-focus.test.js +0 -49
  155. /package/dist/{clients/attach/__tests__/file-review-focus.test.d.ts → core/__tests__/profile-project-memory-delivery.test.d.ts} +0 -0
@@ -0,0 +1,274 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { posixProcessGroupSpawnOptions, terminateProcessGroup } from '../../../hook-process.js';
3
+ import { ExitCode } from '../../../types.js';
4
+ import { isRecord } from '../../../shared/predicates.js';
5
+ import { validateCoreDeclaredResult } from '../../command-plugins/transport/exec-invoke.js';
6
+ import { CrtrError } from '../../errors.js';
7
+ import { diag } from '../../io.js';
8
+ const MAX_STDOUT = 10 * 1024 * 1024;
9
+ export const EXEC_HOOK_TIMEOUT_MS = 15_000;
10
+ const RESERVED_CODES = new Set([
11
+ 'internal',
12
+ 'unknown_path',
13
+ 'command_collision',
14
+ 'command_hook_collision',
15
+ 'plugin_protocol_error',
16
+ 'hook_protocol_error',
17
+ ]);
18
+ const SNAKE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
19
+ const OPERATION_ID = /^[0-9a-f]{32}$/;
20
+ const NEXT = 'Run `crtr pkg plugin show` or `crtr sys doctor` to inspect the plugin.';
21
+ /** Invoke one hook executable; after failures retain the completed primary result. */
22
+ export async function invokeExecHook(spec, invocation) {
23
+ const cwd = process.cwd();
24
+ const request = buildRequest(spec, invocation, cwd);
25
+ const res = await runHookProcess(spec, invocation, JSON.stringify(request), cwd);
26
+ if (res.signal !== null) {
27
+ throw protocolError(spec, invocation, `executable was killed by signal ${res.signal}`, 'a clean exit with one JSON envelope on stdout');
28
+ }
29
+ const envelope = parseEnvelope(spec, invocation, res.stdout);
30
+ if (envelope.ok && res.status !== 0) {
31
+ diag(`crtr: ${label(spec)} returned ok:true but exited ${res.status} (honoring the envelope)`);
32
+ }
33
+ else if (!envelope.ok && res.status === 0) {
34
+ diag(`crtr: ${label(spec)} returned ok:false but exited 0 (honoring the envelope)`);
35
+ }
36
+ if (!envelope.ok)
37
+ throwHookFailure(spec, invocation, envelope);
38
+ if (spec.phase !== 'replace')
39
+ return;
40
+ const result = envelope.result;
41
+ if (!isRecord(result)) {
42
+ throw protocolError(spec, invocation, 'replacement success result was not an object', 'result: an object matching the target output declaration');
43
+ }
44
+ const output = spec.output;
45
+ if (output === undefined) {
46
+ throw protocolError(spec, invocation, 'replacement target output declaration was missing', 'the target leaf output declaration');
47
+ }
48
+ const issue = validateCoreDeclaredResult(output, result);
49
+ if (issue !== null) {
50
+ const field = output.find((candidate) => candidate.name === issue.field);
51
+ if (issue.receivedType !== undefined) {
52
+ throw protocolError(spec, invocation, `replacement output field "${issue.field}" has the wrong type`, `${issue.field}: ${field?.type ?? 'unknown'}`);
53
+ }
54
+ throw protocolError(spec, invocation, `replacement result is missing required output field "${issue.field}"`, `a "${issue.field}" field of type ${field?.type ?? 'unknown'}`);
55
+ }
56
+ return result;
57
+ }
58
+ async function runHookProcess(spec, invocation, input, cwd) {
59
+ let child;
60
+ try {
61
+ child = spawn(spec.executable, ['--crtr-hook-protocol', '1'], {
62
+ cwd,
63
+ env: process.env,
64
+ stdio: ['pipe', 'pipe', 'inherit'],
65
+ ...posixProcessGroupSpawnOptions(),
66
+ });
67
+ }
68
+ catch (error) {
69
+ throw protocolError(spec, invocation, `executable could not be run (${describeError(error)})`, 'a runnable hook executable');
70
+ }
71
+ return new Promise((resolve, reject) => {
72
+ let stdoutBytes = 0;
73
+ const stdout = [];
74
+ let failure;
75
+ let closed = false;
76
+ let cleaned = false;
77
+ let status = null;
78
+ let signal = null;
79
+ let timeout;
80
+ const finish = () => {
81
+ if (!closed || (failure !== undefined && !cleaned))
82
+ return;
83
+ if (timeout !== undefined)
84
+ clearTimeout(timeout);
85
+ if (failure?.kind === 'timeout') {
86
+ reject(protocolError(spec, invocation, `executable timed out after ${EXEC_HOOK_TIMEOUT_MS}ms`, 'one JSON envelope before the execution timeout'));
87
+ return;
88
+ }
89
+ if (failure?.kind === 'output_limit') {
90
+ reject(protocolError(spec, invocation, 'stdout exceeded the 10 MiB cap', 'one JSON envelope under 10 MiB on stdout'));
91
+ return;
92
+ }
93
+ if (failure?.kind === 'spawn') {
94
+ reject(protocolError(spec, invocation, `executable could not be run (${describeError(failure.error)})`, 'a runnable hook executable'));
95
+ return;
96
+ }
97
+ if (failure?.kind === 'cleanup') {
98
+ reject(protocolError(spec, invocation, `executable cleanup failed (${describeError(failure.error)})`, 'a hook executable whose process group can be terminated'));
99
+ return;
100
+ }
101
+ resolve({ stdout: Buffer.concat(stdout).toString('utf8'), status, signal });
102
+ };
103
+ const terminate = (reason) => {
104
+ if (failure !== undefined)
105
+ return;
106
+ failure = reason;
107
+ if (timeout !== undefined)
108
+ clearTimeout(timeout);
109
+ void terminateProcessGroup(child).then(() => {
110
+ cleaned = true;
111
+ finish();
112
+ }, (error) => {
113
+ failure = { kind: 'cleanup', error };
114
+ cleaned = true;
115
+ finish();
116
+ });
117
+ };
118
+ child.stdout.on('data', (chunk) => {
119
+ stdoutBytes += chunk.byteLength;
120
+ if (stdoutBytes > MAX_STDOUT) {
121
+ terminate({ kind: 'output_limit' });
122
+ return;
123
+ }
124
+ stdout.push(chunk);
125
+ });
126
+ child.once('error', (error) => {
127
+ if (child.pid === undefined) {
128
+ failure = { kind: 'spawn', error };
129
+ closed = true;
130
+ cleaned = true;
131
+ finish();
132
+ return;
133
+ }
134
+ terminate({ kind: 'spawn', error });
135
+ });
136
+ child.once('close', (nextStatus, nextSignal) => {
137
+ status = nextStatus;
138
+ signal = nextSignal;
139
+ closed = true;
140
+ finish();
141
+ });
142
+ timeout = setTimeout(() => terminate({ kind: 'timeout' }), EXEC_HOOK_TIMEOUT_MS);
143
+ child.stdin.end(input);
144
+ });
145
+ }
146
+ function buildRequest(spec, invocation, cwd) {
147
+ if (!OPERATION_ID.test(invocation.operationId)) {
148
+ throw protocolError(spec, invocation, 'operationId was not a 32-character lowercase hexadecimal string', 'a valid crouter operation identity');
149
+ }
150
+ if (!isJson(invocation.input)) {
151
+ throw protocolError(spec, invocation, 'input was not JSON-compatible', 'a JSON object');
152
+ }
153
+ if (!invocation.providedParams.every((param) => typeof param === 'string')) {
154
+ throw protocolError(spec, invocation, 'providedParams contained a non-string value', 'an array of parameter names');
155
+ }
156
+ const base = {
157
+ protocolVersion: 1,
158
+ op: spec.op,
159
+ command: [...spec.commandPath],
160
+ operationId: invocation.operationId,
161
+ input: invocation.input,
162
+ context: { cwd, providedParams: [...invocation.providedParams] },
163
+ };
164
+ if (spec.phase === 'after') {
165
+ if (invocation.result === undefined || !isJson(invocation.result)) {
166
+ throw protocolError(spec, invocation, 'after hook result was missing or not JSON-compatible', 'the completed primary result');
167
+ }
168
+ return { ...base, phase: 'after', result: invocation.result };
169
+ }
170
+ if (invocation.result !== undefined) {
171
+ throw protocolError(spec, invocation, `${spec.phase} hook invocation included a result`, 'result only for after hooks');
172
+ }
173
+ return { ...base, phase: spec.phase };
174
+ }
175
+ function parseEnvelope(spec, invocation, stdout) {
176
+ let value;
177
+ try {
178
+ value = JSON.parse(stdout.trim());
179
+ }
180
+ catch {
181
+ throw protocolError(spec, invocation, 'stdout was not a single JSON envelope', 'exactly one JSON envelope on stdout');
182
+ }
183
+ if (!isRecord(value)) {
184
+ throw protocolError(spec, invocation, 'stdout was not a JSON object', 'a { protocolVersion: 1, ok: boolean, ... } envelope');
185
+ }
186
+ if (value['protocolVersion'] !== 1) {
187
+ throw protocolError(spec, invocation, `unsupported protocolVersion ${String(value['protocolVersion'])}`, 'protocolVersion exactly 1');
188
+ }
189
+ if (value['ok'] === true) {
190
+ const expectedKeys = spec.phase === 'replace' ? ['protocolVersion', 'ok', 'result'] : ['protocolVersion', 'ok'];
191
+ if (!hasExactKeys(value, expectedKeys)) {
192
+ throw protocolError(spec, invocation, 'success envelope had missing or extra fields', `{ ${expectedKeys.join(', ')} } only`);
193
+ }
194
+ if (spec.phase === 'replace') {
195
+ if (!isJson(value['result'])) {
196
+ throw protocolError(spec, invocation, 'replacement result was not JSON-compatible', 'a JSON result');
197
+ }
198
+ return { protocolVersion: 1, ok: true, result: value['result'] };
199
+ }
200
+ return { protocolVersion: 1, ok: true };
201
+ }
202
+ if (value['ok'] !== false || !hasExactKeys(value, ['protocolVersion', 'ok', 'error'])) {
203
+ throw protocolError(spec, invocation, 'failure envelope had missing or extra fields', '{ protocolVersion, ok, error } only');
204
+ }
205
+ const error = value['error'];
206
+ if (!isRecord(error) || !hasExactKeys(error, ['code', 'message'], ['field', 'next'])) {
207
+ throw protocolError(spec, invocation, 'error envelope was missing fields or contained extra fields', 'error: { code, message, field?, next? }');
208
+ }
209
+ if (typeof error['code'] !== 'string' || error['code'] === '' || typeof error['message'] !== 'string' || error['message'] === '') {
210
+ throw protocolError(spec, invocation, 'error envelope had an empty or non-string code/message', 'non-empty error code and message strings');
211
+ }
212
+ if ((error['field'] !== undefined && typeof error['field'] !== 'string') || (error['next'] !== undefined && typeof error['next'] !== 'string')) {
213
+ throw protocolError(spec, invocation, 'error envelope had a non-string field/next', 'string field and next values when present');
214
+ }
215
+ return {
216
+ protocolVersion: 1,
217
+ ok: false,
218
+ error: {
219
+ code: error['code'],
220
+ message: error['message'],
221
+ ...(error['field'] === undefined ? {} : { field: error['field'] }),
222
+ ...(error['next'] === undefined ? {} : { next: error['next'] }),
223
+ },
224
+ };
225
+ }
226
+ function throwHookFailure(spec, invocation, failure) {
227
+ const { code, message, field, next } = failure.error;
228
+ if (!SNAKE.test(code) || RESERVED_CODES.has(code)) {
229
+ throw protocolError(spec, invocation, `error code "${code}" was invalid or reserved`, 'a non-reserved lowercase snake_case code');
230
+ }
231
+ throw new CrtrError(code, `${label(spec)}: ${message}`, ExitCode.GENERAL, failureDetails(spec, invocation, field, next));
232
+ }
233
+ function protocolError(spec, invocation, received, expected) {
234
+ return new CrtrError('hook_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, failureDetails(spec, invocation, undefined, NEXT, received));
235
+ }
236
+ function failureDetails(spec, invocation, field, next, received) {
237
+ const identity = {
238
+ plugin: spec.plugin,
239
+ op: spec.op,
240
+ phase: spec.phase,
241
+ command: [...spec.commandPath],
242
+ ...(received === undefined ? {} : { received }),
243
+ ...(field === undefined ? {} : { field }),
244
+ };
245
+ if (spec.phase !== 'after')
246
+ return { ...identity, next: next ?? NEXT };
247
+ const guidance = 'Do not retry the original command; its primary action completed. Inspect and reconcile the failed after hook.';
248
+ return {
249
+ ...identity,
250
+ primaryCompleted: true,
251
+ result: invocation.result,
252
+ next: next === undefined || next === NEXT ? guidance : `${next} ${guidance}`,
253
+ };
254
+ }
255
+ function label(spec) {
256
+ return `plugin "${spec.plugin}" ${spec.phase} hook "${spec.op}" for \`${spec.commandPath.join(' ')}\``;
257
+ }
258
+ function hasExactKeys(value, required, optional = []) {
259
+ const keys = Object.keys(value);
260
+ return required.every((key) => Object.hasOwn(value, key))
261
+ && keys.every((key) => required.includes(key) || optional.includes(key));
262
+ }
263
+ function isJson(value) {
264
+ if (value === null || typeof value === 'boolean' || typeof value === 'string')
265
+ return true;
266
+ if (typeof value === 'number')
267
+ return Number.isFinite(value);
268
+ if (Array.isArray(value))
269
+ return value.every(isJson);
270
+ return isRecord(value) && Object.values(value).every(isJson);
271
+ }
272
+ function describeError(error) {
273
+ return error instanceof Error ? error.message : String(error);
274
+ }
@@ -0,0 +1,2 @@
1
+ /** Whether an enabled command plugin survives nearest-scope name shadowing. */
2
+ export declare function hasEffectiveCommandPlugins(startDir?: string, profileId?: string | null): boolean;
@@ -0,0 +1,17 @@
1
+ import { listInstalledPlugins, listInstalledPluginsInRoot } from '../resolver.js';
2
+ import { projectScopeRoots } from '../scope.js';
3
+ /** Whether an enabled command plugin survives nearest-scope name shadowing. */
4
+ export function hasEffectiveCommandPlugins(startDir = process.cwd(), profileId) {
5
+ const seen = new Set();
6
+ const consider = (plugin) => {
7
+ if (seen.has(plugin.name))
8
+ return false;
9
+ seen.add(plugin.name);
10
+ return plugin.enabled && (plugin.manifest.commands !== undefined || plugin.manifest.transport !== undefined);
11
+ };
12
+ for (const root of projectScopeRoots(startDir, profileId)) {
13
+ if (listInstalledPluginsInRoot('project', root).some(consider))
14
+ return true;
15
+ }
16
+ return listInstalledPlugins('user').some(consider);
17
+ }
@@ -16,6 +16,11 @@ export declare function validateDeclaredResult(output: Field[], result: Record<s
16
16
  field: string;
17
17
  receivedType?: string;
18
18
  } | null;
19
+ /** Core leaf declarations may explicitly require a nullable field. */
20
+ export declare function validateCoreDeclaredResult(output: Field[], result: Record<string, unknown>): {
21
+ field: string;
22
+ receivedType?: string;
23
+ } | null;
19
24
  /** Execute one external leaf and return its result object (crtr renders it via
20
25
  * the generic renderer; `--json` mirrors it). Throws a CrtrError for any
21
26
  * protocol violation or the plugin's own recoverable error. */
@@ -21,13 +21,23 @@ const NEXT = 'Run `crtr pkg plugin show` or `crtr sys doctor` to inspect the plu
21
21
  * Returns null if valid; otherwise returns a { field, receivedType } error.
22
22
  * Transport-neutral: used by exec and HTTP transport adapters. */
23
23
  export function validateDeclaredResult(output, result) {
24
+ return validateFields(output, result, false);
25
+ }
26
+ /** Core leaf declarations may explicitly require a nullable field. */
27
+ export function validateCoreDeclaredResult(output, result) {
28
+ return validateFields(output, result, true);
29
+ }
30
+ function validateFields(output, result, honorDeclaredNull) {
24
31
  for (const f of output) {
25
- const present = Object.prototype.hasOwnProperty.call(result, f.name) && result[f.name] !== undefined && result[f.name] !== null;
26
- if (f.required && !present) {
32
+ const value = result[f.name];
33
+ const present = Object.prototype.hasOwnProperty.call(result, f.name)
34
+ && value !== undefined
35
+ && (honorDeclaredNull || value !== null);
36
+ if (f.required && !present)
27
37
  return { field: f.name };
28
- }
29
- if (present && !typeMatches(f.type, result[f.name])) {
30
- return { field: f.name, receivedType: typeof result[f.name] };
38
+ const matches = honorDeclaredNull ? coreTypeMatches(f.type, value) : typeMatches(f.type, value);
39
+ if (present && !matches) {
40
+ return { field: f.name, receivedType: value === null ? 'null' : typeof value };
31
41
  }
32
42
  }
33
43
  return null;
@@ -150,6 +160,49 @@ function typeMatches(type, v) {
150
160
  return isRecord(v);
151
161
  return true;
152
162
  }
163
+ function coreTypeMatches(type, v) {
164
+ const alternatives = type.split('|').map((part) => part.trim()).filter(Boolean);
165
+ if (alternatives.length === 1 && !knownType(alternatives[0]))
166
+ return true;
167
+ const allKnown = alternatives.every((part) => knownType(part) || /^[a-z][a-z0-9_-]*$/.test(part));
168
+ if (!allKnown)
169
+ return true;
170
+ return alternatives.some((part) => atomMatches(part, v));
171
+ }
172
+ function knownType(type) {
173
+ const t = type.toLowerCase();
174
+ return t === 'null'
175
+ || t === 'string'
176
+ || t === 'path'
177
+ || t === 'markdown'
178
+ || t === 'int'
179
+ || t === 'integer'
180
+ || t === 'number'
181
+ || t === 'float'
182
+ || t === 'bool'
183
+ || t === 'boolean'
184
+ || t === 'array'
185
+ || t.endsWith('[]')
186
+ || t === 'object';
187
+ }
188
+ function atomMatches(type, v) {
189
+ const t = type.toLowerCase();
190
+ if (t === 'null')
191
+ return v === null;
192
+ if (t === 'string' || t === 'path' || t === 'markdown')
193
+ return typeof v === 'string';
194
+ if (t === 'int' || t === 'integer')
195
+ return typeof v === 'number' && Number.isInteger(v);
196
+ if (t === 'number' || t === 'float')
197
+ return typeof v === 'number';
198
+ if (t === 'bool' || t === 'boolean')
199
+ return typeof v === 'boolean';
200
+ if (t === 'array' || t.endsWith('[]'))
201
+ return Array.isArray(v);
202
+ if (t === 'object')
203
+ return isRecord(v);
204
+ return typeof v === 'string' && v === type;
205
+ }
153
206
  function protocolError(spec, received, expected) {
154
207
  return new CrtrError('plugin_protocol_error', `${label(spec)}: ${received}. Expected ${expected}.`, ExitCode.GENERAL, { received, next: NEXT });
155
208
  }
@@ -1,4 +1,4 @@
1
- import type { RootHelp, RootEntry, BranchHelp, LeafHelp, InputParam, SubTier } from './help.js';
1
+ import type { RootHelp, RootEntry, BranchHelp, LeafHelp, EffectiveLeafHelp, InputParam, SubTier } from './help.js';
2
2
  import { CrtrError } from './errors.js';
3
3
  /** Runtime context passed to a leaf's run function. Includes metadata about
4
4
  * which parameters were explicitly provided vs. defaulted. Used by configured
@@ -47,6 +47,11 @@ export interface LeafDef {
47
47
  * 'hidden' keeps an internal leaf out of every listing. */
48
48
  tier?: SubTier;
49
49
  help: LeafHelp;
50
+ /** Invocation-local immutable help view. Present only on hook-eligible core
51
+ * leaves; static discovery may change Effects but never executes hooks. */
52
+ effectiveHelp?: () => Promise<EffectiveLeafHelp>;
53
+ /** Opt this conditionally-void leaf out of core command hooks. */
54
+ hookEligible?: false;
50
55
  /** Opt into editor slash-command exposure (see SlashSpec). */
51
56
  slash?: SlashSpec;
52
57
  run: (input: Record<string, unknown>, context?: LeafRunContext) => Promise<Record<string, unknown> | void>;
@@ -100,6 +105,8 @@ export declare function defineLeaf(opts: {
100
105
  tier?: SubTier;
101
106
  help: LeafHelp;
102
107
  slash?: SlashSpec;
108
+ /** Opt this conditionally-void leaf out of core command hooks. */
109
+ hookEligible?: false;
103
110
  run: (input: Record<string, unknown>, context?: LeafRunContext) => Promise<Record<string, unknown> | void>;
104
111
  render?: (result: Record<string, unknown>) => string;
105
112
  }): LeafDef;
@@ -34,6 +34,7 @@ export function defineLeaf(opts) {
34
34
  tier: opts.tier,
35
35
  help: freezeCommandMetadata(opts.help),
36
36
  slash: opts.slash === undefined ? undefined : freezeCommandMetadata(opts.slash),
37
+ hookEligible: opts.hookEligible,
37
38
  run: opts.run,
38
39
  render: opts.render,
39
40
  });
@@ -187,19 +188,20 @@ function renderNode(node) {
187
188
  return renderBranch(node.help);
188
189
  return renderLeafArgv(node.help);
189
190
  }
190
- /** Render a node's help plus any plugin help addenda targeting its walked
191
- * command path — append-only, attributed blocks a plugin adds beneath core
192
- * contract text, never inside it. Plugin discovery loads only here (dynamic
193
- * import on the help path), so the dispatch path's module graph and cold
194
- * start are unchanged; the lookup reads stored manifest bytes only. The
195
- * lookup receives the install gate's exact strict-validation inputs (reserved
196
- * core names + the full core command path set, both from build-root), so a
197
- * manifest rejected at ingress contributes nothing here either — loading
198
- * every subtree for that path set is a help-path-only cost. */
191
+ /** Render effective Effects for a hook-eligible core leaf, then append any
192
+ * commands.json helpAddenda beneath the contract. Both lookups read stored
193
+ * manifest bytes only. Addenda validation receives the install gate's exact
194
+ * reserved core names and full core path set, so a manifest rejected at
195
+ * ingress contributes nothing here either. */
199
196
  async function renderNodeWithAddenda(node, path) {
200
- const body = renderNode(node);
197
+ const body = node.kind === 'leaf' && node.effectiveHelp !== undefined
198
+ ? renderLeafArgv(await node.effectiveHelp())
199
+ : renderNode(node);
201
200
  if (path.length === 0)
202
201
  return body;
202
+ const { hasEffectiveCommandPlugins } = await import('./command-plugins/presence.js');
203
+ if (!hasEffectiveCommandPlugins())
204
+ return body;
203
205
  const [{ collectHelpAddenda }, { SUBTREE_NAMES, coreCommandPaths }] = await Promise.all([
204
206
  import('./command-plugins/help-addenda.js'),
205
207
  import('../build-root.js'),
@@ -216,6 +216,12 @@ export interface LeafHelp {
216
216
  * default preview behaviour. */
217
217
  preview?: PreviewMeta;
218
218
  }
219
+ /** Invocation-local help view for one hook-eligible core leaf. It shares every
220
+ * non-effects field with the frozen core help and owns a separately frozen
221
+ * effects list that describes the effective pipeline. */
222
+ export type EffectiveLeafHelp = Readonly<Omit<LeafHelp, 'effects'> & {
223
+ effects: readonly string[];
224
+ }>;
219
225
  /** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
220
226
  * subtree that owns the state authors it through this, so the tag name and any
221
227
  * scalar metadata (e.g. a count) travel with the data and render identically
@@ -225,4 +231,4 @@ export interface LeafHelp {
225
231
  export declare function stateBlock(tag: string, attrs: Record<string, string | number>, body: string): string;
226
232
  export declare function renderRoot(h: RootHelp): string;
227
233
  export declare function renderBranch(h: BranchHelp): string;
228
- export declare function renderLeafArgv(h: LeafHelp): string;
234
+ export declare function renderLeafArgv(h: LeafHelp | EffectiveLeafHelp): string;
package/dist/core/io.d.ts CHANGED
@@ -30,7 +30,7 @@ export declare function setJsonOutput(v: boolean): void;
30
30
  export declare function isJsonOutput(): boolean;
31
31
  /** Structured error payload. `error` is a stable code the agent branches on;
32
32
  * `next` is the recovery road sign. */
33
- export interface ErrorPayload {
33
+ interface ErrorPayloadBase {
34
34
  error: string;
35
35
  message: string;
36
36
  received?: unknown;
@@ -41,6 +41,13 @@ export interface ErrorPayload {
41
41
  http_status?: number;
42
42
  next: string;
43
43
  }
44
+ export type ErrorPayload = ErrorPayloadBase & ({
45
+ primaryCompleted: true;
46
+ result: Record<string, unknown>;
47
+ } | {
48
+ primaryCompleted?: never;
49
+ result?: never;
50
+ });
44
51
  /** A command-level failure: surfaces as the JSON response on stdout. */
45
52
  export declare class InputError extends CrtrError {
46
53
  payload: ErrorPayload;
@@ -94,3 +101,4 @@ export declare function apiErrorToCliError(err: ApiError, next?: string): CrtrEr
94
101
  * Runtime/internal failures go to stderr as `{error:"internal"}` — raw traces
95
102
  * never reach the agent. Exits non-zero either way. */
96
103
  export declare function handle(e: unknown): void;
104
+ export {};
package/dist/core/io.js CHANGED
@@ -193,16 +193,58 @@ function payloadOf(e) {
193
193
  if (e instanceof InputError)
194
194
  return e.payload;
195
195
  const d = (e.details !== undefined ? e.details : {});
196
+ const result = d.primaryCompleted === true
197
+ && d.result !== null
198
+ && typeof d.result === 'object'
199
+ && !Array.isArray(d.result)
200
+ ? jsonSafeResult(d.result)
201
+ : undefined;
196
202
  const next = d.next !== undefined
197
203
  ? d.next
198
204
  : 'Inspect the error and adjust the call. See -h for the schema.';
199
- return {
205
+ const base = {
200
206
  error: e.code,
201
207
  message: e.message,
202
208
  received: d.received,
203
209
  field: d.field,
204
210
  ...(typeof d.http_status === 'number' ? { http_status: d.http_status } : {}),
205
- next,
211
+ };
212
+ return result === undefined
213
+ ? { ...base, next }
214
+ : { ...base, primaryCompleted: true, result, next };
215
+ }
216
+ /** Detach completed results from getters/prototypes and keep every error sink
217
+ * JSON-safe. Tagged values preserve a truthful representation when a trusted
218
+ * primary violates its declared JSON result contract. */
219
+ function jsonSafeResult(result) {
220
+ const seen = new WeakSet();
221
+ try {
222
+ const encoded = JSON.stringify(result, (_key, value) => {
223
+ if (typeof value === 'bigint')
224
+ return { $crtrType: 'bigint', value: value.toString() };
225
+ if (typeof value === 'number' && !Number.isFinite(value))
226
+ return { $crtrType: 'number', value: String(value) };
227
+ if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') {
228
+ return { $crtrType: typeof value };
229
+ }
230
+ if (value !== null && typeof value === 'object') {
231
+ if (seen.has(value))
232
+ return { $crtrType: 'circular' };
233
+ seen.add(value);
234
+ }
235
+ return value;
236
+ });
237
+ if (encoded !== undefined) {
238
+ const decoded = JSON.parse(encoded);
239
+ if (decoded !== null && typeof decoded === 'object' && !Array.isArray(decoded)) {
240
+ return decoded;
241
+ }
242
+ }
243
+ }
244
+ catch { /* a throwing getter/toJSON must not suppress the partial failure */ }
245
+ return {
246
+ $crtrType: 'unavailable',
247
+ message: 'The completed primary result could not be represented as JSON.',
206
248
  };
207
249
  }
208
250
  // ---------------------------------------------------------------------------
@@ -6,7 +6,8 @@ import type { RefMeta } from '../runtime/broker-protocol.js';
6
6
  * (`refs`, stably sorted by winning scope then name) plus the resolution set
7
7
  * (`names` — doc names plus every proper directory prefix, because a bare-dir
8
8
  * ref is a legal listing link) that both the broker's per-submission resolver
9
- * and `buildGuidance` test tokens against.
9
+ * and `buildGuidance` test tokens against. `refs` additionally labels Gateway
10
+ * eligibility; the terminal viewer still receives every referenceable row.
10
11
  */
11
12
  export declare function buildRefInventory(): {
12
13
  refs: RefMeta[];
@@ -30,8 +30,9 @@
30
30
  // `/reload` — this module itself holds no cache beyond the session parse
31
31
  // cache it reads through.
32
32
  import { listAllMemoryDocs } from '../memory-resolver.js';
33
+ import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from './extensions.js';
33
34
  import { parseSubstrateDoc } from '../substrate/schema.js';
34
- import { cachedSubstrateDocsInclusive } from '../substrate/session-cache.js';
35
+ import { cachedAllMemoryDocsInclusive, cachedSubstrateDocsInclusive } from '../substrate/session-cache.js';
35
36
  // Display-sort weight matching resolution precedence (node > project >
36
37
  // profile > user > builtin) — display-only; resolution itself is
37
38
  // name-only (`names.has(candidate)`), never scope-aware.
@@ -49,21 +50,27 @@ const SCOPE_SORT_RANK = {
49
50
  * (`refs`, stably sorted by winning scope then name) plus the resolution set
50
51
  * (`names` — doc names plus every proper directory prefix, because a bare-dir
51
52
  * ref is a legal listing link) that both the broker's per-submission resolver
52
- * and `buildGuidance` test tokens against.
53
+ * and `buildGuidance` test tokens against. `refs` additionally labels Gateway
54
+ * eligibility; the terminal viewer still receives every referenceable row.
53
55
  */
54
56
  export function buildRefInventory() {
55
- const docs = cachedSubstrateDocsInclusive(listAllMemoryDocs, parseSubstrateDoc);
57
+ const memoryDocs = cachedAllMemoryDocsInclusive(listAllMemoryDocs);
58
+ const substrateDocs = cachedSubstrateDocsInclusive(listAllMemoryDocs, parseSubstrateDoc);
59
+ const substrateDocsByPath = new Map(substrateDocs.map((doc) => [doc.path, doc]));
56
60
  // First-wins by canonical name: docs arrive already in precedence order
57
61
  // (listAllMemoryDocs' nearest-first source ordering), so the first doc seen
58
62
  // for a name is the winner.
59
63
  const winners = new Map();
60
- for (const doc of docs) {
61
- if (!winners.has(doc.name))
62
- winners.set(doc.name, doc);
64
+ for (const memoryDoc of memoryDocs) {
65
+ const doc = substrateDocsByPath.get(memoryDoc.path);
66
+ if (doc !== undefined && !winners.has(doc.name))
67
+ winners.set(doc.name, { doc, memoryDoc });
63
68
  }
64
69
  const refs = [];
65
- for (const [name, doc] of winners) {
66
- refs.push({ name, kind: doc.kind, scope: doc.scope, shortForm: doc.shortForm });
70
+ for (const [name, { doc, memoryDoc }] of winners) {
71
+ const gatewayVisible = doc.scope !== 'builtin'
72
+ && projectEffectiveMemoryExtensions(memoryDoc.frontmatter?.['extensions'], memoryExtensionEffectiveCatalog(memoryDoc))['northlight']?.['visibility'] !== false;
73
+ refs.push({ name, kind: doc.kind, scope: doc.scope, shortForm: doc.shortForm, gatewayVisible });
67
74
  }
68
75
  refs.sort((a, b) => {
69
76
  const scopeDelta = SCOPE_SORT_RANK[a.scope] - SCOPE_SORT_RANK[b.scope];