@raidiant/notifai 10.1.1 → 10.1.3

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 (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. 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
- })