@raidiant/notifai 9.2.2 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-doctor.js +83 -6
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +80 -23
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +0 -4
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +12 -6
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +3 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +7 -4
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +6 -7
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +28 -52
- package/dist/hooks.js +393 -152
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +17 -39
- package/dist/install-hooks.js +21 -41
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +2 -2
- package/dist/orca-session-title.js +3 -4
- package/dist/orca-session-title.js.map +1 -1
- package/dist/program.d.ts +6 -1
- package/dist/program.js +46 -5
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +2 -0
- package/dist/send.js +17 -15
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- package/package.json +3 -3
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +1 -1
- package/src/commands-doctor.ts +96 -8
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +95 -31
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +0 -4
- package/src/commands-send.ts +10 -5
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +386 -23
- package/src/commands.ts +10 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +7 -4
- package/src/config.test.ts +44 -23
- package/src/config.ts +13 -15
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +812 -83
- package/src/hooks.ts +529 -185
- package/src/install-hooks.test.ts +13 -16
- package/src/install-hooks.ts +40 -58
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +8 -49
- package/src/orca-session-title.ts +3 -5
- package/src/program.test.ts +45 -0
- package/src/program.ts +60 -4
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +18 -20
- package/src/session-activation.ts +3 -4
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
package/src/commands.test.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { CAPABILITIES_V1, PLATFORMS, REPLY_MAX_QUESTIONS } from '@raidiant/notifai-protocol'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
2
4
|
import {
|
|
3
5
|
existsSync,
|
|
4
6
|
mkdirSync,
|
|
@@ -56,6 +58,7 @@ import {
|
|
|
56
58
|
repliesCommand,
|
|
57
59
|
sendCommand,
|
|
58
60
|
statusCommand,
|
|
61
|
+
agentSessionRenameCommand,
|
|
59
62
|
type CommandDeps,
|
|
60
63
|
type CommandIo,
|
|
61
64
|
type CommandSpinner,
|
|
@@ -68,6 +71,7 @@ import {
|
|
|
68
71
|
codexTrustKey,
|
|
69
72
|
findInstallations,
|
|
70
73
|
handlerEvent,
|
|
74
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
71
75
|
settingsFile,
|
|
72
76
|
} from './install-hooks.js'
|
|
73
77
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
@@ -78,6 +82,7 @@ import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
|
78
82
|
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
79
83
|
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
80
84
|
import type { Tone } from './ui/theme.js'
|
|
85
|
+
import { projectBinding, projectEnabled } from './project-enablement.js'
|
|
81
86
|
|
|
82
87
|
afterEach(() => {
|
|
83
88
|
resetLatestPublishedCliVersionForTest()
|
|
@@ -416,6 +421,31 @@ function replyResponse(
|
|
|
416
421
|
}
|
|
417
422
|
|
|
418
423
|
describe('command contracts', () => {
|
|
424
|
+
it('durably enables a valid in-Project send before missing authentication stops it', async () => {
|
|
425
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-explicit-enable-'))
|
|
426
|
+
const io = new CapturedIo()
|
|
427
|
+
const env = isolatedEnv(cwd)
|
|
428
|
+
const deps = {
|
|
429
|
+
...makeDeps(io, {} as ApiClient),
|
|
430
|
+
cwd,
|
|
431
|
+
env,
|
|
432
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
433
|
+
}
|
|
434
|
+
expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done' })).toBe(EXIT.auth)
|
|
435
|
+
expect(projectEnabled(projectBinding(cwd, env))).toBe(true)
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('sends deliberately Projectless without enabling the inferred Project', async () => {
|
|
439
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-projectless-'))
|
|
440
|
+
const io = new CapturedIo()
|
|
441
|
+
const env = isolatedEnv(cwd)
|
|
442
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
443
|
+
const client = { submit: async (body: SubmitNotificationRequestT) => ((submitted = body), receipt) } as unknown as ApiClient
|
|
444
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
445
|
+
expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done', projectless: true })).toBe(EXIT.ok)
|
|
446
|
+
expect(submitted?.draft.project).toBeUndefined()
|
|
447
|
+
expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
|
|
448
|
+
})
|
|
419
449
|
it('shows an actionable no-plan access state', async () => {
|
|
420
450
|
const io = new CapturedIo()
|
|
421
451
|
const client = {
|
|
@@ -736,6 +766,28 @@ describe('command contracts', () => {
|
|
|
736
766
|
expect(io.errLines).toEqual([])
|
|
737
767
|
})
|
|
738
768
|
|
|
769
|
+
it('sends an Account custom sound name on the draft', async () => {
|
|
770
|
+
const io = new CapturedIo()
|
|
771
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
772
|
+
const client = {
|
|
773
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
774
|
+
submitted = body
|
|
775
|
+
return receipt
|
|
776
|
+
},
|
|
777
|
+
} as unknown as ApiClient
|
|
778
|
+
|
|
779
|
+
expect(
|
|
780
|
+
await sendCommand(makeDeps(io, client), {
|
|
781
|
+
kind: 'done',
|
|
782
|
+
title: 'Build finished',
|
|
783
|
+
body: 'All checks passed.',
|
|
784
|
+
sound: 'Kitchen timer',
|
|
785
|
+
}),
|
|
786
|
+
).toBe(EXIT.ok)
|
|
787
|
+
expect(submitted?.draft.platform?.ios?.sound).toBe('Kitchen timer')
|
|
788
|
+
expect(submitted?.draft.platform?.android?.sound).toBe('Kitchen timer')
|
|
789
|
+
})
|
|
790
|
+
|
|
739
791
|
it('prints Android downgrade warnings before submitting', async () => {
|
|
740
792
|
const io = new CapturedIo()
|
|
741
793
|
let submitCalls = 0
|
|
@@ -829,7 +881,7 @@ describe('command contracts', () => {
|
|
|
829
881
|
expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
830
882
|
})
|
|
831
883
|
|
|
832
|
-
it('uses
|
|
884
|
+
it('uses trusted Orca task context for an active Claude Agent Session label', async () => {
|
|
833
885
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-claude-title-'))
|
|
834
886
|
const io = new CapturedIo()
|
|
835
887
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -852,10 +904,7 @@ describe('command contracts', () => {
|
|
|
852
904
|
...makeDeps(io, client),
|
|
853
905
|
cwd,
|
|
854
906
|
env,
|
|
855
|
-
orcaSessionTitle: (
|
|
856
|
-
expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
|
|
857
|
-
return 'Worker - semantic session implementation'
|
|
858
|
-
},
|
|
907
|
+
orcaSessionTitle: () => 'Agent Session context labels',
|
|
859
908
|
}
|
|
860
909
|
|
|
861
910
|
expect(
|
|
@@ -868,12 +917,12 @@ describe('command contracts', () => {
|
|
|
868
917
|
|
|
869
918
|
expect(submitted?.draft.source).toMatchObject({
|
|
870
919
|
session_id: 'orca-claude-session',
|
|
871
|
-
session_label: '
|
|
920
|
+
session_label: 'Agent Session context labels',
|
|
872
921
|
harness: 'claude-code',
|
|
873
922
|
})
|
|
874
923
|
})
|
|
875
924
|
|
|
876
|
-
it('uses
|
|
925
|
+
it('uses trusted Orca task context for an active Codex Agent Session label', async () => {
|
|
877
926
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
|
|
878
927
|
const io = new CapturedIo()
|
|
879
928
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -894,10 +943,7 @@ describe('command contracts', () => {
|
|
|
894
943
|
TERM_PROGRAM: 'Orca',
|
|
895
944
|
ORCA_WORKTREE_ID: worktreeId,
|
|
896
945
|
},
|
|
897
|
-
orcaSessionTitle: (
|
|
898
|
-
expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
|
|
899
|
-
return 'Worker - semantic session implementation'
|
|
900
|
-
},
|
|
946
|
+
orcaSessionTitle: () => 'Release verification',
|
|
901
947
|
}
|
|
902
948
|
|
|
903
949
|
expect(
|
|
@@ -910,11 +956,79 @@ describe('command contracts', () => {
|
|
|
910
956
|
|
|
911
957
|
expect(submitted?.draft.source).toMatchObject({
|
|
912
958
|
session_id: 'orca-codex-thread',
|
|
913
|
-
session_label: '
|
|
959
|
+
session_label: 'Release verification',
|
|
914
960
|
harness: 'codex',
|
|
915
961
|
})
|
|
916
962
|
})
|
|
917
963
|
|
|
964
|
+
it('sends after isolating an invalid session-name record without losing valid names', async () => {
|
|
965
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-invalid-session-name-'))
|
|
966
|
+
const io = new CapturedIo()
|
|
967
|
+
const env = {
|
|
968
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
969
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
970
|
+
CODEX_THREAD_ID: 'current-session',
|
|
971
|
+
}
|
|
972
|
+
const file = path.join(stateDir(env), 'session-labels.json')
|
|
973
|
+
const validKey = createHash('sha256').update('unrelated-session').digest('hex')
|
|
974
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
975
|
+
writeFileSync(
|
|
976
|
+
file,
|
|
977
|
+
`${JSON.stringify(
|
|
978
|
+
{
|
|
979
|
+
version: 1,
|
|
980
|
+
sessions: {
|
|
981
|
+
[validKey]: {
|
|
982
|
+
label: 'Unrelated work',
|
|
983
|
+
source: 'explicit',
|
|
984
|
+
first_seen_at: 1_777_777_777_000,
|
|
985
|
+
harness: 'codex',
|
|
986
|
+
},
|
|
987
|
+
obsolete: {
|
|
988
|
+
label: 'Old shape',
|
|
989
|
+
source: 'legacy',
|
|
990
|
+
first_seen_at: 'unknown',
|
|
991
|
+
},
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
null,
|
|
995
|
+
2,
|
|
996
|
+
)}\n`,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
1000
|
+
const client = {
|
|
1001
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1002
|
+
submitted = body
|
|
1003
|
+
return receipt
|
|
1004
|
+
},
|
|
1005
|
+
} as unknown as ApiClient
|
|
1006
|
+
|
|
1007
|
+
expect(
|
|
1008
|
+
await sendCommand(
|
|
1009
|
+
{ ...makeDeps(io, client), cwd, env },
|
|
1010
|
+
{ title: 'Store recovered', body: 'The valid Agent Session name survived.', kind: 'done' },
|
|
1011
|
+
),
|
|
1012
|
+
).toBe(EXIT.ok)
|
|
1013
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
1014
|
+
session_id: 'current-session',
|
|
1015
|
+
harness: 'codex',
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
const recovered = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
1019
|
+
sessions: Record<string, { label: string }>
|
|
1020
|
+
}
|
|
1021
|
+
expect(recovered.sessions[validKey]?.label).toBe('Unrelated work')
|
|
1022
|
+
expect(recovered.sessions['obsolete']).toBeUndefined()
|
|
1023
|
+
const backups = readdirSync(path.dirname(file)).filter((name) =>
|
|
1024
|
+
/^session-labels\.invalid-[a-f0-9]{64}\.json$/u.test(name),
|
|
1025
|
+
)
|
|
1026
|
+
expect(backups).toHaveLength(1)
|
|
1027
|
+
expect(readFileSync(path.join(path.dirname(file), backups[0]!), 'utf8')).toContain(
|
|
1028
|
+
'"obsolete"',
|
|
1029
|
+
)
|
|
1030
|
+
})
|
|
1031
|
+
|
|
918
1032
|
it('falls back safely when Orca returns a private path as its title', async () => {
|
|
919
1033
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-private-title-'))
|
|
920
1034
|
const io = new CapturedIo()
|
|
@@ -936,7 +1050,6 @@ describe('command contracts', () => {
|
|
|
936
1050
|
TERM_PROGRAM: 'Orca',
|
|
937
1051
|
ORCA_WORKTREE_ID: `repo-123::${cwd}`,
|
|
938
1052
|
},
|
|
939
|
-
orcaSessionTitle: () => '/private/untrusted/worktree',
|
|
940
1053
|
}
|
|
941
1054
|
|
|
942
1055
|
expect(
|
|
@@ -1022,6 +1135,113 @@ describe('command contracts', () => {
|
|
|
1022
1135
|
expect(existsSync(path.join(cwd, 'state', 'notifai', 'session-labels.json'))).toBe(false)
|
|
1023
1136
|
})
|
|
1024
1137
|
|
|
1138
|
+
it('sends Hermes Source Context from HERMES_SESSION_ID and the invocation cwd', async () => {
|
|
1139
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-send-'))
|
|
1140
|
+
const repo = path.join(root, 'notifai')
|
|
1141
|
+
const worktree = path.join(root, 'hermes-topic')
|
|
1142
|
+
mkdirSync(repo)
|
|
1143
|
+
const git = (...args: string[]) => execFileSync('git', ['-C', repo, ...args], { stdio: 'ignore' })
|
|
1144
|
+
git('init')
|
|
1145
|
+
git('config', 'user.email', 'test@example.invalid')
|
|
1146
|
+
git('config', 'user.name', 'Test')
|
|
1147
|
+
git('config', 'commit.gpgsign', 'false')
|
|
1148
|
+
writeFileSync(path.join(repo, 'file'), 'x')
|
|
1149
|
+
git('add', 'file')
|
|
1150
|
+
git('commit', '-m', 'fixture')
|
|
1151
|
+
git('worktree', 'add', worktree, '-b', 'feature/hermes-send')
|
|
1152
|
+
|
|
1153
|
+
const io = new CapturedIo()
|
|
1154
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
1155
|
+
const client = {
|
|
1156
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1157
|
+
submitted = body
|
|
1158
|
+
return receipt
|
|
1159
|
+
},
|
|
1160
|
+
} as unknown as ApiClient
|
|
1161
|
+
const sessionId = '20260828_111302_29a404'
|
|
1162
|
+
const deps = {
|
|
1163
|
+
...makeDeps(io, client),
|
|
1164
|
+
cwd: worktree,
|
|
1165
|
+
env: {
|
|
1166
|
+
...isolatedEnv(worktree),
|
|
1167
|
+
HERMES_SESSION_ID: sessionId,
|
|
1168
|
+
},
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
expect(
|
|
1172
|
+
await sendCommand(deps, {
|
|
1173
|
+
title: 'Hermes baseline landed',
|
|
1174
|
+
body: 'Classic CLI send carries exact session identity.',
|
|
1175
|
+
kind: 'done',
|
|
1176
|
+
}),
|
|
1177
|
+
).toBe(EXIT.ok)
|
|
1178
|
+
|
|
1179
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
1180
|
+
session_id: sessionId,
|
|
1181
|
+
harness: 'hermes',
|
|
1182
|
+
branch: 'feature/hermes-send',
|
|
1183
|
+
worktree: 'hermes-topic',
|
|
1184
|
+
})
|
|
1185
|
+
expect(io.outLines.join('\n')).not.toContain(sessionId)
|
|
1186
|
+
|
|
1187
|
+
submitted = undefined
|
|
1188
|
+
expect(
|
|
1189
|
+
await sendCommand(
|
|
1190
|
+
{
|
|
1191
|
+
...deps,
|
|
1192
|
+
env: {
|
|
1193
|
+
...deps.env,
|
|
1194
|
+
HERMES_SESSION_KEY: 'gateway-route-key-must-not-be-session-id',
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
title: 'Hermes deferred surface sent',
|
|
1199
|
+
body: 'The send stays universal while unproven Source Context is omitted.',
|
|
1200
|
+
kind: 'done',
|
|
1201
|
+
},
|
|
1202
|
+
),
|
|
1203
|
+
).toBe(EXIT.ok)
|
|
1204
|
+
expect(submitted?.draft.source).toMatchObject({
|
|
1205
|
+
branch: 'feature/hermes-send',
|
|
1206
|
+
worktree: 'hermes-topic',
|
|
1207
|
+
})
|
|
1208
|
+
expect(submitted?.draft.source).not.toHaveProperty('session_id')
|
|
1209
|
+
expect(submitted?.draft.source).not.toHaveProperty('harness')
|
|
1210
|
+
expect(JSON.stringify(submitted?.draft.source)).not.toContain('gateway-route-key')
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
it('does not attribute Hermes Source Context when another harness marker is nested', async () => {
|
|
1214
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-send-'))
|
|
1215
|
+
const io = new CapturedIo()
|
|
1216
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
1217
|
+
const client = {
|
|
1218
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
1219
|
+
submitted = body
|
|
1220
|
+
return receipt
|
|
1221
|
+
},
|
|
1222
|
+
} as unknown as ApiClient
|
|
1223
|
+
const deps = {
|
|
1224
|
+
...makeDeps(io, client),
|
|
1225
|
+
cwd,
|
|
1226
|
+
env: {
|
|
1227
|
+
...isolatedEnv(cwd),
|
|
1228
|
+
CLAUDECODE: '1',
|
|
1229
|
+
CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
|
|
1230
|
+
HERMES_SESSION_ID: '20260828_111302_29a404',
|
|
1231
|
+
},
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
expect(
|
|
1235
|
+
await sendCommand(deps, {
|
|
1236
|
+
title: 'Nested send stays honest',
|
|
1237
|
+
body: 'Ambiguous ownership omits harness identity.',
|
|
1238
|
+
kind: 'update',
|
|
1239
|
+
}),
|
|
1240
|
+
).toBe(EXIT.ok)
|
|
1241
|
+
expect(submitted?.draft.source?.harness).toBeUndefined()
|
|
1242
|
+
expect(submitted?.draft.source?.session_id).toBeUndefined()
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1025
1245
|
it('uploads repeatable images in order and sends only canonical media references', async () => {
|
|
1026
1246
|
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-media-order-'))
|
|
1027
1247
|
const first = path.join(root, 'first.png')
|
|
@@ -1179,6 +1399,8 @@ describe('command contracts', () => {
|
|
|
1179
1399
|
// Kind now selects the sound, so the help must say so — and must not carry
|
|
1180
1400
|
// the retired separation it replaced.
|
|
1181
1401
|
expect(source).toContain('Kind is required, and it selects the sound')
|
|
1402
|
+
expect(source).toContain('Device default')
|
|
1403
|
+
expect(source).toContain('custom name or id')
|
|
1182
1404
|
expect(source).not.toContain('it never chooses banner sound or interruption level')
|
|
1183
1405
|
expect(source).not.toContain('Kind profiles apply automatically')
|
|
1184
1406
|
})
|
|
@@ -3520,19 +3742,20 @@ describe('config surfaces', () => {
|
|
|
3520
3742
|
expect(io.errLines[0]).toBe('Unknown setting "ask_notification".')
|
|
3521
3743
|
})
|
|
3522
3744
|
|
|
3523
|
-
it('refuses
|
|
3524
|
-
// `config set sound whatever` used to be written straight to disk: the
|
|
3525
|
-
// typo only surfaced when a notification failed to carry the sound.
|
|
3745
|
+
it('refuses a sound that cannot be a shipped name, custom id, or custom name', async () => {
|
|
3526
3746
|
const io = new CapturedIo()
|
|
3527
|
-
expect(await configSetCommand(configDeps(io), 'sound', '
|
|
3747
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'n'.repeat(200), { yes: true })).toBe(
|
|
3528
3748
|
EXIT.usage,
|
|
3529
3749
|
)
|
|
3530
|
-
expect(io.errLines[0]).toContain('
|
|
3750
|
+
expect(io.errLines[0]).toContain('custom sound')
|
|
3531
3751
|
})
|
|
3532
3752
|
|
|
3533
|
-
it('
|
|
3753
|
+
it('accepts a shipped sound and an Account custom name', async () => {
|
|
3534
3754
|
const io = new CapturedIo()
|
|
3535
3755
|
expect(await configSetCommand(configDeps(io), 'sound', 'none', { yes: true })).toBe(EXIT.ok)
|
|
3756
|
+
expect(await configSetCommand(configDeps(io), 'sound', 'Kitchen timer', { yes: true })).toBe(
|
|
3757
|
+
EXIT.ok,
|
|
3758
|
+
)
|
|
3536
3759
|
})
|
|
3537
3760
|
|
|
3538
3761
|
it('removes one machine setting, preserves its siblings, and restores default provenance', async () => {
|
|
@@ -6093,7 +6316,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6093
6316
|
expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
|
|
6094
6317
|
})
|
|
6095
6318
|
|
|
6096
|
-
it('registers an Orca-managed Claude question
|
|
6319
|
+
it('registers an Orca-managed Claude question without borrowing the worktree title', () => {
|
|
6097
6320
|
const cwd = scratchDir('notifai-orca-claude-ask-')
|
|
6098
6321
|
const io = new CapturedIo()
|
|
6099
6322
|
const env = {
|
|
@@ -6108,7 +6331,6 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6108
6331
|
cwd,
|
|
6109
6332
|
env,
|
|
6110
6333
|
now: () => 42,
|
|
6111
|
-
orcaSessionTitle: () => 'Worker - semantic session implementation',
|
|
6112
6334
|
}
|
|
6113
6335
|
|
|
6114
6336
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
@@ -6121,9 +6343,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6121
6343
|
expect(askCommand(deps, 'Ship the semantic resolver?', {})).toBe(EXIT.ok)
|
|
6122
6344
|
expect(readSessionState('orca-claude-question', env).pending?.[0]?.source).toMatchObject({
|
|
6123
6345
|
session_id: 'orca-claude-question',
|
|
6124
|
-
session_label: 'Worker - semantic session implementation',
|
|
6125
6346
|
harness: 'claude-code',
|
|
6126
6347
|
})
|
|
6348
|
+
expect(readSessionState('orca-claude-question', env).pending?.[0]?.source?.session_label)
|
|
6349
|
+
.toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
6127
6350
|
})
|
|
6128
6351
|
|
|
6129
6352
|
it('judges the fired check against the active harness, not a global installation of another', async () => {
|
|
@@ -6276,6 +6499,16 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6276
6499
|
await doctorCommand(deps, {})
|
|
6277
6500
|
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
|
|
6278
6501
|
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
6502
|
+
const readiness = await assessReadiness(deps)
|
|
6503
|
+
expect(readiness.states.find((state) => state.id === 'hooks-trust')?.remedy).toMatchObject({
|
|
6504
|
+
command: '/hooks',
|
|
6505
|
+
user_action: {
|
|
6506
|
+
code: 'codex_hook_approval_required',
|
|
6507
|
+
harness: 'codex',
|
|
6508
|
+
action: 'approve_or_enable_notifai_hooks',
|
|
6509
|
+
message: expect.stringContaining('Open `/hooks` in Codex'),
|
|
6510
|
+
},
|
|
6511
|
+
})
|
|
6279
6512
|
})
|
|
6280
6513
|
|
|
6281
6514
|
it('gives doctor the same active-Codex diagnosis as ask', async () => {
|
|
@@ -6490,6 +6723,92 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6490
6723
|
expect(readSessionState('opencode-current', env).pending).toBeUndefined()
|
|
6491
6724
|
})
|
|
6492
6725
|
|
|
6726
|
+
it('refuses Hermes ask as unsupported rather than as missing hooks', () => {
|
|
6727
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-ask-unsupported-'))
|
|
6728
|
+
const io = new CapturedIo()
|
|
6729
|
+
const env = {
|
|
6730
|
+
...isolatedEnv(cwd),
|
|
6731
|
+
HERMES_SESSION_ID: '20260828_111302_29a404',
|
|
6732
|
+
}
|
|
6733
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
6734
|
+
|
|
6735
|
+
expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
|
|
6736
|
+
const payload = JSON.parse(io.outLines.join('\n')) as { code: string; message: string; remedy: string }
|
|
6737
|
+
expect(payload.code).toBe('question_routing_unavailable')
|
|
6738
|
+
expect(payload.message).toMatch(/no proven continuation owner/i)
|
|
6739
|
+
expect(payload.message).not.toMatch(/hooks are not installed/i)
|
|
6740
|
+
expect(payload.remedy).toMatch(/send --reply/)
|
|
6741
|
+
expect(hooksInstallCommand(deps, { harness: 'hermes', execPath, scriptPath })).toBe(EXIT.usage)
|
|
6742
|
+
expect(io.errLines.join('\n')).toMatch(/Unknown harness "hermes"/)
|
|
6743
|
+
})
|
|
6744
|
+
|
|
6745
|
+
it('fails closed when Hermes nested markers leave session ownership ambiguous', () => {
|
|
6746
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-ask-'))
|
|
6747
|
+
const io = new CapturedIo()
|
|
6748
|
+
const env = {
|
|
6749
|
+
...isolatedEnv(cwd),
|
|
6750
|
+
CLAUDECODE: '1',
|
|
6751
|
+
CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
|
|
6752
|
+
HERMES_SESSION_ID: '20260828_111302_29a404',
|
|
6753
|
+
}
|
|
6754
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
6755
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(EXIT.ok)
|
|
6756
|
+
writeSessionState('claude-orchestrator', env, {
|
|
6757
|
+
harness: 'claude-code',
|
|
6758
|
+
last_prompt_at: 42,
|
|
6759
|
+
last_stop_at: 41,
|
|
6760
|
+
})
|
|
6761
|
+
writeProjectSession(cwd, env, 'claude-orchestrator', 42, 'claude-code')
|
|
6762
|
+
io.outLines = []
|
|
6763
|
+
io.errLines = []
|
|
6764
|
+
|
|
6765
|
+
expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
|
|
6766
|
+
const payload = JSON.parse(io.outLines.join('\n')) as { code: string; message: string }
|
|
6767
|
+
expect(payload.code).toBe('session_identity_ambiguous')
|
|
6768
|
+
expect(payload.message).toMatch(/Several harness sessions could own this shell/i)
|
|
6769
|
+
expect(payload.message).toMatch(/Hermes/)
|
|
6770
|
+
expect(readSessionState('claude-orchestrator', env).pending).toBeUndefined()
|
|
6771
|
+
})
|
|
6772
|
+
|
|
6773
|
+
it('does not attribute doctor readiness when Hermes nested markers are ambiguous', async () => {
|
|
6774
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-nested-doctor-'))
|
|
6775
|
+
const io = new CapturedIo()
|
|
6776
|
+
const env = {
|
|
6777
|
+
...isolatedEnv(cwd),
|
|
6778
|
+
CLAUDECODE: '1',
|
|
6779
|
+
CLAUDE_CODE_SESSION_ID: 'claude-orchestrator',
|
|
6780
|
+
HERMES_SESSION_ID: '20260828_111302_29a404',
|
|
6781
|
+
}
|
|
6782
|
+
const readiness = await assessReadiness({
|
|
6783
|
+
...makeDeps(io, {} as ApiClient),
|
|
6784
|
+
cwd,
|
|
6785
|
+
env,
|
|
6786
|
+
now: () => 42,
|
|
6787
|
+
})
|
|
6788
|
+
const hooks = readiness.states.find((state) => state.id === 'hooks')
|
|
6789
|
+
expect(hooks?.status).toBe('optional-gap')
|
|
6790
|
+
expect(hooks?.detail).toMatch(/Several harness sessions could own this shell/i)
|
|
6791
|
+
expect(hooks?.detail).toMatch(/Claude Code, Hermes/)
|
|
6792
|
+
expect(hooks?.remedy).toBeUndefined()
|
|
6793
|
+
expect(JSON.stringify(hooks)).not.toContain('hooks install --harness claude-code')
|
|
6794
|
+
})
|
|
6795
|
+
|
|
6796
|
+
it('does not tell an active Hermes session to install managed hooks', async () => {
|
|
6797
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hermes-doctor-'))
|
|
6798
|
+
const io = new CapturedIo()
|
|
6799
|
+
const env = {
|
|
6800
|
+
...isolatedEnv(cwd),
|
|
6801
|
+
HERMES_SESSION_ID: '20260828_111302_29a404',
|
|
6802
|
+
}
|
|
6803
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
6804
|
+
const readiness = await assessReadiness(deps)
|
|
6805
|
+
const hooks = readiness.states.find((state) => state.id === 'hooks')
|
|
6806
|
+
expect(hooks?.status).toBe('optional-gap')
|
|
6807
|
+
expect(hooks?.remedy).toBeUndefined()
|
|
6808
|
+
expect(JSON.stringify(hooks)).not.toContain('hooks install --harness hermes')
|
|
6809
|
+
expect(hooks?.detail).toMatch(/no proven continuation owner/i)
|
|
6810
|
+
})
|
|
6811
|
+
|
|
6493
6812
|
it('refuses Cursor when its active-agent marker has no exact conversation id', () => {
|
|
6494
6813
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-active-cursor-'))
|
|
6495
6814
|
const io = new CapturedIo()
|
|
@@ -6650,7 +6969,8 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6650
6969
|
const out = io.outLines.join('\n')
|
|
6651
6970
|
expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
|
|
6652
6971
|
expect(out).toContain('needs `async: true`')
|
|
6653
|
-
expect(out).toContain('kills the backgrounded waiter
|
|
6972
|
+
expect(out).toContain('kills the backgrounded waiter silently before the complete answer window')
|
|
6973
|
+
expect(out).toContain(`needs an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s`)
|
|
6654
6974
|
})
|
|
6655
6975
|
|
|
6656
6976
|
/** A configured Claude Code project with one live session claiming `pid`. */
|
|
@@ -7284,3 +7604,46 @@ describe('a second device that disagrees', () => {
|
|
|
7284
7604
|
expect(contradictingAnswer([])).toBeNull()
|
|
7285
7605
|
})
|
|
7286
7606
|
})
|
|
7607
|
+
|
|
7608
|
+
describe('Agent Session rename command', () => {
|
|
7609
|
+
it('renames only the exact active Agent Session and updates its local frozen label', async () => {
|
|
7610
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-rename-'))
|
|
7611
|
+
const io = new CapturedIo()
|
|
7612
|
+
const seen: { session_id: string; label: string }[] = []
|
|
7613
|
+
const client = {
|
|
7614
|
+
putAgentSessionLabel: async (body: { session_id: string; label: string }) => {
|
|
7615
|
+
seen.push(body)
|
|
7616
|
+
return {
|
|
7617
|
+
...body,
|
|
7618
|
+
renamed_by: 'agent' as const,
|
|
7619
|
+
updated_at: '2026-08-28T10:00:00.000Z',
|
|
7620
|
+
}
|
|
7621
|
+
},
|
|
7622
|
+
} as unknown as ApiClient
|
|
7623
|
+
const deps = {
|
|
7624
|
+
...makeDeps(io, client),
|
|
7625
|
+
cwd,
|
|
7626
|
+
env: {
|
|
7627
|
+
XDG_CONFIG_HOME: path.join(cwd, 'config'),
|
|
7628
|
+
XDG_STATE_HOME: path.join(cwd, 'state'),
|
|
7629
|
+
CODEX_THREAD_ID: '01a04789-9589-76b2-82c8-bfe63062c867',
|
|
7630
|
+
},
|
|
7631
|
+
}
|
|
7632
|
+
|
|
7633
|
+
expect(await agentSessionRenameCommand(deps, ' Hermes Runtime Support ')).toBe(EXIT.ok)
|
|
7634
|
+
expect(seen).toEqual([{
|
|
7635
|
+
session_id: '01a04789-9589-76b2-82c8-bfe63062c867',
|
|
7636
|
+
label: 'Hermes Runtime Support',
|
|
7637
|
+
}])
|
|
7638
|
+
expect(io.outLines.join('\n')).toContain('Hermes Runtime Support')
|
|
7639
|
+
})
|
|
7640
|
+
|
|
7641
|
+
it('fails closed when the harness does not expose an exact current Agent Session', async () => {
|
|
7642
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-session-rename-none-'))
|
|
7643
|
+
const io = new CapturedIo()
|
|
7644
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
7645
|
+
|
|
7646
|
+
expect(await agentSessionRenameCommand(deps, 'A new job')).toBe(EXIT.usage)
|
|
7647
|
+
expect(io.errLines.join('\n')).toMatch(/exact active Agent Session/i)
|
|
7648
|
+
})
|
|
7649
|
+
})
|
package/src/commands.ts
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
deviceInventory,
|
|
18
18
|
devicesCommand,
|
|
19
19
|
} from './commands-devices.js'
|
|
20
|
+
export { soundsCommand } from './commands-sounds.js'
|
|
20
21
|
export {
|
|
21
22
|
contradictingAnswer,
|
|
22
23
|
repliesCommand,
|
|
@@ -62,4 +63,13 @@ export {
|
|
|
62
63
|
type SetupScope,
|
|
63
64
|
} from './commands-init.js'
|
|
64
65
|
export { assessReadiness, doctorCommand } from './commands-doctor.js'
|
|
66
|
+
export {
|
|
67
|
+
projectDisableCommand,
|
|
68
|
+
projectEnableCommand,
|
|
69
|
+
projectStatusCommand,
|
|
70
|
+
} from './commands-project.js'
|
|
71
|
+
export {
|
|
72
|
+
agentSessionRenameCommand,
|
|
73
|
+
type AgentSessionRenameFlags,
|
|
74
|
+
} from './commands-agent-sessions.js'
|
|
65
75
|
export { realIo } from './commands-io.js'
|
package/src/config-schema.ts
CHANGED
|
@@ -100,7 +100,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
100
100
|
unit: 's',
|
|
101
101
|
summary: 'Optional delay before a question may reach your devices',
|
|
102
102
|
detail:
|
|
103
|
-
'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\
|
|
103
|
+
'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nThis controls when the question reaches devices, not how the harness keeps the Agent Session available afterward. Claude Code waits out of band; Codex holds the asking turn.',
|
|
104
104
|
example: '0',
|
|
105
105
|
},
|
|
106
106
|
|
|
@@ -111,7 +111,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
111
111
|
unit: 's',
|
|
112
112
|
summary: 'How long the service keeps accepting your answer to a question',
|
|
113
113
|
detail:
|
|
114
|
-
'A question stays answerable for this long after it
|
|
114
|
+
'A question stays answerable for this long after it reaches the service. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nQuestion Routing keeps the exact Agent Session available for this complete window. Claude Code waits out of band and wakes the Agent Session; Codex keeps its turn held. This is separate from `--reply-timeout`, which controls how long a direct `send --reply` command blocks.',
|
|
115
115
|
example: '86400',
|
|
116
116
|
},
|
|
117
117
|
|
|
@@ -121,7 +121,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
121
121
|
kind: 'enum',
|
|
122
122
|
choices: CLI_SOUNDS,
|
|
123
123
|
choiceHints: {
|
|
124
|
-
default: '
|
|
124
|
+
default: 'Device default',
|
|
125
125
|
done: 'the completion chime',
|
|
126
126
|
attention: 'a distinct attention tone',
|
|
127
127
|
alert: 'the most insistent tone',
|
|
@@ -129,7 +129,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
129
129
|
},
|
|
130
130
|
summary: 'Set the sound notifications from here use',
|
|
131
131
|
detail:
|
|
132
|
-
'Setting this pins one sound for every notification sent from here, whatever kind it is.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and
|
|
132
|
+
'Setting this pins one sound for every notification sent from here, whatever kind it is. Shipped names are `default` (Device default), `done`, `attention`, `alert`, and `none`; an Account custom sound name or id from `notifai sounds` is accepted too.\n\nLeave it unset and each notification arrives with the sound its kind implies: the completion chime when work finished, the most insistent tone when it failed, a distinct attention tone for a question or blocked work, and Device default for ordinary news. A common explicit preference is `none`, to make a noisy project completely silent.',
|
|
133
133
|
unsetMeans: 'not set; each kind brings its own sound',
|
|
134
134
|
},
|
|
135
135
|
interruption_level: {
|
|
@@ -281,6 +281,9 @@ export function boundsHint(key: ConfigKey): string | null {
|
|
|
281
281
|
/** What a `config set` caller may legally pass, phrased for an error message. */
|
|
282
282
|
export function acceptedValues(key: ConfigKey): string {
|
|
283
283
|
const info = INFO[key]
|
|
284
|
+
if (key === 'sound') {
|
|
285
|
+
return `${CLI_SOUNDS.join(', ')}, a custom sound id, or a custom sound name`
|
|
286
|
+
}
|
|
284
287
|
switch (info.kind) {
|
|
285
288
|
case 'boolean':
|
|
286
289
|
return 'true or false'
|