@north-light/crouter 0.3.311 → 0.3.312

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 (51) hide show
  1. package/dist/clients/attach/viewer.js +394 -394
  2. package/dist/core/__tests__/context-intro.test.js +4 -4
  3. package/dist/core/__tests__/human-deliver.test.js +18 -18
  4. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  5. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  6. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  7. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  8. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  9. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  10. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  11. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  12. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  13. package/dist/core/command-hooks/artifact.d.ts +6 -0
  14. package/dist/core/command-hooks/artifact.js +22 -6
  15. package/dist/core/command-hooks/discovery.d.ts +2 -0
  16. package/dist/core/command-hooks/discovery.js +2 -0
  17. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  18. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  19. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  20. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  21. package/dist/core/command-hooks/schema.d.ts +2 -2
  22. package/dist/core/command-hooks/schema.js +5 -4
  23. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  24. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  25. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  26. package/dist/core/errors.d.ts +1 -0
  27. package/dist/core/errors.js +4 -0
  28. package/dist/core/human/feedback-companion.js +1 -1
  29. package/dist/core/review/companion.d.ts +1 -1
  30. package/dist/core/review/companion.js +2 -2
  31. package/dist/core/review/realize.js +6 -6
  32. package/dist/core/runtime/nodes.d.ts +5 -1
  33. package/dist/core/runtime/nodes.js +26 -11
  34. package/dist/core/runtime/recycle.js +16 -12
  35. package/dist/core/runtime/reset.js +4 -1
  36. package/dist/core/runtime/revive.js +13 -12
  37. package/dist/core/runtime/spawn.d.ts +4 -1
  38. package/dist/core/runtime/spawn.js +30 -3
  39. package/dist/core/runtime/warm-pool.d.ts +2 -2
  40. package/dist/core/runtime/warm-pool.js +23 -3
  41. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  42. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  43. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  44. package/dist/daemon/api/handlers/human.js +5 -2
  45. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  46. package/dist/daemon/api/handlers/nodes.js +34 -29
  47. package/dist/hook-authoring.d.ts +28 -2
  48. package/dist/hook-authoring.js +53 -1
  49. package/dist/index.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/runtime.lock.json +5 -5
@@ -76,9 +76,9 @@ function makeWorkTree() {
76
76
  'NESTED NEIGHBOUR BODY\n');
77
77
  return work;
78
78
  }
79
- test('a nested store front door surfaces on a read beneath its owning dir, not outside it', () => {
79
+ test('a nested store front door surfaces on a read beneath its owning dir, not outside it', async () => {
80
80
  const work = makeWorkTree();
81
- const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
81
+ const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
82
82
  const inside = join(work, 'pkg', 'lib', 'file.ts');
83
83
  mkdirSync(join(work, 'pkg', 'lib'), { recursive: true });
84
84
  writeFileSync(inside, 'export const x = 1;\n');
@@ -93,9 +93,9 @@ test('a nested store front door surfaces on a read beneath its owning dir, not o
93
93
  const renderedOutside = renderOnReadDocs(node, outside, freshTranscriptTarget());
94
94
  assert.ok(!renderedOutside.includes(NESTED_BODY), `read outside pkg must not surface the nested front door; got: ${renderedOutside}`);
95
95
  });
96
- test('workspace-open and on-read share one exposure ledger', () => {
96
+ test('workspace-open and on-read share one exposure ledger', async () => {
97
97
  const work = makeWorkTree();
98
- const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
98
+ const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
99
99
  const transcript = freshTranscriptTarget();
100
100
  const opened = renderWorkspaceOpenDocs(node, transcript);
101
101
  assert.ok(opened.includes(ROOT_BODY), 'workspace open delivers the mounted root front door');
@@ -109,7 +109,7 @@ test('workspace-open and on-read share one exposure ledger', () => {
109
109
  assert.ok(onRead.includes('name="work/pkg"'), 'the nested front door answers under the root namespace plus owner path');
110
110
  assert.ok(!onRead.includes(ROOT_BODY), 'the root front door already delivered at workspace open — never twice');
111
111
  });
112
- test('a hand-authored `./`-anchored read glob in a user-scope doc never matches a read', () => {
112
+ test('a hand-authored `./`-anchored read glob in a user-scope doc never matches a read', async () => {
113
113
  const userStore = join(fakeHome, '.crouter', 'memory');
114
114
  mkdirSync(userStore, { recursive: true });
115
115
  writeFileSync(join(userStore, 'control.md'), '---\nkind: knowledge\n' +
@@ -124,7 +124,7 @@ test('a hand-authored `./`-anchored read glob in a user-scope doc never matches
124
124
  'USER DOT BODY MUST NOT FIRE\n');
125
125
  const work = join(fakeHome, 'work');
126
126
  mkdirSync(work, { recursive: true });
127
- const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
127
+ const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
128
128
  const readFile = join(fakeHome, 'notes', 'thing.md');
129
129
  mkdirSync(join(fakeHome, 'notes'), { recursive: true });
130
130
  writeFileSync(readFile, '# a note\n');
@@ -350,11 +350,11 @@ after(() => {
350
350
  restore('CRTR_PROFILE_ID', prevProfile);
351
351
  restore('CRTR_NODE_ID', prevNode);
352
352
  });
353
- function newNode(kind = 'general') {
354
- return spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
353
+ async function newNode(kind = 'general') {
354
+ return (await spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId })).node_id;
355
355
  }
356
- test('boot delivery caps every profile project at its relationship, including the one used as cwd', () => {
357
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
356
+ test('boot delivery caps every profile project at its relationship, including the one used as cwd', async () => {
357
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
358
358
  // The control: an uncapped relationship still renders its authored content.
359
359
  assert.ok(boot.includes(B.pcontent), `the content-capped project renders its body; got:\n${boot}`);
360
360
  // The reported failure. cwd reaches this root before the manifest pointer
@@ -369,22 +369,22 @@ test('boot delivery caps every profile project at its relationship, including th
369
369
  assert.match(boot, /[├└]─ t9-pname-doc$/m, `the name-capped doc discloses as a bare name; got:\n${boot}`);
370
370
  assert.ok(!boot.includes('When the name project delivers'), 'a name-capped doc discloses no preview line');
371
371
  });
372
- test('surface-entry gates select the rung by node kind while the document gate remains absolute', () => {
372
+ test('surface-entry gates select the rung by node kind while the document gate remains absolute', async () => {
373
373
  for (const kind of ['design', 'spec', 'plan', 'developer']) {
374
- const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
374
+ const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
375
375
  assert.ok(boot.includes(B.kindSplit), `${kind} receives the content rung; got:\n${boot}`);
376
376
  }
377
377
  for (const kind of ['general', 'review', 'advisor']) {
378
- const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
378
+ const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
379
379
  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}`);
380
380
  assert.ok(!boot.includes(B.kindSplit), `${kind} does not receive the body`);
381
381
  }
382
382
  for (const kind of ['explore', 'personal-assistant']) {
383
- const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
383
+ const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
384
384
  assert.ok(!boot.includes('t9-kind-split'), `${kind} receives no kind-split entry; got:\n${boot}`);
385
385
  assert.ok(!boot.includes(B.kindSplit), `${kind} receives no kind-split body`);
386
386
  }
387
- const developer = renderKnowledgeBlock(newNode('developer'), freshTranscriptTarget());
387
+ const developer = renderKnowledgeBlock(await newNode('developer'), freshTranscriptTarget());
388
388
  assert.ok(!developer.includes('t9-document-gate-wins'), `a failing document gate excludes every entry; got:\n${developer}`);
389
389
  assert.ok(!developer.includes(B.documentGateWins), 'a failing document gate excludes its content');
390
390
  assert.ok(developer.includes(B.codeCraftIndex), 'the directory document delivers its boot content');
@@ -394,8 +394,8 @@ test('surface-entry gates select the rung by node kind while the document gate r
394
394
  // The ancestor listing, not just the doc's own directory.
395
395
  assert.match(developer, /sibling {2}# read when: When the dev sibling applies\./, `content delivery exposes the enclosing directory's members too; got:\n${developer}`);
396
396
  });
397
- test('context docs accounting reports content attachment raises from the renderer', () => {
398
- const node = newNode('developer');
397
+ test('context docs accounting reports content attachment raises from the renderer', async () => {
398
+ const node = await newNode('developer');
399
399
  const subject = assembleNodeSubject(node);
400
400
  assert.ok(subject !== null, 'the fixture node assembles a subject');
401
401
  const target = { cwd: cwdProject, profileId, nodeId: node };
@@ -431,8 +431,8 @@ test('context docs accounting reports content attachment raises from the rendere
431
431
  assert.equal(viewerRecord?.deliveryCause, 'directory listing attached to content delivery');
432
432
  plans.dispose();
433
433
  });
434
- test('context docs accounting adds full-view workspace content attachments', () => {
435
- const node = newNode();
434
+ test('context docs accounting adds full-view workspace content attachments', async () => {
435
+ const node = await newNode();
436
436
  const subject = assembleNodeSubject(node);
437
437
  assert.ok(subject !== null, 'the fixture node assembles a subject');
438
438
  const target = { cwd: outsideProject, profileId, nodeId: node };
@@ -446,8 +446,8 @@ test('context docs accounting adds full-view workspace content attachments', ()
446
446
  assert.equal(companion?.final_rung, 'content');
447
447
  assert.equal(companion?.delivery_cause, 'memory-read companion of content delivery');
448
448
  });
449
- test('boot content triggers memory-read companions to a deduplicated fixpoint', () => {
450
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
449
+ test('boot content triggers memory-read companions to a deduplicated fixpoint', async () => {
450
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
451
451
  for (const body of [B.companionSource, B.companionA, B.companionB]) {
452
452
  assert.equal(boot.split(body).length - 1, 1, `content companion ${body} delivers once; got:\n${boot}`);
453
453
  }
@@ -455,8 +455,8 @@ test('boot content triggers memory-read companions to a deduplicated fixpoint',
455
455
  // discloses its members exactly as the doc that routed to it did.
456
456
  assert.match(boot, /member-1 {2}# read when: When the companion member applies\./, `a content companion exposes its own directory's members; got:\n${boot}`);
457
457
  });
458
- test('one body delivered across both boot partitions renders exactly once', () => {
459
- const node = newNode();
458
+ test('one body delivered across both boot partitions renders exactly once', async () => {
459
+ const node = await newNode();
460
460
  const subject = assembleNodeSubject(node);
461
461
  assert.ok(subject !== null, 'the fixture node assembles a subject');
462
462
  const state = emptyContextExposureState();
@@ -470,8 +470,8 @@ test('one body delivered across both boot partitions renders exactly once', () =
470
470
  const occurrences = (preferences.split(B.crossPartition).length - 1) + (knowledge.split(B.crossPartition).length - 1);
471
471
  assert.equal(occurrences, 1, `the companion body renders once across both partitions; got:\n${preferences}\n---\n${knowledge}`);
472
472
  });
473
- test('a node-local doc floors to name only without boot entries', () => {
474
- const node = newNode('explore');
473
+ test('a node-local doc floors to name only without boot entries', async () => {
474
+ const node = await newNode('explore');
475
475
  writeDoc(memoryDir(node), 't9-node-local-bootless', bootlessDoc('When the node-local fallback applies', 'BODY-T9-NODE-LOCAL-BOOTLESS'));
476
476
  writeDoc(memoryDir(node), 't9-node-local-entry-gated', [
477
477
  '---',
@@ -491,8 +491,8 @@ test('a node-local doc floors to name only without boot entries', () => {
491
491
  assert.ok(!boot.includes('t9-node-local-entry-gated'), `ineligible boot entries keep a node-local doc absent; got:\n${boot}`);
492
492
  assert.ok(!boot.includes('BODY-T9-NODE-LOCAL-ENTRY-GATED'), 'ineligible boot entries disclose no body');
493
493
  });
494
- test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', () => {
495
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
494
+ test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', async () => {
495
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
496
496
  assert.ok(!boot.includes(B.pnone), 'a none-capped body must not render');
497
497
  assert.ok(!boot.includes('pnone'), 'a none-capped store must not appear as a name either — not even its namespace row');
498
498
  // Hidden counts are disclosure too: the control project's namespace row proves
@@ -512,7 +512,7 @@ test('a capped nearer candidate yields the shared identity instead of suppressin
512
512
  // physical candidates. Automatic delivery filters candidates by the profile
513
513
  // cap BEFORE dedupe, so the `none`-capped nearer one drops out and the farther
514
514
  // one delivers; a deliberate read stays winner-only on source order.
515
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
515
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
516
516
  assert.ok(boot.includes(B.dupFar), `the farther uncapped candidate delivers the shared identity; got:\n${boot}`);
517
517
  assert.ok(!boot.includes(B.dupNear), 'the none-capped nearer candidate delivers nothing');
518
518
  const read = (await readLeaf.run({
@@ -524,14 +524,14 @@ test('a capped nearer candidate yields the shared identity instead of suppressin
524
524
  }));
525
525
  assert.ok(read.content.includes(B.dupNear) && !read.content.includes(B.dupFar), `a deliberate read resolves the source-order winner; got:\n${read.content}`);
526
526
  });
527
- test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', () => {
528
- const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
527
+ test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', async () => {
528
+ const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
529
529
  assert.match(boot, /[├└]─ t9-namedup-doc$/m, `the name-capped project discloses its doc as a bare name; got:\n${boot}`);
530
530
  assert.ok(!boot.includes(B.nameFromPname), 'a name-capped doc discloses at `name`, never its body');
531
531
  assert.ok(boot.includes(B.nameFromPcontent), 'the content project delivers its own distinct identity in full');
532
532
  });
533
- test('workspace-open clamps each project store the same way boot does', () => {
534
- const opened = renderWorkspaceOpenDocs(newNode(), freshTranscriptTarget());
533
+ test('workspace-open clamps each project store the same way boot does', async () => {
534
+ const opened = renderWorkspaceOpenDocs(await newNode(), freshTranscriptTarget());
535
535
  assert.ok(opened.includes(B.pcontent), `the content-capped project delivers its body; got:\n${opened}`);
536
536
  assert.ok(!opened.includes(B.ppreview), `the cwd project must not deliver its body; got:\n${opened}`);
537
537
  assert.match(opened, /name="ppreview\/t9-ppreview-doc" readWhen="When the cwd project delivers\./, `the cwd project delivers its preview line under its canonical name; got:\n${opened}`);
@@ -539,8 +539,8 @@ test('workspace-open clamps each project store the same way boot does', () => {
539
539
  assert.ok(!opened.includes('t9-pnone-doc'), 'the none-capped project delivers nothing at workspace-open');
540
540
  assert.ok(!opened.includes(B.sharedFromPnone), 'nor its side of a name collision');
541
541
  });
542
- test('workspace-open answers its listings from the workspace it opened, not the ambient one', () => {
543
- const node = newNode();
542
+ test('workspace-open answers its listings from the workspace it opened, not the ambient one', async () => {
543
+ const node = await newNode();
544
544
  const subject = assembleNodeSubject(node);
545
545
  assert.ok(subject !== null, 'the fixture node assembles a subject');
546
546
  // The process cwd is `ppreview`; this workspace is outside the profile
@@ -551,7 +551,7 @@ test('workspace-open answers its listings from the workspace it opened, not the
551
551
  assert.match(opened, /\[\[poutside\/t9-ws-guide\/member-1\]\]: When the outside guide member applies\./, `the listing carries that directory's own members; got:\n${opened}`);
552
552
  });
553
553
  test('memory list and find record synthetic pages without crediting matched documents', async () => {
554
- const node = newNode();
554
+ const node = await newNode();
555
555
  process.env['CRTR_NODE_ID'] = node;
556
556
  try {
557
557
  const listed = await listLeaf.run({
@@ -589,7 +589,7 @@ test('memory list and find record synthetic pages without crediting matched docu
589
589
  }
590
590
  });
591
591
  test('an explicit read after boot content adds no duplicate directory attachments', async () => {
592
- const node = newNode('developer');
592
+ const node = await newNode('developer');
593
593
  process.env['CRTR_NODE_ID'] = node;
594
594
  try {
595
595
  const state = loadContextExposureState(node);
@@ -614,7 +614,7 @@ test('an explicit read after boot content adds no duplicate directory attachment
614
614
  }
615
615
  });
616
616
  test('an explicit `crtr memory read` returns and records the full body a cap withheld', async () => {
617
- const node = newNode();
617
+ const node = await newNode();
618
618
  process.env['CRTR_NODE_ID'] = node;
619
619
  const state = loadContextExposureState(node);
620
620
  const transcript = exposureTarget(state, 'transcript');
@@ -50,7 +50,7 @@ after(async () => {
50
50
  });
51
51
  test('a --worktree spawn at an already-existing node id throws and leaves NO worktree registered', async () => {
52
52
  const nodeId = 'duplicate-id';
53
- spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId });
53
+ await spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId });
54
54
  await assert.rejects(() => spawnChild({ kind: 'general', cwd: repoDir, prompt: '', worktree: true, nodeId }));
55
55
  assert.ok(!existsSync(managedWorktreePath(nodeId)), 'the conflicting spawn must not leave a worktree checkout directory');
56
56
  assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/crtr/${nodeId}`], repoDir).status, 1, 'the conflicting spawn must not leave a registered crtr/<id> branch');
@@ -59,12 +59,12 @@ test('a --worktree spawn at an already-existing node id throws and leaves NO wor
59
59
  assert.equal(getNode(nodeId).kind, 'general');
60
60
  assert.equal(getNode(nodeId).managed_worktree, undefined);
61
61
  });
62
- test('spawnNode({ nodeId }): a hyphen-less opaque id spawns at the exact given id', () => {
62
+ test('spawnNode({ nodeId }): a hyphen-less opaque id spawns at the exact given id', async () => {
63
63
  // Regression for the reviewed opaque-id-contract violation: the old validator
64
64
  // required >=2 hyphen segments and rejected a hyphen-less id outright,
65
65
  // contradicting the design's "opaque, never parsed for structure" ID scheme
66
66
  // that remote and cloud spawns depend on.
67
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId: 'remote42' });
67
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId: 'remote42' });
68
68
  assert.equal(meta.node_id, 'remote42');
69
69
  assert.notEqual(getNode('remote42'), null);
70
70
  });
@@ -33,7 +33,7 @@ after(() => {
33
33
  delete process.env['CRTR_HOME'];
34
34
  });
35
35
  test('the claim path persists a frozen preference snapshot', async () => {
36
- const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
36
+ const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
37
37
  assert.equal(loadContextExposureState(meta.node_id).preferenceSnapshot, null, 'a spare mints with nothing frozen');
38
38
  await deliverBearingsLive(meta.node_id);
39
39
  assert.notEqual(loadContextExposureState(meta.node_id).preferenceSnapshot, null, 'claim freezes the snapshot the persona gate requires');
@@ -1,9 +1,15 @@
1
1
  import type { PluginManifest } from '../../types.js';
2
2
  import { type HookManifestIssue, type ValidatedHookManifest } from './schema.js';
3
+ export interface LifecycleDeclarationCandidate {
4
+ event: string;
5
+ op: string;
6
+ index: number;
7
+ }
3
8
  export interface HookArtifactValidation {
4
9
  manifestPath?: string;
5
10
  executablePath?: string;
6
11
  manifest?: ValidatedHookManifest;
12
+ lifecycleCandidates: readonly LifecycleDeclarationCandidate[];
7
13
  issues: HookManifestIssue[];
8
14
  }
9
15
  /** Statically validates the local hook artifact a source plugin ships. */
@@ -1,6 +1,21 @@
1
1
  import { readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { isAbsolute, resolve, sep } from 'node:path';
3
3
  import { validateHookManifest } from './schema.js';
4
+ function lifecycleCandidates(raw) {
5
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
6
+ return [];
7
+ const lifecycle = raw['lifecycle'];
8
+ if (!Array.isArray(lifecycle))
9
+ return [];
10
+ return lifecycle.flatMap((value, index) => {
11
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
12
+ return [];
13
+ const declaration = value;
14
+ return typeof declaration['event'] === 'string'
15
+ ? [{ event: declaration['event'], op: typeof declaration['op'] === 'string' ? declaration['op'] : '<invalid>', index }]
16
+ : [];
17
+ });
18
+ }
4
19
  function safeFilePath(root, pointer) {
5
20
  if (isAbsolute(pointer))
6
21
  return null;
@@ -29,10 +44,10 @@ export function validatePluginHookArtifact(root, manifest) {
29
44
  expected: 'both hooks and hookExecutable',
30
45
  next: hasHooks ? 'Add plugin.json.hookExecutable or remove hooks.' : 'Add plugin.json.hooks or remove hookExecutable.',
31
46
  });
32
- return { issues };
47
+ return { lifecycleCandidates: [], issues };
33
48
  }
34
49
  if (!hasHooks)
35
- return { issues };
50
+ return { lifecycleCandidates: [], issues };
36
51
  if (typeof hooks !== 'string' || hooks.length === 0) {
37
52
  issues.push({
38
53
  code: 'hook_manifest_invalid',
@@ -54,7 +69,7 @@ export function validatePluginHookArtifact(root, manifest) {
54
69
  });
55
70
  }
56
71
  if (issues.length > 0)
57
- return { issues };
72
+ return { lifecycleCandidates: [], issues };
58
73
  const hooksPointer = hooks;
59
74
  const executablePointer = executable;
60
75
  const manifestPath = safeFilePath(root, hooksPointer);
@@ -90,7 +105,7 @@ export function validatePluginHookArtifact(root, manifest) {
90
105
  });
91
106
  }
92
107
  if (manifestPath === null)
93
- return { ...(executablePath === null ? {} : { executablePath }), issues };
108
+ return { lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
94
109
  let text;
95
110
  try {
96
111
  text = readFileSync(manifestPath, 'utf8');
@@ -103,7 +118,7 @@ export function validatePluginHookArtifact(root, manifest) {
103
118
  expected: 'a readable JSON file',
104
119
  next: 'Reinstall or update the plugin.',
105
120
  });
106
- return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
121
+ return { manifestPath, lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
107
122
  }
108
123
  let raw;
109
124
  try {
@@ -117,11 +132,12 @@ export function validatePluginHookArtifact(root, manifest) {
117
132
  expected: 'a JSON object',
118
133
  next: 'Regenerate hooks.json.',
119
134
  });
120
- return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
135
+ return { manifestPath, lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
121
136
  }
122
137
  const validation = validateHookManifest(raw);
123
138
  return {
124
139
  manifestPath,
140
+ lifecycleCandidates: lifecycleCandidates(raw),
125
141
  ...(executablePath === null ? {} : { executablePath }),
126
142
  ...(validation.manifest === undefined ? {} : { manifest: validation.manifest }),
127
143
  issues: [...issues, ...validation.issues],
@@ -1,4 +1,5 @@
1
1
  import type { InstalledPlugin } from '../../types.js';
2
+ import { type LifecycleDeclarationCandidate } from './artifact.js';
2
3
  import type { CoreHookCatalog } from './catalog.js';
3
4
  import type { HookManifestIssue, HookPhase, LifecycleHookPhase, ValidatedHookManifest } from './schema.js';
4
5
  import type { LifecycleEvent } from './lifecycle-catalog.js';
@@ -65,6 +66,7 @@ export interface HookPluginValidation {
65
66
  manifestPath?: string;
66
67
  executablePath?: string;
67
68
  manifest?: ValidatedHookManifest;
69
+ lifecycleCandidates: readonly LifecycleDeclarationCandidate[];
68
70
  hooks: readonly EffectiveHook[];
69
71
  issues: readonly HookDiscoveryIssue[];
70
72
  }
@@ -109,6 +109,7 @@ export function compileHookRegistry(catalog, plugins) {
109
109
  ...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
110
110
  ...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
111
111
  ...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
112
+ lifecycleCandidates: Object.freeze([...artifact.lifecycleCandidates]),
112
113
  hooks: Object.freeze(compiled),
113
114
  issues: Object.freeze(pluginIssues),
114
115
  }));
@@ -183,6 +184,7 @@ export function compileLifecycleHookRegistry(plugins) {
183
184
  ...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
184
185
  ...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
185
186
  ...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
187
+ lifecycleCandidates: Object.freeze([...artifact.lifecycleCandidates]),
186
188
  hooks: Object.freeze([]),
187
189
  issues: Object.freeze(pluginIssues),
188
190
  }));
@@ -1,3 +1,21 @@
1
- export declare const LIFECYCLE_EVENTS: readonly ["node:start", "node:close"];
2
- export type LifecycleEvent = typeof LIFECYCLE_EVENTS[number];
1
+ export declare const LIFECYCLE_CATALOG: {
2
+ readonly 'node:create': {
3
+ readonly phase: "before";
4
+ readonly hookTimeoutMs: 5000;
5
+ readonly eventBudgetMs: 10000;
6
+ };
7
+ readonly 'node:start': {
8
+ readonly phase: "on";
9
+ readonly hookTimeoutMs: 5000;
10
+ readonly eventBudgetMs: 10000;
11
+ };
12
+ readonly 'node:close': {
13
+ readonly phase: "on";
14
+ readonly hookTimeoutMs: 120000;
15
+ readonly eventBudgetMs: 180000;
16
+ };
17
+ };
18
+ export type LifecycleEvent = keyof typeof LIFECYCLE_CATALOG;
19
+ export type LifecycleHookPhase = (typeof LIFECYCLE_CATALOG)[LifecycleEvent]['phase'];
20
+ export declare const LIFECYCLE_EVENTS: readonly ("node:create" | "node:start" | "node:close")[];
3
21
  export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
@@ -1,4 +1,9 @@
1
- export const LIFECYCLE_EVENTS = ['node:start', 'node:close'];
1
+ export const LIFECYCLE_CATALOG = {
2
+ 'node:create': { phase: 'before', hookTimeoutMs: 5_000, eventBudgetMs: 10_000 },
3
+ 'node:start': { phase: 'on', hookTimeoutMs: 5_000, eventBudgetMs: 10_000 },
4
+ 'node:close': { phase: 'on', hookTimeoutMs: 120_000, eventBudgetMs: 180_000 },
5
+ };
6
+ export const LIFECYCLE_EVENTS = Object.freeze(Object.keys(LIFECYCLE_CATALOG));
2
7
  export function isLifecycleEvent(value) {
3
- return LIFECYCLE_EVENTS.includes(value);
8
+ return Object.hasOwn(LIFECYCLE_CATALOG, value);
4
9
  }
@@ -0,0 +1,4 @@
1
+ import type { NodeCreateHookRequest } from '../../hook-authoring.js';
2
+ export type NodeCreateRequest = NodeCreateHookRequest['create'];
3
+ /** Admit one node creation and return the release that must run after its row exists. */
4
+ export declare function admitNodeCreate(create: NodeCreateRequest): Promise<() => void>;
@@ -0,0 +1,51 @@
1
+ import { crtrHome } from '../canvas/paths.js';
2
+ import { nodeCreateRefused } from '../errors.js';
3
+ import { discoverLifecycleHookRegistry } from './discovery.js';
4
+ import { invokeBlockingLifecycleHooks } from './transport/exec-lifecycle.js';
5
+ let nodeCreateGate = Promise.resolve();
6
+ async function enterNodeCreateGate() {
7
+ let resolve;
8
+ const previous = nodeCreateGate;
9
+ nodeCreateGate = new Promise((release) => { resolve = release; });
10
+ await previous;
11
+ let released = false;
12
+ return () => {
13
+ if (released)
14
+ return;
15
+ released = true;
16
+ resolve();
17
+ };
18
+ }
19
+ function refusedCreateHookDiscovery(cwd, profileId) {
20
+ const registry = discoverLifecycleHookRegistry(cwd, profileId);
21
+ const plan = registry.plans.get('node:create') ?? [];
22
+ const failed = registry.validations.flatMap((validation) => validation.lifecycleCandidates
23
+ .filter((candidate) => candidate.event === 'node:create' && !plan.some((hook) => hook.plugin.name === validation.plugin.name && hook.declarationIndex === candidate.index))
24
+ .map((candidate) => ({
25
+ validation,
26
+ candidate,
27
+ issue: validation.issues.find((issue) => issue.path === `lifecycle[${candidate.index}]` || issue.path?.startsWith(`lifecycle[${candidate.index}].`)) ?? validation.issues[0],
28
+ })))[0];
29
+ if (failed === undefined)
30
+ return registry;
31
+ const { validation, candidate, issue } = failed;
32
+ throw nodeCreateRefused(`plugin "${validation.plugin.name}" lifecycle hook "${candidate.op}" for node:create could not be discovered: ${issue.message}`, `Refused by plugin ${validation.plugin.name}, op ${candidate.op}.`);
33
+ }
34
+ /** Admit one node creation and return the release that must run after its row exists. */
35
+ export async function admitNodeCreate(create) {
36
+ const release = await enterNodeCreateGate();
37
+ try {
38
+ const registry = refusedCreateHookDiscovery(create.cwd, create.profile);
39
+ const refusal = await invokeBlockingLifecycleHooks(registry.plans.get('node:create') ?? [], {
40
+ create,
41
+ runtime: { canvasHome: crtrHome(), profile: create.profile },
42
+ });
43
+ if (refusal !== null)
44
+ throw nodeCreateRefused(refusal.message, refusal.next);
45
+ return release;
46
+ }
47
+ catch (error) {
48
+ release();
49
+ throw error;
50
+ }
51
+ }
@@ -1,6 +1,6 @@
1
- import { type LifecycleEvent } from './lifecycle-catalog.js';
1
+ import { type LifecycleEvent, type LifecycleHookPhase } from './lifecycle-catalog.js';
2
+ export type { LifecycleHookPhase } from './lifecycle-catalog.js';
2
3
  export type HookPhase = 'before' | 'after' | 'replace';
3
- export type LifecycleHookPhase = 'on';
4
4
  export interface DeclaredHook {
5
5
  target: string;
6
6
  phase: HookPhase;
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from '../../shared/predicates.js';
2
- import { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
2
+ import { LIFECYCLE_CATALOG, LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
3
3
  function typeName(value) {
4
4
  if (value === null)
5
5
  return 'null';
@@ -61,8 +61,9 @@ function validateLifecycleDeclaration(value, index, issue) {
61
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
62
  return undefined;
63
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`);
64
+ const phase = LIFECYCLE_CATALOG[value['event']].phase;
65
+ if (value['phase'] !== phase) {
66
+ issue('hook_manifest_invalid', `phase must be ${phase}`, String(value['phase']), phase, `Set phase to ${phase}.`, `${path}.phase`);
66
67
  return undefined;
67
68
  }
68
69
  if (!isEffects(value['effects'])) {
@@ -71,7 +72,7 @@ function validateLifecycleDeclaration(value, index, issue) {
71
72
  }
72
73
  return {
73
74
  event: value['event'],
74
- phase: 'on',
75
+ phase,
75
76
  op: value['op'],
76
77
  description: value['description'],
77
78
  effects: value['effects'],
@@ -170,7 +170,9 @@ function buildRequest(spec, invocation, cwd) {
170
170
  if (invocation.result !== undefined) {
171
171
  throw protocolError(spec, invocation, `${spec.phase} hook invocation included a result`, 'result only for after hooks');
172
172
  }
173
- return { ...base, phase: spec.phase };
173
+ if (spec.phase === 'before')
174
+ return { ...base, phase: 'before' };
175
+ return { ...base, phase: 'replace' };
174
176
  }
175
177
  function parseEnvelope(spec, invocation, stdout) {
176
178
  let value;
@@ -1,12 +1,22 @@
1
- import type { LifecycleHookRequest } from '../../../hook-authoring.js';
1
+ import type { LifecycleHookRequest, NodeCreateHookRequest } from '../../../hook-authoring.js';
2
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 {
3
+ export interface NodeLifecycleHookInvocation {
6
4
  node: LifecycleHookRequest['node'];
7
5
  runtime: LifecycleHookRequest['runtime'];
8
6
  }
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 event budget. */
12
- export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: LifecycleHookInvocation): Promise<void>;
7
+ export interface NodeCreateHookInvocation {
8
+ create: NodeCreateHookRequest['create'];
9
+ runtime: NodeCreateHookRequest['runtime'];
10
+ }
11
+ export interface BlockingLifecycleHookRefusal {
12
+ hook: EffectiveLifecycleHook;
13
+ message: string;
14
+ next: string;
15
+ }
16
+ /** Runs one lifecycle hook. `node:start` and `node:close` remain best-effort. */
17
+ export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: NodeLifecycleHookInvocation, timeoutMs: number): Promise<void>;
18
+ /** Executes non-blocking lifecycle hooks in discovery order within the event's catalogued budget. */
19
+ export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: NodeLifecycleHookInvocation): Promise<void>;
20
+ /** Runs blocking `node:create` hooks. Every protocol or process failure refuses the create. */
21
+ export declare function invokeBlockingLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: NodeCreateHookInvocation): Promise<BlockingLifecycleHookRefusal | null>;
22
+ export declare function lifecycleHookAttribution(hook: EffectiveLifecycleHook): string;