@raidiant/notifai 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +509 -94
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +68 -28
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +17 -1
- package/dist/install-hooks.js +57 -10
- package/dist/install-hooks.js.map +1 -1
- package/dist/main.js +27 -4
- package/dist/main.js.map +1 -1
- package/dist/send.js +1 -0
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +442 -18
- package/src/commands.ts +593 -82
- package/src/hooks.test.ts +126 -1
- package/src/hooks.ts +77 -27
- package/src/install-hooks.test.ts +77 -0
- package/src/install-hooks.ts +54 -15
- package/src/main.ts +27 -4
- package/src/send.ts +3 -0
package/src/hooks.test.ts
CHANGED
|
@@ -523,6 +523,37 @@ describe('superseding a live question', () => {
|
|
|
523
523
|
expect(readSessionState('sup2', h.env).retiring).toEqual([])
|
|
524
524
|
})
|
|
525
525
|
|
|
526
|
+
it('keeps the original delivery targets when routing changes before retirement', async () => {
|
|
527
|
+
const h = harness([])
|
|
528
|
+
writeSessionState('sup-targets', h.env, { last_prompt_at: AWAY })
|
|
529
|
+
registerQuestion('sup-targets', h.env, { question: 'Ship it?' })
|
|
530
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
531
|
+
|
|
532
|
+
const live = readSessionState('sup-targets', h.env).pending
|
|
533
|
+
expect(live).toMatchObject({
|
|
534
|
+
request_id: 'req_hook_1',
|
|
535
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// The user changes their default routing before the agent supersedes the
|
|
539
|
+
// question. Retirement belongs to the Deliveries that actually carried the
|
|
540
|
+
// first question, not to whichever Device Installation is selected now.
|
|
541
|
+
const configDir = path.join(h.env['XDG_CONFIG_HOME'] as string, 'notifai')
|
|
542
|
+
mkdirSync(configDir, { recursive: true })
|
|
543
|
+
writeFileSync(path.join(configDir, 'config.toml'), 'devices = ["dev_iphone"]\n')
|
|
544
|
+
registerQuestion('sup-targets', h.env, { question: 'Deploy it?' })
|
|
545
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sup-targets' }))
|
|
546
|
+
|
|
547
|
+
const retirement = h.recorder.submitted.find(
|
|
548
|
+
(submission) => submission.draft.event === 'question_retired',
|
|
549
|
+
)
|
|
550
|
+
expect(retirement?.draft.lifecycle?.retires_request_id).toBe('req_hook_1')
|
|
551
|
+
expect(retirement?.draft.targets).toEqual({
|
|
552
|
+
mode: 'selected',
|
|
553
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
526
557
|
it('sweeps a queued retirement even on a turn continuing from an answer', async () => {
|
|
527
558
|
// stop_hook_active short-circuits the escalation path, and a superseding
|
|
528
559
|
// turn is very often exactly this turn.
|
|
@@ -641,7 +672,13 @@ describe('a question that outlives its session', () => {
|
|
|
641
672
|
const h = harness([])
|
|
642
673
|
orphanRetirements(
|
|
643
674
|
h.env,
|
|
644
|
-
[{
|
|
675
|
+
[{
|
|
676
|
+
request_id: 'req_old',
|
|
677
|
+
collapse_key: 'ck_old',
|
|
678
|
+
device_ids: ['dev_iphone'],
|
|
679
|
+
question: 'Old?',
|
|
680
|
+
state: 'expired',
|
|
681
|
+
}],
|
|
645
682
|
undefined,
|
|
646
683
|
NOW - 25 * 3600 * 1000,
|
|
647
684
|
)
|
|
@@ -735,6 +772,23 @@ describe('ask registration', () => {
|
|
|
735
772
|
expect(askCommand(h.deps, 'Ship it?', {})).toBe(EXIT.ok)
|
|
736
773
|
expect(readSessionState('solo-session', h.env).pending?.question).toBe('Ship it?')
|
|
737
774
|
})
|
|
775
|
+
|
|
776
|
+
it('fails explicitly instead of rerouting incomplete live state', () => {
|
|
777
|
+
const h = harness()
|
|
778
|
+
writeSessionState('incomplete', h.env, {
|
|
779
|
+
pending: {
|
|
780
|
+
question: 'Original?',
|
|
781
|
+
request_id: 'req_original',
|
|
782
|
+
collapse_key: 'collapse-original',
|
|
783
|
+
},
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
expect(askCommand(h.deps, 'Replacement?', { session: 'incomplete' })).toBe(EXIT.failed)
|
|
787
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
788
|
+
'refusing to retire it without request, collapse, and device identifiers',
|
|
789
|
+
)
|
|
790
|
+
expect(readSessionState('incomplete', h.env).pending?.question).toBe('Original?')
|
|
791
|
+
})
|
|
738
792
|
})
|
|
739
793
|
|
|
740
794
|
describe('user-prompt-submit hook', () => {
|
|
@@ -744,6 +798,59 @@ describe('user-prompt-submit hook', () => {
|
|
|
744
798
|
expect(readSessionState('s10', h.env).last_prompt_at).toBe(NOW)
|
|
745
799
|
})
|
|
746
800
|
|
|
801
|
+
it('preserves a delivered question through ask replacement and prompt transition', async () => {
|
|
802
|
+
const h = harness([], 900)
|
|
803
|
+
await hookRunCommand(
|
|
804
|
+
h.deps,
|
|
805
|
+
'user-prompt-submit',
|
|
806
|
+
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
807
|
+
)
|
|
808
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: ['Yes', 'No'] })).toBe(EXIT.ok)
|
|
809
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'transition', cwd: h.deps.cwd }))
|
|
810
|
+
|
|
811
|
+
const live = readSessionState('transition', h.env).pending
|
|
812
|
+
expect(live).toMatchObject({
|
|
813
|
+
question: 'Ship it?',
|
|
814
|
+
request_id: 'req_hook_1',
|
|
815
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
816
|
+
})
|
|
817
|
+
expect(live?.collapse_key).toMatch(/^notifai-hook-/)
|
|
818
|
+
|
|
819
|
+
// A second plain `ask` resolves through the project pointer and supersedes
|
|
820
|
+
// the live question. The next prompt then resets presence before another
|
|
821
|
+
// Stop can run — the transition that used to erase the retirement debt.
|
|
822
|
+
expect(askCommand(h.deps, 'Deploy it?', { choice: ['Staging', 'Production'] })).toBe(EXIT.ok)
|
|
823
|
+
expect(readSessionState('transition', h.env).retiring).toEqual([
|
|
824
|
+
{
|
|
825
|
+
request_id: live?.request_id,
|
|
826
|
+
collapse_key: live?.collapse_key,
|
|
827
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
828
|
+
question: 'Ship it?',
|
|
829
|
+
state: 'superseded',
|
|
830
|
+
},
|
|
831
|
+
])
|
|
832
|
+
|
|
833
|
+
await hookRunCommand(
|
|
834
|
+
h.deps,
|
|
835
|
+
'user-prompt-submit',
|
|
836
|
+
stdin({ session_id: 'transition', cwd: h.deps.cwd }),
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
const retirement = h.recorder.submitted.find(
|
|
840
|
+
(submission) => submission.draft.event === 'question_retired',
|
|
841
|
+
)?.draft
|
|
842
|
+
expect(retirement?.delivery.collapse_key).toBe(live?.collapse_key)
|
|
843
|
+
expect(retirement?.lifecycle).toEqual({
|
|
844
|
+
tier: 'done',
|
|
845
|
+
state: 'superseded',
|
|
846
|
+
retires_request_id: live?.request_id,
|
|
847
|
+
})
|
|
848
|
+
expect(retirement?.targets).toEqual({
|
|
849
|
+
mode: 'selected',
|
|
850
|
+
device_ids: ['dev_iphone', 'dev_mac'],
|
|
851
|
+
})
|
|
852
|
+
})
|
|
853
|
+
|
|
747
854
|
it('retires a question a real timed-out Stop left live on the devices', async () => {
|
|
748
855
|
// Drives the actual flow rather than hand-writing state: the previous
|
|
749
856
|
// version of this test fabricated a shape production never wrote, so it
|
|
@@ -832,6 +939,24 @@ describe('session-end hook', () => {
|
|
|
832
939
|
expect(readSessionState('s12', h.env)).toEqual({})
|
|
833
940
|
expect(h.recorder.closed).toEqual([])
|
|
834
941
|
})
|
|
942
|
+
|
|
943
|
+
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
944
|
+
const h = harness()
|
|
945
|
+
writeSessionState('s-incomplete', h.env, {
|
|
946
|
+
pending: {
|
|
947
|
+
question: 'Still live?',
|
|
948
|
+
request_id: 'req_incomplete',
|
|
949
|
+
collapse_key: 'collapse-incomplete',
|
|
950
|
+
},
|
|
951
|
+
})
|
|
952
|
+
|
|
953
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: 's-incomplete' }))
|
|
954
|
+
|
|
955
|
+
expect(readSessionState('s-incomplete', h.env).pending?.request_id).toBe('req_incomplete')
|
|
956
|
+
expect(h.io.errLines.join('\n')).toContain(
|
|
957
|
+
'refusing to retire it without request, collapse, and device identifiers',
|
|
958
|
+
)
|
|
959
|
+
})
|
|
835
960
|
})
|
|
836
961
|
|
|
837
962
|
describe('malformed input', () => {
|
package/src/hooks.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface SessionState {
|
|
|
67
67
|
export interface RetiringQuestion {
|
|
68
68
|
request_id: string
|
|
69
69
|
collapse_key: string
|
|
70
|
+
/** The Device Installations that actually received the question. */
|
|
71
|
+
device_ids: string[]
|
|
70
72
|
/** Shown if the companion has no history entry to correlate against. */
|
|
71
73
|
question: string
|
|
72
74
|
state: LifecycleEndState
|
|
@@ -118,6 +120,8 @@ export interface PendingQuestion {
|
|
|
118
120
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
119
121
|
request_id?: string
|
|
120
122
|
collapse_key?: string
|
|
123
|
+
/** Exact fanout of the live question; routing config may change afterwards. */
|
|
124
|
+
device_ids?: string[]
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
/** How often the grace window rechecks whether the user has come back. */
|
|
@@ -649,24 +653,56 @@ export function parkForRetirement(
|
|
|
649
653
|
pending: PendingQuestion,
|
|
650
654
|
state: LifecycleEndState,
|
|
651
655
|
): void {
|
|
652
|
-
|
|
656
|
+
const retirement = retiringQuestion(pending, state)
|
|
657
|
+
if (retirement === null) return
|
|
653
658
|
const current = readSessionState(sessionId, env)
|
|
654
659
|
const already = current.retiring ?? []
|
|
655
|
-
if (already.some((entry) => entry.request_id ===
|
|
660
|
+
if (already.some((entry) => entry.request_id === retirement.request_id)) return
|
|
656
661
|
writeSessionState(sessionId, env, {
|
|
657
662
|
...current,
|
|
658
|
-
retiring: [
|
|
659
|
-
...already,
|
|
660
|
-
{
|
|
661
|
-
request_id: pending.request_id,
|
|
662
|
-
collapse_key: pending.collapse_key,
|
|
663
|
-
question: pending.question,
|
|
664
|
-
state,
|
|
665
|
-
},
|
|
666
|
-
],
|
|
663
|
+
retiring: [...already, retirement],
|
|
667
664
|
})
|
|
668
665
|
}
|
|
669
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Convert live question state into a complete retirement instruction.
|
|
669
|
+
*
|
|
670
|
+
* A request/collapse pair without the original Delivery targets is not safe to
|
|
671
|
+
* send: re-resolving today's routing can miss a device that still shows the
|
|
672
|
+
* question or retire a device that never received it. Fail explicitly and keep
|
|
673
|
+
* the pending record instead of silently broadening or narrowing the fanout.
|
|
674
|
+
*/
|
|
675
|
+
function retiringQuestion(
|
|
676
|
+
pending: PendingQuestion,
|
|
677
|
+
state: LifecycleEndState,
|
|
678
|
+
): RetiringQuestion | null {
|
|
679
|
+
const hasRequest = pending.request_id !== undefined
|
|
680
|
+
const hasCollapse = pending.collapse_key !== undefined
|
|
681
|
+
const hasDevices = pending.device_ids !== undefined && pending.device_ids.length > 0
|
|
682
|
+
if (!hasRequest && !hasCollapse && !hasDevices) return null
|
|
683
|
+
if (!hasRequest || !hasCollapse || !hasDevices) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
'live question state is incomplete; refusing to retire it without request, collapse, and device identifiers',
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
request_id: pending.request_id!,
|
|
690
|
+
collapse_key: pending.collapse_key!,
|
|
691
|
+
device_ids: [...pending.device_ids!],
|
|
692
|
+
question: pending.question,
|
|
693
|
+
state,
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function retirementDeviceIds(entry: RetiringQuestion): string[] {
|
|
698
|
+
if (!Array.isArray(entry.device_ids) || entry.device_ids.length === 0) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
`retirement for ${entry.request_id} is missing its device identifiers; refusing to re-resolve routing`,
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
return entry.device_ids
|
|
704
|
+
}
|
|
705
|
+
|
|
670
706
|
/**
|
|
671
707
|
* Send every parked retirement, and forget the ones that landed. A failure
|
|
672
708
|
* leaves the entry in place for the next hook rather than losing it, which is
|
|
@@ -692,7 +728,7 @@ export async function drainRetirements(
|
|
|
692
728
|
entry.question,
|
|
693
729
|
entry.state,
|
|
694
730
|
entry.request_id,
|
|
695
|
-
|
|
731
|
+
retirementDeviceIds(entry),
|
|
696
732
|
session,
|
|
697
733
|
)
|
|
698
734
|
if (sent) retired.push(entry.request_id)
|
|
@@ -777,7 +813,7 @@ export async function drainOrphanRetirements(
|
|
|
777
813
|
entry.question,
|
|
778
814
|
entry.state,
|
|
779
815
|
entry.request_id,
|
|
780
|
-
|
|
816
|
+
retirementDeviceIds(entry),
|
|
781
817
|
entry.session,
|
|
782
818
|
)
|
|
783
819
|
if (sent) done.push(entry.request_id)
|
|
@@ -811,18 +847,19 @@ export async function handleUserPromptSubmit(
|
|
|
811
847
|
|
|
812
848
|
const state = readSessionState(sessionId, ctx.env)
|
|
813
849
|
const pending = state.pending
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
850
|
+
// Park before dropping `pending`. If the process dies between these writes,
|
|
851
|
+
// the next hook sees both copies and dedupes them; the old order could die in
|
|
852
|
+
// the gap after erasing the only request/collapse/device identifiers.
|
|
853
|
+
if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
|
|
854
|
+
const parked = readSessionState(sessionId, ctx.env)
|
|
817
855
|
writeSessionState(sessionId, ctx.env, {
|
|
818
856
|
last_prompt_at: ctx.now(),
|
|
819
|
-
...(
|
|
857
|
+
...(parked.retiring !== undefined ? { retiring: parked.retiring } : {}),
|
|
820
858
|
})
|
|
821
859
|
// The bridge that lets a plain `notifai ask` find its own session: an agent
|
|
822
860
|
// shell command gets no hook payload and no harness exports the id.
|
|
823
861
|
if (envelope.cwd !== undefined) writeProjectSession(envelope.cwd, ctx.env, sessionId, ctx.now())
|
|
824
862
|
|
|
825
|
-
if (pending !== undefined) parkForRetirement(sessionId, ctx.env, pending, 'answered_elsewhere')
|
|
826
863
|
const retired = await drainRetirements(ctx, sessionId, ctx.env, sessionLabel(ctx, envelope))
|
|
827
864
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
828
865
|
const swept = [...retired, ...orphaned]
|
|
@@ -938,7 +975,12 @@ async function escalate(
|
|
|
938
975
|
// snapshot in `state` predates it.
|
|
939
976
|
writeSessionState(sessionId, ctx.env, {
|
|
940
977
|
...readSessionState(sessionId, ctx.env),
|
|
941
|
-
pending: {
|
|
978
|
+
pending: {
|
|
979
|
+
...pending,
|
|
980
|
+
request_id: live.requestId,
|
|
981
|
+
collapse_key: live.collapseKey,
|
|
982
|
+
device_ids: live.devices,
|
|
983
|
+
},
|
|
942
984
|
})
|
|
943
985
|
},
|
|
944
986
|
})
|
|
@@ -962,7 +1004,12 @@ async function escalate(
|
|
|
962
1004
|
// set is also what stops the next Stop pushing the same question again.
|
|
963
1005
|
writeSessionState(sessionId, ctx.env, {
|
|
964
1006
|
...carried,
|
|
965
|
-
pending: {
|
|
1007
|
+
pending: {
|
|
1008
|
+
...pending,
|
|
1009
|
+
request_id: asked.requestId,
|
|
1010
|
+
collapse_key: asked.collapseKey,
|
|
1011
|
+
device_ids: asked.devices,
|
|
1012
|
+
},
|
|
966
1013
|
})
|
|
967
1014
|
notes.push(
|
|
968
1015
|
asked.degraded
|
|
@@ -1023,13 +1070,16 @@ export function handleSessionEnd(
|
|
|
1023
1070
|
const state = readSessionState(sessionId, env)
|
|
1024
1071
|
const orphans: RetiringQuestion[] = [...(state.retiring ?? [])]
|
|
1025
1072
|
const pending = state.pending
|
|
1026
|
-
if (pending
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1073
|
+
if (pending !== undefined) {
|
|
1074
|
+
try {
|
|
1075
|
+
const orphan = retiringQuestion(pending, 'expired')
|
|
1076
|
+
if (orphan !== null) orphans.push(orphan)
|
|
1077
|
+
} catch (err) {
|
|
1078
|
+
notes.push(err instanceof Error ? err.message : String(err))
|
|
1079
|
+
// Preserve the only identifiers instead of turning an explicit corrupt
|
|
1080
|
+
// state into an unretirable question during SessionEnd cleanup.
|
|
1081
|
+
return { notes }
|
|
1082
|
+
}
|
|
1033
1083
|
}
|
|
1034
1084
|
if (orphans.length > 0) {
|
|
1035
1085
|
const label = env['NOTIFAI_SESSION']
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
HARNESSES,
|
|
24
24
|
applyPlan,
|
|
25
|
+
detectHarness,
|
|
25
26
|
buildHookConfig,
|
|
26
27
|
codexLayerDir,
|
|
27
28
|
codexProjectRoot,
|
|
@@ -498,3 +499,79 @@ describe('the OpenCode adapter', () => {
|
|
|
498
499
|
expect(opencodePluginTarget(`${OPENCODE_PLUGIN_MARKER}\nconst EXEC = whatever\n`)).toBeNull()
|
|
499
500
|
})
|
|
500
501
|
})
|
|
502
|
+
|
|
503
|
+
describe('detectHarness', () => {
|
|
504
|
+
function project(...entries: string[]): string {
|
|
505
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-'))
|
|
506
|
+
for (const entry of entries) {
|
|
507
|
+
if (entry.endsWith('.md')) writeFileSync(path.join(dir, entry), '# project\n')
|
|
508
|
+
else mkdirSync(path.join(dir, entry), { recursive: true })
|
|
509
|
+
}
|
|
510
|
+
return dir
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
it('detects Claude Code from a project .claude directory', () => {
|
|
514
|
+
expect(detectHarness(project('.claude'))).toBe('claude-code')
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it('detects Claude Code from CLAUDE.md alone', () => {
|
|
518
|
+
// A repository worked in daily through Claude Code may never accumulate a
|
|
519
|
+
// .claude/ directory; its project file is the only marker there is.
|
|
520
|
+
expect(detectHarness(project('CLAUDE.md'))).toBe('claude-code')
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('detects Claude Code from .claude and CLAUDE.md together without double-counting', () => {
|
|
524
|
+
expect(detectHarness(project('.claude', 'CLAUDE.md'))).toBe('claude-code')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('does not infer a harness from AGENTS.md', () => {
|
|
528
|
+
// AGENTS.md began as a Codex convention and is now read by most agent
|
|
529
|
+
// tooling, Claude Code included. Naming a harness from it would be a guess
|
|
530
|
+
// wearing detection's clothes. With no other project evidence this falls
|
|
531
|
+
// through to machine evidence, which on a multi-tool machine is ambiguous.
|
|
532
|
+
const dir = project('AGENTS.md')
|
|
533
|
+
const answer = detectHarness(dir)
|
|
534
|
+
expect(answer).not.toBe('codex')
|
|
535
|
+
expect(HARNESSES.includes(answer as never) || answer === null).toBe(true)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('pairs AGENTS.md with CLAUDE.md as Claude Code, not Codex', () => {
|
|
539
|
+
// The shape this very repository has, and four of the five projects the
|
|
540
|
+
// release was verified in.
|
|
541
|
+
expect(detectHarness(project('CLAUDE.md', 'AGENTS.md'))).toBe('claude-code')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('detects each harness from its own project directory', () => {
|
|
545
|
+
expect(detectHarness(project('.codex'))).toBe('codex')
|
|
546
|
+
expect(detectHarness(project('.cursor'))).toBe('cursor')
|
|
547
|
+
expect(detectHarness(project('.opencode'))).toBe('opencode')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('returns null when the project itself names two harnesses', () => {
|
|
551
|
+
// Genuine ambiguity, and the caller must ask rather than pick. Not the
|
|
552
|
+
// same as having no evidence.
|
|
553
|
+
expect(detectHarness(project('.claude', '.codex'))).toBeNull()
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('prefers project evidence over anything installed on the machine', () => {
|
|
557
|
+
// The regression. Machine-global markers used to be OR-ed in per harness,
|
|
558
|
+
// so a developer with several agent tools installed got a candidate for
|
|
559
|
+
// every one of them, the "exactly one" test never passed, and detection
|
|
560
|
+
// returned null in every repository on that machine — including ones whose
|
|
561
|
+
// own contents named a single harness unambiguously.
|
|
562
|
+
//
|
|
563
|
+
// This asserts the ordering rather than the machine state, so it holds on
|
|
564
|
+
// a CI box with a bare home directory and on a developer laptop carrying
|
|
565
|
+
// all four.
|
|
566
|
+
expect(detectHarness(project('.cursor'))).toBe('cursor')
|
|
567
|
+
expect(detectHarness(project('CLAUDE.md'))).toBe('claude-code')
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('falls back to machine evidence only when the project offers none', () => {
|
|
571
|
+
const empty = mkdtempSync(path.join(os.tmpdir(), 'notifai-detect-empty-'))
|
|
572
|
+
// Whatever this machine has, an empty project must never crash and must
|
|
573
|
+
// return either a single harness or null — never an arbitrary pick.
|
|
574
|
+
const answer = detectHarness(empty)
|
|
575
|
+
expect(answer === null || HARNESSES.includes(answer)).toBe(true)
|
|
576
|
+
})
|
|
577
|
+
})
|
package/src/install-hooks.ts
CHANGED
|
@@ -342,24 +342,63 @@ export function configHome(env: NodeJS.ProcessEnv, variable: string, fallback: s
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
/** Best-effort detection so `hooks install` usually needs no flags. */
|
|
345
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Harnesses this *project* shows evidence of, in the working directory only.
|
|
347
|
+
*
|
|
348
|
+
* `CLAUDE.md` counts because it is Claude Code's own project file and is
|
|
349
|
+
* frequently the only marker: a repository can be worked in daily through
|
|
350
|
+
* Claude Code and never accumulate a `.claude/` directory.
|
|
351
|
+
*
|
|
352
|
+
* `AGENTS.md` deliberately counts for nothing. It began as a Codex
|
|
353
|
+
* convention and is now read by most agent tooling — including Claude Code,
|
|
354
|
+
* and including this repository, where the two filenames are the same
|
|
355
|
+
* document. Treating it as evidence of any one harness would be a guess
|
|
356
|
+
* dressed up as detection.
|
|
357
|
+
*/
|
|
358
|
+
function localHarnessEvidence(cwd: string): Harness[] {
|
|
346
359
|
const found: Harness[] = []
|
|
347
|
-
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(
|
|
360
|
+
if (existsSync(path.join(cwd, '.claude')) || existsSync(path.join(cwd, 'CLAUDE.md'))) {
|
|
348
361
|
found.push('claude-code')
|
|
349
362
|
}
|
|
350
|
-
if (existsSync(path.join(cwd, '.codex'))
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
+
if (existsSync(path.join(cwd, '.codex'))) found.push('codex')
|
|
364
|
+
if (existsSync(path.join(cwd, '.cursor'))) found.push('cursor')
|
|
365
|
+
if (existsSync(path.join(cwd, '.opencode'))) found.push('opencode')
|
|
366
|
+
return found
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Harnesses installed anywhere on this machine — a much weaker signal. */
|
|
370
|
+
function globalHarnessEvidence(): Harness[] {
|
|
371
|
+
const home = os.homedir()
|
|
372
|
+
const found: Harness[] = []
|
|
373
|
+
if (existsSync(path.join(home, '.claude'))) found.push('claude-code')
|
|
374
|
+
if (existsSync(path.join(home, '.codex'))) found.push('codex')
|
|
375
|
+
if (existsSync(path.join(home, '.cursor'))) found.push('cursor')
|
|
376
|
+
if (existsSync(path.join(home, '.config', 'opencode'))) found.push('opencode')
|
|
377
|
+
return found
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Which harness to wire in this directory, or null when it is genuinely
|
|
382
|
+
* unclear.
|
|
383
|
+
*
|
|
384
|
+
* Project evidence decides, and machine evidence is consulted only when the
|
|
385
|
+
* project offers none. The two were previously OR-ed together per harness,
|
|
386
|
+
* which meant a developer with several agent tools installed could never get
|
|
387
|
+
* a detection at all: every `~/.<tool>` directory contributed a candidate, the
|
|
388
|
+
* "exactly one" test never passed, and the answer was null in every repository
|
|
389
|
+
* on the machine. Verified on a machine carrying all four — `notifai init
|
|
390
|
+
* --hooks` failed in five separate projects, each of which plainly used Claude
|
|
391
|
+
* Code.
|
|
392
|
+
*
|
|
393
|
+
* Having a tool installed says nothing about which one *this* project is
|
|
394
|
+
* worked in. Ambiguity within the project is still ambiguity, and returns
|
|
395
|
+
* null so the caller can ask rather than guess.
|
|
396
|
+
*/
|
|
397
|
+
export function detectHarness(cwd: string): Harness | null {
|
|
398
|
+
const local = localHarnessEvidence(cwd)
|
|
399
|
+
if (local.length > 0) return local.length === 1 ? local[0]! : null
|
|
400
|
+
const global = globalHarnessEvidence()
|
|
401
|
+
return global.length === 1 ? global[0]! : null
|
|
363
402
|
}
|
|
364
403
|
|
|
365
404
|
interface SettingsDocument {
|
package/src/main.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
3
5
|
import { Command } from 'commander'
|
|
4
6
|
import {
|
|
5
7
|
askCommand,
|
|
@@ -62,9 +64,30 @@ async function readStdin(timeoutMs = 2000, maxBytes = 1_000_000): Promise<string
|
|
|
62
64
|
})
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* One source of truth for the version: the manifest npm actually published.
|
|
69
|
+
*
|
|
70
|
+
* It was hardcoded here as well, and the first patch release proved why that
|
|
71
|
+
* does not hold — `package.json` moved and `notifai --version` kept reporting
|
|
72
|
+
* the old number, which is exactly the string someone pastes into a bug report
|
|
73
|
+
* to say what they are running.
|
|
74
|
+
*/
|
|
75
|
+
function version(): string {
|
|
76
|
+
try {
|
|
77
|
+
const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
78
|
+
const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
79
|
+
if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { version?: unknown }).version === 'string') {
|
|
80
|
+
return (parsed as { version: string }).version
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Fall through: an unreadable manifest must not stop the CLI running.
|
|
84
|
+
}
|
|
85
|
+
return 'unknown'
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
const program = new Command('notifai')
|
|
66
89
|
.description('Send native device notifications from agents and local programs')
|
|
67
|
-
.version(
|
|
90
|
+
.version(version())
|
|
68
91
|
|
|
69
92
|
program
|
|
70
93
|
.command('login')
|
|
@@ -292,11 +315,11 @@ config
|
|
|
292
315
|
program
|
|
293
316
|
.command('init')
|
|
294
317
|
.description(
|
|
295
|
-
'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness. ' +
|
|
318
|
+
'Set up NotifAI here, idempotently: sign-in, project id, hooks, device readiness, live receipt proof. ' +
|
|
296
319
|
'Interactive at a human terminal; never prompts otherwise (agents: pass flags)',
|
|
297
320
|
)
|
|
298
321
|
.option('--project-id <id>', 'project identifier slug (default: derived from the directory name)')
|
|
299
|
-
.option('--skills', '
|
|
322
|
+
.option('--skills', 'install/update the agent skill from its pinned public release')
|
|
300
323
|
.option('--no-skills', 'suppress the optional agent-skill status line')
|
|
301
324
|
.option('--hooks', 'install harness hooks for registered-question routing')
|
|
302
325
|
.option('--no-hooks', 'skip the hooks without being asked')
|
|
@@ -306,7 +329,7 @@ program
|
|
|
306
329
|
|
|
307
330
|
program
|
|
308
331
|
.command('doctor')
|
|
309
|
-
.description('Audit config, credential, server, contract, device, and
|
|
332
|
+
.description('Audit config, credential, server, contract, device, hook, and saved receipt proof (no live send)')
|
|
310
333
|
.option('--json', 'machine-readable output')
|
|
311
334
|
.action(async (opts: { json?: boolean }) => {
|
|
312
335
|
process.exit(await doctorCommand(deps, opts))
|
package/src/send.ts
CHANGED
|
@@ -196,6 +196,9 @@ export function formatReceipt(receipt: SubmissionReceipt): string {
|
|
|
196
196
|
const detail = d.provider_reason ? ` (${d.provider_reason})` : ''
|
|
197
197
|
lines.push(` ${d.device_name}: ${d.state}${detail}`)
|
|
198
198
|
}
|
|
199
|
+
lines.push(
|
|
200
|
+
` device receipt is not assessed here; run notifai status ${receipt.request_id} for Companion Receipt evidence`,
|
|
201
|
+
)
|
|
199
202
|
for (const w of receipt.warnings) lines.push(` warning ${w.path}: ${w.message}`)
|
|
200
203
|
return lines.join('\n')
|
|
201
204
|
}
|