@north-light/crouter 0.3.269 → 0.3.271

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 (78) hide show
  1. package/dist/api/dto/broker.d.ts +11 -2
  2. package/dist/api/dto/broker.js +2 -2
  3. package/dist/api/dto/nodes.d.ts +4 -0
  4. package/dist/commands/pkg/plugin-inspect.js +2 -2
  5. package/dist/commands/pkg/plugin-manage.js +3 -3
  6. package/dist/commands/sys/doctor.js +4 -3
  7. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +2 -2
  8. package/dist/core/__tests__/helpers/harness.js +1 -0
  9. package/dist/core/__tests__/human-deliver.test.js +1 -1
  10. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  11. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  12. package/dist/core/__tests__/integration/revive.test.js +15 -15
  13. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  14. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  15. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  16. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  17. package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -0
  18. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  19. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  20. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  21. package/dist/core/command-hooks/discovery.d.ts +27 -1
  22. package/dist/core/command-hooks/discovery.js +52 -0
  23. package/dist/core/command-hooks/index.d.ts +4 -3
  24. package/dist/core/command-hooks/index.js +2 -1
  25. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  26. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  27. package/dist/core/command-hooks/report.d.ts +11 -2
  28. package/dist/core/command-hooks/report.js +10 -1
  29. package/dist/core/command-hooks/schema.d.ts +16 -1
  30. package/dist/core/command-hooks/schema.js +128 -40
  31. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  32. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  33. package/dist/core/human/feedback-companion.js +1 -1
  34. package/dist/core/review/realize.js +1 -1
  35. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  36. package/dist/core/runtime/broker/engine-drive.js +29 -1
  37. package/dist/core/runtime/broker/frame-client.js +3 -4
  38. package/dist/core/runtime/broker/rebind.js +7 -0
  39. package/dist/core/runtime/broker-protocol.d.ts +11 -2
  40. package/dist/core/runtime/broker.d.ts +1 -1
  41. package/dist/core/runtime/broker.js +13 -3
  42. package/dist/core/runtime/fleet.d.ts +5 -6
  43. package/dist/core/runtime/node-read.d.ts +2 -0
  44. package/dist/core/runtime/node-read.js +18 -9
  45. package/dist/core/runtime/nodes.js +6 -1
  46. package/dist/core/runtime/revive-all.d.ts +1 -1
  47. package/dist/core/runtime/revive-all.js +2 -2
  48. package/dist/core/runtime/revive.d.ts +2 -2
  49. package/dist/core/runtime/revive.js +49 -13
  50. package/dist/core/runtime/session-visibility.d.ts +10 -14
  51. package/dist/core/runtime/session-visibility.js +43 -30
  52. package/dist/core/runtime/stamp/channel.d.ts +10 -6
  53. package/dist/core/runtime/stamp/channel.js +16 -7
  54. package/dist/core/runtime/stamp/protocol.d.ts +4 -0
  55. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  56. package/dist/core/runtime/turn-visibility.js +31 -0
  57. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  58. package/dist/daemon/api/bridge.js +1 -1
  59. package/dist/daemon/api/handlers/attach.js +4 -4
  60. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  61. package/dist/daemon/api/handlers/messages.js +2 -2
  62. package/dist/daemon/api/handlers/nodes.js +7 -4
  63. package/dist/daemon/cron/sinks.js +3 -3
  64. package/dist/daemon/fleet.js +10 -22
  65. package/dist/daemon/messaging/node-message.js +1 -1
  66. package/dist/daemon/profile-delete.js +1 -1
  67. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  68. package/dist/hook-authoring.d.ts +31 -1
  69. package/dist/hook-authoring.js +42 -5
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +3 -0
  72. package/dist/pi-extensions/canvas-stamp.js +3 -3
  73. package/dist/shared/env.d.ts +2 -0
  74. package/dist/shared/env.js +4 -0
  75. package/dist/shared/generated-context.d.ts +1 -2
  76. package/dist/shared/generated-context.js +3 -4
  77. package/package.json +1 -1
  78. package/runtime.lock.json +2 -2
@@ -103,10 +103,10 @@ after(() => {
103
103
  else
104
104
  process.env['CRTR_HOME'] = priorHome;
105
105
  });
106
- test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', () => {
106
+ test('a parked node with mail waiting re-enters fresh even when the caller asked to resume, and the marker clears', async () => {
107
107
  parkResident('parked', { roadmap: '# Roadmap\n\n- one open item\n' });
108
108
  deliverMail('parked');
109
- const result = reviveNode('parked', { resume: true, capacity: 'freeze' });
109
+ const result = await reviveNode('parked', { resume: true, capacity: 'freeze' });
110
110
  assert.equal(result.resumed, false, 'the concluded transcript is not replayed');
111
111
  assert.deepEqual(placements(), [{ nodeId: 'parked', resuming: false }]);
112
112
  assert.equal(kickedOff(launches[0]), true, 'the fresh window is handed its bearings');
@@ -115,15 +115,15 @@ test('a parked node with mail waiting re-enters fresh even when the caller asked
115
115
  assert.equal(reopened.intent ?? null, null, 'the park marker clears on revival');
116
116
  assert.equal(reopened.cycle_pending, true, 'the fresh cycle is provisional until session_start confirms it');
117
117
  });
118
- test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', () => {
118
+ test('a parked node grounded only by a goal still re-enters fresh when mail rides the revive', async () => {
119
119
  parkResident('goal-only', { goal: 'Watch the deploy lane.' });
120
120
  deliverMail('goal-only');
121
- assert.equal(reviveNode('goal-only', { resume: true, capacity: 'freeze' }).resumed, false);
121
+ assert.equal((await reviveNode('goal-only', { resume: true, capacity: 'freeze' })).resumed, false);
122
122
  assert.equal(kickedOff(launches[0]), true);
123
123
  });
124
- test('an input-less revival of a parked node quietly resumes and injects no kickoff', () => {
124
+ test('an input-less revival of a parked node quietly resumes and injects no kickoff', async () => {
125
125
  parkResident('quiet', { roadmap: '# Roadmap\n\n- one open item\n' });
126
- const result = reviveNode('quiet', { resume: true, capacity: 'freeze' });
126
+ const result = await reviveNode('quiet', { resume: true, capacity: 'freeze' });
127
127
  assert.equal(result.resumed, true, 'nothing arrived, so there is nothing a fresh window would answer');
128
128
  assert.deepEqual(placements(), [{ nodeId: 'quiet', resuming: true }]);
129
129
  assert.equal(kickedOff(launches[0]), false, 'a quiet resume generates nothing');
@@ -132,29 +132,29 @@ test('an input-less revival of a parked node quietly resumes and injects no kick
132
132
  assert.equal(reopened.status, 'active');
133
133
  assert.equal(reopened.intent ?? null, null, 'the park marker clears on a quiet resume too');
134
134
  });
135
- test('deferred mail alone is not input, so the revival still resumes quietly', () => {
135
+ test('deferred mail alone is not input, so the revival still resumes quietly', async () => {
136
136
  parkResident('deferred', { roadmap: '# Roadmap\n\n- one open item\n' });
137
137
  deliverMail('deferred', 'deferred');
138
- assert.equal(reviveNode('deferred', { resume: true, capacity: 'freeze' }).resumed, true);
138
+ assert.equal((await reviveNode('deferred', { resume: true, capacity: 'freeze' })).resumed, true);
139
139
  assert.equal(kickedOff(launches[0]), false);
140
140
  assert.equal(readCursor('deferred'), undefined, 'the deferred entry rides the next natural cycle, unconsumed');
141
141
  });
142
- test('mail already covered by the cursor is not input, so the revival still resumes quietly', () => {
142
+ test('mail already covered by the cursor is not input, so the revival still resumes quietly', async () => {
143
143
  parkResident('stale', { roadmap: '# Roadmap\n\n- one open item\n' });
144
144
  writeCursor('stale', deliverMail('stale'));
145
- assert.equal(reviveNode('stale', { resume: true, capacity: 'freeze' }).resumed, true, 'only the UNSEEN suffix counts as input');
145
+ assert.equal((await reviveNode('stale', { resume: true, capacity: 'freeze' })).resumed, true, 'only the UNSEEN suffix counts as input');
146
146
  assert.equal(kickedOff(launches[0]), false);
147
147
  });
148
- test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', () => {
148
+ test('a parked node with no goal and no roadmap resumes its transcript rather than waking amnesiac', async () => {
149
149
  parkResident('bare');
150
150
  deliverMail('bare');
151
- const result = reviveNode('bare', { resume: true, capacity: 'freeze' });
151
+ const result = await reviveNode('bare', { resume: true, capacity: 'freeze' });
152
152
  assert.equal(result.resumed, true, 'the degraded park has nothing on disk to ground a fresh window');
153
153
  assert.deepEqual(placements(), [{ nodeId: 'bare', resuming: true }]);
154
154
  });
155
- test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', () => {
155
+ test('an ordinary done node with a roadmap still resumes — only the park marker vetoes', async () => {
156
156
  parkResident('finished', { roadmap: '# Roadmap\n\n- one open item\n' });
157
157
  transition('finished', 'revive');
158
158
  transition('finished', 'finish');
159
- assert.equal(reviveNode('finished', { resume: true, capacity: 'freeze' }).resumed, true);
159
+ assert.equal((await reviveNode('finished', { resume: true, capacity: 'freeze' })).resumed, true);
160
160
  });
@@ -117,7 +117,7 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
117
117
  const filler = await h.spawnHeadlessChild(root, 'terminal thaw filler');
118
118
  assert.equal(h.fleet.size(), 2, 'the parked target is non-live while the cap remains full');
119
119
  appendInbox(second, { from: root, tier: 'normal', kind: 'message', label: 'durable wake' });
120
- assert.equal(reviveNode(second, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
120
+ assert.equal((await reviveNode(second, { resume: true, capacity: 'freeze' })).outcome, 'frozen');
121
121
  closeDb();
122
122
  const frozenTerminal = h.node(second);
123
123
  assert.equal(frozenTerminal.status, 'done', 'freezing preserves the terminal status');
@@ -144,7 +144,7 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
144
144
  created: new Date().toISOString(),
145
145
  pi_session_id: 'resident-drain-session',
146
146
  });
147
- assert.equal(reviveNode('resident-drain', { resume: true, capacity: 'freeze' }).outcome, 'frozen');
147
+ assert.equal((await reviveNode('resident-drain', { resume: true, capacity: 'freeze' })).outcome, 'frozen');
148
148
  await h.tick();
149
149
  closeDb();
150
150
  const drained = h.node('resident-drain');
@@ -109,6 +109,8 @@ test('a managed-cooling terminal authors a rate-limit auto fault whose deadline
109
109
  seedPool('openai-codex', false);
110
110
  const child = await h.spawnHeadlessChild(parent, 'cooling terminal', { model: OPENAI_STRONG.spec });
111
111
  await h.waitFor(() => agentStartCount(child) >= 1, { label: 'initial cooling fixture turn started' });
112
+ const kickoffObserver = await attachUntil(child, 'observer', 'cooling-terminal-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'cooling terminal kickoff settled');
113
+ kickoffObserver.close();
112
114
  const baselineStarts = agentStartCount(child);
113
115
  // A terminal managed-cooling error at agent_end: its explanatory prose avoids
114
116
  // rate-limit words entirely; the typed diagnostic alone must classify the
@@ -79,7 +79,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
79
79
  },
80
80
  },
81
81
  });
82
- reviveNode(manager, { resume: true, capacity: 'freeze' });
82
+ await reviveNode(manager, { resume: true, capacity: 'freeze' });
83
83
  await h.awaitBoot(manager);
84
84
  // A live managed child makes this unfocused terminal manager legitimately
85
85
  // awaiting, so its real stophook takes the idle-release branch.
@@ -172,7 +172,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
172
172
  },
173
173
  });
174
174
  subscribe(root, nodeId, true);
175
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
175
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
176
176
  const boot = await h.awaitBoot(nodeId);
177
177
  assert.equal(h.fleet.has(nodeId), true, 'the revived broker is fleet-owned');
178
178
  const seenAt = 10_000;
@@ -223,7 +223,7 @@ test('a non-attending observer does not prevent unattended resident parking', {
223
223
  intent: 'idle-release',
224
224
  });
225
225
  updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
226
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
226
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
227
227
  const boot = await h.awaitBoot(nodeId);
228
228
  await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
229
229
  assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
@@ -268,7 +268,7 @@ test('a connected broker viewer keeps an obligation-free resident live', { timeo
268
268
  env: {},
269
269
  },
270
270
  });
271
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
271
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
272
272
  const boot = await h.awaitBoot(nodeId);
273
273
  // awaitBoot returns on the boot proof, which is written BEFORE the broker's
274
274
  // own `persistAttachState()` lays down the truthful zero state. Writing our
@@ -311,7 +311,7 @@ test('the unattended park clock cancels an obligation-free terminal node and spa
311
311
  try {
312
312
  const boot = async (id) => {
313
313
  updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
314
- reviveNode(id, { resume: true, capacity: 'freeze' });
314
+ await reviveNode(id, { resume: true, capacity: 'freeze' });
315
315
  return (await h.awaitBoot(id)).pid;
316
316
  };
317
317
  const settled = h.fabricateBrokerNode({ id: 'terminal-no-work', parent: root, status: 'idle', intent: 'idle-release' });
@@ -378,7 +378,7 @@ test('a resident managed child parks without finalizing while a terminal child i
378
378
  subscribe(terminalManager, terminal, true);
379
379
  const boot = async (id) => {
380
380
  updateNode(id, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
381
- reviveNode(id, { resume: true, capacity: 'freeze' });
381
+ await reviveNode(id, { resume: true, capacity: 'freeze' });
382
382
  return (await h.awaitBoot(id)).pid;
383
383
  };
384
384
  const residentPid = await boot(resident);
@@ -439,7 +439,7 @@ test('an unattended resident awaiting a live child remains wakeable', { timeout:
439
439
  env: {},
440
440
  },
441
441
  });
442
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
442
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
443
443
  const boot = await h.awaitBoot(nodeId);
444
444
  const seenAt = 20_000;
445
445
  await h.tick(seenAt);
@@ -42,7 +42,7 @@ test('a Jiti-loaded watcher prepends its held deferred batch to a native broker
42
42
  },
43
43
  },
44
44
  });
45
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
45
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
46
46
  await h.awaitBoot(nodeId);
47
47
  const entry = appendInbox(nodeId, {
48
48
  from: 'child',
@@ -73,11 +73,11 @@ test('yield-message survives one pre-session_start retry and clears on session_s
73
73
  // the retry still roots a fresh cycle carrying the note, never reopening the
74
74
  // old session. Driving reviveNode directly is exactly what focus's revive path
75
75
  // calls, minus a tmux pane this contract does not involve.
76
- const focusRevive = reviveNode(id, { resume: true, capacity: 'freeze' });
76
+ const focusRevive = await reviveNode(id, { resume: true, capacity: 'freeze' });
77
77
  const focusPid = focusRevive.launch?.pid;
78
78
  assert.ok(focusPid != null, 'the competing focus invocation launches the replacement broker');
79
79
  assert.equal(focusRevive.resumed, false, 'refresh intent overrides this invocation\'s strict-resume request');
80
- assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation synchronously owns the authoritative fleet slot');
80
+ assert.equal(h.fleet.get(id)?.pid, focusPid, 'the focus invocation owns the authoritative fleet slot');
81
81
  // Registering the manual launch cancels the exit policy's deferred recovery.
82
82
  // Its pid must therefore be the successful observed boot, proving these
83
83
  // assertions belong to the contender above rather than an already-queued boot.
@@ -1,6 +1,7 @@
1
1
  import type { InstalledPlugin } from '../../types.js';
2
2
  import type { CoreHookCatalog } from './catalog.js';
3
- import type { HookManifestIssue, HookPhase, ValidatedHookManifest } from './schema.js';
3
+ import type { HookManifestIssue, HookPhase, LifecycleHookPhase, ValidatedHookManifest } from './schema.js';
4
+ import type { LifecycleEvent } from './lifecycle-catalog.js';
4
5
  export type HookDiscoveryIssueCode = HookManifestIssue['code'] | 'command_hook_target_invalid' | 'command_hook_collision';
5
6
  export interface HookDiscoveryIssue {
6
7
  code: HookDiscoveryIssueCode;
@@ -38,6 +39,27 @@ export interface EffectiveHookPlan {
38
39
  after: readonly EffectiveHook[];
39
40
  replacements: readonly EffectiveHook[];
40
41
  }
42
+ /** One lifecycle declaration resolved to the local executable that receives it. */
43
+ export interface EffectiveLifecycleHook {
44
+ executable: string;
45
+ plugin: HookPluginAttribution;
46
+ event: LifecycleEvent;
47
+ phase: LifecycleHookPhase;
48
+ op: string;
49
+ description: string;
50
+ effects: readonly string[];
51
+ /** Index in this plugin's lifecycle declaration array. */
52
+ declarationIndex: number;
53
+ /** Stable discovery order: scope, lexical plugin name, declaration index. */
54
+ order: number;
55
+ }
56
+ export interface LifecycleHookRegistry {
57
+ hooks: readonly EffectiveLifecycleHook[];
58
+ plans: ReadonlyMap<LifecycleEvent, readonly EffectiveLifecycleHook[]>;
59
+ validations: readonly HookPluginValidation[];
60
+ issues: readonly HookDiscoveryIssue[];
61
+ valid: boolean;
62
+ }
41
63
  export interface HookPluginValidation {
42
64
  plugin: InstalledPlugin;
43
65
  manifestPath?: string;
@@ -74,6 +96,10 @@ export interface HookPluginCandidate {
74
96
  * manifest files to validate them but never executes a plugin executable.
75
97
  */
76
98
  export declare function compileHookRegistry(catalog: CoreHookCatalog, plugins: readonly InstalledPlugin[]): HookRegistry;
99
+ /** Compiles lifecycle declarations from resolved plugins without loading the command tree. */
100
+ export declare function compileLifecycleHookRegistry(plugins: readonly InstalledPlugin[]): LifecycleHookRegistry;
101
+ /** Discovers lifecycle hooks for one node's resolved cwd and profile. */
102
+ export declare function discoverLifecycleHookRegistry(startDir: string, profileId?: string | null): LifecycleHookRegistry;
77
103
  /** Discovers and compiles the invocation-local effective registry. */
78
104
  export declare function discoverHookRegistry(catalog: CoreHookCatalog, startDir?: string, profileId?: string | null): HookRegistry;
79
105
  /** Compile the exact effective set a source candidate would create before its
@@ -153,6 +153,58 @@ export function compileHookRegistry(catalog, plugins) {
153
153
  valid: issues.length === 0,
154
154
  });
155
155
  }
156
+ /** Compiles lifecycle declarations from resolved plugins without loading the command tree. */
157
+ export function compileLifecycleHookRegistry(plugins) {
158
+ const validations = [];
159
+ const hooks = [];
160
+ const issues = [];
161
+ let order = 0;
162
+ for (const plugin of plugins) {
163
+ const artifact = validatePluginHookArtifact(plugin.root, plugin.manifest);
164
+ const pluginIssues = artifactIssues(plugin, artifact.issues);
165
+ let compiled = [];
166
+ if (pluginIssues.length === 0 && artifact.manifest !== undefined && artifact.executablePath !== undefined) {
167
+ const lifecycle = artifact.manifest.schemaVersion === 2 ? artifact.manifest.lifecycle : [];
168
+ compiled = lifecycle.map((hook, declarationIndex) => Object.freeze({
169
+ executable: artifact.executablePath,
170
+ plugin: Object.freeze({ name: plugin.name, scope: plugin.scope, root: plugin.root }),
171
+ event: hook.event,
172
+ phase: hook.phase,
173
+ op: hook.op,
174
+ description: hook.description,
175
+ effects: Object.freeze([...hook.effects]),
176
+ declarationIndex,
177
+ order: order++,
178
+ }));
179
+ hooks.push(...compiled);
180
+ }
181
+ validations.push(Object.freeze({
182
+ plugin,
183
+ ...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
184
+ ...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
185
+ ...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
186
+ hooks: Object.freeze([]),
187
+ issues: Object.freeze(pluginIssues),
188
+ }));
189
+ issues.push(...pluginIssues);
190
+ }
191
+ const plans = new Map();
192
+ for (const hook of hooks) {
193
+ const entries = plans.get(hook.event) ?? [];
194
+ plans.set(hook.event, Object.freeze([...entries, hook]));
195
+ }
196
+ return Object.freeze({
197
+ hooks: Object.freeze(hooks),
198
+ plans,
199
+ validations: Object.freeze(validations),
200
+ issues: Object.freeze(issues),
201
+ valid: issues.length === 0,
202
+ });
203
+ }
204
+ /** Discovers lifecycle hooks for one node's resolved cwd and profile. */
205
+ export function discoverLifecycleHookRegistry(startDir, profileId) {
206
+ return compileLifecycleHookRegistry(effectiveHookPlugins(startDir, profileId));
207
+ }
156
208
  /** Discovers and compiles the invocation-local effective registry. */
157
209
  export function discoverHookRegistry(catalog, startDir = process.cwd(), profileId) {
158
210
  return compileHookRegistry(catalog, effectiveHookPlugins(startDir, profileId));
@@ -1,6 +1,7 @@
1
1
  export { createCoreHookCatalog, type CoreHookCatalog, type CoreHookTarget } from './catalog.js';
2
2
  export { projectEffectiveLeafHelp } from './help.js';
3
- export { compileHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, effectiveHookPlugins, hookDiscoveryScopes, type EffectiveHook, type EffectiveHookPlan, type HookDiscoveryIssue, type HookPluginAttribution, type HookPluginValidation, type HookRegistry, type HookScope, } from './discovery.js';
3
+ export { compileHookRegistry, compileLifecycleHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, discoverLifecycleHookRegistry, effectiveHookPlugins, hookDiscoveryScopes, type EffectiveHook, type EffectiveHookPlan, type EffectiveLifecycleHook, type HookDiscoveryIssue, type HookPluginAttribution, type HookPluginValidation, type HookRegistry, type HookScope, type LifecycleHookRegistry, } from './discovery.js';
4
4
  export { validatePluginHookArtifact, type HookArtifactValidation } from './artifact.js';
5
- export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING, type HookDeclarationReport, type HookReport } from './report.js';
6
- export { validateHookManifest, type DeclaredHook, type HookManifestIssue, type HookManifestValidation, type HookPhase, type ValidatedHookManifest, } from './schema.js';
5
+ export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING, type HookDeclarationReport, type HookReport, type LifecycleHookDeclarationReport } from './report.js';
6
+ export { validateHookManifest, type DeclaredHook, type DeclaredLifecycleHook, type HookManifestIssue, type HookManifestValidation, type HookPhase, type LifecycleHookPhase, type ValidatedHookManifest, } from './schema.js';
7
+ export { LIFECYCLE_EVENTS, isLifecycleEvent, type LifecycleEvent } from './lifecycle-catalog.js';
@@ -1,6 +1,7 @@
1
1
  export { createCoreHookCatalog } from './catalog.js';
2
2
  export { projectEffectiveLeafHelp } from './help.js';
3
- export { compileHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, effectiveHookPlugins, hookDiscoveryScopes, } from './discovery.js';
3
+ export { compileHookRegistry, compileLifecycleHookRegistry, discoverCandidateHookRegistry, discoverHookRegistry, discoverHookRegistryInScopes, discoverLifecycleHookRegistry, effectiveHookPlugins, hookDiscoveryScopes, } from './discovery.js';
4
4
  export { validatePluginHookArtifact } from './artifact.js';
5
5
  export { hasDeclaredHooks, hookReport, HOOK_TRUST_WARNING } from './report.js';
6
6
  export { validateHookManifest, } from './schema.js';
7
+ export { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
@@ -0,0 +1,3 @@
1
+ export declare const LIFECYCLE_EVENTS: readonly ["node:start"];
2
+ export type LifecycleEvent = typeof LIFECYCLE_EVENTS[number];
3
+ export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
@@ -0,0 +1,4 @@
1
+ export const LIFECYCLE_EVENTS = ['node:start'];
2
+ export function isLifecycleEvent(value) {
3
+ return LIFECYCLE_EVENTS.includes(value);
4
+ }
@@ -1,7 +1,7 @@
1
1
  import type { PluginManifest } from '../../types.js';
2
2
  import type { HookDiscoveryIssue, HookPluginValidation } from './discovery.js';
3
- import type { DeclaredHook } from './schema.js';
4
- export declare const HOOK_TRUST_WARNING = "Hooks receive normalized inputs; after hooks receive results. They run implicitly and may block or replace declared targets. The local executable runs with caller authority and can transmit received data.";
3
+ import type { DeclaredHook, DeclaredLifecycleHook } from './schema.js';
4
+ export declare const HOOK_TRUST_WARNING = "Command hooks receive normalized inputs and may block or replace declared targets. Lifecycle hooks run unprompted at every declared event before node launch. Both run local executables with runtime authority and may transmit received data.";
5
5
  export interface HookDeclarationReport {
6
6
  target: string;
7
7
  phase: DeclaredHook['phase'];
@@ -9,6 +9,13 @@ export interface HookDeclarationReport {
9
9
  description: string;
10
10
  effects: readonly string[];
11
11
  }
12
+ export interface LifecycleHookDeclarationReport {
13
+ event: DeclaredLifecycleHook['event'];
14
+ phase: DeclaredLifecycleHook['phase'];
15
+ op: string;
16
+ description: string;
17
+ effects: readonly string[];
18
+ }
12
19
  /** One static hook artifact report shared by plugin inspection and lifecycle
13
20
  * output. It only reads declarations and filesystem metadata; it never runs
14
21
  * the hook executable. */
@@ -16,6 +23,8 @@ export interface HookReport {
16
23
  manifestPath?: string;
17
24
  executablePath?: string;
18
25
  declarations: readonly HookDeclarationReport[];
26
+ /** Present only for schemaVersion 2 manifests. */
27
+ lifecycle?: readonly LifecycleHookDeclarationReport[];
19
28
  issues: readonly HookDiscoveryIssue[];
20
29
  trust: typeof HOOK_TRUST_WARNING;
21
30
  }
@@ -1,4 +1,4 @@
1
- export const HOOK_TRUST_WARNING = 'Hooks receive normalized inputs; after hooks receive results. They run implicitly and may block or replace declared targets. The local executable runs with caller authority and can transmit received data.';
1
+ export const HOOK_TRUST_WARNING = 'Command hooks receive normalized inputs and may block or replace declared targets. Lifecycle hooks run unprompted at every declared event before node launch. Both run local executables with runtime authority and may transmit received data.';
2
2
  export function hasDeclaredHooks(manifest) {
3
3
  return manifest.hooks !== undefined || manifest.hookExecutable !== undefined;
4
4
  }
@@ -13,6 +13,15 @@ export function hookReport(validation, issues = validation.issues) {
13
13
  description: hook.description,
14
14
  effects: Object.freeze([...hook.effects]),
15
15
  }))),
16
+ ...(validation.manifest?.schemaVersion === 2 ? {
17
+ lifecycle: Object.freeze(validation.manifest.lifecycle.map((hook) => Object.freeze({
18
+ event: hook.event,
19
+ phase: hook.phase,
20
+ op: hook.op,
21
+ description: hook.description,
22
+ effects: Object.freeze([...hook.effects]),
23
+ }))),
24
+ } : {}),
16
25
  issues: Object.freeze([...issues]),
17
26
  trust: HOOK_TRUST_WARNING,
18
27
  });
@@ -1,4 +1,6 @@
1
+ import { type LifecycleEvent } from './lifecycle-catalog.js';
1
2
  export type HookPhase = 'before' | 'after' | 'replace';
3
+ export type LifecycleHookPhase = 'on';
2
4
  export interface DeclaredHook {
3
5
  target: string;
4
6
  phase: HookPhase;
@@ -6,10 +8,23 @@ export interface DeclaredHook {
6
8
  description: string;
7
9
  effects: string[];
8
10
  }
9
- export interface ValidatedHookManifest {
11
+ export interface DeclaredLifecycleHook {
12
+ event: LifecycleEvent;
13
+ phase: LifecycleHookPhase;
14
+ op: string;
15
+ description: string;
16
+ effects: string[];
17
+ }
18
+ export interface ValidatedV1HookManifest {
10
19
  schemaVersion: 1;
11
20
  hooks: DeclaredHook[];
12
21
  }
22
+ export interface ValidatedV2HookManifest {
23
+ schemaVersion: 2;
24
+ hooks: DeclaredHook[];
25
+ lifecycle: DeclaredLifecycleHook[];
26
+ }
27
+ export type ValidatedHookManifest = ValidatedV1HookManifest | ValidatedV2HookManifest;
13
28
  export type HookIssueCode = 'hook_manifest_unreadable' | 'hook_manifest_invalid' | 'hook_schema_version' | 'hook_path_unsafe' | 'hook_not_executable';
14
29
  export interface HookManifestIssue {
15
30
  code: HookIssueCode;
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from '../../shared/predicates.js';
2
+ import { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
2
3
  function typeName(value) {
3
4
  if (value === null)
4
5
  return 'null';
@@ -6,6 +7,98 @@ function typeName(value) {
6
7
  return 'array';
7
8
  return typeof value;
8
9
  }
10
+ function validateCommandDeclaration(value, index, issue) {
11
+ const path = `hooks[${index}]`;
12
+ if (!isRecord(value)) {
13
+ issue('hook_manifest_invalid', 'hook must be an object', typeName(value), '{ target, phase, op, description, effects }', 'Fix the hook declaration.', path);
14
+ return undefined;
15
+ }
16
+ const keys = Object.keys(value).filter((key) => !['target', 'phase', 'op', 'description', 'effects'].includes(key));
17
+ if (keys.length > 0) {
18
+ issue('hook_manifest_invalid', 'unknown hook keys', keys.join(', '), 'only: target, phase, op, description, effects', 'Remove the unknown keys.', path);
19
+ return undefined;
20
+ }
21
+ for (const field of ['target', 'op', 'description']) {
22
+ if (typeof value[field] !== 'string' || value[field].length === 0) {
23
+ issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
24
+ return undefined;
25
+ }
26
+ }
27
+ if (value['phase'] !== 'before' && value['phase'] !== 'after' && value['phase'] !== 'replace') {
28
+ issue('hook_manifest_invalid', 'phase must be before|after|replace', String(value['phase']), 'before | after | replace', 'Set phase to a supported hook phase.', `${path}.phase`);
29
+ return undefined;
30
+ }
31
+ if (!isEffects(value['effects'])) {
32
+ issue('hook_manifest_invalid', 'effects must be a non-empty string array', typeName(value['effects']), 'a non-empty array of non-empty strings', 'Declare every persistent effect.', `${path}.effects`);
33
+ return undefined;
34
+ }
35
+ return {
36
+ target: value['target'],
37
+ phase: value['phase'],
38
+ op: value['op'],
39
+ description: value['description'],
40
+ effects: value['effects'],
41
+ };
42
+ }
43
+ function validateLifecycleDeclaration(value, index, issue) {
44
+ const path = `lifecycle[${index}]`;
45
+ if (!isRecord(value)) {
46
+ issue('hook_manifest_invalid', 'lifecycle hook must be an object', typeName(value), '{ event, phase, op, description, effects }', 'Fix the lifecycle declaration.', path);
47
+ return undefined;
48
+ }
49
+ const keys = Object.keys(value).filter((key) => !['event', 'phase', 'op', 'description', 'effects'].includes(key));
50
+ if (keys.length > 0) {
51
+ issue('hook_manifest_invalid', 'unknown lifecycle hook keys', keys.join(', '), 'only: event, phase, op, description, effects', 'Remove the unknown keys.', path);
52
+ return undefined;
53
+ }
54
+ for (const field of ['event', 'op', 'description']) {
55
+ if (typeof value[field] !== 'string' || value[field].length === 0) {
56
+ issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
57
+ return undefined;
58
+ }
59
+ }
60
+ if (!isLifecycleEvent(value['event'])) {
61
+ issue('hook_manifest_invalid', 'event must name a supported lifecycle event', String(value['event']), LIFECYCLE_EVENTS.join(' | '), `Set event to one of: ${LIFECYCLE_EVENTS.join(', ')}.`, `${path}.event`);
62
+ return undefined;
63
+ }
64
+ if (value['phase'] !== 'on') {
65
+ issue('hook_manifest_invalid', 'phase must be on', String(value['phase']), 'on', 'Set phase to on.', `${path}.phase`);
66
+ return undefined;
67
+ }
68
+ if (!isEffects(value['effects'])) {
69
+ issue('hook_manifest_invalid', 'effects must be a non-empty string array', typeName(value['effects']), 'a non-empty array of non-empty strings', 'Declare every persistent effect.', `${path}.effects`);
70
+ return undefined;
71
+ }
72
+ return {
73
+ event: value['event'],
74
+ phase: 'on',
75
+ op: value['op'],
76
+ description: value['description'],
77
+ effects: value['effects'],
78
+ };
79
+ }
80
+ function isEffects(value) {
81
+ return Array.isArray(value) && value.length > 0 && value.every((effect) => typeof effect === 'string' && effect.length > 0);
82
+ }
83
+ function validateV1(raw, issue) {
84
+ const unknown = Object.keys(raw).filter((key) => key !== 'schemaVersion' && key !== 'hooks');
85
+ if (unknown.length > 0) {
86
+ issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks', 'Remove the unknown keys.');
87
+ return { issues: [] };
88
+ }
89
+ if (!Array.isArray(raw['hooks']) || raw['hooks'].length === 0) {
90
+ issue('hook_manifest_invalid', 'hooks must be a non-empty array', typeName(raw['hooks']), 'a non-empty array of hook declarations', 'Declare at least one hook.', 'hooks');
91
+ return { issues: [] };
92
+ }
93
+ const hooks = [];
94
+ for (let index = 0; index < raw['hooks'].length; index++) {
95
+ const hook = validateCommandDeclaration(raw['hooks'][index], index, issue);
96
+ if (hook === undefined)
97
+ return { issues: [] };
98
+ hooks.push(hook);
99
+ }
100
+ return { manifest: { schemaVersion: 1, hooks }, issues: [] };
101
+ }
9
102
  /** Validates the declarative hooks.json payload without filesystem access. */
10
103
  export function validateHookManifest(raw) {
11
104
  const issues = [];
@@ -16,53 +109,48 @@ export function validateHookManifest(raw) {
16
109
  issue('hook_manifest_invalid', 'manifest must be an object', typeName(raw), '{ schemaVersion, hooks }', 'Provide a valid hooks.json manifest.');
17
110
  return { issues };
18
111
  }
19
- const unknown = Object.keys(raw).filter((key) => key !== 'schemaVersion' && key !== 'hooks');
112
+ if (raw['schemaVersion'] === 1) {
113
+ const validation = validateV1(raw, issue);
114
+ return { ...(validation.manifest === undefined ? {} : { manifest: validation.manifest }), issues };
115
+ }
116
+ if (raw['schemaVersion'] !== 2) {
117
+ issue('hook_schema_version', 'schemaVersion must be exactly 1 or 2', String(raw['schemaVersion']), '1 | 2', 'Update the manifest schema version to 1 or 2.', 'schemaVersion');
118
+ return { issues };
119
+ }
120
+ const unknown = Object.keys(raw).filter((key) => key !== 'schemaVersion' && key !== 'hooks' && key !== 'lifecycle');
20
121
  if (unknown.length > 0) {
21
- issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks', 'Remove the unknown keys.');
122
+ issue('hook_manifest_invalid', 'unknown top-level keys', unknown.join(', '), 'only: schemaVersion, hooks, lifecycle', 'Remove the unknown keys.');
22
123
  return { issues };
23
124
  }
24
- if (raw['schemaVersion'] !== 1) {
25
- issue('hook_schema_version', 'schemaVersion must be exactly 1', String(raw['schemaVersion']), '1', 'Update the manifest schema version to 1.', 'schemaVersion');
125
+ const rawHooks = raw['hooks'];
126
+ const rawLifecycle = raw['lifecycle'];
127
+ if (rawHooks !== undefined && !Array.isArray(rawHooks)) {
128
+ issue('hook_manifest_invalid', 'hooks must be an array when present', typeName(rawHooks), 'an array of hook declarations', 'Fix hooks.', 'hooks');
26
129
  return { issues };
27
130
  }
28
- if (!Array.isArray(raw['hooks']) || raw['hooks'].length === 0) {
29
- issue('hook_manifest_invalid', 'hooks must be a non-empty array', typeName(raw['hooks']), 'a non-empty array of hook declarations', 'Declare at least one hook.', 'hooks');
131
+ if (rawLifecycle !== undefined && !Array.isArray(rawLifecycle)) {
132
+ issue('hook_manifest_invalid', 'lifecycle must be an array when present', typeName(rawLifecycle), 'an array of lifecycle hook declarations', 'Fix lifecycle.', 'lifecycle');
30
133
  return { issues };
31
134
  }
32
- const hooks = [];
33
- for (let index = 0; index < raw['hooks'].length; index++) {
34
- const value = raw['hooks'][index];
35
- const path = `hooks[${index}]`;
36
- if (!isRecord(value)) {
37
- issue('hook_manifest_invalid', 'hook must be an object', typeName(value), '{ target, phase, op, description, effects }', 'Fix the hook declaration.', path);
38
- return { issues };
39
- }
40
- const keys = Object.keys(value).filter((key) => !['target', 'phase', 'op', 'description', 'effects'].includes(key));
41
- if (keys.length > 0) {
42
- issue('hook_manifest_invalid', 'unknown hook keys', keys.join(', '), 'only: target, phase, op, description, effects', 'Remove the unknown keys.', path);
43
- return { issues };
44
- }
45
- for (const field of ['target', 'op', 'description']) {
46
- if (typeof value[field] !== 'string' || value[field].length === 0) {
47
- issue('hook_manifest_invalid', `${field} must be a non-empty string`, typeName(value[field]), 'a non-empty string', `Set ${field}.`, `${path}.${field}`);
48
- return { issues };
49
- }
50
- }
51
- if (value['phase'] !== 'before' && value['phase'] !== 'after' && value['phase'] !== 'replace') {
52
- issue('hook_manifest_invalid', 'phase must be before|after|replace', String(value['phase']), 'before | after | replace', 'Set phase to a supported hook phase.', `${path}.phase`);
135
+ const hooks = rawHooks ?? [];
136
+ const lifecycle = rawLifecycle ?? [];
137
+ if (hooks.length === 0 && lifecycle.length === 0) {
138
+ issue('hook_manifest_invalid', 'hooks or lifecycle must be a non-empty array', 'both arrays were empty or absent', 'at least one command or lifecycle hook declaration', 'Declare at least one hook.', 'hooks');
139
+ return { issues };
140
+ }
141
+ const declaredHooks = [];
142
+ for (let index = 0; index < hooks.length; index++) {
143
+ const hook = validateCommandDeclaration(hooks[index], index, issue);
144
+ if (hook === undefined)
53
145
  return { issues };
54
- }
55
- if (!Array.isArray(value['effects']) || value['effects'].length === 0 || !value['effects'].every((effect) => typeof effect === 'string' && effect.length > 0)) {
56
- issue('hook_manifest_invalid', 'effects must be a non-empty string array', typeName(value['effects']), 'a non-empty array of non-empty strings', 'Declare every persistent effect.', `${path}.effects`);
146
+ declaredHooks.push(hook);
147
+ }
148
+ const declaredLifecycle = [];
149
+ for (let index = 0; index < lifecycle.length; index++) {
150
+ const hook = validateLifecycleDeclaration(lifecycle[index], index, issue);
151
+ if (hook === undefined)
57
152
  return { issues };
58
- }
59
- hooks.push({
60
- target: value['target'],
61
- phase: value['phase'],
62
- op: value['op'],
63
- description: value['description'],
64
- effects: value['effects'],
65
- });
66
- }
67
- return { manifest: { schemaVersion: 1, hooks }, issues };
153
+ declaredLifecycle.push(hook);
154
+ }
155
+ return { manifest: { schemaVersion: 2, hooks: declaredHooks, lifecycle: declaredLifecycle }, issues };
68
156
  }
@@ -0,0 +1,12 @@
1
+ import type { LifecycleHookRequest } from '../../../hook-authoring.js';
2
+ import type { EffectiveLifecycleHook } from '../discovery.js';
3
+ export declare const LIFECYCLE_HOOK_TIMEOUT_MS = 5000;
4
+ export declare const LIFECYCLE_EVENT_BUDGET_MS = 10000;
5
+ export interface LifecycleHookInvocation {
6
+ node: LifecycleHookRequest['node'];
7
+ runtime: LifecycleHookRequest['runtime'];
8
+ }
9
+ /** Runs one lifecycle hook. The node launch remains authoritative on every failure. */
10
+ export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: LifecycleHookInvocation, timeoutMs: number): Promise<void>;
11
+ /** Executes lifecycle hooks in discovery order, within one bounded start-event budget. */
12
+ export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;