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