@north-light/crouter 0.3.221 → 0.3.223

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 (169) hide show
  1. package/dist/api/client.d.ts +21 -1
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/chat-inventory.d.ts +13 -0
  4. package/dist/api/dto/human-requests.d.ts +88 -0
  5. package/dist/api/dto/human-requests.js +4 -0
  6. package/dist/api/dto/human.d.ts +3 -0
  7. package/dist/api/dto/reviews.d.ts +2 -0
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +10 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +31 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +14 -0
  14. package/dist/builtin-memory/{insights/listen.md → 00-runtime-base/02-insight-capture.md} +1 -0
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +27 -0
  16. package/dist/builtin-memory/{02-lifecycle/01-resident.md → 02-turn-lifecycle/02-resident.md} +5 -0
  17. package/dist/builtin-memory/04-base-worker.md +4 -8
  18. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  19. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +1 -0
  20. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +1 -0
  21. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -1
  22. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +2 -1
  23. package/dist/builtin-memory/05-kinds/design/design-contract.md +19 -0
  24. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -0
  25. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +1 -0
  26. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -0
  27. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +1 -0
  28. package/dist/builtin-memory/05-kinds/general/00-base.md +1 -0
  29. package/dist/builtin-memory/05-kinds/plan/00-base.md +2 -1
  30. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +2 -1
  31. package/dist/builtin-memory/05-kinds/plan/plan-contract.md +28 -0
  32. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +1 -0
  33. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +1 -0
  34. package/dist/builtin-memory/05-kinds/plan/reviewers/lens-contract.md +1 -0
  35. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +1 -0
  36. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -0
  37. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -0
  38. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -0
  39. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -0
  40. package/dist/builtin-memory/05-kinds/review/companion/00-base.md +1 -0
  41. package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -0
  42. package/dist/builtin-memory/05-kinds/spec/00-base.md +4 -3
  43. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -0
  44. package/dist/builtin-memory/05-kinds/spec/requirements.md +1 -0
  45. package/dist/builtin-memory/design/guide.md +35 -0
  46. package/dist/builtin-memory/design/roadmap.md +21 -0
  47. package/dist/builtin-memory/insights/capture.md +1 -1
  48. package/dist/builtin-memory/internal/memory-loading.md +4 -4
  49. package/dist/builtin-memory/internal/plugins.md +10 -1
  50. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  51. package/dist/builtin-memory/plan/roadmap.md +6 -28
  52. package/dist/builtin-memory/spec/guide.md +19 -8
  53. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +28 -15
  54. package/dist/clients/attach/render/markdown-source.js +106 -1
  55. package/dist/clients/attach/session/file-links.d.ts +13 -4
  56. package/dist/clients/attach/session/file-links.js +54 -58
  57. package/dist/clients/attach/viewer.js +545 -543
  58. package/dist/clients/inbox/controller.js +1 -1
  59. package/dist/clients/inbox/page-adapter.js +2 -1
  60. package/dist/clients/inbox/resolve.d.ts +1 -0
  61. package/dist/clients/inbox/review/review-client.js +3 -1
  62. package/dist/clients/inbox/tui/panel.js +23 -4
  63. package/dist/clients/inbox/tui/render.js +6 -0
  64. package/dist/clients/inbox/tui/types.d.ts +8 -0
  65. package/dist/commands/__tests__/human.test.js +2 -2
  66. package/dist/commands/human/request.d.ts +2 -0
  67. package/dist/commands/human/request.js +281 -0
  68. package/dist/commands/human.js +5 -2
  69. package/dist/commands/memory/shared.d.ts +2 -2
  70. package/dist/commands/memory/shared.js +11 -6
  71. package/dist/commands/pkg/browse/doc-view.js +2 -0
  72. package/dist/commands/sys/config.js +2 -2
  73. package/dist/commands/sys/doctor.js +54 -2
  74. package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +7 -4
  75. package/dist/core/__tests__/fixtures/memory-slash-live-probe.d.ts +1 -0
  76. package/dist/core/__tests__/fixtures/memory-slash-live-probe.js +71 -0
  77. package/dist/core/__tests__/human-action-delivery.test.d.ts +1 -0
  78. package/dist/core/__tests__/human-action-delivery.test.js +140 -0
  79. package/dist/core/__tests__/human-actions.test.d.ts +1 -0
  80. package/dist/core/__tests__/human-actions.test.js +116 -0
  81. package/dist/core/__tests__/inline-memory-refs.test.js +1 -1
  82. package/dist/core/__tests__/profile-project-memory-delivery.test.js +70 -3
  83. package/dist/core/__tests__/prospective-inventory-capability-parity.test.d.ts +1 -0
  84. package/dist/core/__tests__/prospective-inventory-capability-parity.test.js +91 -0
  85. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.d.ts +1 -0
  86. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +127 -0
  87. package/dist/core/__tests__/seam/prospective-inventory-stdout.test.d.ts +1 -0
  88. package/dist/core/__tests__/seam/prospective-inventory-stdout.test.js +31 -0
  89. package/dist/core/canvas/db.js +23 -0
  90. package/dist/core/canvas/human-deliveries.d.ts +53 -0
  91. package/dist/core/canvas/human-deliveries.js +75 -0
  92. package/dist/core/config.d.ts +13 -1
  93. package/dist/core/config.js +51 -1
  94. package/dist/core/feed/inbox.d.ts +6 -0
  95. package/dist/core/feed/inbox.js +9 -1
  96. package/dist/core/human/action-binding.d.ts +21 -0
  97. package/dist/core/human/action-binding.js +40 -0
  98. package/dist/core/human/completion.d.ts +38 -0
  99. package/dist/core/human/completion.js +27 -0
  100. package/dist/core/human/convention.d.ts +2 -0
  101. package/dist/core/human/convention.js +2 -0
  102. package/dist/core/human/tickets.d.ts +25 -6
  103. package/dist/core/human/tickets.js +19 -13
  104. package/dist/core/human/types.d.ts +5 -0
  105. package/dist/core/human-actions.d.ts +25 -0
  106. package/dist/core/human-actions.js +101 -0
  107. package/dist/core/memory-resolver.js +1 -1
  108. package/dist/core/profiles/select.d.ts +2 -0
  109. package/dist/core/profiles/select.js +21 -4
  110. package/dist/core/runtime/broker/frame-dispatch.js +2 -5
  111. package/dist/core/runtime/broker-inventory.d.ts +1 -2
  112. package/dist/core/runtime/broker-inventory.js +2 -77
  113. package/dist/core/runtime/broker-persona-guidance.js +1 -1
  114. package/dist/core/runtime/broker.js +4 -4
  115. package/dist/core/runtime/chat-inventory-rows.d.ts +8 -0
  116. package/dist/core/runtime/chat-inventory-rows.js +105 -0
  117. package/dist/core/runtime/command-surface.d.ts +8 -3
  118. package/dist/core/runtime/command-surface.js +42 -6
  119. package/dist/core/runtime/launch-target.d.ts +25 -0
  120. package/dist/core/runtime/launch-target.js +54 -0
  121. package/dist/core/runtime/persona.js +3 -3
  122. package/dist/core/runtime/prospective-inventory-cli.d.ts +1 -0
  123. package/dist/core/runtime/prospective-inventory-cli.js +61 -0
  124. package/dist/core/runtime/prospective-inventory.d.ts +10 -0
  125. package/dist/core/runtime/prospective-inventory.js +88 -0
  126. package/dist/core/runtime/spawn.d.ts +3 -1
  127. package/dist/core/runtime/spawn.js +5 -3
  128. package/dist/core/substrate/frontmatter-validation.d.ts +2 -5
  129. package/dist/core/substrate/frontmatter-validation.js +8 -4
  130. package/dist/core/substrate/gate.d.ts +4 -1
  131. package/dist/core/substrate/gate.js +5 -0
  132. package/dist/core/substrate/on-read.js +21 -32
  133. package/dist/core/substrate/render-node.d.ts +3 -2
  134. package/dist/core/substrate/render-node.js +3 -2
  135. package/dist/core/substrate/render.js +64 -37
  136. package/dist/core/substrate/schema.d.ts +17 -8
  137. package/dist/core/substrate/schema.js +14 -10
  138. package/dist/core/substrate/surface-match.d.ts +8 -7
  139. package/dist/core/substrate/surface-match.js +15 -14
  140. package/dist/core/user-settings.d.ts +4 -0
  141. package/dist/core/user-settings.js +1 -0
  142. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +52 -3
  143. package/dist/daemon/api/handlers/human-requests.d.ts +2 -0
  144. package/dist/daemon/api/handlers/human-requests.js +409 -0
  145. package/dist/daemon/api/handlers/human.js +3 -0
  146. package/dist/daemon/api/handlers/inbox.js +3 -0
  147. package/dist/daemon/api/handlers/nodes.d.ts +1 -3
  148. package/dist/daemon/api/handlers/nodes.js +11 -46
  149. package/dist/daemon/api/handlers/prospective-chat-inventory.d.ts +2 -0
  150. package/dist/daemon/api/handlers/prospective-chat-inventory.js +59 -0
  151. package/dist/daemon/api/handlers/reviews.js +10 -2
  152. package/dist/daemon/api/server.js +4 -0
  153. package/dist/daemon/crtrd.js +6 -0
  154. package/dist/daemon/human/deliver-action.d.ts +16 -0
  155. package/dist/daemon/human/deliver-action.js +168 -0
  156. package/dist/daemon/human/finish.d.ts +8 -5
  157. package/dist/daemon/human/finish.js +45 -6
  158. package/dist/daemon/human/sweep.js +4 -1
  159. package/dist/daemon/reconcilers/human-delivery-lane.d.ts +10 -0
  160. package/dist/daemon/reconcilers/human-delivery-lane.js +41 -0
  161. package/dist/daemon/review/finish.d.ts +8 -3
  162. package/dist/daemon/review/finish.js +19 -1
  163. package/dist/types.d.ts +8 -0
  164. package/dist/types.js +1 -0
  165. package/package.json +1 -1
  166. package/runtime.lock.json +2 -2
  167. package/dist/builtin-memory/00-runtime-base.md +0 -55
  168. package/dist/builtin-memory/design.md +0 -55
  169. /package/dist/builtin-memory/{02-lifecycle/00-terminal.md → 02-turn-lifecycle/01-terminal.md} +0 -0
@@ -15,10 +15,12 @@ import { detectPackageManager } from './setup-core.js';
15
15
  import { validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
16
16
  import { discoverHookRegistry } from '../../core/command-hooks/discovery.js';
17
17
  import { resolveBinContributions } from '../../core/runtime/bin-contributions.js';
18
+ import { inspectHumanActions } from '../../core/human-actions.js';
18
19
  import { buildBrokerEnv, resolvePathExecutable } from '../../core/runtime/spawn-env.js';
19
20
  import { listAllPlugins } from '../../core/resolver.js';
20
21
  import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
21
22
  import { processGeneration, processLocatedGeneration, runtimeManifest, selectedGeneration } from './shared.js';
23
+ import { CONFIG_FILE } from '../../types.js';
22
24
  function pass(scope, name, message) {
23
25
  return { scope, name, status: 'pass', message };
24
26
  }
@@ -198,6 +200,55 @@ function runBinContributionChecks(scopes) {
198
200
  }
199
201
  return results;
200
202
  }
203
+ /** Validate every scope-configured completion action without executing it. */
204
+ function runHumanActionChecks(scopes) {
205
+ const inScope = new Set(scopes);
206
+ const userConfig = join(scopeRoot('user'), CONFIG_FILE);
207
+ const results = [];
208
+ const winningOrigins = new Map();
209
+ for (const action of inspectHumanActions(process.cwd())) {
210
+ if ('kind' in action) {
211
+ if (action.kind === 'unknown_action')
212
+ continue;
213
+ const scope = action.configOrigin === userConfig ? 'user' : 'project';
214
+ const block = action.block === true;
215
+ const winningOrigin = block ? undefined : winningOrigins.get(action.name);
216
+ if (!block && winningOrigin === undefined)
217
+ winningOrigins.set(action.name, action.configOrigin);
218
+ if (!inScope.has(scope))
219
+ continue;
220
+ const checkName = block ? `humanActions:block@${action.configOrigin}` : `humanActions:${action.name}@${action.configOrigin}`;
221
+ const remediation = {
222
+ kind: 'human_action',
223
+ description: block
224
+ ? `Fix or remove the \`humanActions\` block in ${action.configOrigin}. Actions run argv directly without a shell.`
225
+ : `Fix or remove the \`humanActions.${action.name}\` declaration in ${action.configOrigin}. Actions run argv directly without a shell.`,
226
+ };
227
+ if (winningOrigin !== undefined) {
228
+ results.push({
229
+ scope,
230
+ name: checkName,
231
+ status: 'pass',
232
+ message: `warning: shadowed by ${winningOrigin}; ${action.configOrigin}: ${action.reason}`,
233
+ remediation,
234
+ });
235
+ }
236
+ else {
237
+ results.push(failCheck(scope, checkName, `${action.configOrigin}: ${action.reason}`, remediation));
238
+ }
239
+ continue;
240
+ }
241
+ const scope = action.configOrigin === userConfig ? 'user' : 'project';
242
+ const winningOrigin = winningOrigins.get(action.name);
243
+ if (winningOrigin === undefined)
244
+ winningOrigins.set(action.name, action.configOrigin);
245
+ if (!inScope.has(scope))
246
+ continue;
247
+ const shadowedPrefix = winningOrigin === undefined ? '' : `shadowed by ${winningOrigin}; `;
248
+ results.push(pass(scope, `humanActions:${action.name}@${action.configOrigin}`, `${shadowedPrefix}${action.configOrigin} → ${action.argv[0]} (cwd ${action.cwd})`));
249
+ }
250
+ return results;
251
+ }
201
252
  /** Check each enabled plugin's advisory PATH requirements against the exact
202
253
  * broker environment a node launched from this cwd/profile receives. */
203
254
  function runRequiresChecks(scopes) {
@@ -506,12 +557,12 @@ export const sysDoctorLeaf = defineLeaf({
506
557
  { kind: 'flag', name: 'remote', type: 'bool', required: false, constraint: 'Check git remotes with ls-remote (slow — makes network calls).' },
507
558
  ],
508
559
  output: [
509
- { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin checks appear as plugin:<name>:commands and plugin:<name>:hooks; both are validated statically and never execute plugin code. Hook failures identify manifest/path/target/collision problems and carry disable/update/remove/show remediation; healthy hooks list effective target, phase, and op. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
560
+ { name: 'checks', type: 'object[]', required: true, constraint: 'Each: {scope, name, status, message, fixed?, remediation?}. status: pass | fail. scope may be user | project | canvas. bin:<name> checks report bare-binary contributions (a `bin` block in a plugin manifest or a scope config.json): a pass names the contributor and the resolved target, a fail names an unsafe/reserved command name, malformed target declaration, missing/non-executable/escaping target, or same-precedence name collision that installed nothing; their remediation is always a human_action. humanActions:<name>@<config-origin> checks validate each user/project scope completion-action declaration: action-name and argv shape, cwd directory, argv[0] regular-file existence, and execute permission; they never execute an action. A malformed effective declaration fails; a malformed shadowed declaration is a pass-level warning that names the winning and shadowed origins. requires:<name> checks report each enabled plugin manifest PATH requirement: a pass names the plugin and resolved executable path; a fail names the plugin and carries its declared install hint as remediation. Runtime checks validate selected and process generation manifests, sealing/containment, and full SHA-256 inventories. terminal:nerd-font (user scope) reports whether a Nerd Font is installed for the viewer\'s icon glyphs; its remediation is always a human_action carrying the platform install command, never auto-fixed. Plugin checks appear as plugin:<name>:commands and plugin:<name>:hooks; both are validated statically and never execute plugin code. Hook failures identify manifest/path/target/collision problems and carry disable/update/remove/show remediation; healthy hooks list effective target, phase, and op. remediation (when present) is {kind, description, ...payload} where kind is remove_config_key | rm_path | human_action; fault human_action remediations include the canonical logs command. Sorted by scope then name.' },
510
561
  { name: 'ok', type: 'boolean', required: true, constraint: 'True when no unresolved fail checks remain.' },
511
562
  ],
512
563
  outputKind: 'object',
513
564
  effects: [
514
- 'Read-only unless --fix is passed. Runtime verification reads and hashes every selected/process generation file; plugin command and hook manifests, bare-binary (`bin`) declarations, and advisory `requires` PATH checks never execute plugin or contributed binaries.',
565
+ 'Read-only unless --fix is passed. Runtime verification reads and hashes every selected/process generation file; plugin command and hook manifests, bare-binary (`bin`) declarations, humanActions declarations, and advisory `requires` PATH checks never execute plugin or contributed binaries.',
515
566
  'With --fix: applies each non-pass check\'s `remediation` — removes stale config entries and deletes dangling plugin/marketplace directories; canvas fault findings and plugin command/hook findings stay read-only (--fix never chmods or rewrites third-party plugin content).',
516
567
  'Each non-pass result carries a structured `remediation` describing the fix action (absolute paths, exact config keys, or a human_action with its command) so callers can apply it directly without --fix.',
517
568
  ],
@@ -531,6 +582,7 @@ export const sysDoctorLeaf = defineLeaf({
531
582
  allResults.push(...await runCommandPluginChecks(scopes));
532
583
  allResults.push(...await runHookPluginChecks(scopes));
533
584
  allResults.push(...runBinContributionChecks(scopes));
585
+ allResults.push(...runHumanActionChecks(scopes));
534
586
  allResults.push(...runRequiresChecks(scopes));
535
587
  allResults.push(...await runFaultChecks());
536
588
  const processLocation = processGeneration();
@@ -41,10 +41,13 @@ function dbReach(root) {
41
41
  }
42
42
  return null;
43
43
  }
44
- test('every shipped broker extension is transitively canvas.db-free', () => {
45
- for (const name of CANVAS_EXTENSION_NAMES) {
46
- const root = join(SOURCE_ROOT, 'pi-extensions', `${name}.ts`);
44
+ test('every shipped broker extension and the prospective preflight are transitively canvas.db-free', () => {
45
+ const roots = [
46
+ ...CANVAS_EXTENSION_NAMES.map((name) => join(SOURCE_ROOT, 'pi-extensions', `${name}.ts`)),
47
+ join(SOURCE_ROOT, 'core/runtime/prospective-inventory-cli.ts'),
48
+ ];
49
+ for (const root of roots) {
47
50
  const reach = dbReach(root);
48
- assert.equal(reach, null, `${name} reaches canvas.db through:\n${reach?.join('\n')}`);
51
+ assert.equal(reach, null, `${root} reaches canvas.db through:\n${reach?.join('\n')}`);
49
52
  }
50
53
  });
@@ -0,0 +1,71 @@
1
+ import { existsSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { DefaultResourceLoader, ExtensionRunner, } from '@earendil-works/pi-coding-agent';
4
+ import { buildRefInventory } from '../../memory/inline-ref-inventory.js';
5
+ import { gatewayCommandRows, gatewayMemoryRows } from '../../runtime/chat-inventory-rows.js';
6
+ import { captureChatCapabilities, registrationChatSurface } from '../../runtime/command-surface.js';
7
+ import { runProspectiveInventory } from '../../runtime/prospective-inventory.js';
8
+ async function main() {
9
+ const extensionPath = process.argv[2];
10
+ const invocations = JSON.parse(process.argv[3] ?? '[]');
11
+ if (extensionPath === undefined)
12
+ throw new Error('extension path is required');
13
+ const cwd = process.cwd();
14
+ const home = process.env['CRTR_HOME'];
15
+ if (home === undefined || home === '')
16
+ throw new Error('CRTR_HOME is required');
17
+ const stateBeforeProspective = existsSync(home);
18
+ const prospective = await runProspectiveInventory({ cwd, profileId: null, extensions: [extensionPath] });
19
+ const stateAfterProspective = existsSync(home);
20
+ const loader = new DefaultResourceLoader({
21
+ cwd,
22
+ agentDir: join(cwd, '.pi'),
23
+ additionalExtensionPaths: [extensionPath],
24
+ noContextFiles: true,
25
+ });
26
+ await loader.reload();
27
+ const loaded = loader.getExtensions();
28
+ if (loaded.errors.length > 0)
29
+ throw new Error(JSON.stringify(loaded.errors));
30
+ const sent = [];
31
+ let activeName = '';
32
+ loaded.runtime.sendMessage = (message, options) => sent.push({ name: activeName, message, options });
33
+ const runner = new ExtensionRunner(loaded.extensions, loaded.runtime, cwd, undefined, undefined);
34
+ const registered = runner.getRegisteredCommands();
35
+ captureChatCapabilities({ commands: registered, templates: [] });
36
+ const commands = gatewayCommandRows(registered.map((command) => {
37
+ const expansion = command.expansion;
38
+ return {
39
+ name: command.invocationName,
40
+ description: command.description ?? '',
41
+ source: 'command',
42
+ ...(expansion === undefined ? {} : { expansion }),
43
+ ...registrationChatSurface(command),
44
+ };
45
+ }));
46
+ const memoryRefs = gatewayMemoryRows(buildRefInventory().refs);
47
+ const notifications = [];
48
+ for (const invocation of invocations) {
49
+ const command = runner.getCommand(invocation.name);
50
+ if (command === undefined)
51
+ throw new Error(`command not registered: ${invocation.name}`);
52
+ activeName = invocation.name;
53
+ await command.handler(invocation.args, {
54
+ cwd,
55
+ ui: { notify: (...args) => notifications.push(args) },
56
+ });
57
+ }
58
+ writeFileSync(3, JSON.stringify({
59
+ prospective,
60
+ stateBeforeProspective,
61
+ stateAfterProspective,
62
+ commands,
63
+ memoryRefs,
64
+ sent,
65
+ notifications,
66
+ }));
67
+ }
68
+ main().catch((error) => {
69
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
70
+ process.exitCode = 1;
71
+ });
@@ -0,0 +1,140 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { closeDb } from '../canvas/db.js';
7
+ import { claimActionDelivery, enqueueActionDelivery, readActionDelivery, } from '../canvas/human-deliveries.js';
8
+ import { actionDeliveryBackoffMs, actionDeliverySettlementForOutcome, deliverAction, } from '../../daemon/human/deliver-action.js';
9
+ let home;
10
+ const priorHome = process.env['CRTR_HOME'];
11
+ beforeEach(() => {
12
+ closeDb();
13
+ home = mkdtempSync(join(tmpdir(), 'crtr-human-action-delivery-'));
14
+ process.env['CRTR_HOME'] = home;
15
+ });
16
+ afterEach(() => {
17
+ closeDb();
18
+ rmSync(home, { recursive: true, force: true });
19
+ });
20
+ after(() => {
21
+ closeDb();
22
+ rmSync(home, { recursive: true, force: true });
23
+ if (priorHome === undefined)
24
+ delete process.env['CRTR_HOME'];
25
+ else
26
+ process.env['CRTR_HOME'] = priorHome;
27
+ });
28
+ test('action delivery retries follow the durable backoff and exit boundary', () => {
29
+ assert.equal(actionDeliveryBackoffMs(1), 5_000);
30
+ assert.equal(actionDeliveryBackoffMs(2), 10_000);
31
+ assert.equal(actionDeliveryBackoffMs(3), 20_000);
32
+ assert.equal(actionDeliveryBackoffMs(11), 3_600_000);
33
+ assert.equal(actionDeliveryBackoffMs(100), 3_600_000);
34
+ const now = 1_000_000;
35
+ const outcome = (exitCode, signal = null) => actionDeliverySettlementForOutcome({
36
+ exitCode,
37
+ signal,
38
+ timedOut: false,
39
+ stderr: 'diagnostic',
40
+ }, 2, now);
41
+ assert.deepEqual(outcome(0), { state: 'accepted' });
42
+ assert.deepEqual(outcome(77), {
43
+ state: 'pending',
44
+ nextAttemptAt: now + 10_000,
45
+ failure: { kind: 'exit', exitCode: 77, stderr: 'diagnostic' },
46
+ });
47
+ assert.deepEqual(outcome(78), {
48
+ state: 'permanent_failed',
49
+ failure: { kind: 'exit', exitCode: 78, stderr: 'diagnostic' },
50
+ });
51
+ assert.equal(outcome(79).state, 'pending');
52
+ assert.equal(outcome(null, 'SIGTERM').state, 'pending');
53
+ assert.equal(actionDeliverySettlementForOutcome({
54
+ exitCode: null,
55
+ signal: null,
56
+ timedOut: true,
57
+ stderr: '',
58
+ }, 1, now).state, 'pending');
59
+ // The kill escalation's signal is the only way to tell the graceful term from the SIGKILL.
60
+ assert.deepEqual(actionDeliverySettlementForOutcome({
61
+ exitCode: null,
62
+ signal: 'SIGKILL',
63
+ timedOut: true,
64
+ stderr: 'stalled',
65
+ }, 1, now), {
66
+ state: 'pending',
67
+ nextAttemptAt: now + 5_000,
68
+ failure: { kind: 'timeout', signal: 'SIGKILL', stderr: 'stalled' },
69
+ });
70
+ assert.equal(actionDeliverySettlementForOutcome({
71
+ exitCode: null,
72
+ signal: null,
73
+ timedOut: false,
74
+ spawnError: new Error('missing executable'),
75
+ stderr: '',
76
+ }, 1, now).state, 'pending');
77
+ });
78
+ test('the delivery child gets the consent boundary env, never crtrd\'s own', async () => {
79
+ const priorUserHome = process.env['HOME'];
80
+ const project = join(home, 'project');
81
+ mkdirSync(join(project, '.crouter'), { recursive: true });
82
+ writeFileSync(join(project, '.crouter', 'config.json'), JSON.stringify({ spawnEnv: { allow: ['NL_*'] } }));
83
+ const envDump = join(home, 'child-env.txt');
84
+ const stdinDump = join(home, 'child-stdin.json');
85
+ const script = join(home, 'action.sh');
86
+ writeFileSync(script, `#!/bin/sh\nenv > ${envDump}\ncat > ${stdinDump}\n`);
87
+ chmodSync(script, 0o755);
88
+ process.env['HOME'] = home; // no real user-scope config contributes to the boundary
89
+ process.env['NL_CORE_URL'] = 'http://core.test';
90
+ process.env['CRTRD_ONLY_SECRET'] = 'must-not-cross';
91
+ const requestId = 'b'.repeat(64);
92
+ try {
93
+ enqueueActionDelivery({
94
+ requestId,
95
+ argv: [script],
96
+ cwd: project,
97
+ completion: { schema: 'crtr.human-completion/v1', requestId, delivery: { attempt: 0 } },
98
+ });
99
+ const claimed = claimActionDelivery(requestId, Date.now(), 'claimant');
100
+ assert.ok(claimed !== null);
101
+ await deliverAction(claimed, 'claimant');
102
+ }
103
+ finally {
104
+ delete process.env['NL_CORE_URL'];
105
+ delete process.env['CRTRD_ONLY_SECRET'];
106
+ if (priorUserHome === undefined)
107
+ delete process.env['HOME'];
108
+ else
109
+ process.env['HOME'] = priorUserHome;
110
+ }
111
+ assert.equal(readActionDelivery(requestId)?.state, 'accepted');
112
+ const childEnv = new Map(readFileSync(envDump, 'utf8').split('\n').flatMap((line) => {
113
+ const split = line.indexOf('=');
114
+ return split === -1 ? [] : [[line.slice(0, split), line.slice(split + 1)]];
115
+ }));
116
+ assert.equal(childEnv.get('CRTRD_ONLY_SECRET'), undefined, 'crtrd host state must not cross without consent');
117
+ assert.equal(childEnv.get('NL_CORE_URL'), 'http://core.test', 'the target cwd\'s spawnEnv.allow carries its own config across');
118
+ assert.equal(childEnv.get('CRTR_HOME'), home, 'an action that calls crtr must reach the daemon that delivered to it');
119
+ assert.equal(JSON.parse(readFileSync(stdinDump, 'utf8')).delivery.attempt, 1);
120
+ });
121
+ test('the guarded claim admits only one contender', () => {
122
+ const requestId = 'a'.repeat(64);
123
+ enqueueActionDelivery({
124
+ requestId,
125
+ argv: ['/bin/true'],
126
+ cwd: home,
127
+ completion: { schema: 'crtr.human-completion/v1', delivery: { attempt: 0 } },
128
+ });
129
+ const now = Date.now();
130
+ const first = claimActionDelivery(requestId, now, 'claimant-a');
131
+ const second = claimActionDelivery(requestId, now, 'claimant-b');
132
+ assert.equal(first?.state, 'running');
133
+ assert.equal(first?.attempt, 1);
134
+ assert.equal(second, null);
135
+ assert.deepEqual(readActionDelivery(requestId) && {
136
+ state: readActionDelivery(requestId).state,
137
+ attempt: readActionDelivery(requestId).attempt,
138
+ claimOwner: readActionDelivery(requestId).claimOwner,
139
+ }, { state: 'running', attempt: 1, claimOwner: 'claimant-a' });
140
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/human-actions.test.ts
2
+ //
3
+ // This pins the silent execution boundary: a request must select the nearest
4
+ // project declaration before farther projects or user scope, and its relative
5
+ // executable/cwd must resolve from that declaration's authoring root.
6
+ import { after, afterEach, before, beforeEach, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { resolveHumanAction } from '../human-actions.js';
12
+ import { createProfile } from '../profiles/manifest.js';
13
+ import { resetScopeCache } from '../scope.js';
14
+ let home;
15
+ let cwd;
16
+ let previousHome;
17
+ let previousProfile;
18
+ let previousCwd;
19
+ before(() => {
20
+ previousHome = process.env['HOME'];
21
+ previousProfile = process.env['CRTR_PROFILE_ID'];
22
+ previousCwd = process.cwd();
23
+ });
24
+ beforeEach(() => {
25
+ home = mkdtempSync(join(tmpdir(), 'crtr-human-actions-home-'));
26
+ cwd = join(home, 'work', 'outer', 'nested', 'src');
27
+ mkdirSync(cwd, { recursive: true });
28
+ process.env['HOME'] = home;
29
+ delete process.env['CRTR_PROFILE_ID'];
30
+ process.chdir(cwd);
31
+ resetScopeCache();
32
+ });
33
+ afterEach(() => {
34
+ process.chdir(previousCwd);
35
+ rmSync(home, { recursive: true, force: true });
36
+ resetScopeCache();
37
+ });
38
+ after(() => {
39
+ process.chdir(previousCwd);
40
+ if (previousHome === undefined)
41
+ delete process.env['HOME'];
42
+ else
43
+ process.env['HOME'] = previousHome;
44
+ if (previousProfile === undefined)
45
+ delete process.env['CRTR_PROFILE_ID'];
46
+ else
47
+ process.env['CRTR_PROFILE_ID'] = previousProfile;
48
+ resetScopeCache();
49
+ });
50
+ function writeAction(root, relativeExecutable, relativeCwd) {
51
+ const executable = join(root, relativeExecutable);
52
+ mkdirSync(join(executable, '..'), { recursive: true });
53
+ writeFileSync(executable, '');
54
+ chmodSync(executable, 0o755);
55
+ mkdirSync(join(root, relativeCwd), { recursive: true });
56
+ }
57
+ test('nearest project declaration wins and resolves paths from its authoring root', () => {
58
+ const userRoot = join(home, '.crouter');
59
+ writeAction(userRoot, 'user-bin', 'user-cwd');
60
+ mkdirSync(userRoot, { recursive: true });
61
+ writeFileSync(join(userRoot, 'config.json'), JSON.stringify({
62
+ humanActions: { release: { argv: ['user-bin'], cwd: 'user-cwd' }, 'user-only': { argv: ['user-bin'], cwd: 'user-cwd' } },
63
+ }));
64
+ const outer = join(home, 'work', 'outer');
65
+ writeAction(outer, 'outer-bin', 'outer-cwd');
66
+ mkdirSync(join(outer, '.crouter'), { recursive: true });
67
+ writeFileSync(join(outer, '.crouter', 'config.json'), JSON.stringify({
68
+ humanActions: { release: { argv: ['outer-bin'], cwd: 'outer-cwd' } },
69
+ }));
70
+ const nearest = join(outer, 'nested');
71
+ writeAction(nearest, 'scripts/finish-release', 'work');
72
+ mkdirSync(join(nearest, '.crouter'), { recursive: true });
73
+ writeFileSync(join(nearest, '.crouter', 'config.json'), JSON.stringify({
74
+ humanActions: { release: { argv: ['scripts/finish-release', '--nearest'], cwd: 'work' } },
75
+ }));
76
+ resetScopeCache();
77
+ assert.deepEqual(resolveHumanAction('release', cwd), {
78
+ name: 'release',
79
+ configOrigin: join(nearest, '.crouter', 'config.json'),
80
+ argv: [join(nearest, 'scripts/finish-release'), '--nearest'],
81
+ cwd: join(nearest, 'work'),
82
+ });
83
+ assert.deepEqual(resolveHumanAction('user-only', cwd), {
84
+ name: 'user-only',
85
+ configOrigin: join(userRoot, 'config.json'),
86
+ argv: [join(userRoot, 'user-bin')],
87
+ cwd: join(userRoot, 'user-cwd'),
88
+ });
89
+ const profileProject = join(home, 'profile-project');
90
+ writeAction(profileProject, 'profile-bin', 'profile-cwd');
91
+ mkdirSync(join(profileProject, '.crouter'), { recursive: true });
92
+ writeFileSync(join(profileProject, '.crouter', 'config.json'), JSON.stringify({
93
+ humanActions: { 'profile-only': { argv: ['profile-bin'], cwd: 'profile-cwd' } },
94
+ }));
95
+ process.env['CRTR_PROFILE_ID'] = createProfile('human-actions-test', [{ path: profileProject, memory: 'content' }]).profileId;
96
+ resetScopeCache();
97
+ assert.deepEqual(resolveHumanAction('profile-only', cwd), { kind: 'unknown_action', name: 'profile-only' });
98
+ });
99
+ test('a malformed nearer humanActions block does not shadow a farther declaration', () => {
100
+ const outer = join(home, 'work', 'outer');
101
+ writeAction(outer, 'outer-bin', 'outer-cwd');
102
+ mkdirSync(join(outer, '.crouter'), { recursive: true });
103
+ writeFileSync(join(outer, '.crouter', 'config.json'), JSON.stringify({
104
+ humanActions: { release: { argv: ['outer-bin'], cwd: 'outer-cwd' } },
105
+ }));
106
+ const nearest = join(outer, 'nested');
107
+ mkdirSync(join(nearest, '.crouter'), { recursive: true });
108
+ writeFileSync(join(nearest, '.crouter', 'config.json'), JSON.stringify({ humanActions: [] }));
109
+ resetScopeCache();
110
+ assert.deepEqual(resolveHumanAction('release', cwd), {
111
+ name: 'release',
112
+ configOrigin: join(outer, '.crouter', 'config.json'),
113
+ argv: [join(outer, 'outer-bin')],
114
+ cwd: join(outer, 'outer-cwd'),
115
+ });
116
+ });
@@ -14,7 +14,7 @@ import { resetScopeCache } from '../scope.js';
14
14
  import { clearSessionCache } from '../substrate/session-cache.js';
15
15
  import { memoryDir as nodeMemoryDir } from '../runtime/memory.js';
16
16
  import { buildRefInventory } from '../memory/inline-ref-inventory.js';
17
- import { gatewayMemoryRows } from '../runtime/broker-inventory.js';
17
+ import { gatewayMemoryRows } from '../runtime/chat-inventory-rows.js';
18
18
  // ---------------------------------------------------------------------------
19
19
  // Wave C4 — buildRefInventory: the authoritative corpus builder. Fixture-
20
20
  // backed (node/project/user scope docs on disk), since precedence dedup,
@@ -21,6 +21,7 @@ import { closeDb } from '../canvas/db.js';
21
21
  import { resetScopeCache } from '../scope.js';
22
22
  import { createProfile } from '../profiles/manifest.js';
23
23
  import { spawnNode } from '../runtime/nodes.js';
24
+ import { memoryDir } from '../runtime/memory.js';
24
25
  import { clearSessionCache } from '../substrate/session-cache.js';
25
26
  import { renderKnowledgeBlock } from '../substrate/render-node.js';
26
27
  import { renderWorkspaceOpenDocs } from '../substrate/on-read-node.js';
@@ -37,6 +38,8 @@ const B = {
37
38
  nameFromPcontent: 'BODY-T9-NAMEDUP-FROM-PCONTENT',
38
39
  userDupFromPnone: 'BODY-T9-USERDUP-FROM-PNONE',
39
40
  userDupFromUser: 'BODY-T9-USERDUP-FROM-USER',
41
+ kindSplit: 'BODY-T9-KIND-SPLIT',
42
+ documentGateWins: 'BODY-T9-DOCUMENT-GATE-WINS',
40
43
  };
41
44
  let home;
42
45
  let canvasHome;
@@ -115,6 +118,30 @@ before(() => {
115
118
  // would otherwise beat.
116
119
  writeDoc(pnone, 't9-userdup-doc', autoDoc('When the user duplicate resolves', B.userDupFromPnone));
117
120
  writeDoc(user, 't9-userdup-doc', autoDoc('When the user duplicate resolves', B.userDupFromUser));
121
+ writeDoc(user, 't9-kind-split', [
122
+ '---',
123
+ 'kind: knowledge',
124
+ 'when-and-why-to-read: "When the node needs this kind-specific guide"',
125
+ 'surfaces:',
126
+ ' - {on: boot, gate: {kind: {in: [design, spec, plan, developer]}}, at: content}',
127
+ ' - {on: boot, gate: {kind: {in: [general, review, advisor]}}, at: preview}',
128
+ '---',
129
+ '',
130
+ B.kindSplit,
131
+ '',
132
+ ].join('\n'));
133
+ writeDoc(user, 't9-document-gate-wins', [
134
+ '---',
135
+ 'kind: knowledge',
136
+ 'when-and-why-to-read: "When document eligibility is tested"',
137
+ 'gate: {kind: {ne: developer}}',
138
+ 'surfaces:',
139
+ ' - {on: boot, gate: {kind: developer}, at: content}',
140
+ '---',
141
+ '',
142
+ B.documentGateWins,
143
+ '',
144
+ ].join('\n'));
118
145
  // Hidden-count pair: a top-level dir always renders, so a dir holding only a
119
146
  // bootless doc shows as `<dir>/` + `[+1 more]` — unless its project is `none`.
120
147
  writeDoc(pnone, join('t9-none-hidden', 'probe'), bootlessDoc('When counted', 'HIDDEN-NONE'));
@@ -152,8 +179,8 @@ after(() => {
152
179
  restore('CRTR_PROFILE_ID', prevProfile);
153
180
  restore('CRTR_NODE_ID', prevNode);
154
181
  });
155
- function newNode() {
156
- return spawnNode({ kind: 'general', cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
182
+ function newNode(kind = 'general') {
183
+ return spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
157
184
  }
158
185
  test('boot delivery caps every profile project at its relationship, including the one used as cwd', () => {
159
186
  const boot = renderKnowledgeBlock(newNode(), new Map());
@@ -168,6 +195,46 @@ test('boot delivery caps every profile project at its relationship, including th
168
195
  assert.match(boot, /[├└]─ t9-pname-doc$/m, `the name-capped doc discloses as a bare name; got:\n${boot}`);
169
196
  assert.ok(!boot.includes('When the name project delivers'), 'a name-capped doc discloses no preview line');
170
197
  });
198
+ test('surface-entry gates select the rung by node kind while the document gate remains absolute', () => {
199
+ for (const kind of ['design', 'spec', 'plan', 'developer']) {
200
+ const boot = renderKnowledgeBlock(newNode(kind), new Map());
201
+ assert.ok(boot.includes(B.kindSplit), `${kind} receives the content rung; got:\n${boot}`);
202
+ }
203
+ for (const kind of ['general', 'review', 'advisor']) {
204
+ const boot = renderKnowledgeBlock(newNode(kind), new Map());
205
+ assert.match(boot, /t9-kind-split {2}# read when: When the node needs this kind-specific guide\./, `${kind} receives the preview rung; got:\n${boot}`);
206
+ assert.ok(!boot.includes(B.kindSplit), `${kind} does not receive the body`);
207
+ }
208
+ for (const kind of ['explore', 'personal-assistant']) {
209
+ const boot = renderKnowledgeBlock(newNode(kind), new Map());
210
+ assert.ok(!boot.includes('t9-kind-split'), `${kind} receives no kind-split entry; got:\n${boot}`);
211
+ assert.ok(!boot.includes(B.kindSplit), `${kind} receives no kind-split body`);
212
+ }
213
+ const developer = renderKnowledgeBlock(newNode('developer'), new Map());
214
+ assert.ok(!developer.includes('t9-document-gate-wins'), `a failing document gate excludes every entry; got:\n${developer}`);
215
+ assert.ok(!developer.includes(B.documentGateWins), 'a failing document gate excludes its content');
216
+ });
217
+ test('a node-local doc floors to name only without boot entries', () => {
218
+ const node = newNode('explore');
219
+ writeDoc(memoryDir(node), 't9-node-local-bootless', bootlessDoc('When the node-local fallback applies', 'BODY-T9-NODE-LOCAL-BOOTLESS'));
220
+ writeDoc(memoryDir(node), 't9-node-local-entry-gated', [
221
+ '---',
222
+ 'kind: knowledge',
223
+ 'when-and-why-to-read: "When the node-local entry gates are tested"',
224
+ 'surfaces:',
225
+ ' - {on: boot, gate: {kind: developer}, at: content}',
226
+ ' - {on: boot, gate: {kind: review}, at: preview}',
227
+ '---',
228
+ '',
229
+ 'BODY-T9-NODE-LOCAL-ENTRY-GATED',
230
+ '',
231
+ ].join('\n'));
232
+ const boot = renderKnowledgeBlock(node, new Map());
233
+ assert.match(boot, /[├└]─ t9-node-local-bootless$/m, `a bootless node-local doc falls to name; got:\n${boot}`);
234
+ assert.ok(!boot.includes('BODY-T9-NODE-LOCAL-BOOTLESS'), 'the fallback discloses no body');
235
+ assert.ok(!boot.includes('t9-node-local-entry-gated'), `ineligible boot entries keep a node-local doc absent; got:\n${boot}`);
236
+ assert.ok(!boot.includes('BODY-T9-NODE-LOCAL-ENTRY-GATED'), 'ineligible boot entries disclose no body');
237
+ });
171
238
  test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', () => {
172
239
  const boot = renderKnowledgeBlock(newNode(), new Map());
173
240
  assert.ok(!boot.includes(B.pnone), 'a none-capped body must not render');
@@ -191,7 +258,7 @@ test('workspace-open clamps each project store the same way boot does', () => {
191
258
  const opened = renderWorkspaceOpenDocs(newNode(), new Map());
192
259
  assert.ok(opened.includes(B.pcontent), `the content-capped project delivers its body; got:\n${opened}`);
193
260
  assert.ok(!opened.includes(B.ppreview), `the cwd project must not deliver its body; got:\n${opened}`);
194
- assert.match(opened, /name="t9-ppreview-doc"[^>]*>\nWhen the cwd project delivers\./, `the cwd project delivers its preview line; got:\n${opened}`);
261
+ assert.match(opened, /name="t9-ppreview-doc" readWhen="When the cwd project delivers\./, `the cwd project delivers its preview line; got:\n${opened}`);
195
262
  assert.match(opened, /name="t9-pname-doc"[^>]*\/>/, `the name-capped doc delivers name-only; got:\n${opened}`);
196
263
  assert.ok(!opened.includes('t9-pnone-doc'), 'the none-capped project delivers nothing at workspace-open');
197
264
  assert.ok(!opened.includes(B.sharedFromPnone), 'nor its side of a name collision');