@raidiant/notifai 7.0.2 → 9.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -0,0 +1,233 @@
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ existsSync,
5
+ fstatSync,
6
+ lstatSync,
7
+ openSync,
8
+ readSync,
9
+ readdirSync,
10
+ realpathSync,
11
+ type Dirent,
12
+ } from 'node:fs'
13
+ import path from 'node:path'
14
+ import { globalConfigDir, personalProjectIdentity } from './config.js'
15
+ import { SHIPPED_GUIDANCE } from './guidance-content.js'
16
+
17
+ /**
18
+ * Layered notification-writing guidance with provenance. Most specific wins,
19
+ * per topic, exactly the discipline config keys follow:
20
+ *
21
+ * project-local > project > global > shipped default
22
+ *
23
+ * A topic is one Markdown file named `<topic>.md`. A user file replaces the
24
+ * shipped topic of the same name wholesale — their words outrank the default,
25
+ * not annotate it — and a file whose name matches no shipped topic is purely
26
+ * additive house rules. There is no session layer: an instruction given in
27
+ * conversation tunes the session by being followed, not persisted.
28
+ *
29
+ * Two of those layers are the User's own word and one is not. `global` and
30
+ * `project-local` live under the User's config home: only they can write
31
+ * there. `project` is `.notifai/guidance` inside the repository, so it
32
+ * arrives with a clone and is written by whoever wrote the repository —
33
+ * which may be nobody the User has ever met. It remains authoritative about
34
+ * *how this project's notifications should read*, because shared house rules
35
+ * are the feature; it is never the User speaking. `authority` carries that
36
+ * distinction to every reader so a cloned repository cannot borrow the
37
+ * User's voice.
38
+ */
39
+
40
+ export type GuidanceSource =
41
+ | `project-local:${string}`
42
+ | `project:${string}`
43
+ | `global:${string}`
44
+ | 'default'
45
+
46
+ /**
47
+ * Who is speaking in a topic.
48
+ *
49
+ * - `user` — the User's own standing word, from a location only they can write.
50
+ * - `repository` — this checkout's shared house rules; committed content, and
51
+ * therefore untrusted input that speaks for the project, not the User.
52
+ * - `shipped` — the default that applies when nobody said otherwise.
53
+ */
54
+ export type GuidanceAuthority = 'user' | 'repository' | 'shipped'
55
+
56
+ export interface ResolvedGuidanceTopic {
57
+ name: string
58
+ source: GuidanceSource
59
+ authority: GuidanceAuthority
60
+ /** One line for lists; user-authored topics describe themselves by content. */
61
+ summary: string
62
+ content: string
63
+ }
64
+
65
+ export function guidanceAuthority(source: GuidanceSource): GuidanceAuthority {
66
+ if (source === 'default') return 'shipped'
67
+ return source.startsWith('project:') ? 'repository' : 'user'
68
+ }
69
+
70
+ /**
71
+ * Topic names double as filenames, and the project layer arrives from a
72
+ * repository — untrusted input. Anything that does not match is not a topic,
73
+ * the same way `coerce` drops an unrecognised config value.
74
+ */
75
+ export const GUIDANCE_TOPIC_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/
76
+
77
+ /**
78
+ * One topic is capped at the body limit. Guidance is read into agent context
79
+ * on every session, and a repository could otherwise feed it without bound.
80
+ */
81
+ export const GUIDANCE_TOPIC_MAX_BYTES = 16_000
82
+
83
+ export function globalGuidanceDir(env: NodeJS.ProcessEnv = process.env): string {
84
+ return path.join(globalConfigDir(env), 'guidance')
85
+ }
86
+
87
+ function isWithin(root: string, candidate: string): boolean {
88
+ const relative = path.relative(root, candidate)
89
+ return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))
90
+ }
91
+
92
+ /** Walk up from cwd looking for `.notifai/guidance` (shared, committed). */
93
+ export function findProjectGuidanceDir(startDir: string): string | null {
94
+ let dir = path.resolve(startDir)
95
+ for (;;) {
96
+ const candidate = path.join(dir, '.notifai', 'guidance')
97
+ if (existsSync(candidate)) {
98
+ try {
99
+ // A clone may contain symlinks. Repository guidance may read only
100
+ // regular content rooted in that repository; it must never turn a
101
+ // local private file into model context by pointing outside the tree.
102
+ const candidateStat = lstatSync(candidate)
103
+ const repositoryRoot = realpathSync(dir)
104
+ const resolved = realpathSync(candidate)
105
+ if (candidateStat.isDirectory() && isWithin(repositoryRoot, resolved)) return candidate
106
+ } catch {
107
+ // An unreadable or unstable candidate is not guidance. Keep walking.
108
+ }
109
+ }
110
+ const parent = path.dirname(dir)
111
+ if (parent === dir) return null
112
+ dir = parent
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Personal project guidance lives beside the personal project config file,
118
+ * under the user's config home rather than the repository:
119
+ * `$XDG_CONFIG_HOME/notifai/projects/<identity>/guidance`.
120
+ */
121
+ export function personalProjectGuidanceDir(
122
+ cwd: string,
123
+ env: NodeJS.ProcessEnv = process.env,
124
+ ): string {
125
+ return path.join(globalConfigDir(env), 'projects', personalProjectIdentity(cwd), 'guidance')
126
+ }
127
+
128
+ function topicsInDir(dir: string): Map<string, string> {
129
+ const topics = new Map<string, string>()
130
+ if (!existsSync(dir)) return topics
131
+ let entries: Dirent[]
132
+ try {
133
+ if (!lstatSync(dir).isDirectory()) return topics
134
+ entries = readdirSync(dir, { withFileTypes: true })
135
+ } catch {
136
+ return topics
137
+ }
138
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
139
+ // In particular, refuse symlinks. A committed link to a User-owned file
140
+ // must not make that file part of repository guidance output.
141
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
142
+ const name = entry.name.slice(0, -'.md'.length)
143
+ if (!GUIDANCE_TOPIC_PATTERN.test(name)) continue
144
+ topics.set(name, path.join(dir, entry.name))
145
+ }
146
+ return topics
147
+ }
148
+
149
+ function readTopicFile(filePath: string): string | null {
150
+ let descriptor: number | null = null
151
+ try {
152
+ descriptor = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW)
153
+ if (!fstatSync(descriptor).isFile()) return null
154
+ const bytes = Buffer.alloc(GUIDANCE_TOPIC_MAX_BYTES + 1)
155
+ let offset = 0
156
+ while (offset < bytes.length) {
157
+ const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset)
158
+ if (count === 0) break
159
+ offset += count
160
+ }
161
+ const truncated = offset > GUIDANCE_TOPIC_MAX_BYTES
162
+ const content = bytes.subarray(0, Math.min(offset, GUIDANCE_TOPIC_MAX_BYTES)).toString('utf8')
163
+ if (content.trim() === '') return null
164
+ return truncated
165
+ ? `${content}\n\n[Truncated: this guidance file exceeds ${GUIDANCE_TOPIC_MAX_BYTES} bytes.]\n`
166
+ : content
167
+ } catch {
168
+ return null
169
+ } finally {
170
+ if (descriptor !== null) closeSync(descriptor)
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Every effective topic, shipped reading order first, additive user topics
176
+ * after it in name order. Each carries the layer that supplied it.
177
+ */
178
+ export function resolveGuidance(options: {
179
+ cwd?: string
180
+ env?: NodeJS.ProcessEnv
181
+ }): ResolvedGuidanceTopic[] {
182
+ const env = options.env ?? process.env
183
+ const cwd = options.cwd ?? process.cwd()
184
+
185
+ // Ascending precedence: a later layer overwrites an earlier one per topic.
186
+ const layers: { dir: string | null; label: 'global' | 'project' | 'project-local' }[] = [
187
+ { dir: globalGuidanceDir(env), label: 'global' },
188
+ { dir: findProjectGuidanceDir(cwd), label: 'project' },
189
+ { dir: personalProjectGuidanceDir(cwd, env), label: 'project-local' },
190
+ ]
191
+
192
+ const winners = new Map<string, { source: GuidanceSource; content: string }>()
193
+ for (const layer of layers) {
194
+ if (layer.dir === null) continue
195
+ for (const [name, filePath] of topicsInDir(layer.dir)) {
196
+ const content = readTopicFile(filePath)
197
+ if (content === null) continue
198
+ winners.set(name, { source: `${layer.label}:${filePath}`, content })
199
+ }
200
+ }
201
+
202
+ const resolved: ResolvedGuidanceTopic[] = SHIPPED_GUIDANCE.map((topic) => {
203
+ const override = winners.get(topic.name)
204
+ winners.delete(topic.name)
205
+ return override === undefined
206
+ ? {
207
+ name: topic.name,
208
+ source: 'default' as const,
209
+ authority: 'shipped' as const,
210
+ summary: topic.summary,
211
+ content: topic.content,
212
+ }
213
+ : {
214
+ name: topic.name,
215
+ source: override.source,
216
+ authority: guidanceAuthority(override.source),
217
+ summary: topic.summary,
218
+ content: override.content,
219
+ }
220
+ })
221
+ for (const name of [...winners.keys()].sort()) {
222
+ const extra = winners.get(name)!
223
+ const authority = guidanceAuthority(extra.source)
224
+ resolved.push({
225
+ name,
226
+ source: extra.source,
227
+ authority,
228
+ summary: authority === 'repository' ? 'Project house rules' : 'User-added guidance',
229
+ content: extra.content,
230
+ })
231
+ }
232
+ return resolved
233
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from 'node:fs'
12
12
  import os from 'node:os'
13
13
  import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
14
15
  import { describe, expect, it } from 'vitest'
15
16
  import {
16
17
  hookAdapterPath,
@@ -27,6 +28,35 @@ function isolated(): { root: string; homeDir: string } {
27
28
  }
28
29
 
29
30
  describe('stable hook adapter', () => {
31
+ it('delivers session activation through the installed adapter before setup exists', () => {
32
+ const { root, homeDir } = isolated()
33
+ const scriptPath = fileURLToPath(new URL('../dist/main.js', import.meta.url))
34
+ const installed = installHookAdapter({ execPath: process.execPath, scriptPath }, homeDir)
35
+ const run = spawnSync(
36
+ installed.path,
37
+ ['hook', 'session-start', '--owner', 'notifai', '--harness', 'codex'],
38
+ {
39
+ encoding: 'utf8',
40
+ 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
+ },
47
+ },
48
+ )
49
+
50
+ expect(run.status).toBe(0)
51
+ expect(run.stderr).toBe('')
52
+ expect(JSON.parse(run.stdout)).toMatchObject({
53
+ hookSpecificOutput: {
54
+ hookEventName: 'SessionStart',
55
+ additionalContext: expect.stringMatching(/Notifai.*session/i),
56
+ },
57
+ })
58
+ })
59
+
30
60
  it('executes paths with spaces, quotes, and shell syntax without evaluating them', () => {
31
61
  const { root, homeDir } = isolated()
32
62
  const odd = path.join(root, "odd ' $(touch should-not-run)")
@@ -122,6 +152,36 @@ describe('stable hook adapter', () => {
122
152
  )
123
153
  })
124
154
 
155
+ it('carries the stable POSIX harness parent through direct and npx targets', () => {
156
+ const { root, homeDir } = isolated()
157
+ const direct = path.join(root, 'direct.js')
158
+ const npmCli = path.join(root, 'npm-cli.js')
159
+ const source = 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n'
160
+ writeFileSync(direct, source)
161
+ writeFileSync(npmCli, source)
162
+
163
+ const directAdapter = installHookAdapter(
164
+ { execPath: process.execPath, scriptPath: direct },
165
+ homeDir,
166
+ )
167
+ const directRun = spawnSync(directAdapter.path, [], { encoding: 'utf8' })
168
+ const npxAdapter = installHookAdapter(
169
+ {
170
+ kind: 'npx',
171
+ execPath: process.execPath,
172
+ npmCli,
173
+ spec: '@raidiant/notifai@0.5.1',
174
+ },
175
+ homeDir,
176
+ )
177
+ const npxRun = spawnSync(npxAdapter.path, [], { encoding: 'utf8' })
178
+
179
+ expect(directRun.status).toBe(0)
180
+ expect(npxRun.status).toBe(0)
181
+ expect(directRun.stdout).toBe(String(process.pid))
182
+ expect(npxRun.stdout).toBe(String(process.pid))
183
+ })
184
+
125
185
  it('uses a fixed home-relative pathname with no XDG input surface', () => {
126
186
  const { homeDir } = isolated()
127
187
  expect(hookAdapterPath(homeDir)).toBe(
@@ -202,6 +262,32 @@ describe('stable hook adapter', () => {
202
262
  ])
203
263
  })
204
264
 
265
+ it('keeps working when a node upgrade moved the pinned runtime but left the CLI', () => {
266
+ const { root, homeDir } = isolated()
267
+ const bin = path.join(root, 'fallback-bin')
268
+ mkdirSync(bin, { recursive: true })
269
+ const pathNode = path.join(bin, 'node')
270
+ writeFileSync(pathNode, '#!/bin/sh\nprintf "ran %s" "$1"\n')
271
+ chmodSync(pathNode, 0o700)
272
+ const pinnedNode = path.join(root, 'pinned-node')
273
+ const cli = path.join(root, 'cli.js')
274
+ writeFileSync(pinnedNode, '#!/bin/sh\n')
275
+ chmodSync(pinnedNode, 0o700)
276
+ writeFileSync(cli, '')
277
+ const installed = installHookAdapter({ execPath: pinnedNode, scriptPath: cli }, homeDir)
278
+ // What `brew upgrade node` and `nvm uninstall` both do: the interpreter
279
+ // moves, the installed CLI does not.
280
+ rmSync(pinnedNode)
281
+
282
+ const run = spawnSync(installed.path, ['hook', 'stop'], {
283
+ encoding: 'utf8',
284
+ env: { PATH: bin },
285
+ })
286
+
287
+ expect(run.status).toBe(0)
288
+ expect(run.stdout).toBe(`ran ${cli}`)
289
+ })
290
+
205
291
  it('fails closed instead of executing an unregistered PATH target after the registered target vanished', () => {
206
292
  const { root, homeDir } = isolated()
207
293
  const bin = path.join(root, 'new-bin')
@@ -237,11 +323,14 @@ describe('Windows hook adapter', () => {
237
323
  }
238
324
  expect(hookAdapterSource(target, 'posix')).toBe(`#!/bin/sh
239
325
  # notifai managed hook adapter
240
- # adapter-version: 1
326
+ # adapter-version: 2
241
327
  # target-exec-json: "/usr/local/bin/node"
242
328
  # target-script-json: "/opt/notifai/dist/main.js"
243
329
  set -eu
244
330
 
331
+ NOTIFAI_HOOK_SOURCE_PID=$PPID
332
+ export NOTIFAI_HOOK_SOURCE_PID
333
+
245
334
  registered_exec='/usr/local/bin/node'
246
335
  registered_script='/opt/notifai/dist/main.js'
247
336
 
@@ -249,6 +338,10 @@ if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
249
338
  exec "$registered_exec" "$registered_script" "$@"
250
339
  fi
251
340
 
341
+ if [ -f "$registered_script" ] && command -v node >/dev/null 2>&1; then
342
+ exec node "$registered_script" "$@"
343
+ fi
344
+
252
345
  printf '%s
253
346
  ' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
254
347
  exit 127
@@ -297,6 +390,25 @@ exit 127
297
390
  ).not.toThrow()
298
391
  })
299
392
 
393
+ it('carries the stable Windows harness parent into each CLI process', () => {
394
+ const { root, homeDir } = isolated()
395
+ const script = path.join(root, 'source-pid.js')
396
+ writeFileSync(
397
+ script,
398
+ 'process.stdout.write(process.env.NOTIFAI_HOOK_SOURCE_PID ?? "missing")\n',
399
+ )
400
+ const installed = installHookAdapter(
401
+ { execPath: process.execPath, scriptPath: script },
402
+ homeDir,
403
+ 'win32',
404
+ )
405
+
406
+ const run = spawnSync(process.execPath, [installed.path], { encoding: 'utf8' })
407
+
408
+ expect(run.status).toBe(0)
409
+ expect(run.stdout).toBe(String(process.pid))
410
+ })
411
+
300
412
  it('does not treat 0666 as a protection or health failure', () => {
301
413
  const { root, homeDir } = isolated()
302
414
  const script = path.join(root, 'target.js')
@@ -13,7 +13,7 @@ import { withTargetFileLock } from './file-lock.js'
13
13
 
14
14
  const ADAPTER_MARKER = '# notifai managed hook adapter'
15
15
  const WIN32_ADAPTER_MARKER = '// notifai managed hook adapter'
16
- const ADAPTER_VERSION = 1
16
+ const ADAPTER_VERSION = 2
17
17
 
18
18
  export type HookHostPlatform = 'posix' | 'win32'
19
19
 
@@ -212,6 +212,21 @@ export function hookAdapterSource(
212
212
  : posixHookAdapterSource(target)
213
213
  }
214
214
 
215
+ /**
216
+ * Two rungs, and the difference between them is the whole safety argument.
217
+ *
218
+ * The registered runtime is a resolved path, so every node version manager
219
+ * makes it version-scoped: a Homebrew upgrade renames its Cellar directory, an
220
+ * nvm version gets pruned, and the pinned interpreter stops existing. The npm
221
+ * shim this adapter wraps already survives that — `main.ts` starts with
222
+ * `#!/usr/bin/env node` — so pinning the interpreter here was stricter than the
223
+ * binary it stands in front of, and the strictness bought nothing.
224
+ *
225
+ * So PATH may supply the *interpreter*, and never the *code*: both rungs keep
226
+ * the registered script path, and no rung resolves `notifai` from PATH. Which
227
+ * Notifai build a trusted hook definition runs stays a property of the
228
+ * installation, not of whatever environment the harness happens to hand us.
229
+ */
215
230
  function posixHookAdapterSource(target: HookAdapterTarget): string {
216
231
  if (isNpxAdapterTarget(target)) {
217
232
  return `#!/bin/sh
@@ -223,6 +238,9 @@ ${ADAPTER_MARKER}
223
238
  # target-spec-json: ${JSON.stringify(target.spec)}
224
239
  set -eu
225
240
 
241
+ NOTIFAI_HOOK_SOURCE_PID=$PPID
242
+ export NOTIFAI_HOOK_SOURCE_PID
243
+
226
244
  registered_exec=${quote(target.execPath)}
227
245
  registered_npm_cli=${quote(target.npmCli)}
228
246
  registered_spec=${quote(target.spec)}
@@ -231,6 +249,10 @@ if [ -x "$registered_exec" ] && [ -f "$registered_npm_cli" ]; then
231
249
  exec "$registered_exec" "$registered_npm_cli" exec --yes --package "$registered_spec" -- notifai "$@"
232
250
  fi
233
251
 
252
+ if [ -f "$registered_npm_cli" ] && command -v node >/dev/null 2>&1; then
253
+ exec node "$registered_npm_cli" exec --yes --package "$registered_spec" -- notifai "$@"
254
+ fi
255
+
234
256
  printf '%s\n' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
235
257
  exit 127
236
258
  `
@@ -242,6 +264,9 @@ ${ADAPTER_MARKER}
242
264
  # target-script-json: ${JSON.stringify(target.scriptPath)}
243
265
  set -eu
244
266
 
267
+ NOTIFAI_HOOK_SOURCE_PID=$PPID
268
+ export NOTIFAI_HOOK_SOURCE_PID
269
+
245
270
  registered_exec=${quote(target.execPath)}
246
271
  registered_script=${quote(target.scriptPath)}
247
272
 
@@ -249,6 +274,10 @@ if [ -x "$registered_exec" ] && [ -f "$registered_script" ]; then
249
274
  exec "$registered_exec" "$registered_script" "$@"
250
275
  fi
251
276
 
277
+ if [ -f "$registered_script" ] && command -v node >/dev/null 2>&1; then
278
+ exec node "$registered_script" "$@"
279
+ fi
280
+
252
281
  printf '%s\n' 'Notifai hook adapter target is stale; run notifai hooks install.' >&2
253
282
  exit 127
254
283
  `
@@ -288,10 +317,15 @@ function isFile(file) {
288
317
  }
289
318
  }
290
319
 
291
- if (isFile(registeredExec) && isFile(registeredCompanion)) {
292
- const result = spawnSync(registeredExec, ${argv}, {
320
+ // The pinned runtime is preferred; the runtime already executing this shim is
321
+ // the fallback. Only the interpreter is allowed to move — the code stays pinned.
322
+ const runtime = isFile(registeredExec) ? registeredExec : process.execPath;
323
+
324
+ if (isFile(registeredCompanion)) {
325
+ const result = spawnSync(runtime, ${argv}, {
293
326
  stdio: "inherit",
294
327
  windowsHide: true,
328
+ env: { ...process.env, NOTIFAI_HOOK_SOURCE_PID: String(process.ppid) },
295
329
  });
296
330
  if (result.error) {
297
331
  process.stderr.write("Notifai hook adapter target is stale; run notifai hooks install.\\n");