@raidiant/notifai 9.2.2 → 10.0.0

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 (149) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/client.d.ts +3 -1
  6. package/dist/client.js +2 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.js +9 -0
  9. package/dist/codex-wake.js.map +1 -1
  10. package/dist/commands-agent-sessions.d.ts +6 -0
  11. package/dist/commands-agent-sessions.js +51 -0
  12. package/dist/commands-agent-sessions.js.map +1 -0
  13. package/dist/commands-config.js +11 -4
  14. package/dist/commands-config.js.map +1 -1
  15. package/dist/commands-core.d.ts +1 -1
  16. package/dist/commands-doctor.js +83 -6
  17. package/dist/commands-doctor.js.map +1 -1
  18. package/dist/commands-harness-context.d.ts +15 -2
  19. package/dist/commands-harness-context.js +32 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hook-shape.d.ts +4 -3
  22. package/dist/commands-hook-shape.js +19 -10
  23. package/dist/commands-hook-shape.js.map +1 -1
  24. package/dist/commands-hooks.d.ts +4 -3
  25. package/dist/commands-hooks.js +80 -23
  26. package/dist/commands-hooks.js.map +1 -1
  27. package/dist/commands-init.js +12 -1
  28. package/dist/commands-init.js.map +1 -1
  29. package/dist/commands-project.d.ts +4 -0
  30. package/dist/commands-project.js +41 -0
  31. package/dist/commands-project.js.map +1 -0
  32. package/dist/commands-send-support.js +0 -4
  33. package/dist/commands-send-support.js.map +1 -1
  34. package/dist/commands-send.js +12 -6
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +58 -0
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-sounds.d.ts +12 -0
  39. package/dist/commands-sounds.js +35 -0
  40. package/dist/commands-sounds.js.map +1 -0
  41. package/dist/commands.d.ts +3 -0
  42. package/dist/commands.js +3 -0
  43. package/dist/commands.js.map +1 -1
  44. package/dist/config-schema.js +7 -4
  45. package/dist/config-schema.js.map +1 -1
  46. package/dist/config.d.ts +6 -7
  47. package/dist/config.js +9 -9
  48. package/dist/config.js.map +1 -1
  49. package/dist/credentials.js +4 -1
  50. package/dist/credentials.js.map +1 -1
  51. package/dist/guidance-content.d.ts +3 -0
  52. package/dist/guidance-content.js +20 -0
  53. package/dist/guidance-content.js.map +1 -1
  54. package/dist/guidance-render.js +3 -3
  55. package/dist/guidance-render.js.map +1 -1
  56. package/dist/harnesses.d.ts +49 -7
  57. package/dist/harnesses.js +90 -24
  58. package/dist/harnesses.js.map +1 -1
  59. package/dist/hooks.d.ts +28 -52
  60. package/dist/hooks.js +393 -152
  61. package/dist/hooks.js.map +1 -1
  62. package/dist/install-hooks.d.ts +17 -39
  63. package/dist/install-hooks.js +21 -41
  64. package/dist/install-hooks.js.map +1 -1
  65. package/dist/invocation-context.d.ts +2 -2
  66. package/dist/orca-session-title.d.ts +2 -2
  67. package/dist/orca-session-title.js +3 -4
  68. package/dist/orca-session-title.js.map +1 -1
  69. package/dist/program.d.ts +6 -1
  70. package/dist/program.js +46 -5
  71. package/dist/program.js.map +1 -1
  72. package/dist/project-enablement.d.ts +15 -0
  73. package/dist/project-enablement.js +87 -0
  74. package/dist/project-enablement.js.map +1 -0
  75. package/dist/question-timing.d.ts +46 -0
  76. package/dist/question-timing.js +48 -0
  77. package/dist/question-timing.js.map +1 -0
  78. package/dist/readiness.d.ts +7 -0
  79. package/dist/readiness.js.map +1 -1
  80. package/dist/send.d.ts +2 -0
  81. package/dist/send.js +17 -15
  82. package/dist/send.js.map +1 -1
  83. package/dist/session-activation.d.ts +1 -1
  84. package/dist/session-activation.js +3 -4
  85. package/dist/session-activation.js.map +1 -1
  86. package/dist/session-labels.d.ts +18 -2
  87. package/dist/session-labels.js +79 -8
  88. package/dist/session-labels.js.map +1 -1
  89. package/dist/sound-ref.d.ts +23 -0
  90. package/dist/sound-ref.js +28 -0
  91. package/dist/sound-ref.js.map +1 -0
  92. package/package.json +3 -3
  93. package/src/claude-wake.test.ts +1 -0
  94. package/src/claude-wake.ts +10 -0
  95. package/src/client.test.ts +12 -0
  96. package/src/client.ts +8 -0
  97. package/src/codex-wake.test.ts +1 -0
  98. package/src/codex-wake.ts +9 -0
  99. package/src/commands-agent-sessions.ts +72 -0
  100. package/src/commands-config.ts +10 -4
  101. package/src/commands-core.ts +1 -1
  102. package/src/commands-doctor.ts +96 -8
  103. package/src/commands-harness-context.ts +48 -2
  104. package/src/commands-hook-shape.ts +26 -10
  105. package/src/commands-hooks.ts +95 -31
  106. package/src/commands-init.ts +11 -1
  107. package/src/commands-project.ts +47 -0
  108. package/src/commands-send-support.ts +0 -4
  109. package/src/commands-send.ts +10 -5
  110. package/src/commands-skill.test.ts +44 -0
  111. package/src/commands-skill.ts +57 -0
  112. package/src/commands-sounds.test.ts +106 -0
  113. package/src/commands-sounds.ts +49 -0
  114. package/src/commands.test.ts +386 -23
  115. package/src/commands.ts +10 -0
  116. package/src/config-schema.test.ts +1 -0
  117. package/src/config-schema.ts +7 -4
  118. package/src/config.test.ts +44 -23
  119. package/src/config.ts +13 -15
  120. package/src/credentials.test.ts +20 -0
  121. package/src/credentials.ts +4 -1
  122. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  123. package/src/guidance-content.ts +22 -0
  124. package/src/guidance-render.ts +3 -3
  125. package/src/harness-integration.test.ts +212 -0
  126. package/src/harnesses.test.ts +107 -8
  127. package/src/harnesses.ts +111 -30
  128. package/src/hook-adapter.test.ts +27 -8
  129. package/src/hooks.test.ts +812 -83
  130. package/src/hooks.ts +529 -185
  131. package/src/install-hooks.test.ts +13 -16
  132. package/src/install-hooks.ts +40 -58
  133. package/src/invocation-context.ts +2 -2
  134. package/src/orca-session-title.test.ts +8 -49
  135. package/src/orca-session-title.ts +3 -5
  136. package/src/program.test.ts +45 -0
  137. package/src/program.ts +60 -4
  138. package/src/project-enablement.test.ts +55 -0
  139. package/src/project-enablement.ts +99 -0
  140. package/src/question-timing.ts +56 -0
  141. package/src/readiness.ts +8 -1
  142. package/src/send.ts +18 -20
  143. package/src/session-activation.ts +3 -4
  144. package/src/session-labels.test.ts +58 -9
  145. package/src/session-labels.ts +95 -12
  146. package/src/skill.test.ts +14 -3
  147. package/src/sound-ref.test.ts +31 -0
  148. package/src/sound-ref.ts +34 -0
  149. package/src/ui/config-view.test.ts +1 -0
@@ -1,5 +1,26 @@
1
+ import { readFileSync } from 'node:fs'
1
2
  import { describe, expect, it } from 'vitest'
2
- import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
3
+ import {
4
+ HARNESS_CAPABILITIES,
5
+ HARNESS_LABELS,
6
+ HERMES_CLASSIC_CLI_LOCAL_CAPABILITY,
7
+ HERMES_QUESTION_ROUTING_UNAVAILABLE,
8
+ HOOK_INSTALLABLE_HARNESSES,
9
+ SOURCE_CONTEXT_HARNESSES,
10
+ hermesClassicCliLocalInstance,
11
+ isHookInstallableHarness,
12
+ questionRoutingCapability,
13
+ } from './harnesses.js'
14
+
15
+ const PINNED_HERMES_TRACE = JSON.parse(
16
+ readFileSync(new URL('./fixtures/hermes-0.20.6-classic-cli-local.json', import.meta.url), 'utf8'),
17
+ ) as {
18
+ instance: { harness: 'hermes'; surface: 'classic-cli'; terminalBackend: 'local' }
19
+ observedToolSubprocessEnv: { HERMES_SESSION_ID: string }
20
+ supported: string[]
21
+ unsupported: string[]
22
+ deferred: string[]
23
+ }
3
24
 
4
25
  /**
5
26
  * This file used to hold a verbatim copy of the table below it, which meant
@@ -8,23 +29,36 @@ import { HARNESS_CAPABILITIES, HARNESS_LABELS, HARNESSES } from './harnesses.js'
8
29
  * edit. What is worth asserting is the shape the rest of the CLI assumes.
9
30
  */
10
31
  describe('harness contract', () => {
11
- it('describes every shipped harness, and only those', () => {
12
- expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
13
- expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...HARNESSES].sort())
32
+ it('describes every shipped Source Context harness, and only those', () => {
33
+ expect(Object.keys(HARNESS_LABELS).sort()).toEqual([...SOURCE_CONTEXT_HARNESSES].sort())
34
+ })
35
+
36
+ it('keeps managed hook installation a strict subset of Source Context', () => {
37
+ expect(HOOK_INSTALLABLE_HARNESSES).toEqual(['claude-code', 'codex', 'cursor', 'opencode'])
38
+ expect(SOURCE_CONTEXT_HARNESSES).toContain('hermes')
39
+ expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
40
+ expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HOOK_INSTALLABLE_HARNESSES].sort())
41
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
42
+ expect(isHookInstallableHarness(harness)).toBe(true)
43
+ }
44
+ expect(isHookInstallableHarness('hermes')).toBe(false)
14
45
  })
15
46
 
16
47
  it('gives a harness that cannot continue a turn no route to pretend with', () => {
17
48
  // `ask` fails closed on these, and the reason it can is that the two facts
18
49
  // agree. A harness claiming a delivery route while declaring it cannot
19
50
  // continue would accept an answer into a void.
20
- for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
51
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
52
+ const capability = questionRoutingCapability(harness)
21
53
  if (capability.stopContinuation !== 'unsupported') continue
22
54
  expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
23
55
  }
56
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
24
57
  })
25
58
 
26
59
  it('gives a harness that can continue a turn at least one real route', () => {
27
- for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
60
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
61
+ const capability = HARNESS_CAPABILITIES[harness]
28
62
  if (capability.stopContinuation === 'unsupported') continue
29
63
  expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
30
64
  expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
@@ -35,8 +69,73 @@ describe('harness contract', () => {
35
69
  })
36
70
 
37
71
  it('explains each harness in prose an operator can act on', () => {
38
- for (const [harness, capability] of Object.entries(HARNESS_CAPABILITIES)) {
39
- expect(capability.deliveryContract.length, harness).toBeGreaterThan(40)
72
+ for (const harness of HOOK_INSTALLABLE_HARNESSES) {
73
+ expect(questionRoutingCapability(harness).deliveryContract.length, harness).toBeGreaterThan(40)
74
+ }
75
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract.length).toBeGreaterThan(40)
76
+ })
77
+
78
+ it('keeps Claude Code, Codex, Cursor, and OpenCode continuation contracts unchanged', () => {
79
+ expect(HARNESS_CAPABILITIES['claude-code'].stopContinuation).toBe('decision-block')
80
+ expect(HARNESS_CAPABILITIES['claude-code'].deliveryRoutes).toEqual([
81
+ 'hook-continuation',
82
+ 'inbox-socket',
83
+ 'cold-resume',
84
+ 'hold-for-next-turn',
85
+ ])
86
+ expect(HARNESS_CAPABILITIES.codex.stopContinuation).toBe('decision-block')
87
+ expect(HARNESS_CAPABILITIES.codex.deliveryRoutes).toEqual([
88
+ 'hook-continuation',
89
+ 'cold-resume',
90
+ 'hold-for-next-turn',
91
+ ])
92
+ expect(HARNESS_CAPABILITIES.cursor).toEqual({
93
+ stopContinuation: 'unsupported',
94
+ deliveryRoutes: ['unsupported'],
95
+ deliveryContract: HARNESS_CAPABILITIES.cursor.deliveryContract,
96
+ })
97
+ expect(HARNESS_CAPABILITIES.opencode.stopContinuation).toBe('unsupported')
98
+ expect(HARNESS_CAPABILITIES.opencode.deliveryRoutes).toEqual(['unsupported'])
99
+ })
100
+
101
+ it('treats the pinned Hermes classic CLI/local trace as send-only', () => {
102
+ expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance).toEqual(PINNED_HERMES_TRACE.instance)
103
+ expect(PINNED_HERMES_TRACE.supported).toContain('deliberate-send')
104
+ expect(PINNED_HERMES_TRACE.unsupported).toContain('question-routing')
105
+ expect(PINNED_HERMES_TRACE.deferred).toContain('gateway')
106
+ expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.sourceContext).toBe(
107
+ 'hermes-session-id-and-invocation-cwd',
108
+ )
109
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.stopContinuation).toBe('unsupported')
110
+ expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
111
+ })
112
+
113
+ it('resolves only the pinned classic CLI/local marker envelope', () => {
114
+ expect(
115
+ hermesClassicCliLocalInstance({
116
+ HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
117
+ }),
118
+ ).toEqual(PINNED_HERMES_TRACE.instance)
119
+ expect(
120
+ hermesClassicCliLocalInstance({
121
+ HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
122
+ HERMES_SESSION_SOURCE: 'cli',
123
+ TERMINAL_ENV: 'local',
124
+ }),
125
+ ).toEqual(PINNED_HERMES_TRACE.instance)
126
+ for (const env of [
127
+ { HERMES_SESSION_KEY: 'gateway-key' },
128
+ { HERMES_SESSION_PLATFORM: 'api_server' },
129
+ { HERMES_SESSION_SOURCE: 'tui' },
130
+ { TERMINAL_ENV: 'docker' },
131
+ { TERMINAL_ENV: 'ssh' },
132
+ ]) {
133
+ expect(
134
+ hermesClassicCliLocalInstance({
135
+ HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
136
+ ...env,
137
+ }),
138
+ ).toBeNull()
40
139
  }
41
140
  })
42
141
  })
package/src/harnesses.ts CHANGED
@@ -1,13 +1,29 @@
1
- /** Every harness Notifai ships. This is the sole source of the harness union. */
2
- export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
1
+ /** Every harness Notifai can name in Source Context when authoritatively known. */
2
+ export const SOURCE_CONTEXT_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode', 'hermes'] as const
3
3
 
4
- export type Harness = (typeof HARNESSES)[number]
4
+ export type SourceContextHarness = (typeof SOURCE_CONTEXT_HARNESSES)[number]
5
5
 
6
- export const HARNESS_LABELS: Record<Harness, string> = {
6
+ /**
7
+ * Harnesses whose lifecycle Notifai can install and manage.
8
+ *
9
+ * Source Context is the broader vocabulary. Managed hook installation is a
10
+ * narrower, independently proven cell: Hermes can appear in Source Context
11
+ * without being hook-installable.
12
+ */
13
+ export const HOOK_INSTALLABLE_HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
14
+
15
+ export type HookInstallableHarness = (typeof HOOK_INSTALLABLE_HARNESSES)[number]
16
+
17
+ export function isHookInstallableHarness(value: string): value is HookInstallableHarness {
18
+ return (HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(value)
19
+ }
20
+
21
+ export const HARNESS_LABELS: Record<SourceContextHarness, string> = {
7
22
  'claude-code': 'Claude Code',
8
23
  codex: 'Codex',
9
24
  cursor: 'Cursor',
10
25
  opencode: 'OpenCode',
26
+ hermes: 'Hermes',
11
27
  }
12
28
 
13
29
  export type DeliveryRoute =
@@ -29,33 +45,98 @@ export interface HarnessCapability {
29
45
  deliveryContract: string
30
46
  }
31
47
 
48
+ const CLAUDE_CODE_CAPABILITY: HarnessCapability = {
49
+ stopContinuation: 'decision-block',
50
+ deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
51
+ deliveryContract:
52
+ 'the Stop hook returns at once and waits out of band through the complete answer window, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
53
+ }
54
+
55
+ const CODEX_CAPABILITY: HarnessCapability = {
56
+ stopContinuation: 'decision-block',
57
+ deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
58
+ deliveryContract:
59
+ 'live Stop continuation while the turn is held through the complete answer window; crash recovery may resume only a stopped thread behind its writer lock',
60
+ }
61
+
62
+ const CURSOR_CAPABILITY: HarnessCapability = {
63
+ stopContinuation: 'unsupported',
64
+ deliveryRoutes: ['unsupported'],
65
+ deliveryContract:
66
+ 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
67
+ }
68
+
69
+ const OPENCODE_CAPABILITY: HarnessCapability = {
70
+ stopContinuation: 'unsupported',
71
+ deliveryRoutes: ['unsupported'],
72
+ deliveryContract:
73
+ 'no proven answer continuation after session.idle; use a blocking reply command',
74
+ }
75
+
76
+ export const HERMES_QUESTION_ROUTING_UNAVAILABLE: HarnessCapability = {
77
+ stopContinuation: 'unsupported',
78
+ deliveryRoutes: ['unsupported'],
79
+ deliveryContract:
80
+ 'Hermes asynchronous ask has no proven continuation owner on this integration surface. Use a blocking `notifai send --reply` question',
81
+ }
82
+
32
83
  /**
33
- * Exhaustive runtime contract. Adding a harness without deciding every
34
- * continuation joint is a type error, not a silently degraded notification.
84
+ * The one Hermes integration instance proven by the pinned vertical probe.
85
+ *
86
+ * This is evidence-bearing capability data, not an adapter. Unsupported and
87
+ * deferred surfaces intentionally have no empty implementation object here.
35
88
  */
36
- export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
37
- 'claude-code': {
38
- stopContinuation: 'decision-block',
39
- deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
40
- deliveryContract:
41
- 'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
42
- },
43
- codex: {
44
- stopContinuation: 'decision-block',
45
- deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
46
- deliveryContract:
47
- 'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
48
- },
49
- cursor: {
50
- stopContinuation: 'unsupported',
51
- deliveryRoutes: ['unsupported'],
52
- deliveryContract:
53
- 'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
54
- },
55
- opencode: {
56
- stopContinuation: 'unsupported',
57
- deliveryRoutes: ['unsupported'],
58
- deliveryContract:
59
- 'no proven answer continuation after session.idle; use a blocking reply command',
89
+ export const HERMES_CLASSIC_CLI_LOCAL_CAPABILITY = {
90
+ instance: {
91
+ harness: 'hermes',
92
+ surface: 'classic-cli',
93
+ terminalBackend: 'local',
60
94
  },
95
+ setup: 'unsupported',
96
+ activation: 'unsupported',
97
+ sourceContext: 'hermes-session-id-and-invocation-cwd',
98
+ continuation: 'unsupported',
99
+ } as const
100
+
101
+ export type HermesClassicCliLocalInstance =
102
+ (typeof HERMES_CLASSIC_CLI_LOCAL_CAPABILITY)['instance']
103
+
104
+ /**
105
+ * Resolve only the Hermes instance whose complete marker envelope was proven.
106
+ *
107
+ * `HERMES_SESSION_ID` is also bridged by deferred gateway, TUI, API, ACP, and
108
+ * remote-backend paths. Their first-party routing/source/backend markers keep
109
+ * those cells distinct. Missing source/backend values are the pinned classic
110
+ * CLI defaults; a gateway key is never part of that instance.
111
+ */
112
+ export function hermesClassicCliLocalInstance(
113
+ env: NodeJS.ProcessEnv,
114
+ ): HermesClassicCliLocalInstance | null {
115
+ const sessionId = (env['HERMES_SESSION_ID'] ?? '').trim()
116
+ const sessionKey = (env['HERMES_SESSION_KEY'] ?? '').trim()
117
+ const platform = (env['HERMES_SESSION_PLATFORM'] ?? '').trim().toLowerCase()
118
+ const source = (env['HERMES_SESSION_SOURCE'] ?? '').trim().toLowerCase()
119
+ const terminalBackend = (env['TERMINAL_ENV'] ?? '').trim().toLowerCase()
120
+ if (sessionId === '' || sessionKey !== '' || platform !== '') return null
121
+ if (source !== '' && source !== 'cli') return null
122
+ if (terminalBackend !== '' && terminalBackend !== 'local') return null
123
+ return HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance
124
+ }
125
+
126
+ /**
127
+ * Question-routing contract for hook-installable harnesses.
128
+ *
129
+ * Hermes is deliberately absent: a send-only surface must not occupy this
130
+ * table with a fake managed-hook continuation route.
131
+ */
132
+ export const HARNESS_CAPABILITIES: Record<HookInstallableHarness, HarnessCapability> = {
133
+ 'claude-code': CLAUDE_CODE_CAPABILITY,
134
+ codex: CODEX_CAPABILITY,
135
+ cursor: CURSOR_CAPABILITY,
136
+ opencode: OPENCODE_CAPABILITY,
137
+ }
138
+
139
+ /** Question-routing capability for the active harness integration. */
140
+ export function questionRoutingCapability(harness: HookInstallableHarness): HarnessCapability {
141
+ return HARNESS_CAPABILITIES[harness]
61
142
  }
@@ -21,6 +21,7 @@ import {
21
21
  isNpxAdapterTarget,
22
22
  } from './hook-adapter.js'
23
23
  import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
24
+ import { enableProject, projectBinding } from './project-enablement.js'
24
25
 
25
26
  function isolated(): { root: string; homeDir: string } {
26
27
  const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hook-adapter-'))
@@ -28,22 +29,40 @@ function isolated(): { root: string; homeDir: string } {
28
29
  }
29
30
 
30
31
  describe('stable hook adapter', () => {
31
- it('delivers session activation through the installed adapter before setup exists', () => {
32
+ it('keeps the installed adapter silent until the Project is enabled', () => {
32
33
  const { root, homeDir } = isolated()
34
+ const env = {
35
+ ...process.env,
36
+ HOME: homeDir,
37
+ XDG_CONFIG_HOME: path.join(root, 'config'),
38
+ XDG_STATE_HOME: path.join(root, 'state'),
39
+ }
33
40
  const scriptPath = fileURLToPath(new URL('../dist/main.js', import.meta.url))
34
41
  const installed = installHookAdapter({ execPath: process.execPath, scriptPath }, homeDir)
42
+ const disabled = spawnSync(
43
+ installed.path,
44
+ ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
45
+ {
46
+ encoding: 'utf8',
47
+ input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
48
+ env,
49
+ },
50
+ )
51
+
52
+ expect(disabled.status).toBe(0)
53
+ expect(disabled.stderr).toBe('')
54
+ expect(disabled.stdout).toBe('')
55
+
56
+ const binding = projectBinding(root, env)
57
+ if (binding === null) throw new Error('test Project binding unavailable')
58
+ enableProject(binding)
35
59
  const run = spawnSync(
36
60
  installed.path,
37
61
  ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
38
62
  {
39
63
  encoding: 'utf8',
40
64
  input: JSON.stringify({ session_id: 'adapter-session', cwd: root, source: 'startup' }),
41
- env: {
42
- ...process.env,
43
- HOME: homeDir,
44
- XDG_CONFIG_HOME: path.join(root, 'config'),
45
- XDG_STATE_HOME: path.join(root, 'state'),
46
- },
65
+ env,
47
66
  },
48
67
  )
49
68
 
@@ -52,7 +71,7 @@ describe('stable hook adapter', () => {
52
71
  expect(JSON.parse(run.stdout)).toMatchObject({
53
72
  hookSpecificOutput: {
54
73
  hookEventName: 'SessionStart',
55
- additionalContext: expect.stringMatching(/Notifai.*session/i),
74
+ additionalContext: expect.stringMatching(/Notifai.*enabled.*Project/i),
56
75
  },
57
76
  })
58
77
  })