@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
@@ -3,8 +3,12 @@ import { type ResolvedReview } from './review-client.js';
3
3
  export interface ReviewSurfaceOptions {
4
4
  review: ResolvedReview;
5
5
  }
6
- /** Mount the review document. Both callers — the inbox's `ReviewAdapter` and
7
- * the viewer's `launchFileReview` — refuse a remote attach and resolve the
8
- * review record themselves, so this function is never reached from a remote
9
- * viewer and never reads a review record of its own. */
6
+ /** Open a dedicated viewer window for a review without replacing the caller's
7
+ * conversation window. The fresh viewer consumes the review id at startup. */
8
+ export declare function openReviewWindow(host: SurfaceHost, review: ResolvedReview): boolean;
9
+ /** Consume the review id handed to a dedicated attach viewer. */
10
+ export declare function takeStartupReviewId(): string | undefined;
11
+ /** Resolve and mount the review seeded into a dedicated viewer window. */
12
+ export declare function openReviewSurfaceById(host: SurfaceHost, reviewId: string): Promise<void>;
13
+ /** Mount the review document inside its dedicated viewer window. */
10
14
  export declare function openReviewSurface(host: SurfaceHost, opts: ReviewSurfaceOptions): Promise<void>;
@@ -1,10 +1,60 @@
1
+ import { basename } from 'node:path';
2
+ import { getPaneOption, openNodeWindow, paneLocation, selectWindow, shellQuote, viewerSplitEnv, } from '../../../core/runtime/placement-tmux.js';
1
3
  import { ReviewDocumentSurface } from './document-surface.js';
2
4
  import { openLiveSource } from './live-source.js';
3
- import { createReviewClient } from './review-client.js';
4
- /** Mount the review document. Both callers — the inbox's `ReviewAdapter` and
5
- * the viewer's `launchFileReview` — refuse a remote attach and resolve the
6
- * review record themselves, so this function is never reached from a remote
7
- * viewer and never reads a review record of its own. */
5
+ import { createReviewClient, ReviewTerminalError, terminalRefusalNotice, } from './review-client.js';
6
+ const ATTACH_REVIEW_ENV = 'CRTR_ATTACH_REVIEW_ID';
7
+ /** Open a dedicated viewer window for a review without replacing the caller's
8
+ * conversation window. The fresh viewer consumes the review id at startup. */
9
+ export function openReviewWindow(host, review) {
10
+ if (host.capabilities.remote) {
11
+ host.notice('The review is unavailable in a remote attach — the file and companion live on the host.');
12
+ return false;
13
+ }
14
+ const callerPane = host.capabilities.selfPane;
15
+ if (callerPane === undefined || callerPane === '') {
16
+ host.notice('Opening a review needs a local viewer in tmux.');
17
+ return false;
18
+ }
19
+ const location = paneLocation(callerPane);
20
+ const nodeId = getPaneOption(callerPane, '@crtr_node');
21
+ if (location === null || nodeId === undefined || nodeId === '') {
22
+ host.notice('Could not resolve the current viewer for this review.');
23
+ return false;
24
+ }
25
+ const opened = openNodeWindow({
26
+ session: location.session,
27
+ name: `review:${basename(review.file)}`,
28
+ cwd: review.cwd,
29
+ env: { ...viewerSplitEnv(), [ATTACH_REVIEW_ENV]: review.reviewId },
30
+ command: `crtr surface attach to ${shellQuote(nodeId)}`,
31
+ });
32
+ if (opened === null) {
33
+ host.notice('tmux could not open the review window.');
34
+ return false;
35
+ }
36
+ selectWindow(location.session, opened.window);
37
+ return true;
38
+ }
39
+ /** Consume the review id handed to a dedicated attach viewer. */
40
+ export function takeStartupReviewId() {
41
+ const reviewId = process.env[ATTACH_REVIEW_ENV];
42
+ delete process.env[ATTACH_REVIEW_ENV];
43
+ return reviewId === undefined || reviewId === '' ? undefined : reviewId;
44
+ }
45
+ /** Resolve and mount the review seeded into a dedicated viewer window. */
46
+ export async function openReviewSurfaceById(host, reviewId) {
47
+ try {
48
+ const review = await createReviewClient().byId(reviewId);
49
+ await openReviewSurface(host, { review });
50
+ }
51
+ catch (error) {
52
+ host.notice(error instanceof ReviewTerminalError
53
+ ? terminalRefusalNotice(error)
54
+ : `couldn't open review: ${describeError(error)}`);
55
+ }
56
+ }
57
+ /** Mount the review document inside its dedicated viewer window. */
8
58
  export async function openReviewSurface(host, opts) {
9
59
  const review = opts.review;
10
60
  let source;
@@ -13,6 +13,7 @@ export interface ResolvedReview {
13
13
  cwd: string;
14
14
  }
15
15
  export interface ReviewClient {
16
+ byId(reviewId: string): Promise<ResolvedReview>;
16
17
  byBridge(bridgeNodeId: string): Promise<ResolvedReview>;
17
18
  createInline(p: {
18
19
  originNodeId: string;
@@ -36,6 +36,10 @@ export function createReviewClient(opts = {}) {
36
36
  }
37
37
  };
38
38
  return {
39
+ async byId(reviewId) {
40
+ const review = await request(() => cliClient().getReview(reviewId));
41
+ return await resolveReview(review, review.companion_node_id, request);
42
+ },
39
43
  async byBridge(bridgeNodeId) {
40
44
  const review = await request(() => cliClient().getReviewByBridge(bridgeNodeId));
41
45
  return await resolveReview(review, review.companion_node_id, request);
@@ -3,20 +3,13 @@ export interface ReviewAdapterOptions {
3
3
  host: SurfaceHost;
4
4
  dir: string;
5
5
  }
6
- /**
7
- * Resolves a durable review ticket to its canonical daemon review and mounts
8
- * the one terminal review surface. Approval flows entirely through
9
- * `reviewClient.submit` inside that surface — this adapter owns only the
10
- * remote refusal, the bridge lookup, and the mounted surface's lifecycle.
11
- */
6
+ /** Resolve a durable review ticket and open it in its own tmux window. */
12
7
  export declare class ReviewAdapter {
13
8
  private readonly opts;
14
9
  private running;
15
10
  private stopped;
16
- private mountedHandle;
17
11
  constructor(opts: ReviewAdapterOptions);
18
12
  start(): Promise<void>;
19
- /** Toggle, cancellation, and controller teardown all take this same path. */
20
13
  stop(): Promise<void>;
21
14
  private run;
22
15
  }
@@ -1,18 +1,12 @@
1
1
  import { basename } from 'node:path';
2
2
  import { ApiError } from '../../api/errors.js';
3
- import { openReviewSurface } from './review/launch.js';
3
+ import { openReviewWindow } from './review/launch.js';
4
4
  import { createReviewClient, ReviewTerminalError, terminalRefusalNotice } from './review/review-client.js';
5
- /**
6
- * Resolves a durable review ticket to its canonical daemon review and mounts
7
- * the one terminal review surface. Approval flows entirely through
8
- * `reviewClient.submit` inside that surface — this adapter owns only the
9
- * remote refusal, the bridge lookup, and the mounted surface's lifecycle.
10
- */
5
+ /** Resolve a durable review ticket and open it in its own tmux window. */
11
6
  export class ReviewAdapter {
12
7
  opts;
13
8
  running = null;
14
9
  stopped = false;
15
- mountedHandle;
16
10
  constructor(opts) {
17
11
  this.opts = opts;
18
12
  }
@@ -22,18 +16,12 @@ export class ReviewAdapter {
22
16
  this.running = this.run();
23
17
  return this.running;
24
18
  }
25
- /** Toggle, cancellation, and controller teardown all take this same path. */
26
19
  async stop() {
27
- if (this.stopped)
28
- return;
29
20
  this.stopped = true;
30
- this.mountedHandle?.close();
31
21
  if (this.running !== null)
32
22
  await this.running;
33
23
  }
34
24
  async run() {
35
- // The refusal precedes the record read: a remote viewer can reach neither
36
- // the reviewed file nor the companion's pane.
37
25
  if (this.opts.host.capabilities.remote) {
38
26
  this.opts.host.notice('The review is unavailable in a remote attach — the file and companion live on the host.');
39
27
  return;
@@ -47,46 +35,10 @@ export class ReviewAdapter {
47
35
  this.opts.host.notice(noticeForBridgeFailure(error));
48
36
  return;
49
37
  }
50
- if (this.stopped)
51
- return;
52
- let resolveClosed;
53
- const closed = new Promise((resolve) => { resolveClosed = resolve; });
54
- const host = wrapHostForMount(this.opts.host, (handle) => {
55
- this.mountedHandle = handle;
56
- handle.onClose(() => {
57
- this.mountedHandle = undefined;
58
- resolveClosed();
59
- });
60
- });
61
- await openReviewSurface(host, { review });
62
- // A live-source failure reports through `host.notice` and returns without
63
- // ever mounting.
64
- if (this.mountedHandle === undefined)
65
- return;
66
- if (this.stopped) {
67
- this.mountedHandle.close();
68
- return;
69
- }
70
- await closed;
38
+ if (!this.stopped)
39
+ openReviewWindow(this.opts.host, review);
71
40
  }
72
41
  }
73
- /** Observes the one surface `openReviewSurface` mounts so this adapter can
74
- * await its close and close it itself from `stop()`, without widening
75
- * `SurfaceHost` or reaching into the real host's internals. */
76
- function wrapHostForMount(host, onMount) {
77
- return {
78
- capabilities: host.capabilities,
79
- mount: (component, opts) => {
80
- const handle = host.mount(component, opts);
81
- onMount(handle);
82
- return handle;
83
- },
84
- notice: (text, opts) => host.notice(text, opts),
85
- suspendForChild: () => host.suspendForChild(),
86
- focusNode: (nodeId) => host.focusNode(nodeId),
87
- pickReviewFile: (spec) => host.pickReviewFile(spec),
88
- };
89
- }
90
42
  function noticeForBridgeFailure(error) {
91
43
  if (error instanceof ReviewTerminalError)
92
44
  return terminalRefusalNotice(error);
@@ -283,8 +283,9 @@ export const lintLeaf = defineLeaf({
283
283
  const { profileId, manifest } = loadProfileManifest(profileIdOrName);
284
284
  profileResolved = true;
285
285
  lintDir(profileMemoryDir(profileId), 'user');
286
- for (const dir of manifest.projects) {
286
+ for (const project of manifest.projects) {
287
287
  profileProjects += 1;
288
+ const dir = project.path;
288
289
  const doors = frontDoorDocs(join(dir, '.crouter', 'memory'));
289
290
  if (doors.length === 0) {
290
291
  findings.push({
@@ -81,6 +81,7 @@ export const readLeaf = defineLeaf({
81
81
  outputKind: 'object',
82
82
  effects: [
83
83
  'Reads the document. A document that embeds shell (`!`cmd`` or a ```! block) runs those commands once, in the current working directory.',
84
+ 'Records a content delivery for this node: a document whose profile project caps automatic boot and workspace-open disclosure below content is still returned whole here, and unsolicited channels stay quiet about it afterwards.',
84
85
  ],
85
86
  },
86
87
  run: async (input) => {
@@ -23,7 +23,7 @@ export function registerMemory() {
23
23
  help: {
24
24
  name: 'memory',
25
25
  summary: 'list, read, search, and write memory documents — knowledge and preferences',
26
- model: 'Documents have path-derived identities and resolve across layered scopes in precedence order: node > project stack > profile > user > builtin. Browse the inventory with `list` to see what is stored; address a document directly with `read` once you know its name — a directory name is a valid read target answering with its listing. One verb per operation: `write` creates, `edit` revises (every revision carries a rationale and is recorded), `move` relocates or renames a doc and rewrites inbound `[[refs]]` corpus-wide, `delete` removes, and `history` reads how a document reached its current state. Beyond listings, delivery is explicit `surfaces` routing: each entry names the event that fires it (boot, workspace-open, read, memory-read, command) and how much delivers (name, preview, content).',
26
+ model: 'Documents have path-derived identities and resolve across layered scopes in precedence order: node > project stack > profile > user > builtin. Browse the inventory with `list` to see what is stored; address a document directly with `read` once you know its name — a directory name is a valid read target answering with its listing. One verb per operation: `write` creates, `edit` revises (every revision carries a rationale and is recorded), `move` relocates or renames a doc and rewrites inbound `[[refs]]` corpus-wide, `delete` removes, and `history` reads how a document reached its current state. Beyond listings, delivery is explicit `surfaces` routing: each entry names the event that fires it (boot, workspace-open, read, memory-read, command) and how much delivers (name, preview, content). A selected profile widens the project stack to every project in its purview, and each of those relationships caps what its stores deliver to the boot and workspace-open events; `read`, find, listings, and file- and command-routed delivery see the full corpus regardless.',
27
27
  },
28
28
  children: [listLeaf, readLeaf, findLeaf, writeLeaf, editLeaf, moveLeaf, historyLeaf, deleteLeaf, originLeaf, lintLeaf],
29
29
  });
@@ -1,13 +1,14 @@
1
- import { join } from 'node:path';
1
+ import { dirname, join, relative, resolve } from 'node:path';
2
2
  import { renameSync } from 'node:fs';
3
3
  import { defineLeaf } from '../../core/command.js';
4
- import { notFound } from '../../core/errors.js';
4
+ import { general, notFound } from '../../core/errors.js';
5
5
  import { listAllMarketplaces, listAllPlugins, findMarketplaceByName } from '../../core/resolver.js';
6
6
  import { resolveScopeArg, requireScopeRoot } from '../../core/scope.js';
7
7
  import { updateConfig, updateState, ensureScopeInitialized, readConfig } from '../../core/config.js';
8
- import { pathExists, ensureDir, removePath, isSymlink, nowIso } from '../../core/fs-utils.js';
9
- import { clone, pull, deriveNameFromUrl, currentSha, isGitRepo } from '../../core/git.js';
10
- import { readMarketplaceManifest } from '../../core/manifest.js';
8
+ import { pathExists, ensureDir, removePath, isSymlink, nowIso, readSymlinkTarget } from '../../core/fs-utils.js';
9
+ import { clone, deriveNameFromUrl, currentSha, gitSync, isGitRepo } from '../../core/git.js';
10
+ import { readMarketplaceManifest, readPluginManifest } from '../../core/manifest.js';
11
+ import { assertPluginCandidateValid, isMarketplaceRelativeSource } from './plugin-manage.js';
11
12
  import { resolveInstallScope } from './shared.js';
12
13
  /** Clone a marketplace repo into `scope` and register it in that scope's
13
14
  * config. Shared by the `pkg market add` leaf and `crtr sys setup`, which
@@ -146,13 +147,158 @@ export const marketRemove = defineLeaf({
146
147
  return { name, removed: true };
147
148
  },
148
149
  });
150
+ function stageGitRoot(url, ref, active, candidate) {
151
+ const result = gitSync(['clone', '--depth', '1', '--branch', ref, url, candidate]);
152
+ if (result.status !== 0) {
153
+ removePath(candidate);
154
+ return undefined;
155
+ }
156
+ return { active, candidate, previous: `${candidate}.previous` };
157
+ }
158
+ function activateStagedRoots(staged) {
159
+ const moved = [];
160
+ const activated = [];
161
+ try {
162
+ for (const root of staged) {
163
+ if (pathExists(root.previous))
164
+ throw general(`prior staged backup remains at ${root.previous}`);
165
+ renameSync(root.active, root.previous);
166
+ moved.push(root);
167
+ }
168
+ for (const root of staged) {
169
+ renameSync(root.candidate, root.active);
170
+ activated.push(root);
171
+ }
172
+ }
173
+ catch (error) {
174
+ for (const root of [...activated].reverse())
175
+ renameSync(root.active, root.candidate);
176
+ for (const root of [...moved].reverse())
177
+ renameSync(root.previous, root.active);
178
+ throw error;
179
+ }
180
+ for (const root of staged)
181
+ removePath(root.previous);
182
+ }
183
+ function candidatePlugin(plugin, root, manifest) {
184
+ return { plugin: { ...plugin, root, manifest }, scopeRoot: dirname(dirname(plugin.root)) };
185
+ }
186
+ async function updateMarketplaceTarget(target) {
187
+ const activeSha = currentSha(target.root) ?? '';
188
+ const sourcedPlugins = listAllPlugins().filter((plugin) => plugin.scope === target.scope && plugin.sourceMarketplace === target.name);
189
+ const stagedRoots = [];
190
+ const cleanupRoots = [];
191
+ let manifest;
192
+ let marketplaceCandidate;
193
+ const candidates = [];
194
+ try {
195
+ if (isGitRepo(target.root)) {
196
+ const candidate = join(dirname(target.root), `.${target.name}.${process.pid}.${Date.now()}.candidate`);
197
+ marketplaceCandidate = stageGitRoot(target.url, target.ref, target.root, candidate);
198
+ if (marketplaceCandidate !== undefined) {
199
+ cleanupRoots.push(candidate);
200
+ const candidateManifest = readMarketplaceManifest(candidate);
201
+ if (candidateManifest === null)
202
+ throw notFound(`marketplace manifest not found in candidate checkout: ${candidate}/.crouter-marketplace/marketplace.json`);
203
+ manifest = candidateManifest;
204
+ if (currentSha(candidate) !== activeSha)
205
+ stagedRoots.push(marketplaceCandidate);
206
+ }
207
+ else {
208
+ const activeManifest = readMarketplaceManifest(target.root);
209
+ if (activeManifest === null)
210
+ throw notFound(`marketplace manifest not found after refresh: ${target.root}/.crouter-marketplace/marketplace.json`);
211
+ manifest = activeManifest;
212
+ }
213
+ }
214
+ else {
215
+ const activeManifest = readMarketplaceManifest(target.root);
216
+ if (activeManifest === null)
217
+ throw notFound(`marketplace manifest not found after refresh: ${target.root}/.crouter-marketplace/marketplace.json`);
218
+ manifest = activeManifest;
219
+ }
220
+ for (const plugin of sourcedPlugins) {
221
+ if (isSymlink(plugin.root)) {
222
+ if (marketplaceCandidate === undefined)
223
+ continue;
224
+ const entry = manifest.plugins.find((candidate) => candidate.name === plugin.name);
225
+ if (entry === undefined || !isMarketplaceRelativeSource(entry.source)) {
226
+ throw general(`marketplace candidate removes relative-source plugin "${plugin.name}" while it is installed`);
227
+ }
228
+ const symlinkTarget = readSymlinkTarget(plugin.root);
229
+ if (symlinkTarget === null)
230
+ throw general(`installed marketplace plugin "${plugin.name}" has no readable symlink target`);
231
+ const observedSource = relative(target.root, resolve(dirname(plugin.root), symlinkTarget));
232
+ const candidateRoot = resolve(marketplaceCandidate.candidate, entry.source);
233
+ if (candidateRoot !== resolve(marketplaceCandidate.candidate, observedSource)) {
234
+ throw general(`marketplace candidate moves installed relative-source plugin "${plugin.name}" from ${observedSource} to ${entry.source}`);
235
+ }
236
+ if (!pathExists(candidateRoot))
237
+ throw notFound(`marketplace candidate source path does not exist for plugin "${plugin.name}": ${candidateRoot}`);
238
+ const candidateManifest = readPluginManifest(candidateRoot);
239
+ if (candidateManifest === null)
240
+ throw notFound(`plugin manifest not found in marketplace candidate: ${candidateRoot}/.crouter-plugin/plugin.json`);
241
+ candidates.push(candidatePlugin(plugin, candidateRoot, candidateManifest));
242
+ continue;
243
+ }
244
+ if (!pathExists(plugin.root) || !isGitRepo(plugin.root))
245
+ continue;
246
+ const remote = gitSync(['config', '--get', 'remote.origin.url'], plugin.root).stdout.trim();
247
+ const branch = gitSync(['symbolic-ref', '--short', 'HEAD'], plugin.root).stdout.trim();
248
+ if (remote === '' || branch === '')
249
+ continue;
250
+ const scopeRoot = plugin.scope === 'project' ? requireScopeRoot('project') : requireScopeRoot('user');
251
+ const candidate = join(scopeRoot, 'tmp', `${plugin.name}.${process.pid}.${Date.now()}.candidate`);
252
+ ensureDir(join(scopeRoot, 'tmp'));
253
+ const staged = stageGitRoot(remote, branch, plugin.root, candidate);
254
+ if (staged === undefined)
255
+ continue;
256
+ cleanupRoots.push(candidate);
257
+ const candidateManifest = readPluginManifest(candidate);
258
+ if (candidateManifest === null)
259
+ throw notFound(`plugin manifest not found in update candidate: ${candidate}/.crouter-plugin/plugin.json`);
260
+ candidates.push(candidatePlugin(plugin, candidate, candidateManifest));
261
+ if (currentSha(candidate) !== currentSha(plugin.root))
262
+ stagedRoots.push(staged);
263
+ }
264
+ for (const candidate of candidates) {
265
+ await assertPluginCandidateValid(candidate.plugin.manifest, candidate.plugin.root, candidate.plugin.scope, candidate.scopeRoot, `${candidate.plugin.root}/.crouter-plugin/plugin.json`, candidates);
266
+ }
267
+ activateStagedRoots(stagedRoots);
268
+ updateState(target.scope, (state) => {
269
+ if (state.marketplaces[target.name] === undefined)
270
+ state.marketplaces[target.name] = {};
271
+ state.marketplaces[target.name].last_updated = nowIso();
272
+ });
273
+ for (const plugin of sourcedPlugins) {
274
+ const entry = manifest.plugins.find((candidate) => candidate.name === plugin.name);
275
+ if (entry?.version !== undefined) {
276
+ updateConfig(plugin.scope, (config) => {
277
+ if (config.plugins[plugin.name] !== undefined)
278
+ config.plugins[plugin.name].version = entry.version;
279
+ });
280
+ }
281
+ updateState(plugin.scope, (state) => {
282
+ if (state.plugins[plugin.name] === undefined)
283
+ state.plugins[plugin.name] = {};
284
+ state.plugins[plugin.name].last_updated = nowIso();
285
+ });
286
+ }
287
+ const sha = currentSha(target.root) ?? '';
288
+ return { name: target.name, updated: activeSha !== sha, sha };
289
+ }
290
+ finally {
291
+ for (const root of cleanupRoots)
292
+ removePath(root);
293
+ }
294
+ }
149
295
  export const marketUpdate = defineLeaf({
150
296
  name: 'update',
151
297
  description: 'pull latest marketplace source',
152
298
  whenToUse: 'refreshing one named marketplace source from git, or all registered marketplace sources when no name is given; also updates installed plugins sourced from refreshed marketplaces',
153
299
  help: {
154
300
  name: 'pkg market update',
155
- summary: 'git pull one or all registered marketplaces',
301
+ summary: 'refresh one or all registered marketplaces',
156
302
  params: [
157
303
  { kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Marketplace name to update. Omit to update all registered marketplaces.' },
158
304
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
@@ -161,79 +307,23 @@ export const marketUpdate = defineLeaf({
161
307
  { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per marketplace processed: {name, updated, sha}.' },
162
308
  ],
163
309
  outputKind: 'object',
164
- effects: ['Runs git pull in marketplace directories. Updates installed plugin content/version for plugins sourced from those marketplaces.'],
310
+ effects: ['Stages and validates marketplace and sourced-plugin revisions before atomically activating them. Updates installed plugin content/version for plugins sourced from refreshed marketplaces.'],
165
311
  },
166
312
  run: async (input) => {
167
313
  const name = input['name'];
168
314
  const scopeInput = input['scope'];
169
315
  const scope = scopeInput === undefined ? undefined : resolveScopeArg(scopeInput);
170
- function pullIfGit(root) {
171
- const shaBefore = currentSha(root);
172
- if (!isGitRepo(root))
173
- return { updated: false, sha: shaBefore ?? '' };
174
- const res = pull(root);
175
- if (res.status !== 0) {
176
- return { updated: false, sha: shaBefore ?? '' };
177
- }
178
- const shaAfter = currentSha(root);
179
- return { updated: shaBefore !== shaAfter, sha: shaAfter ?? '' };
180
- }
181
- async function doUpdate(targets) {
182
- const results = [];
183
- for (const target of targets) {
184
- const pullSummary = pullIfGit(target.root);
185
- const freshManifest = readMarketplaceManifest(target.root);
186
- if (freshManifest === null) {
187
- throw notFound(`marketplace manifest not found after refresh: ${target.root}/.crouter-marketplace/marketplace.json`);
188
- }
189
- updateState(target.scope, (s) => {
190
- if (s.marketplaces[target.name] === undefined) {
191
- s.marketplaces[target.name] = {};
192
- }
193
- s.marketplaces[target.name].last_updated = nowIso();
194
- });
195
- const sourcedPlugins = listAllPlugins().filter((plugin) => plugin.scope === target.scope && plugin.sourceMarketplace === target.name);
196
- for (const plugin of sourcedPlugins) {
197
- if (isSymlink(plugin.root)) {
198
- // Marketplace pull above updates symlinked relative-source plugins.
199
- }
200
- else if (pathExists(plugin.root)) {
201
- pullIfGit(plugin.root);
202
- }
203
- const pluginEntry = freshManifest.plugins.find((p) => p.name === plugin.name);
204
- if (pluginEntry !== undefined && pluginEntry.version !== undefined) {
205
- updateConfig(plugin.scope, (c) => {
206
- if (c.plugins[plugin.name] !== undefined) {
207
- c.plugins[plugin.name].version = pluginEntry.version;
208
- }
209
- });
210
- }
211
- updateState(plugin.scope, (s) => {
212
- if (s.plugins[plugin.name] === undefined) {
213
- s.plugins[plugin.name] = {};
214
- }
215
- s.plugins[plugin.name].last_updated = nowIso();
216
- });
217
- }
218
- results.push({ name: target.name, updated: pullSummary.updated, sha: pullSummary.sha });
219
- }
220
- return results;
221
- }
222
- if (name !== undefined) {
223
- const found = scope === undefined || scope === 'all' || scope === 'builtin'
224
- ? findMarketplaceByName(name)
225
- : findMarketplaceByName(name, scope);
226
- if (!found)
227
- throw notFound(`marketplace not found: ${name}${scope === undefined || scope === 'all' || scope === 'builtin' ? '' : ` (scope: ${scope})`}`);
228
- const results = await doUpdate([{ name: found.name, scope: found.scope, root: found.root }]);
229
- return { updated: results };
230
- }
231
- const targets = (scope === undefined || scope === 'all'
232
- ? listAllMarketplaces()
233
- : scope === 'builtin'
234
- ? []
235
- : listAllMarketplaces().filter((marketplace) => marketplace.scope === scope)).map((m) => ({ name: m.name, scope: m.scope, root: m.root }));
236
- const results = await doUpdate(targets);
237
- return { updated: results };
316
+ const targets = name === undefined
317
+ ? (scope === undefined || scope === 'all' ? listAllMarketplaces() : scope === 'builtin' ? [] : listAllMarketplaces().filter((marketplace) => marketplace.scope === scope))
318
+ : (() => {
319
+ const found = scope === undefined || scope === 'all' || scope === 'builtin' ? findMarketplaceByName(name) : findMarketplaceByName(name, scope);
320
+ if (found === null)
321
+ throw notFound(`marketplace not found: ${name}${scope === undefined || scope === 'all' || scope === 'builtin' ? '' : ` (scope: ${scope})`}`);
322
+ return [found];
323
+ })();
324
+ const updated = [];
325
+ for (const target of targets)
326
+ updated.push(await updateMarketplaceTarget(target));
327
+ return { updated };
238
328
  },
239
329
  });
@@ -4,8 +4,22 @@ import { paginate } from '../../core/pagination.js';
4
4
  import { listInstalledPlugins, findPluginByName, } from '../../core/resolver.js';
5
5
  import { listPluginMemoryDocs } from '../../core/memory-resolver.js';
6
6
  import { validatePluginCommands, validateEffectiveCommandPlugins } from '../../core/command-plugins/discovery.js';
7
- import { SUBTREE_NAMES, coreCommandPaths } from '../../build-root.js';
7
+ import { compileHookRegistry, discoverHookRegistry } from '../../core/command-hooks/discovery.js';
8
+ import { hasDeclaredHooks, hookReport } from '../../core/command-hooks/report.js';
9
+ import { SUBTREE_NAMES, coreCommandPaths, coreHookCatalog } from '../../build-root.js';
8
10
  import { resolveScopeArg, projectScopeRoot } from '../../core/scope.js';
11
+ async function hookInspectionReport(plugin) {
12
+ if (!hasDeclaredHooks(plugin.manifest))
13
+ return undefined;
14
+ const catalog = await coreHookCatalog();
15
+ const effective = discoverHookRegistry(catalog);
16
+ const validation = effective.validations.find((entry) => entry.plugin.root === plugin.root);
17
+ if (validation === undefined) {
18
+ const isolated = compileHookRegistry(catalog, [plugin]);
19
+ return hookReport(isolated.validations[0]);
20
+ }
21
+ return hookReport(validation, effective.issues.filter((issue) => issue.plugin === plugin.name && issue.scope === plugin.scope));
22
+ }
9
23
  export const pluginList = defineLeaf({
10
24
  name: 'list',
11
25
  description: 'paginated list of installed plugins',
@@ -93,9 +107,10 @@ export const pluginShow = defineLeaf({
93
107
  { name: 'manifest', type: 'object', required: true, constraint: 'Full plugin.json contents.' },
94
108
  { name: 'docs', type: 'object[]', required: true, constraint: 'Each: {name, path}. Memory docs provided by the plugin (its `<pluginName>/` memory subtree).' },
95
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), issues: object[] ({code, path?, message, received, expected, next})}. Validated statically; command execution never occurs.' },
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}[], issues: object[] ({code, path?, message, received, expected, next}), trust: string}. Static only: no hook executable runs.' },
96
111
  ],
97
112
  outputKind: 'object',
98
- effects: ['None. Read-only. Command manifests are validated statically; commands are never executed.'],
113
+ effects: ['None. Read-only. Command and hook manifests are validated statically; plugin executables are never executed.'],
99
114
  },
100
115
  run: async (input) => {
101
116
  const name = input['name'];
@@ -134,6 +149,7 @@ export const pluginShow = defineLeaf({
134
149
  issues: v.issues,
135
150
  };
136
151
  }
152
+ const hooks = await hookInspectionReport(found);
137
153
  return {
138
154
  name: found.name,
139
155
  scope: found.scope,
@@ -145,6 +161,7 @@ export const pluginShow = defineLeaf({
145
161
  path: d.path,
146
162
  })),
147
163
  ...(commands !== undefined ? { commands } : {}),
164
+ ...(hooks !== undefined ? { hooks } : {}),
148
165
  };
149
166
  },
150
167
  });
@@ -1,16 +1,21 @@
1
- import { type Scope } from '../../types.js';
1
+ import { type HookPluginCandidate } from '../../core/command-hooks/discovery.js';
2
+ import { type PluginManifest, type Scope } from '../../types.js';
2
3
  interface MarketplaceRef {
3
4
  marketplace: string;
4
5
  plugin: string;
5
6
  }
7
+ /** Validate a package candidate before it gains an active plugin path. The
8
+ * candidate namespace shadows any installed declaration of the same name. */
9
+ export declare function assertPluginCandidateValid(manifest: PluginManifest, root: string, scope: Scope, scopeRootPath: string, where: string, candidates?: readonly HookPluginCandidate[]): Promise<void>;
10
+ export declare function isMarketplaceRelativeSource(source: string): boolean;
6
11
  /** Install one marketplace plugin into a scope. Exported so `sys setup` can
7
12
  * install the default plugin set through the same path `pkg plugin install`
8
13
  * uses — refresh the source, resolve the entry, link/clone, register. */
9
- export declare function installFromMarketplace(ref: MarketplaceRef, scope: Scope): {
14
+ export declare function installFromMarketplace(ref: MarketplaceRef, scope: Scope): Promise<{
10
15
  name: string;
11
16
  scope: Scope;
12
17
  path: string;
13
- };
18
+ }>;
14
19
  export declare const pluginInstall: import("../../core/command.js").LeafDef;
15
20
  export declare const pluginRemove: import("../../core/command.js").LeafDef;
16
21
  export declare const pluginEnable: import("../../core/command.js").LeafDef;