@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
package/src/guidance.ts DELETED
@@ -1,233 +0,0 @@
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
- }
@@ -1,294 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import { mkdtempSync } from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { describe, expect, it } from 'vitest'
6
- import {
7
- resolveActiveHarness,
8
- sourceContextHarnessSession,
9
- } from './commands-harness-context.js'
10
- import {
11
- HARNESS_CAPABILITIES,
12
- HERMES_CLASSIC_CLI_LOCAL_CAPABILITY,
13
- HERMES_QUESTION_ROUTING_UNAVAILABLE,
14
- } from './harnesses.js'
15
- import { buildSourceContext, inferInvocationContext, type GitCommand } from './invocation-context.js'
16
-
17
- const PINNED = JSON.parse(
18
- readFileSync(new URL('./fixtures/hermes-0.20.6-classic-cli-local.json', import.meta.url), 'utf8'),
19
- ) as {
20
- durableSessionId: string
21
- observedToolSubprocessEnv: { HERMES_SESSION_ID: string }
22
- instance: { harness: 'hermes'; surface: 'classic-cli'; terminalBackend: 'local' }
23
- cwd: { invocation: string }
24
- evidence: {
25
- nativeHostOs: 'macos'
26
- portableFixtureFamilies: ['posix', 'windows']
27
- nativeOsUnrun: ['linux', 'windows']
28
- }
29
- compression: {
30
- defaultMode: 'in-place'
31
- sessionIdBefore: string
32
- sessionIdAfter: string
33
- legacyRotatingSessionId: string
34
- }
35
- }
36
-
37
- const SYNTHETIC_GATEWAY_SESSION_KEY = 'synthetic-gateway-route-key-not-a-session-id'
38
-
39
- function stateEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
40
- return {
41
- XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-context-')),
42
- ...overrides,
43
- }
44
- }
45
-
46
- function fixtureGit(values: Record<string, string | null>): GitCommand {
47
- return (_cwd, args) => values[args.join(' ')] ?? null
48
- }
49
-
50
- describe('Hermes classic CLI/local Source Context seam', () => {
51
- it('attributes an uncontested HERMES_SESSION_ID as the durable Agent Session', () => {
52
- const cwd = PINNED.cwd.invocation
53
- const now = 1
54
- const resolution = resolveActiveHarness(
55
- { HERMES_SESSION_ID: PINNED.observedToolSubprocessEnv.HERMES_SESSION_ID },
56
- cwd,
57
- now,
58
- )
59
- expect(resolution.contested).toEqual([])
60
- expect(resolution.active).toMatchObject({
61
- harness: 'hermes',
62
- sessionId: PINNED.durableSessionId,
63
- })
64
- expect(sourceContextHarnessSession({ HERMES_SESSION_ID: PINNED.durableSessionId }, cwd, now)).toEqual(
65
- resolution.active,
66
- )
67
- })
68
-
69
- it('never treats HERMES_SESSION_KEY as Agent Session identity', () => {
70
- const cwd = '/workspace'
71
- const now = 1
72
- const keyOnly = resolveActiveHarness(
73
- { HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY },
74
- cwd,
75
- now,
76
- )
77
- expect(keyOnly.active).toBeNull()
78
-
79
- const gatewayEnv = {
80
- HERMES_SESSION_ID: PINNED.observedToolSubprocessEnv.HERMES_SESSION_ID,
81
- HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY,
82
- }
83
- const both = resolveActiveHarness(gatewayEnv, cwd, now)
84
- expect(both.active?.sessionId).toBe(PINNED.durableSessionId)
85
- expect(both.active?.sessionId).not.toBe(SYNTHETIC_GATEWAY_SESSION_KEY)
86
- expect(sourceContextHarnessSession(gatewayEnv, cwd, now)).toBeNull()
87
- })
88
-
89
- it('fails closed when Hermes markers nest with a hook-installable harness', () => {
90
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-'))
91
- const env = {
92
- CLAUDECODE: '1',
93
- CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
94
- HERMES_SESSION_ID: PINNED.durableSessionId,
95
- }
96
- const resolution = resolveActiveHarness(env, cwd, 42)
97
- expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
98
- 'claude-code',
99
- 'hermes',
100
- ])
101
- expect(sourceContextHarnessSession(env, cwd, 42)).toBeNull()
102
- })
103
-
104
- it('does not apply the send-only mix rule to a Hermes-free Claude/Codex nest', () => {
105
- // Existing send attribution still uses declared order when nothing has
106
- // fired. Live-evidence ownership for ask lives in commands.test.ts.
107
- const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-codex-pair-'))
108
- const env = {
109
- CLAUDECODE: '1',
110
- CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
111
- CODEX_THREAD_ID: 'codex-current-thread',
112
- }
113
- const resolution = resolveActiveHarness(env, cwd, 42)
114
- expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
115
- 'claude-code',
116
- 'codex',
117
- ])
118
- expect(sourceContextHarnessSession(env, cwd, 42)?.harness).toBe('claude-code')
119
- })
120
-
121
- it('builds Source Context from HERMES_SESSION_ID plus invocation cwd git/worktree', () => {
122
- const env = stateEnv({
123
- HERMES_SESSION_ID: PINNED.durableSessionId,
124
- HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY,
125
- })
126
- const invocation = inferInvocationContext(
127
- '/worktrees/hermes-topic/apps/cli',
128
- fixtureGit({
129
- 'rev-parse --git-common-dir': '/code/notifai/.git',
130
- 'rev-parse --git-dir': '/code/notifai/.git/worktrees/hermes-topic',
131
- 'rev-parse --show-toplevel': '/worktrees/hermes-topic/',
132
- 'rev-parse --abbrev-ref HEAD': 'feature/hermes-send',
133
- }),
134
- )
135
- const active = resolveActiveHarness(env, '/worktrees/hermes-topic/apps/cli', Date.now()).active
136
- const built = buildSourceContext({
137
- env,
138
- invocation,
139
- ...(active === null ? {} : { activeHarness: active }),
140
- now: new Date(2026, 7, 28, 11, 13).getTime(),
141
- })
142
- expect(built).toMatchObject({
143
- ok: true,
144
- source: {
145
- session_id: PINNED.durableSessionId,
146
- harness: 'hermes',
147
- branch: 'feature/hermes-send',
148
- worktree: 'hermes-topic',
149
- },
150
- })
151
- if (built.ok) {
152
- expect(JSON.stringify(built.source)).not.toContain('gateway-route-key')
153
- expect(JSON.stringify(built.source)).not.toContain('/worktrees/')
154
- expect(JSON.stringify(built.source)).not.toContain('/code/')
155
- }
156
- })
157
-
158
- it('omits harness attribution when nested markers make ownership ambiguous', () => {
159
- const env = stateEnv({
160
- CLAUDECODE: '1',
161
- CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
162
- HERMES_SESSION_ID: PINNED.durableSessionId,
163
- })
164
- const invocation = inferInvocationContext('/workspace', fixtureGit({}))
165
- const active = sourceContextHarnessSession(env, '/workspace', 1)
166
- expect(active).toBeNull()
167
- expect(
168
- buildSourceContext({
169
- env,
170
- invocation,
171
- now: 1,
172
- }),
173
- ).toEqual({ ok: true })
174
- })
175
-
176
- it('does not invent a Question Routing route for the pinned Hermes instance', () => {
177
- expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance).toEqual(PINNED.instance)
178
- expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.stopContinuation).toBe('unsupported')
179
- expect(Object.keys(HARNESS_CAPABILITIES)).not.toContain('hermes')
180
- })
181
-
182
- it('keeps unproven Hermes surfaces out of Source Context', () => {
183
- for (const extra of [
184
- { HERMES_SESSION_KEY: SYNTHETIC_GATEWAY_SESSION_KEY },
185
- { HERMES_SESSION_PLATFORM: 'api_server' },
186
- { HERMES_SESSION_SOURCE: 'tui' },
187
- { TERMINAL_ENV: 'docker' },
188
- { TERMINAL_ENV: 'ssh' },
189
- ]) {
190
- expect(
191
- sourceContextHarnessSession(
192
- { HERMES_SESSION_ID: PINNED.durableSessionId, ...extra },
193
- '/workspace',
194
- 1,
195
- ),
196
- ).toBeNull()
197
- }
198
- })
199
-
200
- it('records the precise platform-fixture and compression evidence boundary', () => {
201
- expect(PINNED.evidence).toEqual({
202
- nativeHostOs: 'macos',
203
- portableFixtureFamilies: ['posix', 'windows'],
204
- nativeOsUnrun: ['linux', 'windows'],
205
- })
206
- expect(PINNED.compression.defaultMode).toBe('in-place')
207
- expect(PINNED.compression.sessionIdAfter).toBe(PINNED.compression.sessionIdBefore)
208
- expect(PINNED.compression.legacyRotatingSessionId).not.toBe(
209
- PINNED.compression.sessionIdBefore,
210
- )
211
- })
212
- })
213
-
214
- describe('OpenClaw agent-local Source Context seam', () => {
215
- const PINNED_OPENCLAW = JSON.parse(
216
- readFileSync(new URL('./fixtures/openclaw-2026.7.1-2-agent-local.json', import.meta.url), 'utf8'),
217
- ) as {
218
- durableSessionKey: string
219
- rotatingSessionId: string
220
- observedPluginEnv: { NOTIFAI_ACTIVE_HARNESS: 'openclaw'; NOTIFAI_ACTIVE_SESSION_ID: string }
221
- instance: { harness: 'openclaw'; surface: 'agent-local'; execHost: 'gateway' }
222
- hooks: {
223
- proven: { activation: string; stop: string; execMarkers: string }
224
- unprovenOnThisSurface: { presence: string; sessionEnd: string }
225
- }
226
- deferred: string[]
227
- }
228
-
229
- it('pins only the probed agent-local surface, not Gateway-embedded', () => {
230
- expect(PINNED_OPENCLAW.instance).toEqual({
231
- harness: 'openclaw',
232
- surface: 'agent-local',
233
- execHost: 'gateway',
234
- })
235
- expect(PINNED_OPENCLAW.durableSessionKey).toBe('agent:main:probe-owner')
236
- expect(PINNED_OPENCLAW.observedPluginEnv.NOTIFAI_ACTIVE_SESSION_ID).toBe(
237
- PINNED_OPENCLAW.durableSessionKey,
238
- )
239
- expect(PINNED_OPENCLAW.hooks.proven).toEqual({
240
- activation: 'before_prompt_build',
241
- stop: 'agent_end',
242
- execMarkers: 'resolve_exec_env',
243
- })
244
- expect(PINNED_OPENCLAW.hooks.unprovenOnThisSurface).toEqual({
245
- presence: 'message_received',
246
- sessionEnd: 'session_end',
247
- })
248
- expect(PINNED_OPENCLAW.deferred).toContain('gateway-embedded')
249
- })
250
-
251
- it('attributes Source Context to the plugin-injected sessionKey', () => {
252
- const env = stateEnv(PINNED_OPENCLAW.observedPluginEnv)
253
- const cwd = '/workspace'
254
- const active = resolveActiveHarness(env, cwd, Date.now()).active
255
- expect(active).toMatchObject({
256
- harness: 'openclaw',
257
- sessionId: PINNED_OPENCLAW.durableSessionKey,
258
- })
259
- const built = buildSourceContext({
260
- env,
261
- invocation: inferInvocationContext(cwd, fixtureGit({})),
262
- ...(active === null ? {} : { activeHarness: active }),
263
- now: Date.now(),
264
- })
265
- expect(built).toMatchObject({
266
- ok: true,
267
- source: {
268
- harness: 'openclaw',
269
- session_id: PINNED_OPENCLAW.durableSessionKey,
270
- },
271
- })
272
- if (built.ok) {
273
- expect(JSON.stringify(built.source)).not.toContain(PINNED_OPENCLAW.rotatingSessionId)
274
- }
275
- })
276
-
277
- it('treats nested OpenClaw and Claude markers as contested until live evidence exists', () => {
278
- const env = stateEnv({
279
- ...PINNED_OPENCLAW.observedPluginEnv,
280
- CLAUDECODE: '1',
281
- CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
282
- })
283
- const resolution = resolveActiveHarness(env, '/workspace', 1)
284
- expect(resolution.contested.map((candidate) => candidate.harness).sort()).toEqual([
285
- 'claude-code',
286
- 'openclaw',
287
- ])
288
- })
289
-
290
- it('keeps OpenClaw Question Routing unsupported', () => {
291
- expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
292
- expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
293
- })
294
- })
@@ -1,149 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import { describe, expect, it } from 'vitest'
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
- }
24
-
25
- /**
26
- * This file used to hold a verbatim copy of the table below it, which meant
27
- * changing a harness contract required changing the same words twice and the
28
- * test could never disagree with the code — only with whoever forgot the second
29
- * edit. What is worth asserting is the shape the rest of the CLI assumes.
30
- */
31
- describe('harness contract', () => {
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([
38
- 'claude-code',
39
- 'codex',
40
- 'cursor',
41
- 'opencode',
42
- 'openclaw',
43
- ])
44
- expect(SOURCE_CONTEXT_HARNESSES).toContain('hermes')
45
- expect(HOOK_INSTALLABLE_HARNESSES).not.toContain('hermes')
46
- expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HOOK_INSTALLABLE_HARNESSES].sort())
47
- for (const harness of HOOK_INSTALLABLE_HARNESSES) {
48
- expect(isHookInstallableHarness(harness)).toBe(true)
49
- }
50
- expect(isHookInstallableHarness('hermes')).toBe(false)
51
- })
52
-
53
- it('gives a harness that cannot continue a turn no route to pretend with', () => {
54
- // `ask` fails closed on these, and the reason it can is that the two facts
55
- // agree. A harness claiming a delivery route while declaring it cannot
56
- // continue would accept an answer into a void.
57
- for (const harness of HOOK_INSTALLABLE_HARNESSES) {
58
- const capability = questionRoutingCapability(harness)
59
- if (capability.stopContinuation !== 'unsupported') continue
60
- expect(capability.deliveryRoutes, harness).toEqual(['unsupported'])
61
- }
62
- expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
63
- })
64
-
65
- it('gives a harness that can continue a turn at least one real route', () => {
66
- for (const harness of HOOK_INSTALLABLE_HARNESSES) {
67
- const capability = HARNESS_CAPABILITIES[harness]
68
- if (capability.stopContinuation === 'unsupported') continue
69
- expect(capability.deliveryRoutes.length, harness).toBeGreaterThan(0)
70
- expect(capability.deliveryRoutes, harness).not.toContain('unsupported')
71
- // The journal is the floor under every supported route: without it an
72
- // answer that misses its turn has nowhere to wait.
73
- expect(capability.deliveryRoutes, harness).toContain('hold-for-next-turn')
74
- }
75
- })
76
-
77
- it('explains each harness in prose an operator can act on', () => {
78
- for (const harness of HOOK_INSTALLABLE_HARNESSES) {
79
- expect(questionRoutingCapability(harness).deliveryContract.length, harness).toBeGreaterThan(40)
80
- }
81
- expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryContract.length).toBeGreaterThan(40)
82
- })
83
-
84
- it('keeps Claude Code, Codex, Cursor, and OpenCode continuation contracts unchanged', () => {
85
- expect(HARNESS_CAPABILITIES['claude-code'].stopContinuation).toBe('decision-block')
86
- expect(HARNESS_CAPABILITIES['claude-code'].deliveryRoutes).toEqual([
87
- 'hook-continuation',
88
- 'inbox-socket',
89
- 'cold-resume',
90
- 'hold-for-next-turn',
91
- ])
92
- expect(HARNESS_CAPABILITIES.codex.stopContinuation).toBe('decision-block')
93
- expect(HARNESS_CAPABILITIES.codex.deliveryRoutes).toEqual([
94
- 'hook-continuation',
95
- 'cold-resume',
96
- 'hold-for-next-turn',
97
- ])
98
- expect(HARNESS_CAPABILITIES.cursor).toEqual({
99
- stopContinuation: 'unsupported',
100
- deliveryRoutes: ['unsupported'],
101
- deliveryContract: HARNESS_CAPABILITIES.cursor.deliveryContract,
102
- })
103
- expect(HARNESS_CAPABILITIES.opencode.stopContinuation).toBe('unsupported')
104
- expect(HARNESS_CAPABILITIES.opencode.deliveryRoutes).toEqual(['unsupported'])
105
- expect(HARNESS_CAPABILITIES.openclaw.stopContinuation).toBe('unsupported')
106
- expect(HARNESS_CAPABILITIES.openclaw.deliveryRoutes).toEqual(['unsupported'])
107
- })
108
-
109
- it('treats the pinned Hermes classic CLI/local trace as send-only', () => {
110
- expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.instance).toEqual(PINNED_HERMES_TRACE.instance)
111
- expect(PINNED_HERMES_TRACE.supported).toContain('deliberate-send')
112
- expect(PINNED_HERMES_TRACE.unsupported).toContain('question-routing')
113
- expect(PINNED_HERMES_TRACE.deferred).toContain('gateway')
114
- expect(HERMES_CLASSIC_CLI_LOCAL_CAPABILITY.sourceContext).toBe(
115
- 'hermes-session-id-and-invocation-cwd',
116
- )
117
- expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.stopContinuation).toBe('unsupported')
118
- expect(HERMES_QUESTION_ROUTING_UNAVAILABLE.deliveryRoutes).toEqual(['unsupported'])
119
- })
120
-
121
- it('resolves only the pinned classic CLI/local marker envelope', () => {
122
- expect(
123
- hermesClassicCliLocalInstance({
124
- HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
125
- }),
126
- ).toEqual(PINNED_HERMES_TRACE.instance)
127
- expect(
128
- hermesClassicCliLocalInstance({
129
- HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
130
- HERMES_SESSION_SOURCE: 'cli',
131
- TERMINAL_ENV: 'local',
132
- }),
133
- ).toEqual(PINNED_HERMES_TRACE.instance)
134
- for (const env of [
135
- { HERMES_SESSION_KEY: 'gateway-key' },
136
- { HERMES_SESSION_PLATFORM: 'api_server' },
137
- { HERMES_SESSION_SOURCE: 'tui' },
138
- { TERMINAL_ENV: 'docker' },
139
- { TERMINAL_ENV: 'ssh' },
140
- ]) {
141
- expect(
142
- hermesClassicCliLocalInstance({
143
- HERMES_SESSION_ID: PINNED_HERMES_TRACE.observedToolSubprocessEnv.HERMES_SESSION_ID,
144
- ...env,
145
- }),
146
- ).toBeNull()
147
- }
148
- })
149
- })