@raidiant/notifai 8.0.0 → 9.1.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 +48 -0
- package/README.md +20 -13
- package/dist/atomic-file.d.ts +14 -0
- package/dist/atomic-file.js +27 -2
- package/dist/atomic-file.js.map +1 -1
- package/dist/cli-bin.d.ts +4 -0
- package/dist/cli-bin.js +62 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli-release.d.ts +10 -0
- package/dist/cli-release.js +75 -0
- package/dist/cli-release.js.map +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +13 -3
- package/dist/client.js.map +1 -1
- package/dist/command-session.d.ts +14 -0
- package/dist/command-session.js +30 -0
- package/dist/command-session.js.map +1 -0
- package/dist/commands-auth.d.ts +6 -0
- package/dist/commands-auth.js +28 -3
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-config.js +22 -3
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +12 -0
- package/dist/commands-core.js +27 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.d.ts +1 -0
- package/dist/commands-doctor.js +87 -29
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-guidance.d.ts +7 -3
- package/dist/commands-guidance.js +40 -31
- package/dist/commands-guidance.js.map +1 -1
- package/dist/commands-harness-context.js +21 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hooks.d.ts +20 -5
- package/dist/commands-hooks.js +509 -155
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +12 -18
- package/dist/commands-init.js +149 -47
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.d.ts +17 -2
- package/dist/commands-send-support.js +61 -5
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -0
- package/dist/commands-send.js +114 -17
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-setup-proof.d.ts +12 -3
- package/dist/commands-setup-proof.js +60 -16
- package/dist/commands-setup-proof.js.map +1 -1
- package/dist/commands-skill.d.ts +11 -1
- package/dist/commands-skill.js +61 -11
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands.d.ts +2 -2
- package/dist/commands.js +1 -1
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +25 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +24 -3
- package/dist/config.js +37 -5
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +10 -1
- package/dist/credentials.js +25 -12
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +20 -0
- package/dist/guidance-content.js +73 -8
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.d.ts +20 -0
- package/dist/guidance-render.js +42 -0
- package/dist/guidance-render.js.map +1 -0
- package/dist/guidance.d.ts +21 -0
- package/dist/guidance.js +73 -18
- package/dist/guidance.js.map +1 -1
- package/dist/hook-adapter.js +37 -3
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +83 -7
- package/dist/hooks.js +288 -90
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +10 -18
- package/dist/install-hooks.js +110 -23
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +1 -1
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +8 -1
- package/dist/logging.js +43 -2
- package/dist/logging.js.map +1 -1
- package/dist/main.js +2 -13
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +19 -0
- package/dist/native-skills.js +30 -8
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.js +25 -1
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/program.js +34 -20
- package/dist/program.js.map +1 -1
- package/dist/readiness.d.ts +6 -0
- package/dist/readiness.js +37 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send-attempts.d.ts +26 -0
- package/dist/send-attempts.js +139 -0
- package/dist/send-attempts.js.map +1 -0
- package/dist/send.d.ts +8 -1
- package/dist/send.js +17 -1
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +11 -0
- package/dist/session-activation.js +46 -0
- package/dist/session-activation.js.map +1 -0
- package/dist/url-policy.d.ts +85 -0
- package/dist/url-policy.js +369 -0
- package/dist/url-policy.js.map +1 -0
- package/package.json +3 -3
- package/src/atomic-file.ts +36 -2
- package/src/cli-bin.test.ts +38 -0
- package/src/cli-bin.ts +67 -0
- package/src/cli-release.test.ts +46 -0
- package/src/cli-release.ts +78 -0
- package/src/client.test.ts +95 -0
- package/src/client.ts +16 -3
- package/src/command-session.ts +38 -0
- package/src/commands-auth.ts +32 -3
- package/src/commands-config.ts +25 -2
- package/src/commands-core.ts +38 -3
- package/src/commands-doctor.ts +117 -41
- package/src/commands-guidance.ts +43 -32
- package/src/commands-harness-context.ts +24 -1
- package/src/commands-hooks.ts +704 -188
- package/src/commands-init.ts +180 -57
- package/src/commands-send-support.test.ts +156 -0
- package/src/commands-send-support.ts +67 -5
- package/src/commands-send.ts +135 -16
- package/src/commands-setup-proof.test.ts +72 -0
- package/src/commands-setup-proof.ts +69 -16
- package/src/commands-skill.ts +78 -15
- package/src/commands.test.ts +1281 -157
- package/src/commands.ts +2 -0
- package/src/config-schema.ts +28 -1
- package/src/config.test.ts +1 -1
- package/src/config.ts +49 -5
- package/src/credentials.test.ts +71 -3
- package/src/credentials.ts +29 -15
- package/src/guidance-content.ts +74 -8
- package/src/guidance-render.ts +56 -0
- package/src/guidance.test.ts +70 -11
- package/src/guidance.ts +100 -18
- package/src/hook-adapter.test.ts +113 -1
- package/src/hook-adapter.ts +37 -3
- package/src/hooks.test.ts +778 -166
- package/src/hooks.ts +368 -129
- package/src/install-hooks.test.ts +135 -16
- package/src/install-hooks.ts +113 -25
- package/src/interactive.ts +1 -1
- package/src/logging.test.ts +66 -3
- package/src/logging.ts +45 -2
- package/src/main.ts +2 -14
- package/src/native-skills.test.ts +41 -1
- package/src/native-skills.ts +40 -6
- package/src/opencode-plugin.ts +25 -1
- package/src/program.test.ts +37 -2
- package/src/program.ts +62 -24
- package/src/readiness.ts +44 -0
- package/src/send-attempts.test.ts +143 -0
- package/src/send-attempts.ts +188 -0
- package/src/send.test.ts +28 -0
- package/src/send.ts +28 -2
- package/src/session-activation.ts +62 -0
- package/src/skill.test.ts +125 -11
- package/src/trust-policy.test.ts +701 -0
- package/src/url-policy.ts +399 -0
package/src/commands.test.ts
CHANGED
|
@@ -63,17 +63,26 @@ import {
|
|
|
63
63
|
import {
|
|
64
64
|
applyPlan,
|
|
65
65
|
buildHookConfig,
|
|
66
|
+
hookCommand,
|
|
66
67
|
codexHookIdentityHash,
|
|
67
68
|
codexTrustKey,
|
|
68
69
|
findInstallations,
|
|
70
|
+
handlerEvent,
|
|
71
|
+
settingsFile,
|
|
69
72
|
} from './install-hooks.js'
|
|
70
73
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
71
74
|
import { nativeSkills as realNativeSkills, type NativeSkill, type NativeSkills, type SkillScope } from './native-skills.js'
|
|
72
|
-
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, stateDir } from './config.js'
|
|
73
|
-
import {
|
|
75
|
+
import { CONFIG_KEYS, loadConfig, personalProjectConfigPath, sessionConfigPath, stateDir } from './config.js'
|
|
76
|
+
import { SETUP_PROOF_STALE_MS, writeSetupProof } from './commands-setup-proof.js'
|
|
77
|
+
import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
78
|
+
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
74
79
|
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
75
80
|
import type { Tone } from './ui/theme.js'
|
|
76
81
|
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
resetLatestPublishedCliVersionForTest()
|
|
84
|
+
})
|
|
85
|
+
|
|
77
86
|
/**
|
|
78
87
|
* The release tag this build pins the agent skill to.
|
|
79
88
|
*
|
|
@@ -328,6 +337,7 @@ function makeDeps(io: CapturedIo, client: ApiClient): CommandDeps {
|
|
|
328
337
|
hookAdapterHome: path.join(testRoot, 'home'),
|
|
329
338
|
cwd: os.tmpdir(),
|
|
330
339
|
clientFactory: () => withCompatibilityDefaults(client),
|
|
340
|
+
fetchImpl: async () => new Response('{}', { status: 503 }),
|
|
331
341
|
}
|
|
332
342
|
}
|
|
333
343
|
|
|
@@ -382,6 +392,8 @@ const reply: ReplyView = {
|
|
|
382
392
|
device_id: 'dev_test',
|
|
383
393
|
device_name: 'iPhone',
|
|
384
394
|
text: 'yes, after the migration',
|
|
395
|
+
answers: [],
|
|
396
|
+
source: null,
|
|
385
397
|
created_at: '2026-08-01T18:01:00.000Z',
|
|
386
398
|
}
|
|
387
399
|
|
|
@@ -573,6 +585,131 @@ describe('command contracts', () => {
|
|
|
573
585
|
expect(io.errLines.join('\n')).toContain('project')
|
|
574
586
|
})
|
|
575
587
|
|
|
588
|
+
it('persists one opaque retry attempt after an ambiguous submit failure', async () => {
|
|
589
|
+
const io = new CapturedIo()
|
|
590
|
+
let attemptedKey = ''
|
|
591
|
+
const client = {
|
|
592
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
593
|
+
attemptedKey = body.idempotency_key
|
|
594
|
+
throw new NetworkError('Connection closed before a response arrived')
|
|
595
|
+
},
|
|
596
|
+
} as unknown as ApiClient
|
|
597
|
+
|
|
598
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
|
|
599
|
+
const deps = makeDeps(io, client)
|
|
600
|
+
deps.env = {
|
|
601
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
602
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
603
|
+
}
|
|
604
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send' })
|
|
605
|
+
expect(
|
|
606
|
+
await sendCommand(deps, {
|
|
607
|
+
kind: 'done',
|
|
608
|
+
title: 'Checks finished',
|
|
609
|
+
body: 'All checks passed.',
|
|
610
|
+
}),
|
|
611
|
+
).toBe(EXIT.network)
|
|
612
|
+
|
|
613
|
+
expect(attemptedKey).toMatch(/^cli-/)
|
|
614
|
+
expect(io.errLines.join('\n')).toContain('exact semantic send with `--retry`')
|
|
615
|
+
expect(io.errLines.join('\n')).toMatch(/Opaque retry attempt: sat_/)
|
|
616
|
+
expect(
|
|
617
|
+
readLogRecords(deps.env, { event: ['send.attempt'] }).records.at(-1)?.data,
|
|
618
|
+
).toMatchObject({
|
|
619
|
+
idempotency_key: attemptedKey,
|
|
620
|
+
attempt_id: expect.stringMatching(/^sat_/),
|
|
621
|
+
replay: false,
|
|
622
|
+
})
|
|
623
|
+
rmSync(root, { recursive: true, force: true })
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('prints an opaque retry instruction for an ambiguous server failure', async () => {
|
|
627
|
+
const io = new CapturedIo()
|
|
628
|
+
const client = {
|
|
629
|
+
submit: async () => {
|
|
630
|
+
throw new ApiCallError(503, 'temporarily_unavailable', 'Try again later.')
|
|
631
|
+
},
|
|
632
|
+
} as unknown as ApiClient
|
|
633
|
+
|
|
634
|
+
expect(
|
|
635
|
+
await sendCommand(makeDeps(io, client), {
|
|
636
|
+
kind: 'failed',
|
|
637
|
+
title: 'Validation could not finish',
|
|
638
|
+
body: 'The service failed while accepting the result.',
|
|
639
|
+
}),
|
|
640
|
+
).toBe(EXIT.network)
|
|
641
|
+
expect(io.errLines.join('\n')).toContain('exact semantic send with `--retry`')
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
it('reuses the opaque attempt key only after an explicit exact semantic retry', async () => {
|
|
645
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-retry-'))
|
|
646
|
+
const io = new CapturedIo()
|
|
647
|
+
const keys: string[] = []
|
|
648
|
+
let calls = 0
|
|
649
|
+
const client = {
|
|
650
|
+
submit: async (body: SubmitNotificationRequestT) => {
|
|
651
|
+
keys.push(body.idempotency_key)
|
|
652
|
+
calls += 1
|
|
653
|
+
if (calls === 1) throw new NetworkError('killed after an ambiguous boundary')
|
|
654
|
+
return { ...receipt, replayed: true }
|
|
655
|
+
},
|
|
656
|
+
} as unknown as ApiClient
|
|
657
|
+
const deps = {
|
|
658
|
+
...makeDeps(io, client),
|
|
659
|
+
cwd: root,
|
|
660
|
+
env: { XDG_CONFIG_HOME: path.join(root, 'config'), XDG_STATE_HOME: path.join(root, 'state') },
|
|
661
|
+
}
|
|
662
|
+
const semantic = { kind: 'done', title: 'Recovery completed', body: 'The exact work is done.' }
|
|
663
|
+
|
|
664
|
+
expect(await sendCommand(deps, semantic)).toBe(EXIT.network)
|
|
665
|
+
expect(await sendCommand(deps, { ...semantic, retry: true })).toBe(EXIT.ok)
|
|
666
|
+
expect(keys).toHaveLength(2)
|
|
667
|
+
expect(keys[1]).toBe(keys[0])
|
|
668
|
+
expect(await sendCommand(deps, { ...semantic, retry: true })).toBe(EXIT.usage)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('routes a send with no active Companion devices into the setup coordinator', async () => {
|
|
672
|
+
const io = new CapturedIo()
|
|
673
|
+
const client = {
|
|
674
|
+
submit: async () => {
|
|
675
|
+
throw new ApiCallError(
|
|
676
|
+
409,
|
|
677
|
+
'no_active_devices',
|
|
678
|
+
'No enabled active Device Installation can receive this request.',
|
|
679
|
+
'Open the Companion App and enable notifications.',
|
|
680
|
+
)
|
|
681
|
+
},
|
|
682
|
+
} as unknown as ApiClient
|
|
683
|
+
|
|
684
|
+
expect(
|
|
685
|
+
await sendCommand(makeDeps(io, client), {
|
|
686
|
+
kind: 'done',
|
|
687
|
+
title: 'Checks finished',
|
|
688
|
+
body: 'All checks passed.',
|
|
689
|
+
}),
|
|
690
|
+
).toBe(EXIT.failed)
|
|
691
|
+
|
|
692
|
+
expect(io.errLines.join('\n')).toMatch(/next:.*`notifai init`.*Companion/i)
|
|
693
|
+
expect(io.errLines.join('\n')).toContain('exact original Notification Request with `--retry`')
|
|
694
|
+
expect(io.errLines.join('\n')).toMatch(/verification request does not replace this Agent Event/i)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
it('routes a send from an unpaired machine into the same setup coordinator', async () => {
|
|
698
|
+
const io = new CapturedIo()
|
|
699
|
+
const deps = makeDeps(io, {} as ApiClient)
|
|
700
|
+
deps.store.load = () => null
|
|
701
|
+
|
|
702
|
+
expect(
|
|
703
|
+
await sendCommand(deps, {
|
|
704
|
+
kind: 'done',
|
|
705
|
+
title: 'Checks finished',
|
|
706
|
+
body: 'All checks passed.',
|
|
707
|
+
}),
|
|
708
|
+
).toBe(EXIT.auth)
|
|
709
|
+
|
|
710
|
+
expect(io.errLines.join('\n')).toMatch(/`notifai init`.*login.*device setup/i)
|
|
711
|
+
})
|
|
712
|
+
|
|
576
713
|
it('authors and validates an Android-specific Notification Request', async () => {
|
|
577
714
|
const io = new CapturedIo()
|
|
578
715
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -1032,11 +1169,13 @@ describe('command contracts', () => {
|
|
|
1032
1169
|
expect(grammar).not.toContain(".option('--detail")
|
|
1033
1170
|
expect(grammar).not.toContain(".option('--session <")
|
|
1034
1171
|
expect(grammar).toContain(".option('--body-file")
|
|
1035
|
-
expect(grammar).toContain(".option('--session-id")
|
|
1036
1172
|
expect(grammar).toContain("'--session-label <text>'")
|
|
1037
1173
|
expect(grammar).toContain("'--image <path|url|media_id>'")
|
|
1038
1174
|
expect(grammar).toContain("'--image-alt <text>'")
|
|
1039
1175
|
}
|
|
1176
|
+
expect(sendGrammar).toContain(".option('--session-id")
|
|
1177
|
+
expect(sendGrammar).toContain(".option('--retry'")
|
|
1178
|
+
expect(askGrammar).not.toContain(".option('--session-id")
|
|
1040
1179
|
// Kind now selects the sound, so the help must say so — and must not carry
|
|
1041
1180
|
// the retired separation it replaced.
|
|
1042
1181
|
expect(source).toContain('Kind is required, and it selects the sound')
|
|
@@ -1209,8 +1348,11 @@ describe('command contracts', () => {
|
|
|
1209
1348
|
request_id: receipt.request_id,
|
|
1210
1349
|
sequence: reply.seq,
|
|
1211
1350
|
device: reply.device_name,
|
|
1212
|
-
|
|
1351
|
+
text_chars: reply.text.length,
|
|
1352
|
+
answers_count: reply.answers.length,
|
|
1213
1353
|
})
|
|
1354
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
1355
|
+
expect(raw).not.toContain(reply.text)
|
|
1214
1356
|
})
|
|
1215
1357
|
|
|
1216
1358
|
it('backs off and retries a transient network error while waiting', async () => {
|
|
@@ -1501,9 +1643,13 @@ describe('command contracts', () => {
|
|
|
1501
1643
|
'acknowledgement.attempted',
|
|
1502
1644
|
'acknowledgement.outcome',
|
|
1503
1645
|
])
|
|
1646
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
1647
|
+
expect(raw).not.toContain('I will deploy staging now.')
|
|
1648
|
+
expect(events[0]?.data).toMatchObject({ characters: 'I will deploy staging now.'.length })
|
|
1649
|
+
expect(events[1]?.data).toMatchObject({ text_chars: 'I will deploy staging now.'.length })
|
|
1504
1650
|
})
|
|
1505
1651
|
|
|
1506
|
-
it('clears
|
|
1652
|
+
it('clears the stable-id session obligation after acknowledgement from another checkout', async () => {
|
|
1507
1653
|
const io = new CapturedIo()
|
|
1508
1654
|
const client = {
|
|
1509
1655
|
putAgentAcknowledgement: async () => ({
|
|
@@ -1526,6 +1672,7 @@ describe('command contracts', () => {
|
|
|
1526
1672
|
acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
|
|
1527
1673
|
})
|
|
1528
1674
|
writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
|
|
1675
|
+
deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-other-checkout-'))
|
|
1529
1676
|
|
|
1530
1677
|
expect(
|
|
1531
1678
|
await acknowledgeCommand(deps, receipt.request_id, {
|
|
@@ -1719,6 +1866,114 @@ describe('command contracts', () => {
|
|
|
1719
1866
|
})
|
|
1720
1867
|
})
|
|
1721
1868
|
|
|
1869
|
+
it('close --pending withdraws an unpushed registration so a later Stop cannot send it', async () => {
|
|
1870
|
+
const io = new CapturedIo()
|
|
1871
|
+
const client = {
|
|
1872
|
+
closeReplies: async () => {
|
|
1873
|
+
throw new Error('unpushed questions must not call the server')
|
|
1874
|
+
},
|
|
1875
|
+
} as unknown as ApiClient
|
|
1876
|
+
const deps = makeDeps(io, client)
|
|
1877
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-unpushed-'))
|
|
1878
|
+
deps.cwd = root
|
|
1879
|
+
deps.env = {
|
|
1880
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1881
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1882
|
+
}
|
|
1883
|
+
writeSessionState('close-unpushed', deps.env, {
|
|
1884
|
+
pending: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1885
|
+
})
|
|
1886
|
+
writeProjectSession(root, deps.env, 'close-unpushed', Date.now(), 'codex')
|
|
1887
|
+
|
|
1888
|
+
expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
|
|
1889
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1890
|
+
session_id: 'close-unpushed',
|
|
1891
|
+
withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1892
|
+
closed: [],
|
|
1893
|
+
})
|
|
1894
|
+
expect(readSessionState('close-unpushed', deps.env).pending).toBeUndefined()
|
|
1895
|
+
})
|
|
1896
|
+
|
|
1897
|
+
it('close withdraws one unpushed question by stable id from another checkout', async () => {
|
|
1898
|
+
const io = new CapturedIo()
|
|
1899
|
+
const client = {
|
|
1900
|
+
closeReplies: async () => {
|
|
1901
|
+
throw new Error('unpushed questions must not call the server')
|
|
1902
|
+
},
|
|
1903
|
+
} as unknown as ApiClient
|
|
1904
|
+
const deps = makeDeps(io, client)
|
|
1905
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-one-unpushed-'))
|
|
1906
|
+
deps.cwd = root
|
|
1907
|
+
deps.env = {
|
|
1908
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1909
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1910
|
+
}
|
|
1911
|
+
writeSessionState('close-one', deps.env, {
|
|
1912
|
+
pending: [
|
|
1913
|
+
{ question: 'Ship it?', question_id: 'q_local' },
|
|
1914
|
+
{ question: 'Keep this?', question_id: 'q_keep' },
|
|
1915
|
+
],
|
|
1916
|
+
})
|
|
1917
|
+
writeProjectSession(root, deps.env, 'close-one', Date.now(), 'codex')
|
|
1918
|
+
deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-other-checkout-'))
|
|
1919
|
+
|
|
1920
|
+
expect(await closeCommand(deps, 'q_local', { json: true })).toBe(EXIT.ok)
|
|
1921
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
1922
|
+
session_id: 'close-one',
|
|
1923
|
+
withdrawn: [{ question: 'Ship it?', question_id: 'q_local' }],
|
|
1924
|
+
closed: [],
|
|
1925
|
+
})
|
|
1926
|
+
expect(readSessionState('close-one', deps.env).pending?.map((entry) => entry.question_id)).toEqual([
|
|
1927
|
+
'q_keep',
|
|
1928
|
+
])
|
|
1929
|
+
})
|
|
1930
|
+
|
|
1931
|
+
it('close --pending closes live questions and leaves nothing for a later Stop to push', async () => {
|
|
1932
|
+
const io = new CapturedIo()
|
|
1933
|
+
const closed: string[] = []
|
|
1934
|
+
const client = {
|
|
1935
|
+
closeReplies: async (requestId: string) => {
|
|
1936
|
+
closed.push(requestId)
|
|
1937
|
+
return replyResponse([])
|
|
1938
|
+
},
|
|
1939
|
+
} as unknown as ApiClient
|
|
1940
|
+
const deps = makeDeps(io, client)
|
|
1941
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-close-pending-mix-'))
|
|
1942
|
+
deps.cwd = root
|
|
1943
|
+
deps.env = {
|
|
1944
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1945
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
1946
|
+
}
|
|
1947
|
+
writeSessionState('close-mix', deps.env, {
|
|
1948
|
+
pending: [
|
|
1949
|
+
{ question: 'Unpushed?', question_id: 'q_local' },
|
|
1950
|
+
{
|
|
1951
|
+
question: 'Already on a device?',
|
|
1952
|
+
request_id: receipt.request_id,
|
|
1953
|
+
collapse_key: 'question-live',
|
|
1954
|
+
device_ids: ['dev_iphone'],
|
|
1955
|
+
},
|
|
1956
|
+
],
|
|
1957
|
+
})
|
|
1958
|
+
writeProjectSession(root, deps.env, 'close-mix', Date.now(), 'codex')
|
|
1959
|
+
|
|
1960
|
+
expect(await closeCommand(deps, undefined, { pending: true, json: true })).toBe(EXIT.ok)
|
|
1961
|
+
expect(closed).toEqual([receipt.request_id])
|
|
1962
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1963
|
+
session_id: 'close-mix',
|
|
1964
|
+
withdrawn: [{ question: 'Unpushed?', question_id: 'q_local' }],
|
|
1965
|
+
closed: [receipt.request_id],
|
|
1966
|
+
})
|
|
1967
|
+
expect(readSessionState('close-mix', deps.env).pending).toBeUndefined()
|
|
1968
|
+
})
|
|
1969
|
+
|
|
1970
|
+
it('close requires a request id or --pending', async () => {
|
|
1971
|
+
const io = new CapturedIo()
|
|
1972
|
+
const client = {} as unknown as ApiClient
|
|
1973
|
+
expect(await closeCommand(makeDeps(io, client), undefined, {})).toBe(EXIT.usage)
|
|
1974
|
+
expect(io.errLines.join('\n')).toContain('Pass a request id or --pending.')
|
|
1975
|
+
})
|
|
1976
|
+
|
|
1722
1977
|
it('passes the replies cursor and prints replies for later retrieval', async () => {
|
|
1723
1978
|
const io = new CapturedIo()
|
|
1724
1979
|
let requested: { waitSeconds: number; afterSeq: number } | undefined
|
|
@@ -1746,7 +2001,7 @@ describe('command contracts', () => {
|
|
|
1746
2001
|
expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
|
|
1747
2002
|
})
|
|
1748
2003
|
|
|
1749
|
-
it('resolves replies --pending
|
|
2004
|
+
it('resolves replies --pending by exact session from another checkout', async () => {
|
|
1750
2005
|
const io = new CapturedIo()
|
|
1751
2006
|
const client = {
|
|
1752
2007
|
replies: async () => replyResponse([reply]),
|
|
@@ -1757,11 +2012,15 @@ describe('command contracts', () => {
|
|
|
1757
2012
|
deps.env = {
|
|
1758
2013
|
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
1759
2014
|
XDG_STATE_HOME: path.join(root, 'state'),
|
|
2015
|
+
CODEX_THREAD_ID: 'pending-session',
|
|
1760
2016
|
}
|
|
1761
2017
|
writeSessionState('pending-session', deps.env, {
|
|
2018
|
+
harness: 'codex',
|
|
2019
|
+
last_prompt_at: Date.now(),
|
|
1762
2020
|
pending: [{ question: 'Deploy?', request_id: receipt.request_id }],
|
|
1763
2021
|
})
|
|
1764
2022
|
writeProjectSession(root, deps.env, 'pending-session', Date.now(), 'codex')
|
|
2023
|
+
deps.cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-pending-other-checkout-'))
|
|
1765
2024
|
|
|
1766
2025
|
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
1767
2026
|
expect(io.outLines).toEqual([
|
|
@@ -1797,8 +2056,9 @@ describe('command contracts', () => {
|
|
|
1797
2056
|
writeProjectSession(root, deps.env, 'pending-multi', Date.now(), 'codex')
|
|
1798
2057
|
|
|
1799
2058
|
expect(await repliesCommand(deps, undefined, { pending: true })).toBe(EXIT.ok)
|
|
1800
|
-
expect(io.outLines[0]).toBe('
|
|
1801
|
-
expect(io.outLines
|
|
2059
|
+
expect(io.outLines[0]).toBe('unpushed question: Not yet asked?')
|
|
2060
|
+
expect(io.outLines.join('\n')).toContain('pending request req_first')
|
|
2061
|
+
expect(io.outLines.join('\n')).toContain('yes, after the migration')
|
|
1802
2062
|
expect(io.outLines.join('\n')).toContain('req_second')
|
|
1803
2063
|
})
|
|
1804
2064
|
|
|
@@ -1995,13 +2255,188 @@ describe('command contracts', () => {
|
|
|
1995
2255
|
})
|
|
1996
2256
|
})
|
|
1997
2257
|
|
|
2258
|
+
describe('credential origin pinning', () => {
|
|
2259
|
+
it('never sends the machine bearer to a flag or env origin override', async () => {
|
|
2260
|
+
const io = new CapturedIo()
|
|
2261
|
+
const seen: { baseUrl: string; bearer: string | null }[] = []
|
|
2262
|
+
const client = {
|
|
2263
|
+
submit: async () => receipt,
|
|
2264
|
+
} as unknown as ApiClient
|
|
2265
|
+
const deps = makeDeps(io, client)
|
|
2266
|
+
deps.env = { ...deps.env, NOTIFAI_BASE_URL: 'https://attacker.example' }
|
|
2267
|
+
deps.clientFactory = (baseUrl, bearer) => {
|
|
2268
|
+
seen.push({ baseUrl, bearer })
|
|
2269
|
+
return withCompatibilityDefaults(client)
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
expect(
|
|
2273
|
+
await sendCommand(deps, {
|
|
2274
|
+
kind: 'update',
|
|
2275
|
+
title: 'T',
|
|
2276
|
+
body: 'B',
|
|
2277
|
+
baseUrl: 'https://attacker.flag',
|
|
2278
|
+
}),
|
|
2279
|
+
).toBe(EXIT.ok)
|
|
2280
|
+
|
|
2281
|
+
expect(seen).toEqual([
|
|
2282
|
+
{
|
|
2283
|
+
baseUrl: 'https://test.notifai.invalid',
|
|
2284
|
+
bearer: 'Bearer nfm_mac_test.test-secret',
|
|
2285
|
+
},
|
|
2286
|
+
])
|
|
2287
|
+
expect(io.errLines.join('\n')).toContain('Ignoring base_url from flag')
|
|
2288
|
+
expect(io.errLines.join('\n')).not.toContain('test-secret')
|
|
2289
|
+
expect(io.errLines.join('\n')).not.toContain('nfm_')
|
|
2290
|
+
expect(io.errLines.join('\n')).not.toContain('https://attacker.example')
|
|
2291
|
+
expect(io.errLines.join('\n')).not.toContain('https://attacker.flag')
|
|
2292
|
+
})
|
|
2293
|
+
|
|
2294
|
+
it('lets unsigned-in login target an origin override', async () => {
|
|
2295
|
+
const io = new CapturedIo()
|
|
2296
|
+
const seen: { baseUrl: string; bearer: string | null }[] = []
|
|
2297
|
+
let beginBody: Parameters<ApiClient['beginPairing']>[0] | null = null
|
|
2298
|
+
let now = 0
|
|
2299
|
+
const client = {
|
|
2300
|
+
beginPairing: async (body: Parameters<ApiClient['beginPairing']>[0]) => {
|
|
2301
|
+
beginBody = body
|
|
2302
|
+
return {
|
|
2303
|
+
pairing_id: 'pair_test',
|
|
2304
|
+
code: 'ABCD-EFGH',
|
|
2305
|
+
approve_url: 'https://selfhost.example/pair/ABCD-EFGH',
|
|
2306
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2307
|
+
poll_interval_seconds: 1,
|
|
2308
|
+
}
|
|
2309
|
+
},
|
|
2310
|
+
pollPairing: async () => ({ status: 'approved', machine_id: 'mac_new' }),
|
|
2311
|
+
} as unknown as ApiClient
|
|
2312
|
+
const saved: { baseUrl?: string } = {}
|
|
2313
|
+
const deps: CommandDeps = {
|
|
2314
|
+
...makeDeps(io, client),
|
|
2315
|
+
now: () => now,
|
|
2316
|
+
sleep: async (milliseconds: number) => {
|
|
2317
|
+
now += milliseconds
|
|
2318
|
+
},
|
|
2319
|
+
store: {
|
|
2320
|
+
load: () => null,
|
|
2321
|
+
save: (credential) => {
|
|
2322
|
+
saved.baseUrl = credential.baseUrl
|
|
2323
|
+
},
|
|
2324
|
+
clear: () => {},
|
|
2325
|
+
describe: () => 'test credential store',
|
|
2326
|
+
},
|
|
2327
|
+
clientFactory: (baseUrl, bearer) => {
|
|
2328
|
+
seen.push({ baseUrl, bearer })
|
|
2329
|
+
return withCompatibilityDefaults(client)
|
|
2330
|
+
},
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
expect(await loginCommand(deps, { baseUrl: 'https://selfhost.example', open: false })).toBe(
|
|
2334
|
+
EXIT.ok,
|
|
2335
|
+
)
|
|
2336
|
+
expect(seen[0]).toEqual({ baseUrl: 'https://selfhost.example', bearer: null })
|
|
2337
|
+
expect(saved.baseUrl).toBe('https://selfhost.example')
|
|
2338
|
+
expect(beginBody).toMatchObject({
|
|
2339
|
+
machine_name: expect.any(String),
|
|
2340
|
+
credential_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2341
|
+
poll_verifier_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2342
|
+
confirmation_hash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
2343
|
+
})
|
|
2344
|
+
expect(io.outLines[1]).toMatch(
|
|
2345
|
+
/^Approve this machine at: https:\/\/selfhost\.example\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
2346
|
+
)
|
|
2347
|
+
})
|
|
2348
|
+
|
|
2349
|
+
it('stops login when the approving account has no product access', async () => {
|
|
2350
|
+
const io = new CapturedIo()
|
|
2351
|
+
let now = 0
|
|
2352
|
+
const client = {
|
|
2353
|
+
beginPairing: async () => ({
|
|
2354
|
+
pairing_id: 'pair_test',
|
|
2355
|
+
code: 'ABCD-EFGH',
|
|
2356
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2357
|
+
expires_at: new Date(10_000).toISOString(),
|
|
2358
|
+
poll_interval_seconds: 1,
|
|
2359
|
+
}),
|
|
2360
|
+
pollPairing: async () => ({
|
|
2361
|
+
status: 'no_active_plan',
|
|
2362
|
+
next_action: 'Ask the account owner for Alpha access.',
|
|
2363
|
+
}),
|
|
2364
|
+
} as unknown as ApiClient
|
|
2365
|
+
const deps: CommandDeps = {
|
|
2366
|
+
...makeDeps(io, client),
|
|
2367
|
+
now: () => now,
|
|
2368
|
+
sleep: async (milliseconds: number) => {
|
|
2369
|
+
now += milliseconds
|
|
2370
|
+
},
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
2374
|
+
expect(io.errLines).toEqual([
|
|
2375
|
+
'This account has no active plan or temporary Alpha access.',
|
|
2376
|
+
'next: Ask the account owner for Alpha access.',
|
|
2377
|
+
'After access is granted, run `notifai login` again.',
|
|
2378
|
+
])
|
|
2379
|
+
})
|
|
2380
|
+
|
|
2381
|
+
it('keeps notification titles out of send logs', async () => {
|
|
2382
|
+
const io = new CapturedIo()
|
|
2383
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-log-'))
|
|
2384
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
2385
|
+
const deps = makeDeps(io, client)
|
|
2386
|
+
deps.env = {
|
|
2387
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
2388
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
2389
|
+
}
|
|
2390
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'send', settings: { level: 'debug' } })
|
|
2391
|
+
|
|
2392
|
+
expect(
|
|
2393
|
+
await sendCommand(deps, {
|
|
2394
|
+
kind: 'update',
|
|
2395
|
+
title: 'SECRET_SEND_TITLE',
|
|
2396
|
+
body: 'SECRET_SEND_BODY',
|
|
2397
|
+
}),
|
|
2398
|
+
).toBe(EXIT.ok)
|
|
2399
|
+
const raw = readFileSync(activeLogPath(deps.env), 'utf8')
|
|
2400
|
+
expect(raw).not.toContain('SECRET_SEND_TITLE')
|
|
2401
|
+
expect(raw).not.toContain('SECRET_SEND_BODY')
|
|
2402
|
+
const submitted = readLogRecords(deps.env, { event: ['send.submitted'] }).records
|
|
2403
|
+
expect(submitted[0]?.data).toMatchObject({
|
|
2404
|
+
request_id: receipt.request_id,
|
|
2405
|
+
title_chars: 'SECRET_SEND_TITLE'.length,
|
|
2406
|
+
})
|
|
2407
|
+
})
|
|
2408
|
+
|
|
2409
|
+
it('refuses a shell-escaped backslash-n body unless the literal flag is set', async () => {
|
|
2410
|
+
const io = new CapturedIo()
|
|
2411
|
+
expect(
|
|
2412
|
+
await sendCommand(makeDeps(io, {} as ApiClient), {
|
|
2413
|
+
kind: 'update',
|
|
2414
|
+
title: 'Escaped',
|
|
2415
|
+
body: 'line1\\nline2',
|
|
2416
|
+
}),
|
|
2417
|
+
).toBe(EXIT.usage)
|
|
2418
|
+
expect(io.errLines.join('\n')).toContain('--body-file -')
|
|
2419
|
+
expect(io.errLines.join('\n')).toContain('--literal-backslash-n')
|
|
2420
|
+
|
|
2421
|
+
io.errLines = []
|
|
2422
|
+
const client = { submit: async () => receipt } as unknown as ApiClient
|
|
2423
|
+
expect(
|
|
2424
|
+
await sendCommand(makeDeps(io, client), {
|
|
2425
|
+
kind: 'update',
|
|
2426
|
+
title: 'Escaped',
|
|
2427
|
+
body: 'line1\\nline2',
|
|
2428
|
+
literalBackslashN: true,
|
|
2429
|
+
}),
|
|
2430
|
+
).toBe(EXIT.ok)
|
|
2431
|
+
})
|
|
2432
|
+
})
|
|
2433
|
+
|
|
1998
2434
|
describe('delivery evidence status', () => {
|
|
1999
2435
|
function snapshot(
|
|
2000
2436
|
companionReceipt: EvidenceSnapshot['deliveries'][number]['companion_receipt'],
|
|
2001
2437
|
): EvidenceSnapshot {
|
|
2002
2438
|
return {
|
|
2003
2439
|
request_id: 'req_status_test',
|
|
2004
|
-
event: 'tests_passed',
|
|
2005
2440
|
accepted_at: '2026-08-05T13:05:48.000Z',
|
|
2006
2441
|
overall: 'provider_accepted_all',
|
|
2007
2442
|
deliveries: [
|
|
@@ -2097,18 +2532,65 @@ describe('delivery evidence status', () => {
|
|
|
2097
2532
|
expect(said).toContain('Reply received: yes')
|
|
2098
2533
|
expect(said).toContain('answers are on the server')
|
|
2099
2534
|
})
|
|
2100
|
-
})
|
|
2101
|
-
|
|
2102
|
-
describe('Cursor hook commands', () => {
|
|
2103
|
-
const execPath = process.execPath
|
|
2104
|
-
const scriptPath = fileURLToPath(import.meta.url)
|
|
2105
|
-
|
|
2106
|
-
it('installs native Cursor hooks with bounded chained answer continuations', () => {
|
|
2107
|
-
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
|
|
2108
|
-
const io = new CapturedIo()
|
|
2109
|
-
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2110
2535
|
|
|
2111
|
-
|
|
2536
|
+
it('treats an ordinary status Companion Receipt as doctor delivery proof', async () => {
|
|
2537
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-status-proof-'))
|
|
2538
|
+
const evidence = snapshot({
|
|
2539
|
+
state: 'observed',
|
|
2540
|
+
observed_at: '2026-08-05T13:17:17.000Z',
|
|
2541
|
+
latency_ms: 1_000,
|
|
2542
|
+
})
|
|
2543
|
+
const device = {
|
|
2544
|
+
device_id: evidence.deliveries[0]!.device_id,
|
|
2545
|
+
display_name: 'iPhone',
|
|
2546
|
+
platform: 'ios' as const,
|
|
2547
|
+
permission_status: 'authorized',
|
|
2548
|
+
registration_healthy: true,
|
|
2549
|
+
app_version: '0.1.0',
|
|
2550
|
+
app_build: '42',
|
|
2551
|
+
os_version: '19.0',
|
|
2552
|
+
capabilities: ['answer'] as const,
|
|
2553
|
+
support: currentSupport,
|
|
2554
|
+
support_state: 'current' as const,
|
|
2555
|
+
derived_status: 'working' as const,
|
|
2556
|
+
status_message: null,
|
|
2557
|
+
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2558
|
+
}
|
|
2559
|
+
const client = {
|
|
2560
|
+
health: async () => true,
|
|
2561
|
+
listDevices: async () => ({ devices: [device] }),
|
|
2562
|
+
accessStatus: async () => ({
|
|
2563
|
+
status: 'active',
|
|
2564
|
+
reason: 'alpha_grant',
|
|
2565
|
+
expires_at: null,
|
|
2566
|
+
email: 'proof@example.com',
|
|
2567
|
+
}),
|
|
2568
|
+
evidence: async () => evidence,
|
|
2569
|
+
} as unknown as ApiClient
|
|
2570
|
+
const io = new CapturedIo()
|
|
2571
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
2572
|
+
|
|
2573
|
+
expect(await statusCommand(deps, evidence.request_id, { json: true })).toBe(EXIT.ok)
|
|
2574
|
+
|
|
2575
|
+
const doctorIo = new CapturedIo()
|
|
2576
|
+
expect(await doctorCommand({ ...deps, io: doctorIo }, { json: true })).toBe(EXIT.ok)
|
|
2577
|
+
const report = JSON.parse(doctorIo.outLines.join('\n')) as {
|
|
2578
|
+
states: { id: string; status: string }[]
|
|
2579
|
+
}
|
|
2580
|
+
expect(report.states.find((state) => state.id === 'proof')).toMatchObject({ status: 'ready' })
|
|
2581
|
+
})
|
|
2582
|
+
})
|
|
2583
|
+
|
|
2584
|
+
describe('Cursor hook commands', () => {
|
|
2585
|
+
const execPath = process.execPath
|
|
2586
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
2587
|
+
|
|
2588
|
+
it('installs native Cursor hooks with bounded chained answer continuations', () => {
|
|
2589
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-install-'))
|
|
2590
|
+
const io = new CapturedIo()
|
|
2591
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2592
|
+
|
|
2593
|
+
expect(
|
|
2112
2594
|
hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath }),
|
|
2113
2595
|
).toBe(EXIT.ok)
|
|
2114
2596
|
|
|
@@ -2122,12 +2604,17 @@ describe('Cursor hook commands', () => {
|
|
|
2122
2604
|
expect(Object.keys(installed.hooks).sort()).toEqual([
|
|
2123
2605
|
'beforeSubmitPrompt',
|
|
2124
2606
|
'sessionEnd',
|
|
2607
|
+
'sessionStart',
|
|
2125
2608
|
'stop',
|
|
2126
2609
|
])
|
|
2127
2610
|
expect(installed.hooks['beforeSubmitPrompt']?.[0]?.command).toContain(
|
|
2128
2611
|
'hook user-prompt-submit --owner notifai --harness cursor',
|
|
2129
2612
|
)
|
|
2130
2613
|
expect(installed.hooks['stop']?.[0]).toMatchObject({
|
|
2614
|
+
command: expect.stringContaining('hook activation-stop --owner notifai --harness cursor'),
|
|
2615
|
+
loop_limit: 1,
|
|
2616
|
+
})
|
|
2617
|
+
expect(installed.hooks['stop']?.[1]).toMatchObject({
|
|
2131
2618
|
command: expect.stringContaining('hook stop --owner notifai --harness cursor'),
|
|
2132
2619
|
loop_limit: 3,
|
|
2133
2620
|
})
|
|
@@ -2163,7 +2650,7 @@ describe('Cursor hook commands', () => {
|
|
|
2163
2650
|
expect(io.outLines).toContain(
|
|
2164
2651
|
`ok Question routing: cursor project (${path.join(cwd, '.cursor', 'hooks.json')})`,
|
|
2165
2652
|
)
|
|
2166
|
-
expect(io.outLines.some((line) => line.includes('Cursor:
|
|
2653
|
+
expect(io.outLines.some((line) => line.includes('Cursor: start one fresh conversation'))).toBe(true)
|
|
2167
2654
|
})
|
|
2168
2655
|
|
|
2169
2656
|
it('uninstalls only Notifai Cursor hooks and preserves foreign hooks', () => {
|
|
@@ -2242,13 +2729,7 @@ describe('Codex hook representation', () => {
|
|
|
2242
2729
|
expect(io.outLines.join('\n')).toMatch(/Stop and the existing one will both fire/i)
|
|
2243
2730
|
})
|
|
2244
2731
|
|
|
2245
|
-
|
|
2246
|
-
* The foreign `hooks.json` stays byte-identical and Notifai lands inline.
|
|
2247
|
-
* Codex will now warn that the layer uses both representations; the install
|
|
2248
|
-
* output says why, because that warning is Codex reporting a merge, not a
|
|
2249
|
-
* fault, and a silent one would read as Notifai's bug.
|
|
2250
|
-
*/
|
|
2251
|
-
it('leaves a foreign hooks.json alone and installs inline instead', () => {
|
|
2732
|
+
it('joins a populated hooks.json without introducing a second representation', () => {
|
|
2252
2733
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-json-install-'))
|
|
2253
2734
|
const layer = path.join(cwd, '.codex')
|
|
2254
2735
|
const json = path.join(layer, 'hooks.json')
|
|
@@ -2266,17 +2747,12 @@ describe('Codex hook representation', () => {
|
|
|
2266
2747
|
|
|
2267
2748
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2268
2749
|
|
|
2269
|
-
const
|
|
2270
|
-
expect(
|
|
2271
|
-
expect(
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
expect(
|
|
2275
|
-
expect(after).toContain('--owner notifai')
|
|
2276
|
-
expect(io.outLines.join('\n')).toContain(toml)
|
|
2277
|
-
expect(io.outLines.join('\n')).toMatch(/Notifai will not modify it/)
|
|
2278
|
-
expect(io.outLines.join('\n')).toMatch(/not a Notifai fault/)
|
|
2279
|
-
expect(io.outLines.join('\n')).not.toMatch(/installed in both/i)
|
|
2750
|
+
const afterJson = readFileSync(json, 'utf8')
|
|
2751
|
+
expect(afterJson).toContain('gdh-stop')
|
|
2752
|
+
expect(afterJson).toContain('--owner notifai')
|
|
2753
|
+
expect(readFileSync(toml, 'utf8')).toBe(beforeToml)
|
|
2754
|
+
expect(io.outLines.join('\n')).toContain(json)
|
|
2755
|
+
expect(io.outLines.join('\n')).not.toMatch(/both representations|not a Notifai fault/i)
|
|
2280
2756
|
})
|
|
2281
2757
|
|
|
2282
2758
|
it('leaves config.toml byte-identical when uninstall finds no Notifai hooks', () => {
|
|
@@ -2293,6 +2769,50 @@ describe('Codex hook representation', () => {
|
|
|
2293
2769
|
expect(io.outLines.join('\n')).toContain('No Notifai hooks found')
|
|
2294
2770
|
})
|
|
2295
2771
|
|
|
2772
|
+
it('leaves config.toml byte-identical on a no-op reinstall', () => {
|
|
2773
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-noop-install-'))
|
|
2774
|
+
const io = new CapturedIo()
|
|
2775
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2776
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2777
|
+
const toml = path.join(cwd, '.codex', 'config.toml')
|
|
2778
|
+
const before = readFileSync(toml, 'utf8')
|
|
2779
|
+
io.outLines = []
|
|
2780
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2781
|
+
expect(readFileSync(toml, 'utf8')).toBe(before)
|
|
2782
|
+
})
|
|
2783
|
+
|
|
2784
|
+
it('deletes an emptied hooks.json instead of leaving an empty residue', () => {
|
|
2785
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-empty-json-'))
|
|
2786
|
+
const layer = path.join(cwd, '.codex')
|
|
2787
|
+
const json = path.join(layer, 'hooks.json')
|
|
2788
|
+
mkdirSync(layer, { recursive: true })
|
|
2789
|
+
applyPlan(json, {
|
|
2790
|
+
hooks: {
|
|
2791
|
+
Stop: [{ hooks: [{ type: 'command', command: hookCommand(scriptPath, 'stop', 'codex') }] }],
|
|
2792
|
+
},
|
|
2793
|
+
})
|
|
2794
|
+
const io = new CapturedIo()
|
|
2795
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2796
|
+
expect(hooksUninstallCommand(deps, { harness: 'codex', scriptPath })).toBe(EXIT.ok)
|
|
2797
|
+
expect(existsSync(json)).toBe(false)
|
|
2798
|
+
})
|
|
2799
|
+
|
|
2800
|
+
it('global install does not leave a dead project .codex after uninstalling that layer', () => {
|
|
2801
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-global-migrate-'))
|
|
2802
|
+
const io = new CapturedIo()
|
|
2803
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
2804
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
2805
|
+
expect(existsSync(path.join(cwd, '.codex'))).toBe(true)
|
|
2806
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath })).toBe(
|
|
2807
|
+
EXIT.ok,
|
|
2808
|
+
)
|
|
2809
|
+
expect(existsSync(path.join(cwd, '.codex', 'hooks.json'))).toBe(false)
|
|
2810
|
+
const leftover = existsSync(path.join(cwd, '.codex'))
|
|
2811
|
+
? readdirSync(path.join(cwd, '.codex'))
|
|
2812
|
+
: []
|
|
2813
|
+
expect(leftover.every((name) => name !== 'hooks.json')).toBe(true)
|
|
2814
|
+
})
|
|
2815
|
+
|
|
2296
2816
|
/**
|
|
2297
2817
|
* Notifai in one file, someone else's hooks in the other: every handler
|
|
2298
2818
|
* fires once, so doctor has nothing to report and the foreign file is not
|
|
@@ -2386,7 +2906,7 @@ describe('harness activation guidance', () => {
|
|
|
2386
2906
|
const execPath = process.execPath
|
|
2387
2907
|
const scriptPath = fileURLToPath(import.meta.url)
|
|
2388
2908
|
|
|
2389
|
-
it('
|
|
2909
|
+
it('requires a fresh Claude Code session so SessionStart can activate it', () => {
|
|
2390
2910
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-claude-activation-'))
|
|
2391
2911
|
const io = new CapturedIo()
|
|
2392
2912
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
@@ -2397,11 +2917,11 @@ describe('harness activation guidance', () => {
|
|
|
2397
2917
|
|
|
2398
2918
|
const output = io.outLines.join('\n')
|
|
2399
2919
|
expect(output).toContain('Installed claude-code hooks in')
|
|
2400
|
-
expect(output).toContain('
|
|
2920
|
+
expect(output).toContain('Start one fresh Claude Code session, send one prompt, then run `notifai doctor`.')
|
|
2401
2921
|
expect(output).not.toMatch(/timeout|asynchronous|600s/i)
|
|
2402
2922
|
})
|
|
2403
2923
|
|
|
2404
|
-
it('
|
|
2924
|
+
it('names Codex trust and fresh-session activation in the correct order', () => {
|
|
2405
2925
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-codex-activation-'))
|
|
2406
2926
|
const io = new CapturedIo()
|
|
2407
2927
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
@@ -2411,8 +2931,8 @@ describe('harness activation guidance', () => {
|
|
|
2411
2931
|
)
|
|
2412
2932
|
|
|
2413
2933
|
const output = io.outLines.join('\n')
|
|
2414
|
-
expect(output).toContain('
|
|
2415
|
-
expect(output).
|
|
2934
|
+
expect(output).toContain('Approve the Notifai handlers in `/hooks` if Codex asks')
|
|
2935
|
+
expect(output).toMatch(/approve[\s\S]*start one fresh Codex session[\s\S]*`notifai doctor`/i)
|
|
2416
2936
|
})
|
|
2417
2937
|
|
|
2418
2938
|
it('names the installed harness in the close, never a different one', () => {
|
|
@@ -2424,7 +2944,7 @@ describe('harness activation guidance', () => {
|
|
|
2424
2944
|
|
|
2425
2945
|
const output = io.outLines.join('\n')
|
|
2426
2946
|
expect(output).toContain('Installed cursor hooks in')
|
|
2427
|
-
expect(output).toContain('
|
|
2947
|
+
expect(output).toContain('Start one fresh Cursor conversation, send one prompt, finish its first turn, then run `notifai doctor`.')
|
|
2428
2948
|
expect(output).not.toMatch(/Codex|Claude Code|OpenCode/)
|
|
2429
2949
|
expect(output).not.toMatch(/timeout|worktree|fails closed/i)
|
|
2430
2950
|
})
|
|
@@ -2487,7 +3007,7 @@ describe('harness activation guidance', () => {
|
|
|
2487
3007
|
)
|
|
2488
3008
|
|
|
2489
3009
|
expect(io.outLines.join('\n')).toContain('Installed opencode hooks in')
|
|
2490
|
-
expect(io.outLines.join('\n')).toContain('
|
|
3010
|
+
expect(io.outLines.join('\n')).toContain('Restart OpenCode, start one fresh session, send one prompt, then run `notifai doctor`.')
|
|
2491
3011
|
expect(io.outLines.join('\n')).not.toMatch(/Permission prompts|exactly-once continuation/)
|
|
2492
3012
|
const pluginFile = path.join(cwd, '.opencode', 'plugins', 'notifai.js')
|
|
2493
3013
|
const plugin = readFileSync(pluginFile, 'utf8')
|
|
@@ -2632,6 +3152,141 @@ describe('stable hook installation', () => {
|
|
|
2632
3152
|
findInstallations(cwd, env, deps.hookAdapterHome).filter((item) => item.harness === 'codex'),
|
|
2633
3153
|
).toEqual([expect.objectContaining({ global: true })])
|
|
2634
3154
|
})
|
|
3155
|
+
|
|
3156
|
+
it('refreshes stale global lifecycle coverage instead of declining a project install', () => {
|
|
3157
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-stale-'))
|
|
3158
|
+
const io = new CapturedIo()
|
|
3159
|
+
const env = isolatedEnv(cwd)
|
|
3160
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3161
|
+
const execPath = process.execPath
|
|
3162
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3163
|
+
|
|
3164
|
+
expect(
|
|
3165
|
+
hooksInstallCommand(deps, {
|
|
3166
|
+
harness: 'cursor',
|
|
3167
|
+
global: true,
|
|
3168
|
+
execPath,
|
|
3169
|
+
scriptPath,
|
|
3170
|
+
}),
|
|
3171
|
+
).toBe(EXIT.ok)
|
|
3172
|
+
const globalFile = settingsFile('cursor', true, cwd, env)
|
|
3173
|
+
const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3174
|
+
hooks: { stop: Array<{ command?: string }> }
|
|
3175
|
+
}
|
|
3176
|
+
settings.hooks.stop = settings.hooks.stop.filter(
|
|
3177
|
+
(handler) => !handler.command?.includes(' hook activation-stop '),
|
|
3178
|
+
)
|
|
3179
|
+
writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
3180
|
+
|
|
3181
|
+
io.outLines = []
|
|
3182
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3183
|
+
expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
|
|
3184
|
+
const installation = findInstallations(cwd, env, deps.hookAdapterHome).find(
|
|
3185
|
+
(item) => item.harness === 'cursor' && item.global,
|
|
3186
|
+
)
|
|
3187
|
+
expect(installation?.handlers.map((handler) => handlerEvent(handler.command))).toContain(
|
|
3188
|
+
'activation-stop',
|
|
3189
|
+
)
|
|
3190
|
+
expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
|
|
3191
|
+
})
|
|
3192
|
+
|
|
3193
|
+
it('refreshes a stale global Stop shape instead of declining a project install', () => {
|
|
3194
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-shape-'))
|
|
3195
|
+
const io = new CapturedIo()
|
|
3196
|
+
const env = isolatedEnv(cwd)
|
|
3197
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3198
|
+
const execPath = process.execPath
|
|
3199
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3200
|
+
|
|
3201
|
+
expect(
|
|
3202
|
+
hooksInstallCommand(deps, {
|
|
3203
|
+
harness: 'cursor',
|
|
3204
|
+
global: true,
|
|
3205
|
+
execPath,
|
|
3206
|
+
scriptPath,
|
|
3207
|
+
}),
|
|
3208
|
+
).toBe(EXIT.ok)
|
|
3209
|
+
const globalFile = settingsFile('cursor', true, cwd, env)
|
|
3210
|
+
const settings = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3211
|
+
hooks: { stop: Array<{ command?: string; timeout?: number }> }
|
|
3212
|
+
}
|
|
3213
|
+
const stop = settings.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))
|
|
3214
|
+
expect(stop).toBeDefined()
|
|
3215
|
+
stop!.timeout = 1
|
|
3216
|
+
writeFileSync(globalFile, `${JSON.stringify(settings, null, 2)}\n`)
|
|
3217
|
+
|
|
3218
|
+
io.outLines = []
|
|
3219
|
+
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3220
|
+
expect(existsSync(path.join(cwd, '.cursor', 'hooks.json'))).toBe(false)
|
|
3221
|
+
const refreshed = JSON.parse(readFileSync(globalFile, 'utf8')) as {
|
|
3222
|
+
hooks: { stop: Array<{ command?: string; timeout?: number }> }
|
|
3223
|
+
}
|
|
3224
|
+
expect(
|
|
3225
|
+
refreshed.hooks.stop.find((handler) => handler.command?.includes(' hook stop '))?.timeout,
|
|
3226
|
+
).toBeGreaterThan(1)
|
|
3227
|
+
expect(io.outLines.join('\n')).toMatch(/Installed cursor hooks/)
|
|
3228
|
+
})
|
|
3229
|
+
|
|
3230
|
+
it('consolidates duplicate global Codex representations before declining a project install', () => {
|
|
3231
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-codex-duplicate-'))
|
|
3232
|
+
const io = new CapturedIo()
|
|
3233
|
+
const env = isolatedEnv(cwd)
|
|
3234
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3235
|
+
const execPath = process.execPath
|
|
3236
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3237
|
+
|
|
3238
|
+
expect(
|
|
3239
|
+
hooksInstallCommand(deps, { harness: 'codex', global: true, execPath, scriptPath }),
|
|
3240
|
+
).toBe(EXIT.ok)
|
|
3241
|
+
const json = path.join(env.CODEX_HOME!, 'hooks.json')
|
|
3242
|
+
applyPlan(json, {
|
|
3243
|
+
hooks: {
|
|
3244
|
+
...buildHookConfig({ adapterPath: hookAdapterPath(deps.hookAdapterHome), harness: 'codex' }),
|
|
3245
|
+
PostToolUse: [{ hooks: [{ type: 'command', command: 'foreign-post-tool' }] }],
|
|
3246
|
+
},
|
|
3247
|
+
})
|
|
3248
|
+
|
|
3249
|
+
io.outLines = []
|
|
3250
|
+
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3251
|
+
expect(existsSync(path.join(cwd, '.codex', 'config.toml'))).toBe(false)
|
|
3252
|
+
expect(readFileSync(json, 'utf8')).toContain('foreign-post-tool')
|
|
3253
|
+
expect(readFileSync(json, 'utf8')).not.toContain('--owner notifai')
|
|
3254
|
+
expect(
|
|
3255
|
+
findInstallations(cwd, env, deps.hookAdapterHome).filter(
|
|
3256
|
+
(installation) => installation.harness === 'codex',
|
|
3257
|
+
),
|
|
3258
|
+
).toEqual([expect.objectContaining({ global: true })])
|
|
3259
|
+
expect(io.outLines.join('\n')).toMatch(/Installed codex hooks/)
|
|
3260
|
+
})
|
|
3261
|
+
|
|
3262
|
+
it('refreshes an obsolete global OpenCode plugin instead of treating empty event requirements as coverage', () => {
|
|
3263
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-hooks-global-opencode-stale-'))
|
|
3264
|
+
const io = new CapturedIo()
|
|
3265
|
+
const env = isolatedEnv(cwd)
|
|
3266
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env }
|
|
3267
|
+
const execPath = process.execPath
|
|
3268
|
+
const scriptPath = fileURLToPath(import.meta.url)
|
|
3269
|
+
|
|
3270
|
+
expect(
|
|
3271
|
+
hooksInstallCommand(deps, {
|
|
3272
|
+
harness: 'opencode',
|
|
3273
|
+
global: true,
|
|
3274
|
+
execPath,
|
|
3275
|
+
scriptPath,
|
|
3276
|
+
}),
|
|
3277
|
+
).toBe(EXIT.ok)
|
|
3278
|
+
const globalFile = settingsFile('opencode', true, cwd, env)
|
|
3279
|
+
const current = readFileSync(globalFile, 'utf8')
|
|
3280
|
+
writeFileSync(globalFile, current.replace('const ADAPTER_VERSION = 11', 'const ADAPTER_VERSION = 10'))
|
|
3281
|
+
|
|
3282
|
+
io.outLines = []
|
|
3283
|
+
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
3284
|
+
expect(existsSync(path.join(cwd, '.opencode', 'plugins', 'notifai.js'))).toBe(false)
|
|
3285
|
+
const refreshed = readFileSync(globalFile, 'utf8')
|
|
3286
|
+
expect(refreshed).toContain('const ADAPTER_VERSION = 11')
|
|
3287
|
+
expect(refreshed).toContain('experimental.chat.system.transform')
|
|
3288
|
+
expect(io.outLines.join('\n')).toMatch(/Installed opencode hooks/)
|
|
3289
|
+
})
|
|
2635
3290
|
})
|
|
2636
3291
|
|
|
2637
3292
|
describe('projectSlugFrom', () => {
|
|
@@ -2967,7 +3622,7 @@ describe('interactive command UX', () => {
|
|
|
2967
3622
|
beginPairing: async () => ({
|
|
2968
3623
|
pairing_id: 'pair_test',
|
|
2969
3624
|
code: 'ABCD-EFGH',
|
|
2970
|
-
approve_url: 'https://
|
|
3625
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
2971
3626
|
expires_at: new Date(10_000).toISOString(),
|
|
2972
3627
|
poll_interval_seconds: 1,
|
|
2973
3628
|
}),
|
|
@@ -2997,7 +3652,9 @@ describe('interactive command UX', () => {
|
|
|
2997
3652
|
expect(io.notes).toEqual([
|
|
2998
3653
|
{
|
|
2999
3654
|
title: 'Approve this machine',
|
|
3000
|
-
message:
|
|
3655
|
+
message: expect.stringMatching(
|
|
3656
|
+
/^Code: ABCD-EFGH\nhttps:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
3657
|
+
),
|
|
3001
3658
|
},
|
|
3002
3659
|
])
|
|
3003
3660
|
expect(io.spinnerEvents).toEqual([
|
|
@@ -3016,7 +3673,7 @@ describe('interactive command UX', () => {
|
|
|
3016
3673
|
beginPairing: async () => ({
|
|
3017
3674
|
pairing_id: 'pair_test',
|
|
3018
3675
|
code: 'ABCD-EFGH',
|
|
3019
|
-
approve_url: 'https://
|
|
3676
|
+
approve_url: 'https://app.notifai.sh/pair/ABCD-EFGH',
|
|
3020
3677
|
expires_at: new Date(10_000).toISOString(),
|
|
3021
3678
|
poll_interval_seconds: 1,
|
|
3022
3679
|
}),
|
|
@@ -3033,7 +3690,9 @@ describe('interactive command UX', () => {
|
|
|
3033
3690
|
expect(await loginCommand(deps, { open: false })).toBe(EXIT.ok)
|
|
3034
3691
|
expect(io.outLines.slice(0, 3)).toEqual([
|
|
3035
3692
|
'Pairing code: ABCD-EFGH',
|
|
3036
|
-
|
|
3693
|
+
expect.stringMatching(
|
|
3694
|
+
/^Approve this machine at: https:\/\/app\.notifai\.sh\/pair\/ABCD-EFGH#confirmation_secret=[A-Za-z0-9_-]{43}$/,
|
|
3695
|
+
),
|
|
3037
3696
|
'Waiting for approval…',
|
|
3038
3697
|
])
|
|
3039
3698
|
})
|
|
@@ -3174,6 +3833,26 @@ describe('interactive command UX', () => {
|
|
|
3174
3833
|
expect(io.intros).toEqual([])
|
|
3175
3834
|
expect(io.checks).toEqual([])
|
|
3176
3835
|
})
|
|
3836
|
+
|
|
3837
|
+
it('consults npm latest from an interactive doctor without failing the run', async () => {
|
|
3838
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-registry-'))
|
|
3839
|
+
const io = new InteractiveIo()
|
|
3840
|
+
const client = {
|
|
3841
|
+
health: async () => true,
|
|
3842
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
3843
|
+
} as unknown as ApiClient
|
|
3844
|
+
const deps: CommandDeps = {
|
|
3845
|
+
...makeDeps(io, client),
|
|
3846
|
+
cwd,
|
|
3847
|
+
env: isolatedEnv(cwd),
|
|
3848
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
3849
|
+
fetchImpl: async () => new Response(JSON.stringify({ latest: '99.0.0' }), { status: 200 }),
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
expect(await doctorCommand(deps, {})).toBe(EXIT.failed)
|
|
3853
|
+
expect(io.outLines).toContain('A newer Notifai is available.')
|
|
3854
|
+
expect(io.outLines).toContain('npm install -g @raidiant/notifai')
|
|
3855
|
+
})
|
|
3177
3856
|
})
|
|
3178
3857
|
|
|
3179
3858
|
describe('compatibility-first update guidance', () => {
|
|
@@ -3421,7 +4100,6 @@ describe('init', () => {
|
|
|
3421
4100
|
): EvidenceSnapshot {
|
|
3422
4101
|
return {
|
|
3423
4102
|
request_id: requestId,
|
|
3424
|
-
event: 'setup_verified',
|
|
3425
4103
|
accepted_at: '2026-08-05T18:00:00.000Z',
|
|
3426
4104
|
overall: 'provider_accepted_all',
|
|
3427
4105
|
deliveries: [
|
|
@@ -3659,18 +4337,22 @@ describe('init', () => {
|
|
|
3659
4337
|
} as unknown as ApiClient
|
|
3660
4338
|
const nativeSkills: NativeSkills = {
|
|
3661
4339
|
add: async () => 0,
|
|
4340
|
+
remove: async () => 0,
|
|
3662
4341
|
list: async (scope) => ({
|
|
3663
|
-
skills:
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
4342
|
+
skills:
|
|
4343
|
+
scope === 'project'
|
|
4344
|
+
? [
|
|
4345
|
+
{
|
|
4346
|
+
name: 'notifai',
|
|
4347
|
+
scope,
|
|
4348
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
4349
|
+
source: 'Raidiant-io/notifai',
|
|
4350
|
+
sourceType: 'github',
|
|
4351
|
+
sourceUrl: 'https://github.com/Raidiant-io/notifai.git',
|
|
4352
|
+
ref: RELEASE_REF,
|
|
4353
|
+
},
|
|
4354
|
+
]
|
|
4355
|
+
: [],
|
|
3674
4356
|
}),
|
|
3675
4357
|
}
|
|
3676
4358
|
const readiness = await assessReadiness(
|
|
@@ -3696,9 +4378,10 @@ describe('init', () => {
|
|
|
3696
4378
|
const calls: SkillScope[] = []
|
|
3697
4379
|
const nativeSkills: NativeSkills = {
|
|
3698
4380
|
add: async () => 0,
|
|
4381
|
+
remove: async () => 0,
|
|
3699
4382
|
list: async (selected) => {
|
|
3700
4383
|
calls.push(selected)
|
|
3701
|
-
return { skills: [managedSkill(selected, cwd)] }
|
|
4384
|
+
return { skills: selected === scope ? [managedSkill(selected, cwd)] : [] }
|
|
3702
4385
|
},
|
|
3703
4386
|
}
|
|
3704
4387
|
|
|
@@ -3710,7 +4393,7 @@ describe('init', () => {
|
|
|
3710
4393
|
status: 'ready',
|
|
3711
4394
|
detail: `installed from ${SKILLS_SOURCE} in the ${scope} scope`,
|
|
3712
4395
|
})
|
|
3713
|
-
expect(calls).toEqual([
|
|
4396
|
+
expect(calls).toEqual(['project', 'global'])
|
|
3714
4397
|
},
|
|
3715
4398
|
)
|
|
3716
4399
|
|
|
@@ -3726,8 +4409,12 @@ describe('init', () => {
|
|
|
3726
4409
|
} as unknown as ApiClient
|
|
3727
4410
|
const nativeSkills: NativeSkills = {
|
|
3728
4411
|
add: async () => 0,
|
|
4412
|
+
remove: async () => 0,
|
|
3729
4413
|
list: async (selected) => ({
|
|
3730
|
-
skills:
|
|
4414
|
+
skills:
|
|
4415
|
+
selected === scope
|
|
4416
|
+
? [{ ...managedSkill(selected, cwd), source: null, sourceType: null, ref: null }]
|
|
4417
|
+
: [],
|
|
3731
4418
|
}),
|
|
3732
4419
|
}
|
|
3733
4420
|
|
|
@@ -3742,6 +4429,192 @@ describe('init', () => {
|
|
|
3742
4429
|
},
|
|
3743
4430
|
)
|
|
3744
4431
|
|
|
4432
|
+
it('reports a duplicate notifai skill across project and global scope', async () => {
|
|
4433
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-duplicate-'))
|
|
4434
|
+
const io = new CapturedIo()
|
|
4435
|
+
const client = {
|
|
4436
|
+
health: async () => true,
|
|
4437
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4438
|
+
listDevices: async () => ({ devices: [] }),
|
|
4439
|
+
} as unknown as ApiClient
|
|
4440
|
+
const nativeSkills: NativeSkills = {
|
|
4441
|
+
add: async () => 0,
|
|
4442
|
+
remove: async () => 0,
|
|
4443
|
+
list: async (scope) => ({
|
|
4444
|
+
skills: [
|
|
4445
|
+
{
|
|
4446
|
+
...managedSkill(scope, cwd),
|
|
4447
|
+
ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
|
|
4448
|
+
path:
|
|
4449
|
+
scope === 'project'
|
|
4450
|
+
? path.join(cwd, '.agents', 'skills', 'notifai')
|
|
4451
|
+
: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
|
|
4452
|
+
},
|
|
4453
|
+
],
|
|
4454
|
+
}),
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
const readiness = await assessReadiness({ ...makeDeps(io, client), cwd, nativeSkills })
|
|
4458
|
+
const skill = readiness.states.find((state) => state.id === 'skill')
|
|
4459
|
+
expect(skill).toMatchObject({
|
|
4460
|
+
status: 'gap',
|
|
4461
|
+
detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
4462
|
+
technical: {
|
|
4463
|
+
project: {
|
|
4464
|
+
ref: RELEASE_REF,
|
|
4465
|
+
path: path.join(cwd, '.agents', 'skills', 'notifai'),
|
|
4466
|
+
current: true,
|
|
4467
|
+
},
|
|
4468
|
+
global: {
|
|
4469
|
+
ref: 'v0.2.1',
|
|
4470
|
+
path: path.join(cwd, 'home', '.agents', 'skills', 'notifai'),
|
|
4471
|
+
current: false,
|
|
4472
|
+
},
|
|
4473
|
+
resolution: 'both-listed',
|
|
4474
|
+
},
|
|
4475
|
+
})
|
|
4476
|
+
|
|
4477
|
+
expect(await doctorCommand({ ...makeDeps(io, client), cwd, nativeSkills }, { json: true })).toBe(
|
|
4478
|
+
EXIT.failed,
|
|
4479
|
+
)
|
|
4480
|
+
const payload = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
4481
|
+
ok: boolean
|
|
4482
|
+
states: Array<{ id: string; status: string; detail: string }>
|
|
4483
|
+
}
|
|
4484
|
+
expect(payload.ok).toBe(false)
|
|
4485
|
+
expect(payload.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4486
|
+
status: 'gap',
|
|
4487
|
+
detail: expect.stringContaining('harness lists both'),
|
|
4488
|
+
})
|
|
4489
|
+
})
|
|
4490
|
+
|
|
4491
|
+
it('does not treat a current pin as ready when a stale duplicate is also installed', async () => {
|
|
4492
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-skill-shadow-'))
|
|
4493
|
+
const io = new CapturedIo()
|
|
4494
|
+
const client = {
|
|
4495
|
+
health: async () => true,
|
|
4496
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
4497
|
+
listDevices: async () => ({ devices: [] }),
|
|
4498
|
+
} as unknown as ApiClient
|
|
4499
|
+
const nativeSkills: NativeSkills = {
|
|
4500
|
+
add: async () => 0,
|
|
4501
|
+
remove: async () => 0,
|
|
4502
|
+
list: async (scope) => ({
|
|
4503
|
+
skills: [
|
|
4504
|
+
{
|
|
4505
|
+
...managedSkill(scope, cwd),
|
|
4506
|
+
ref: scope === 'global' ? RELEASE_REF : 'v0.2.1',
|
|
4507
|
+
},
|
|
4508
|
+
],
|
|
4509
|
+
}),
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
const readiness = await assessReadiness(
|
|
4513
|
+
{ ...makeDeps(io, client), cwd, nativeSkills },
|
|
4514
|
+
{ skillScope: 'global' },
|
|
4515
|
+
)
|
|
4516
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
4517
|
+
status: 'gap',
|
|
4518
|
+
detail: `project (v0.2.1) and global (${RELEASE_REF}) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
4519
|
+
})
|
|
4520
|
+
})
|
|
4521
|
+
|
|
4522
|
+
it('uninstalls the other skill scope before installing the chosen one', async () => {
|
|
4523
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-prevented-'))
|
|
4524
|
+
const io = new CapturedIo()
|
|
4525
|
+
const removed: SkillScope[] = []
|
|
4526
|
+
const added: Array<SkillScope | undefined> = []
|
|
4527
|
+
let globalPresent = true
|
|
4528
|
+
const nativeSkills: NativeSkills = {
|
|
4529
|
+
add: async (options) => {
|
|
4530
|
+
added.push(options.scope)
|
|
4531
|
+
return 0
|
|
4532
|
+
},
|
|
4533
|
+
remove: async (options) => {
|
|
4534
|
+
removed.push(options.scope)
|
|
4535
|
+
if (options.scope === 'global') globalPresent = false
|
|
4536
|
+
return 0
|
|
4537
|
+
},
|
|
4538
|
+
list: async (scope) => ({
|
|
4539
|
+
skills: scope === 'global' && globalPresent ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
|
|
4540
|
+
}),
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
expect(
|
|
4544
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4545
|
+
skills: true,
|
|
4546
|
+
setupScope: 'project',
|
|
4547
|
+
hooks: false,
|
|
4548
|
+
}),
|
|
4549
|
+
).toBe(EXIT.ok)
|
|
4550
|
+
expect(removed).toEqual(['global'])
|
|
4551
|
+
expect(added).toEqual(['project'])
|
|
4552
|
+
})
|
|
4553
|
+
|
|
4554
|
+
it('clears a pre-existing skill duplicate by keeping the chosen setup scope', async () => {
|
|
4555
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-duplicate-clear-'))
|
|
4556
|
+
const io = new CapturedIo()
|
|
4557
|
+
const removed: SkillScope[] = []
|
|
4558
|
+
const present = new Set<SkillScope>(['project', 'global'])
|
|
4559
|
+
const nativeSkills: NativeSkills = {
|
|
4560
|
+
add: async (options) => {
|
|
4561
|
+
if (options.scope !== undefined) present.add(options.scope)
|
|
4562
|
+
return 0
|
|
4563
|
+
},
|
|
4564
|
+
remove: async (options) => {
|
|
4565
|
+
removed.push(options.scope)
|
|
4566
|
+
present.delete(options.scope)
|
|
4567
|
+
return 0
|
|
4568
|
+
},
|
|
4569
|
+
list: async (scope) => ({
|
|
4570
|
+
skills: present.has(scope)
|
|
4571
|
+
? [
|
|
4572
|
+
{
|
|
4573
|
+
...managedSkill(scope, cwd),
|
|
4574
|
+
ref: scope === 'project' ? RELEASE_REF : 'v0.2.1',
|
|
4575
|
+
},
|
|
4576
|
+
]
|
|
4577
|
+
: [],
|
|
4578
|
+
}),
|
|
4579
|
+
}
|
|
4580
|
+
|
|
4581
|
+
expect(
|
|
4582
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4583
|
+
skills: true,
|
|
4584
|
+
setupScope: 'project',
|
|
4585
|
+
hooks: false,
|
|
4586
|
+
}),
|
|
4587
|
+
).toBe(EXIT.ok)
|
|
4588
|
+
expect(removed).toEqual(['global'])
|
|
4589
|
+
expect([...present]).toEqual(['project'])
|
|
4590
|
+
})
|
|
4591
|
+
|
|
4592
|
+
it('updates the existing skill scope instead of adding a second copy', async () => {
|
|
4593
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-update-existing-'))
|
|
4594
|
+
const io = new InteractiveIo()
|
|
4595
|
+
const received: Array<SkillScope | undefined> = []
|
|
4596
|
+
const installed = true
|
|
4597
|
+
const nativeSkills: NativeSkills = {
|
|
4598
|
+
add: async (options) => {
|
|
4599
|
+
received.push(options.scope)
|
|
4600
|
+
return 0
|
|
4601
|
+
},
|
|
4602
|
+
remove: async () => 0,
|
|
4603
|
+
list: async (scope) => ({
|
|
4604
|
+
skills: installed && scope === 'global' ? [{ ...managedSkill('global', cwd), ref: 'v0.2.1' }] : [],
|
|
4605
|
+
}),
|
|
4606
|
+
}
|
|
4607
|
+
|
|
4608
|
+
expect(
|
|
4609
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4610
|
+
skills: true,
|
|
4611
|
+
hooks: false,
|
|
4612
|
+
}),
|
|
4613
|
+
).toBe(EXIT.ok)
|
|
4614
|
+
expect(received).toEqual(['global'])
|
|
4615
|
+
expect(io.outLines.join('\n')).toContain('global scope')
|
|
4616
|
+
})
|
|
4617
|
+
|
|
3745
4618
|
it('requires an explicit skill scope before unattended installation', async () => {
|
|
3746
4619
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-scope-required-'))
|
|
3747
4620
|
const io = new CapturedIo()
|
|
@@ -3751,6 +4624,7 @@ describe('init', () => {
|
|
|
3751
4624
|
addCalls += 1
|
|
3752
4625
|
return 0
|
|
3753
4626
|
},
|
|
4627
|
+
remove: async () => 0,
|
|
3754
4628
|
list: async () => ({ skills: [] }),
|
|
3755
4629
|
}
|
|
3756
4630
|
|
|
@@ -3758,7 +4632,7 @@ describe('init', () => {
|
|
|
3758
4632
|
EXIT.usage,
|
|
3759
4633
|
)
|
|
3760
4634
|
expect(addCalls).toBe(0)
|
|
3761
|
-
expect(io.errLines.join('\n')).toContain('--
|
|
4635
|
+
expect(io.errLines.join('\n')).toContain('--setup-scope project')
|
|
3762
4636
|
})
|
|
3763
4637
|
|
|
3764
4638
|
it('rejects an invalid unattended skill scope instead of guessing', async () => {
|
|
@@ -3786,6 +4660,7 @@ describe('init', () => {
|
|
|
3786
4660
|
installed = true
|
|
3787
4661
|
return 0
|
|
3788
4662
|
},
|
|
4663
|
+
remove: async () => 0,
|
|
3789
4664
|
list: async (selected) => ({
|
|
3790
4665
|
skills: installed && selected === scope ? [managedSkill(scope, cwd)] : [],
|
|
3791
4666
|
}),
|
|
@@ -3802,27 +4677,82 @@ describe('init', () => {
|
|
|
3802
4677
|
},
|
|
3803
4678
|
)
|
|
3804
4679
|
|
|
3805
|
-
it('
|
|
3806
|
-
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-
|
|
4680
|
+
it('asks one setup-scope question and passes that scope to the native installer', async () => {
|
|
4681
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-skill-setup-scope-ask-'))
|
|
3807
4682
|
const io = new InteractiveIo()
|
|
4683
|
+
io.selectAnswer = 'project'
|
|
3808
4684
|
const calls: { submit: number } = { submit: 0 }
|
|
3809
|
-
let receivedScope: SkillScope | undefined
|
|
4685
|
+
let receivedScope: SkillScope | undefined
|
|
3810
4686
|
const nativeSkills: NativeSkills = {
|
|
3811
4687
|
add: async (options) => {
|
|
3812
4688
|
receivedScope = options.scope
|
|
3813
4689
|
return 0
|
|
3814
4690
|
},
|
|
4691
|
+
remove: async () => 0,
|
|
3815
4692
|
list: async () => ({ skills: [] }),
|
|
3816
4693
|
}
|
|
3817
4694
|
|
|
3818
4695
|
expect(await initCommand(setupReadyDeps(io, cwd, nativeSkills, calls), { skills: true, hooks: false })).toBe(
|
|
3819
4696
|
EXIT.ok,
|
|
3820
4697
|
)
|
|
3821
|
-
expect(
|
|
4698
|
+
expect(io.prompts[0]).toContain('this project only, or to every project on this machine')
|
|
4699
|
+
expect(receivedScope).toBe('project')
|
|
3822
4700
|
expect(calls.submit).toBe(1)
|
|
3823
4701
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
3824
4702
|
})
|
|
3825
4703
|
|
|
4704
|
+
it('accepts --setup-scope as the unattended skill, hooks, and config scope', async () => {
|
|
4705
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-flag-'))
|
|
4706
|
+
const io = new CapturedIo()
|
|
4707
|
+
let receivedScope: SkillScope | undefined
|
|
4708
|
+
const nativeSkills: NativeSkills = {
|
|
4709
|
+
add: async (options) => {
|
|
4710
|
+
receivedScope = options.scope
|
|
4711
|
+
return 0
|
|
4712
|
+
},
|
|
4713
|
+
remove: async () => 0,
|
|
4714
|
+
list: async (scope) => ({
|
|
4715
|
+
skills: receivedScope === scope ? [managedSkill(scope, cwd)] : [],
|
|
4716
|
+
}),
|
|
4717
|
+
}
|
|
4718
|
+
|
|
4719
|
+
expect(
|
|
4720
|
+
await initCommand(setupReadyDeps(io, cwd, nativeSkills, { submit: 0 }), {
|
|
4721
|
+
skills: true,
|
|
4722
|
+
setupScope: 'project',
|
|
4723
|
+
hooks: false,
|
|
4724
|
+
}),
|
|
4725
|
+
).toBe(EXIT.ok)
|
|
4726
|
+
expect(receivedScope).toBe('project')
|
|
4727
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(true)
|
|
4728
|
+
})
|
|
4729
|
+
|
|
4730
|
+
it('stamps project identity outside the repo when setup-scope is global', async () => {
|
|
4731
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-setup-scope-global-config-'))
|
|
4732
|
+
const io = new CapturedIo()
|
|
4733
|
+
const deps: CommandDeps = {
|
|
4734
|
+
...makeDeps(io, {} as ApiClient),
|
|
4735
|
+
cwd,
|
|
4736
|
+
env: isolatedEnv(cwd),
|
|
4737
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
expect(await initCommand(deps, { setupScope: 'global', skills: false, hooks: false })).toBe(EXIT.failed)
|
|
4741
|
+
expect(existsSync(path.join(cwd, '.notifai', 'config.toml'))).toBe(false)
|
|
4742
|
+
expect(readFileSync(personalProjectConfigPath(cwd, deps.env), 'utf8')).toMatch(/project = "/)
|
|
4743
|
+
})
|
|
4744
|
+
|
|
4745
|
+
it('rejects disagreeing --setup-scope and --skills-scope', async () => {
|
|
4746
|
+
const io = new CapturedIo()
|
|
4747
|
+
expect(
|
|
4748
|
+
await initCommand(
|
|
4749
|
+
{ ...makeDeps(io, {} as ApiClient), cwd: mkdtempSync(path.join(os.tmpdir(), 'init-scope-conflict-')) },
|
|
4750
|
+
{ skills: true, setupScope: 'project', skillsScope: 'global' },
|
|
4751
|
+
),
|
|
4752
|
+
).toBe(EXIT.usage)
|
|
4753
|
+
expect(io.errLines.join('\n')).toContain('disagree')
|
|
4754
|
+
})
|
|
4755
|
+
|
|
3826
4756
|
it('installs every detected project harness when --hooks is set', async () => {
|
|
3827
4757
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-hooks-all-detected-'))
|
|
3828
4758
|
mkdirSync(path.join(cwd, '.claude'))
|
|
@@ -3831,6 +4761,7 @@ describe('init', () => {
|
|
|
3831
4761
|
const calls = { submit: 0 }
|
|
3832
4762
|
const nativeSkills: NativeSkills = {
|
|
3833
4763
|
add: async () => 0,
|
|
4764
|
+
remove: async () => 0,
|
|
3834
4765
|
list: async () => ({ skills: [] }),
|
|
3835
4766
|
}
|
|
3836
4767
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
@@ -3855,6 +4786,7 @@ describe('init', () => {
|
|
|
3855
4786
|
const calls = { submit: 0 }
|
|
3856
4787
|
const nativeSkills: NativeSkills = {
|
|
3857
4788
|
add: async () => 0,
|
|
4789
|
+
remove: async () => 0,
|
|
3858
4790
|
list: async () => ({ skills: [] }),
|
|
3859
4791
|
}
|
|
3860
4792
|
const deps = setupReadyDeps(io, cwd, nativeSkills, calls)
|
|
@@ -3872,6 +4804,7 @@ describe('init', () => {
|
|
|
3872
4804
|
const calls: { submit: number } = { submit: 0 }
|
|
3873
4805
|
const nativeSkills: NativeSkills = {
|
|
3874
4806
|
add: async () => 1,
|
|
4807
|
+
remove: async () => 0,
|
|
3875
4808
|
list: async () => ({ skills: [] }),
|
|
3876
4809
|
}
|
|
3877
4810
|
|
|
@@ -4012,6 +4945,7 @@ describe('init', () => {
|
|
|
4012
4945
|
nativeSkills: {
|
|
4013
4946
|
list: async () => ({ skills: [] }),
|
|
4014
4947
|
add: async () => 0,
|
|
4948
|
+
remove: async () => 0,
|
|
4015
4949
|
} satisfies NativeSkills,
|
|
4016
4950
|
}
|
|
4017
4951
|
|
|
@@ -4109,7 +5043,7 @@ describe('init', () => {
|
|
|
4109
5043
|
)
|
|
4110
5044
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
4111
5045
|
expect(submitCalls).toBe(1)
|
|
4112
|
-
expect(submittedDraft?.draft
|
|
5046
|
+
expect(submittedDraft?.draft).not.toHaveProperty('event')
|
|
4113
5047
|
expect(submittedDraft?.draft.targets).toEqual({ mode: 'selected', device_ids: ['dev_iphone'] })
|
|
4114
5048
|
|
|
4115
5049
|
io.outLines = []
|
|
@@ -4267,6 +5201,52 @@ describe('init', () => {
|
|
|
4267
5201
|
expect(io.outLines.join('\n')).toContain('All set.')
|
|
4268
5202
|
})
|
|
4269
5203
|
|
|
5204
|
+
it('replaces a stale unknown setup proof instead of rechecking it forever', async () => {
|
|
5205
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-stale-'))
|
|
5206
|
+
const io = new CapturedIo()
|
|
5207
|
+
let submitCalls = 0
|
|
5208
|
+
const client = {
|
|
5209
|
+
health: async () => true,
|
|
5210
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5211
|
+
listDevices: async () => ({ devices: [readyIphone] }),
|
|
5212
|
+
submit: async () => {
|
|
5213
|
+
submitCalls += 1
|
|
5214
|
+
return setupReceipt('req_fresh')
|
|
5215
|
+
},
|
|
5216
|
+
evidence: async (requestId: string) =>
|
|
5217
|
+
setupEvidence(
|
|
5218
|
+
requestId,
|
|
5219
|
+
requestId === 'req_fresh'
|
|
5220
|
+
? {
|
|
5221
|
+
state: 'observed',
|
|
5222
|
+
observed_at: '2026-08-25T00:00:02.000Z',
|
|
5223
|
+
latency_ms: 1_000,
|
|
5224
|
+
}
|
|
5225
|
+
: { state: 'unknown', observed_at: null, latency_ms: null },
|
|
5226
|
+
),
|
|
5227
|
+
} as unknown as ApiClient
|
|
5228
|
+
const now = Date.parse('2026-08-25T00:00:00.000Z')
|
|
5229
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5230
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "stale-proof"\n')
|
|
5231
|
+
const deps: CommandDeps = {
|
|
5232
|
+
...makeDeps(io, client),
|
|
5233
|
+
cwd,
|
|
5234
|
+
env: isolatedEnv(cwd),
|
|
5235
|
+
now: () => now,
|
|
5236
|
+
}
|
|
5237
|
+
writeSetupProof(deps, {
|
|
5238
|
+
request_id: 'req_old',
|
|
5239
|
+
device_id: readyIphone.device_id,
|
|
5240
|
+
project: 'stale-proof',
|
|
5241
|
+
started_at: new Date(now - SETUP_PROOF_STALE_MS - 1).toISOString(),
|
|
5242
|
+
})
|
|
5243
|
+
|
|
5244
|
+
expect(await initCommand(deps, { hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5245
|
+
expect(submitCalls).toBe(1)
|
|
5246
|
+
expect(io.outLines.join('\n')).toContain('older than 24h without a Companion Receipt')
|
|
5247
|
+
expect(io.outLines.join('\n')).toContain('All set.')
|
|
5248
|
+
})
|
|
5249
|
+
|
|
4270
5250
|
it('reports a proof-state write failure instead of crashing or sending twice', async () => {
|
|
4271
5251
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-proof-unwritable-'))
|
|
4272
5252
|
const io = new CapturedIo()
|
|
@@ -4386,42 +5366,6 @@ describe('init', () => {
|
|
|
4386
5366
|
)
|
|
4387
5367
|
})
|
|
4388
5368
|
|
|
4389
|
-
it('stops login when the approval page reports no Alpha access', async () => {
|
|
4390
|
-
const io = new CapturedIo()
|
|
4391
|
-
let now = 0
|
|
4392
|
-
let polls = 0
|
|
4393
|
-
const client = {
|
|
4394
|
-
beginPairing: async () => ({
|
|
4395
|
-
pairing_id: 'pair_no_plan',
|
|
4396
|
-
code: 'NOPE-PLAN',
|
|
4397
|
-
approve_url: 'https://test.notifai.invalid/approve?code=NOPE-PLAN',
|
|
4398
|
-
expires_at: new Date(60_000).toISOString(),
|
|
4399
|
-
poll_interval_seconds: 1,
|
|
4400
|
-
}),
|
|
4401
|
-
pollPairing: async () => {
|
|
4402
|
-
polls += 1
|
|
4403
|
-
return {
|
|
4404
|
-
status: 'no_active_plan' as const,
|
|
4405
|
-
next_action: 'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
4406
|
-
}
|
|
4407
|
-
},
|
|
4408
|
-
} as unknown as ApiClient
|
|
4409
|
-
const deps = {
|
|
4410
|
-
...makeDeps(io, client),
|
|
4411
|
-
now: () => now,
|
|
4412
|
-
sleep: async (milliseconds: number) => {
|
|
4413
|
-
now += milliseconds
|
|
4414
|
-
},
|
|
4415
|
-
}
|
|
4416
|
-
|
|
4417
|
-
expect(await loginCommand(deps, { open: false })).toBe(EXIT.auth)
|
|
4418
|
-
expect(polls).toBe(1)
|
|
4419
|
-
expect(io.errLines.join('\n')).toContain('no active plan or temporary Alpha access')
|
|
4420
|
-
expect(io.errLines.join('\n')).toContain(
|
|
4421
|
-
'Open https://test.notifai.invalid/support to request Alpha access, then retry.',
|
|
4422
|
-
)
|
|
4423
|
-
})
|
|
4424
|
-
|
|
4425
5369
|
it('treats a revoked credential as the one blocker and points back to pairing', async () => {
|
|
4426
5370
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-revoked-'))
|
|
4427
5371
|
const io = new CapturedIo()
|
|
@@ -4446,7 +5390,7 @@ describe('init', () => {
|
|
|
4446
5390
|
expect(out.match(/^Next:/gm)).toHaveLength(1)
|
|
4447
5391
|
})
|
|
4448
5392
|
|
|
4449
|
-
it('
|
|
5393
|
+
it('reuses proof across worktrees of one Project on the same Approved Machine', async () => {
|
|
4450
5394
|
const stateRoot = mkdtempSync(path.join(os.tmpdir(), 'init-worktrees-state-'))
|
|
4451
5395
|
let submitCalls = 0
|
|
4452
5396
|
const client = {
|
|
@@ -4478,7 +5422,62 @@ describe('init', () => {
|
|
|
4478
5422
|
).toBe(EXIT.ok)
|
|
4479
5423
|
}
|
|
4480
5424
|
|
|
4481
|
-
expect(submitCalls).toBe(
|
|
5425
|
+
expect(submitCalls).toBe(1)
|
|
5426
|
+
})
|
|
5427
|
+
|
|
5428
|
+
it('returns one authoritative structured final readiness without duplicate probes', async () => {
|
|
5429
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'init-json-ready-'))
|
|
5430
|
+
const io = new InteractiveIo()
|
|
5431
|
+
let healthCalls = 0
|
|
5432
|
+
let compatibilityCalls = 0
|
|
5433
|
+
let deviceCalls = 0
|
|
5434
|
+
const client = {
|
|
5435
|
+
health: async () => {
|
|
5436
|
+
healthCalls += 1
|
|
5437
|
+
return true
|
|
5438
|
+
},
|
|
5439
|
+
compatibility: async () => {
|
|
5440
|
+
compatibilityCalls += 1
|
|
5441
|
+
return currentCompatibility
|
|
5442
|
+
},
|
|
5443
|
+
listDevices: async () => {
|
|
5444
|
+
deviceCalls += 1
|
|
5445
|
+
return { devices: [readyIphone] }
|
|
5446
|
+
},
|
|
5447
|
+
evidence: async (requestId: string) =>
|
|
5448
|
+
setupEvidence(requestId, {
|
|
5449
|
+
state: 'observed',
|
|
5450
|
+
observed_at: '2026-08-25T00:00:02.000Z',
|
|
5451
|
+
latency_ms: 1_000,
|
|
5452
|
+
}),
|
|
5453
|
+
} as unknown as ApiClient
|
|
5454
|
+
mkdirSync(path.join(cwd, '.notifai'), { recursive: true })
|
|
5455
|
+
writeFileSync(path.join(cwd, '.notifai', 'config.toml'), 'project = "json-ready"\n')
|
|
5456
|
+
const deps = { ...makeDeps(io, client), cwd, env: isolatedEnv(cwd) }
|
|
5457
|
+
writeSetupProof(deps, {
|
|
5458
|
+
request_id: 'req_json_ready',
|
|
5459
|
+
device_id: readyIphone.device_id,
|
|
5460
|
+
project: 'json-ready',
|
|
5461
|
+
started_at: '2026-08-25T00:00:00.000Z',
|
|
5462
|
+
})
|
|
5463
|
+
|
|
5464
|
+
expect(await initCommand(deps, { json: true, hooks: false, skills: false })).toBe(EXIT.ok)
|
|
5465
|
+
expect(io.prompts).toEqual([])
|
|
5466
|
+
expect(io.outLines).toHaveLength(1)
|
|
5467
|
+
const result = JSON.parse(io.outLines[0] ?? '{}') as {
|
|
5468
|
+
ready: boolean
|
|
5469
|
+
can_send: boolean
|
|
5470
|
+
question_routing_ready: boolean
|
|
5471
|
+
states: Array<{ id: string; status: string; remedy: unknown }>
|
|
5472
|
+
}
|
|
5473
|
+
expect(result.ready).toBe(true)
|
|
5474
|
+
expect(result.can_send).toBe(true)
|
|
5475
|
+
expect(result.question_routing_ready).toBe(false)
|
|
5476
|
+
expect(result.states.length).toBeGreaterThan(8)
|
|
5477
|
+
expect(result.states.every((state) => 'remedy' in state)).toBe(true)
|
|
5478
|
+
expect(healthCalls).toBe(1)
|
|
5479
|
+
expect(compatibilityCalls).toBe(1)
|
|
5480
|
+
expect(deviceCalls).toBe(1)
|
|
4482
5481
|
})
|
|
4483
5482
|
})
|
|
4484
5483
|
|
|
@@ -4600,6 +5599,54 @@ describe('readiness assessment cost', () => {
|
|
|
4600
5599
|
detail: `installed from ${SKILLS_SOURCE} in the project scope`,
|
|
4601
5600
|
})
|
|
4602
5601
|
})
|
|
5602
|
+
|
|
5603
|
+
it('detects a lock-file duplicate across project and global scope', async () => {
|
|
5604
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'assess-lock-skill-duplicate-'))
|
|
5605
|
+
const env = { ...isolatedEnv(cwd), PATH: '/nonexistent' }
|
|
5606
|
+
writeFileSync(
|
|
5607
|
+
path.join(cwd, 'skills-lock.json'),
|
|
5608
|
+
`${JSON.stringify({
|
|
5609
|
+
skills: {
|
|
5610
|
+
notifai: {
|
|
5611
|
+
source: 'Raidiant-io/notifai',
|
|
5612
|
+
sourceType: 'github',
|
|
5613
|
+
ref: RELEASE_REF,
|
|
5614
|
+
},
|
|
5615
|
+
},
|
|
5616
|
+
})}\n`,
|
|
5617
|
+
)
|
|
5618
|
+
mkdirSync(path.join(env.XDG_STATE_HOME!, 'skills'), { recursive: true })
|
|
5619
|
+
writeFileSync(
|
|
5620
|
+
path.join(env.XDG_STATE_HOME!, 'skills', '.skill-lock.json'),
|
|
5621
|
+
`${JSON.stringify({
|
|
5622
|
+
skills: {
|
|
5623
|
+
notifai: {
|
|
5624
|
+
source: 'Raidiant-io/notifai',
|
|
5625
|
+
sourceType: 'github',
|
|
5626
|
+
ref: 'v0.2.1',
|
|
5627
|
+
},
|
|
5628
|
+
},
|
|
5629
|
+
})}\n`,
|
|
5630
|
+
)
|
|
5631
|
+
const io = new CapturedIo()
|
|
5632
|
+
const client = {
|
|
5633
|
+
health: async () => true,
|
|
5634
|
+
capabilities: async () => ({ schema_version: 1, platform: 'ios' }),
|
|
5635
|
+
listDevices: async () => ({ devices: [] }),
|
|
5636
|
+
} as unknown as ApiClient
|
|
5637
|
+
|
|
5638
|
+
const readiness = await assessReadiness({
|
|
5639
|
+
...makeDeps(io, client),
|
|
5640
|
+
cwd,
|
|
5641
|
+
env,
|
|
5642
|
+
nativeSkills: realNativeSkills,
|
|
5643
|
+
})
|
|
5644
|
+
expect(readiness.states.find((state) => state.id === 'skill')).toMatchObject({
|
|
5645
|
+
status: 'gap',
|
|
5646
|
+
detail: `project (${RELEASE_REF}) and global (v0.2.1) are both installed, so the harness lists both. Keep either project or global and uninstall the other.`,
|
|
5647
|
+
technical: { resolution: 'both-listed' },
|
|
5648
|
+
})
|
|
5649
|
+
})
|
|
4603
5650
|
})
|
|
4604
5651
|
|
|
4605
5652
|
describe('an outage is not an answer', () => {
|
|
@@ -4750,8 +5797,8 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4750
5797
|
|
|
4751
5798
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
4752
5799
|
const said = io.errLines.join(' ')
|
|
4753
|
-
expect(said).toMatch(/
|
|
4754
|
-
expect(said).toContain('notifai
|
|
5800
|
+
expect(said).toMatch(/Codex hooks are not installed/i)
|
|
5801
|
+
expect(said).toContain('notifai init --json')
|
|
4755
5802
|
expect(said).not.toMatch(/Claude Code: send one new prompt|OpenCode: restart/)
|
|
4756
5803
|
})
|
|
4757
5804
|
|
|
@@ -4768,12 +5815,13 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4768
5815
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
4769
5816
|
|
|
4770
5817
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
4771
|
-
|
|
5818
|
+
trustInstalledCodexHooks(cwd, env)
|
|
5819
|
+
writeSessionState('codex-other-thread', env, { harness: 'codex', last_prompt_at: 42 })
|
|
4772
5820
|
writeProjectSession(cwd, env, 'codex-other-thread', 42, 'codex')
|
|
4773
5821
|
io.outLines = []
|
|
4774
5822
|
|
|
4775
5823
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
4776
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
5824
|
+
expect(io.errLines.join(' ')).toMatch(/exact Codex session has not fired UserPromptSubmit/i)
|
|
4777
5825
|
expect(io.outLines).not.toContain(
|
|
4778
5826
|
'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
4779
5827
|
)
|
|
@@ -4793,16 +5841,14 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4793
5841
|
|
|
4794
5842
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
4795
5843
|
trustInstalledCodexHooks(cwd, env)
|
|
4796
|
-
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
5844
|
+
writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
4797
5845
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
4798
|
-
writeSessionState('claude-concurrent', env, { last_prompt_at: 43, last_stop_at: 43 })
|
|
5846
|
+
writeSessionState('claude-concurrent', env, { harness: 'claude-code', last_prompt_at: 43, last_stop_at: 43 })
|
|
4799
5847
|
writeProjectSession(cwd, env, 'claude-concurrent', 43, 'claude-code')
|
|
4800
5848
|
io.outLines = []
|
|
4801
5849
|
|
|
4802
5850
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
4803
|
-
expect(io.outLines).
|
|
4804
|
-
'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
4805
|
-
)
|
|
5851
|
+
expect(io.outLines.some((line) => line.startsWith('Question registered (q_'))).toBe(true)
|
|
4806
5852
|
expect(readSessionState('codex-current-thread', env).pending?.[0]?.source).toMatchObject({
|
|
4807
5853
|
session_id: 'codex-current-thread',
|
|
4808
5854
|
harness: 'codex',
|
|
@@ -4812,6 +5858,49 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4812
5858
|
).toBe('Gentle Salmon')
|
|
4813
5859
|
})
|
|
4814
5860
|
|
|
5861
|
+
it('uses exact lifecycle state across checkouts while Project stays invocation-owned', () => {
|
|
5862
|
+
const first = scratchDir('notifai-exact-session-first-')
|
|
5863
|
+
const second = scratchDir('notifai-exact-session-second-')
|
|
5864
|
+
const io = new CapturedIo()
|
|
5865
|
+
const env = {
|
|
5866
|
+
...isolatedEnv(first),
|
|
5867
|
+
CLAUDECODE: '1',
|
|
5868
|
+
CLAUDE_CODE_SESSION_ID: 'claude-cross-checkout',
|
|
5869
|
+
}
|
|
5870
|
+
const firstDeps = { ...makeDeps(io, {} as ApiClient), cwd: first, env, now: () => 42 }
|
|
5871
|
+
expect(
|
|
5872
|
+
hooksInstallCommand(firstDeps, {
|
|
5873
|
+
harness: 'claude-code',
|
|
5874
|
+
execPath,
|
|
5875
|
+
scriptPath,
|
|
5876
|
+
}),
|
|
5877
|
+
).toBe(EXIT.ok)
|
|
5878
|
+
writeSessionState('claude-cross-checkout', env, {
|
|
5879
|
+
harness: 'claude-code',
|
|
5880
|
+
activation_cwd: first,
|
|
5881
|
+
last_prompt_at: 42,
|
|
5882
|
+
last_stop_at: 41,
|
|
5883
|
+
})
|
|
5884
|
+
writeProjectSession(first, env, 'claude-cross-checkout', 42, 'claude-code')
|
|
5885
|
+
mkdirSync(path.join(second, '.notifai'), { recursive: true })
|
|
5886
|
+
writeFileSync(
|
|
5887
|
+
path.join(second, '.notifai', 'config.toml'),
|
|
5888
|
+
'project = "second-project"\nask_notifications = false\n',
|
|
5889
|
+
)
|
|
5890
|
+
mkdirSync(path.dirname(sessionConfigPath('claude-cross-checkout', env)), { recursive: true })
|
|
5891
|
+
writeFileSync(sessionConfigPath('claude-cross-checkout', env), 'ask_notifications = true\n')
|
|
5892
|
+
io.outLines = []
|
|
5893
|
+
|
|
5894
|
+
const secondDeps = { ...firstDeps, cwd: second }
|
|
5895
|
+
expect(askCommand(secondDeps, 'Use this checkout?', {})).toBe(EXIT.ok)
|
|
5896
|
+
const pending = readSessionState('claude-cross-checkout', env).pending?.[0]
|
|
5897
|
+
expect(pending?.project).toBe('second-project')
|
|
5898
|
+
expect(pending?.source).toMatchObject({
|
|
5899
|
+
session_id: 'claude-cross-checkout',
|
|
5900
|
+
harness: 'claude-code',
|
|
5901
|
+
})
|
|
5902
|
+
})
|
|
5903
|
+
|
|
4815
5904
|
it('uploads ask images before registration and freezes canonical body media', async () => {
|
|
4816
5905
|
const cwd = scratchDir('notifai-ask-media-')
|
|
4817
5906
|
const first = path.join(cwd, 'first.png')
|
|
@@ -4846,7 +5935,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4846
5935
|
|
|
4847
5936
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
4848
5937
|
trustInstalledCodexHooks(cwd, env)
|
|
4849
|
-
writeSessionState('codex-media-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
5938
|
+
writeSessionState('codex-media-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
4850
5939
|
writeProjectSession(cwd, env, 'codex-media-thread', 42, 'codex')
|
|
4851
5940
|
io.outLines = []
|
|
4852
5941
|
|
|
@@ -4897,7 +5986,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4897
5986
|
hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
|
|
4898
5987
|
).toBe(EXIT.ok)
|
|
4899
5988
|
trustInstalledCodexHooks(cwd, env)
|
|
4900
|
-
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
5989
|
+
writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
4901
5990
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
4902
5991
|
io.outLines = []
|
|
4903
5992
|
|
|
@@ -4927,9 +6016,9 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4927
6016
|
trustInstalledCodexHooks(cwd, env)
|
|
4928
6017
|
// Both sessions are live here. The parent fired when its own turn began;
|
|
4929
6018
|
// the child fired for the turn that is running this command.
|
|
4930
|
-
writeSessionState('codex-orchestrator', env, { last_prompt_at: 40, last_stop_at: 39 })
|
|
6019
|
+
writeSessionState('codex-orchestrator', env, { harness: 'codex', last_prompt_at: 40, last_stop_at: 39 })
|
|
4931
6020
|
writeProjectSession(cwd, env, 'codex-orchestrator', 40, 'codex')
|
|
4932
|
-
writeSessionState('claude-current', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6021
|
+
writeSessionState('claude-current', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
|
|
4933
6022
|
writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
|
|
4934
6023
|
io.outLines = []
|
|
4935
6024
|
|
|
@@ -4962,7 +6051,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4962
6051
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
4963
6052
|
EXIT.ok,
|
|
4964
6053
|
)
|
|
4965
|
-
writeSessionState('orca-claude-question', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6054
|
+
writeSessionState('orca-claude-question', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
|
|
4966
6055
|
writeProjectSession(cwd, env, 'orca-claude-question', 42, 'claude-code')
|
|
4967
6056
|
io.outLines = []
|
|
4968
6057
|
|
|
@@ -4990,7 +6079,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
4990
6079
|
hooksInstallCommand(deps, { harness: 'claude-code', global: true, execPath, scriptPath }),
|
|
4991
6080
|
).toBe(EXIT.ok)
|
|
4992
6081
|
trustInstalledCodexHooks(cwd, env)
|
|
4993
|
-
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6082
|
+
writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
4994
6083
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
4995
6084
|
|
|
4996
6085
|
const readiness = await assessReadiness(deps)
|
|
@@ -5024,11 +6113,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5024
6113
|
const fired = readiness.states.find((state) => state.id === 'hooks-fired')
|
|
5025
6114
|
expect(fired?.status).toBe('optional-gap')
|
|
5026
6115
|
expect(fired?.detail).toMatch(/Claude Code/)
|
|
5027
|
-
expect(fired?.detail).toMatch(/Codex:
|
|
6116
|
+
expect(fired?.detail).toMatch(/Codex: approve[\s\S]*start one fresh session/i)
|
|
5028
6117
|
|
|
5029
6118
|
io.errLines = []
|
|
5030
6119
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5031
|
-
expect(io.errLines.join(' ')).toMatch(/Several harness
|
|
6120
|
+
expect(io.errLines.join(' ')).toMatch(/Several harness sessions could own this shell/i)
|
|
5032
6121
|
})
|
|
5033
6122
|
|
|
5034
6123
|
it('makes one work-resumption commitment per offered answer part of the asking turn', () => {
|
|
@@ -5045,7 +6134,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5045
6134
|
|
|
5046
6135
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5047
6136
|
trustInstalledCodexHooks(cwd, env)
|
|
5048
|
-
writeSessionState('codex-commitment-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6137
|
+
writeSessionState('codex-commitment-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
5049
6138
|
writeProjectSession(cwd, env, 'codex-commitment-thread', 42, 'codex')
|
|
5050
6139
|
io.outLines = []
|
|
5051
6140
|
|
|
@@ -5081,7 +6170,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5081
6170
|
|
|
5082
6171
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5083
6172
|
trustInstalledCodexHooks(cwd, env)
|
|
5084
|
-
writeSessionState('codex-free-text-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6173
|
+
writeSessionState('codex-free-text-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
5085
6174
|
writeProjectSession(cwd, env, 'codex-free-text-thread', 42, 'codex')
|
|
5086
6175
|
io.outLines = []
|
|
5087
6176
|
|
|
@@ -5113,7 +6202,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5113
6202
|
configFile,
|
|
5114
6203
|
readFileSync(configFile, 'utf8').replace(codexHookIdentityHash(stop!), 'sha256:obsolete'),
|
|
5115
6204
|
)
|
|
5116
|
-
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6205
|
+
writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
5117
6206
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
5118
6207
|
io.outLines = []
|
|
5119
6208
|
|
|
@@ -5144,6 +6233,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5144
6233
|
} as unknown as ApiClient
|
|
5145
6234
|
const nativeSkills: NativeSkills = {
|
|
5146
6235
|
add: async () => 0,
|
|
6236
|
+
remove: async () => 0,
|
|
5147
6237
|
list: async (scope) => ({
|
|
5148
6238
|
skills:
|
|
5149
6239
|
scope === 'global'
|
|
@@ -5187,12 +6277,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5187
6277
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
5188
6278
|
EXIT.ok,
|
|
5189
6279
|
)
|
|
5190
|
-
writeSessionState('claude-other', env, { last_prompt_at: 42 })
|
|
6280
|
+
writeSessionState('claude-other', env, { harness: 'claude-code', last_prompt_at: 42 })
|
|
5191
6281
|
writeProjectSession(cwd, env, 'claude-other', 42, 'claude-code')
|
|
5192
6282
|
io.outLines = []
|
|
5193
6283
|
|
|
5194
6284
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5195
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
6285
|
+
expect(io.errLines.join(' ')).toMatch(/exact Claude Code session has not fired UserPromptSubmit/i)
|
|
5196
6286
|
})
|
|
5197
6287
|
|
|
5198
6288
|
it('accepts a Claude Code subprocess when its documented session id owns the pointer', () => {
|
|
@@ -5210,7 +6300,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5210
6300
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
5211
6301
|
EXIT.ok,
|
|
5212
6302
|
)
|
|
5213
|
-
writeSessionState('claude-parent-loop', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6303
|
+
writeSessionState('claude-parent-loop', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
|
|
5214
6304
|
writeProjectSession(cwd, env, 'claude-parent-loop', 42, 'claude-code')
|
|
5215
6305
|
io.outLines = []
|
|
5216
6306
|
|
|
@@ -5231,7 +6321,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5231
6321
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
5232
6322
|
EXIT.ok,
|
|
5233
6323
|
)
|
|
5234
|
-
writeSessionState('claude-last-writer', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6324
|
+
writeSessionState('claude-last-writer', env, { harness: 'claude-code', last_prompt_at: 42, last_stop_at: 41 })
|
|
5235
6325
|
writeProjectSession(cwd, env, 'claude-last-writer', 42, 'claude-code')
|
|
5236
6326
|
io.outLines = []
|
|
5237
6327
|
|
|
@@ -5260,7 +6350,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5260
6350
|
}),
|
|
5261
6351
|
})
|
|
5262
6352
|
trustInstalledCodexHooks(cwd, env)
|
|
5263
|
-
writeSessionState('codex-current-thread', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6353
|
+
writeSessionState('codex-current-thread', env, { harness: 'codex', last_prompt_at: 42, last_stop_at: 41 })
|
|
5264
6354
|
writeProjectSession(cwd, env, 'codex-current-thread', 42, 'codex')
|
|
5265
6355
|
io.outLines = []
|
|
5266
6356
|
|
|
@@ -5283,7 +6373,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5283
6373
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
5284
6374
|
EXIT.ok,
|
|
5285
6375
|
)
|
|
5286
|
-
writeSessionState('claude-current', env, { last_prompt_at: 42 })
|
|
6376
|
+
writeSessionState('claude-current', env, { harness: 'claude-code', last_prompt_at: 42 })
|
|
5287
6377
|
writeProjectSession(cwd, env, 'claude-current', 42, 'claude-code')
|
|
5288
6378
|
io.outLines = []
|
|
5289
6379
|
|
|
@@ -5308,12 +6398,12 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5308
6398
|
}
|
|
5309
6399
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
5310
6400
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5311
|
-
writeSessionState('opencode-other', env, { last_prompt_at: 42 })
|
|
6401
|
+
writeSessionState('opencode-other', env, { harness: 'opencode', last_prompt_at: 42 })
|
|
5312
6402
|
writeProjectSession(cwd, env, 'opencode-other', 42, 'opencode')
|
|
5313
6403
|
io.outLines = []
|
|
5314
6404
|
|
|
5315
6405
|
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5316
|
-
expect(io.errLines.join(' ')).toMatch(/
|
|
6406
|
+
expect(io.errLines.join(' ')).toMatch(/no proven answer continuation/i)
|
|
5317
6407
|
})
|
|
5318
6408
|
|
|
5319
6409
|
it('rejects OpenCode before registration even with an exact matching pointer', () => {
|
|
@@ -5328,7 +6418,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5328
6418
|
}
|
|
5329
6419
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
5330
6420
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5331
|
-
writeSessionState('opencode-current', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6421
|
+
writeSessionState('opencode-current', env, { harness: 'opencode', last_prompt_at: 42, last_stop_at: 41 })
|
|
5332
6422
|
writeProjectSession(cwd, env, 'opencode-current', 42, 'opencode')
|
|
5333
6423
|
io.outLines = []
|
|
5334
6424
|
|
|
@@ -5347,7 +6437,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5347
6437
|
}
|
|
5348
6438
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
5349
6439
|
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5350
|
-
writeSessionState('cursor-live', env, { last_prompt_at: 42, last_stop_at: 41 })
|
|
6440
|
+
writeSessionState('cursor-live', env, { harness: 'cursor', last_prompt_at: 42, last_stop_at: 41 })
|
|
5351
6441
|
writeProjectSession(cwd, env, 'cursor-live', 42, 'cursor')
|
|
5352
6442
|
io.outLines = []
|
|
5353
6443
|
|
|
@@ -5370,7 +6460,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5370
6460
|
}
|
|
5371
6461
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env, now: () => 42 }
|
|
5372
6462
|
expect(hooksInstallCommand(deps, { harness: 'codex', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5373
|
-
writeSessionState('codex-live', env, { last_prompt_at: 42 })
|
|
6463
|
+
writeSessionState('codex-live', env, { harness: 'codex', last_prompt_at: 42 })
|
|
5374
6464
|
writeProjectSession(cwd, env, 'codex-live', 42, 'codex')
|
|
5375
6465
|
io.outLines = []
|
|
5376
6466
|
|
|
@@ -5401,7 +6491,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5401
6491
|
|
|
5402
6492
|
const pointer = readiness.states.find((s) => s.id === 'hooks-active-session')
|
|
5403
6493
|
expect(pointer?.status).toBe('optional-gap')
|
|
5404
|
-
expect(pointer?.detail).toMatch(/has not published
|
|
6494
|
+
expect(pointer?.detail).toMatch(/has not published exact lifecycle state/)
|
|
5405
6495
|
expect(pointer?.remedy?.summary).toMatch(/send one Claude Code prompt/)
|
|
5406
6496
|
expect(pointer?.remedy?.by === 'user-here' ? pointer.remedy.command : '').toBe(
|
|
5407
6497
|
'notifai doctor',
|
|
@@ -5447,6 +6537,28 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5447
6537
|
expect(io.outLines.join('\n')).toMatch(/ok\s+Turn\-end hook shape/)
|
|
5448
6538
|
})
|
|
5449
6539
|
|
|
6540
|
+
it('diagnoses an older hook install that lacks proactive lifecycle handlers', async () => {
|
|
6541
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-missing-lifecycle-'))
|
|
6542
|
+
const io = new PlainInteractiveIo()
|
|
6543
|
+
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: isolatedEnv(cwd) }
|
|
6544
|
+
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
6545
|
+
EXIT.ok,
|
|
6546
|
+
)
|
|
6547
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.local.json')
|
|
6548
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as {
|
|
6549
|
+
hooks: Record<string, unknown>
|
|
6550
|
+
}
|
|
6551
|
+
delete settings.hooks.SessionStart
|
|
6552
|
+
delete settings.hooks.SubagentStart
|
|
6553
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`)
|
|
6554
|
+
|
|
6555
|
+
const readiness = await assessReadiness(deps)
|
|
6556
|
+
const current = readiness.states.find((state) => state.id === 'hooks-stale')
|
|
6557
|
+
expect(current?.status).toBe('gap')
|
|
6558
|
+
expect(current?.detail).toMatch(/missing session-start, subagent-start/i)
|
|
6559
|
+
expect(current?.detail).toMatch(/notifai hooks install/i)
|
|
6560
|
+
})
|
|
6561
|
+
|
|
5450
6562
|
it('names an old blocking Claude Stop handler as the reason no wake can happen', async () => {
|
|
5451
6563
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-doctor-stale-shape-'))
|
|
5452
6564
|
const io = new PlainInteractiveIo()
|
|
@@ -5598,7 +6710,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5598
6710
|
expect(payload).toMatchObject({ ok: false, exit_code: EXIT.failed })
|
|
5599
6711
|
})
|
|
5600
6712
|
|
|
5601
|
-
it('
|
|
6713
|
+
it('returns a stable exact-session failure when lifecycle context is absent', () => {
|
|
5602
6714
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-firstrun-'))
|
|
5603
6715
|
mkdirSync(path.join(cwd, '.claude'), { recursive: true })
|
|
5604
6716
|
applyPlan(path.join(cwd, '.claude', 'settings.local.json'), {
|
|
@@ -5610,13 +6722,18 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5610
6722
|
const io = new CapturedIo()
|
|
5611
6723
|
const deps = { ...makeDeps(io, {} as ApiClient), cwd, env: { XDG_STATE_HOME: cwd } }
|
|
5612
6724
|
|
|
5613
|
-
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
6725
|
+
expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
|
|
6726
|
+
expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
6727
|
+
ok: false,
|
|
6728
|
+
registered: false,
|
|
6729
|
+
code: 'session_identity_missing',
|
|
6730
|
+
check_id: 'exact_session',
|
|
6731
|
+
exit_code: EXIT.usage,
|
|
6732
|
+
remedy: expect.stringMatching(/exact session|blocking/i),
|
|
6733
|
+
})
|
|
5617
6734
|
})
|
|
5618
6735
|
|
|
5619
|
-
it('
|
|
6736
|
+
it('does not guess a Cursor conversation when lifecycle context is absent', () => {
|
|
5620
6737
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-cursor-first-run-'))
|
|
5621
6738
|
const io = new CapturedIo()
|
|
5622
6739
|
const deps = {
|
|
@@ -5628,11 +6745,14 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5628
6745
|
expect(hooksInstallCommand(deps, { harness: 'cursor', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5629
6746
|
io.outLines = []
|
|
5630
6747
|
|
|
5631
|
-
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5632
|
-
expect(io.
|
|
6748
|
+
expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
|
|
6749
|
+
expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
6750
|
+
code: 'session_identity_missing',
|
|
6751
|
+
check_id: 'exact_session',
|
|
6752
|
+
})
|
|
5633
6753
|
})
|
|
5634
6754
|
|
|
5635
|
-
it('
|
|
6755
|
+
it('does not infer OpenCode from an installed adapter alone', () => {
|
|
5636
6756
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-opencode-first-run-'))
|
|
5637
6757
|
const io = new CapturedIo()
|
|
5638
6758
|
const deps = {
|
|
@@ -5647,13 +6767,14 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5647
6767
|
expect(hooksInstallCommand(deps, { harness: 'opencode', execPath, scriptPath })).toBe(EXIT.ok)
|
|
5648
6768
|
io.outLines = []
|
|
5649
6769
|
|
|
5650
|
-
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
6770
|
+
expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
|
|
6771
|
+
expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
6772
|
+
code: 'session_identity_missing',
|
|
6773
|
+
check_id: 'exact_session',
|
|
6774
|
+
})
|
|
5654
6775
|
})
|
|
5655
6776
|
|
|
5656
|
-
it('
|
|
6777
|
+
it('does not turn missing harness identity into an installation guess', () => {
|
|
5657
6778
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-noinstall-'))
|
|
5658
6779
|
const io = new CapturedIo()
|
|
5659
6780
|
const deps = {
|
|
@@ -5668,8 +6789,11 @@ describe('asking before the hooks have ever run', () => {
|
|
|
5668
6789
|
},
|
|
5669
6790
|
}
|
|
5670
6791
|
|
|
5671
|
-
expect(askCommand(deps, 'Ship it?', {})).toBe(EXIT.usage)
|
|
5672
|
-
expect(io.
|
|
6792
|
+
expect(askCommand(deps, 'Ship it?', { json: true })).toBe(EXIT.usage)
|
|
6793
|
+
expect(JSON.parse(io.outLines.at(-1) ?? '{}')).toMatchObject({
|
|
6794
|
+
code: 'session_identity_missing',
|
|
6795
|
+
check_id: 'exact_session',
|
|
6796
|
+
})
|
|
5673
6797
|
})
|
|
5674
6798
|
})
|
|
5675
6799
|
|