@raidiant/notifai 0.1.8 → 0.2.1
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/README.md +1 -1
- package/dist/commands.d.ts +30 -12
- package/dist/commands.js +234 -78
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +38 -24
- package/dist/hooks.js +316 -187
- package/dist/hooks.js.map +1 -1
- package/dist/main.js +35 -3
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +19 -26
- package/dist/send.js +35 -45
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +174 -16
- package/src/commands.ts +270 -80
- package/src/hooks.test.ts +314 -83
- package/src/hooks.ts +389 -223
- package/src/install-hooks.test.ts +8 -15
- package/src/main.ts +41 -4
- package/src/send.ts +50 -48
package/src/commands.ts
CHANGED
|
@@ -6,12 +6,15 @@ import path from 'node:path'
|
|
|
6
6
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
7
7
|
import {
|
|
8
8
|
CAPABILITIES_V1,
|
|
9
|
+
QUESTION_TEXT_MAX_LENGTH,
|
|
10
|
+
REPLY_MAX_QUESTIONS,
|
|
9
11
|
REPLY_MAX_WINDOW_SECONDS,
|
|
10
12
|
validateDraft,
|
|
11
13
|
type EvidenceSnapshot,
|
|
12
14
|
type AccountAccessResponse,
|
|
13
15
|
type ListRepliesResponse,
|
|
14
16
|
type Platform,
|
|
17
|
+
type QuestionT,
|
|
15
18
|
type ReplyView,
|
|
16
19
|
type RoutableDevice,
|
|
17
20
|
type SubmissionReceipt,
|
|
@@ -82,11 +85,11 @@ import {
|
|
|
82
85
|
} from './opencode-plugin.js'
|
|
83
86
|
import {
|
|
84
87
|
CHOICE_USAGE,
|
|
85
|
-
ambiguousChoiceSplit,
|
|
86
88
|
buildDraft,
|
|
87
89
|
formatReceipt,
|
|
88
90
|
parseChoices,
|
|
89
91
|
receiptExitCode,
|
|
92
|
+
slugify,
|
|
90
93
|
type SendFlags,
|
|
91
94
|
} from './send.js'
|
|
92
95
|
import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
|
|
@@ -634,20 +637,25 @@ export async function repliesCommand(
|
|
|
634
637
|
deps.io.err('Pass a request id or --pending, not both.')
|
|
635
638
|
return EXIT.usage
|
|
636
639
|
}
|
|
637
|
-
let
|
|
640
|
+
let requestIds = requestedId === undefined ? [] : [requestedId]
|
|
638
641
|
if (flags.pending === true) {
|
|
639
642
|
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
640
643
|
if (sessionId === null) {
|
|
641
644
|
deps.io.err('No active session pointer is available in this directory.')
|
|
642
645
|
return EXIT.noReply
|
|
643
646
|
}
|
|
644
|
-
|
|
645
|
-
|
|
647
|
+
// Every delivered question in the session's queue, in registration order —
|
|
648
|
+
// an agent may have several outstanding at once.
|
|
649
|
+
const state = readSessionState(sessionId, deps.env)
|
|
650
|
+
requestIds = (Array.isArray(state.pending) ? state.pending : [])
|
|
651
|
+
.map((entry) => entry.request_id)
|
|
652
|
+
.filter((id): id is string => id !== undefined)
|
|
653
|
+
if (requestIds.length === 0) {
|
|
646
654
|
deps.io.err(`Session ${sessionId} has no pushed question pending.`)
|
|
647
655
|
return EXIT.noReply
|
|
648
656
|
}
|
|
649
657
|
}
|
|
650
|
-
if (
|
|
658
|
+
if (requestIds.length === 0) {
|
|
651
659
|
deps.io.err('Pass a request id or --pending.')
|
|
652
660
|
return EXIT.usage
|
|
653
661
|
}
|
|
@@ -656,24 +664,40 @@ export async function repliesCommand(
|
|
|
656
664
|
const authed = authedClient(deps, config)
|
|
657
665
|
if (!authed) return EXIT.auth
|
|
658
666
|
try {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
667
|
+
let anyReplies = false
|
|
668
|
+
let anyDegraded = false
|
|
669
|
+
let allTimedOut = true
|
|
670
|
+
const jsonBodies: object[] = []
|
|
671
|
+
for (const requestId of requestIds) {
|
|
672
|
+
const result = await waitForReply(authed.client, requestId, {
|
|
673
|
+
timeoutSeconds: waitSeconds,
|
|
674
|
+
afterSeq,
|
|
675
|
+
now: deps.now,
|
|
676
|
+
sleep: deps.sleep,
|
|
677
|
+
})
|
|
678
|
+
if (flags.json) {
|
|
679
|
+
jsonBodies.push({ ...result.response, degraded: result.degraded })
|
|
680
|
+
} else if (result.response.replies.length > 0) {
|
|
681
|
+
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
682
|
+
printReplies(deps, result.response.replies)
|
|
683
|
+
} else {
|
|
684
|
+
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
685
|
+
}
|
|
686
|
+
anyReplies ||= result.response.replies.length > 0
|
|
687
|
+
anyDegraded ||= result.degraded
|
|
688
|
+
allTimedOut &&= result.timedOut
|
|
689
|
+
}
|
|
665
690
|
if (flags.json) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
printReplies(deps, result.response.replies)
|
|
691
|
+
// One request keeps the response shape agents already parse; several
|
|
692
|
+
// (only possible via --pending) arrive as an array in queue order.
|
|
693
|
+
deps.io.out(JSON.stringify(jsonBodies.length === 1 ? jsonBodies[0] : jsonBodies, null, 2))
|
|
670
694
|
}
|
|
671
|
-
|
|
672
|
-
if (result.degraded) {
|
|
695
|
+
if (anyDegraded) {
|
|
673
696
|
deps.io.err(DEGRADED_WAIT_WARNING)
|
|
674
697
|
return EXIT.network
|
|
675
698
|
}
|
|
676
|
-
|
|
699
|
+
if (anyReplies) return EXIT.ok
|
|
700
|
+
return allTimedOut ? EXIT.noReply : EXIT.ok
|
|
677
701
|
} catch (err) {
|
|
678
702
|
return reportError(deps, err)
|
|
679
703
|
}
|
|
@@ -770,28 +794,27 @@ function printReplies(deps: CommandDeps, replies: ReplyView[]): void {
|
|
|
770
794
|
}
|
|
771
795
|
|
|
772
796
|
/**
|
|
773
|
-
* The
|
|
797
|
+
* The correction note for a wait that saw more than one answer.
|
|
774
798
|
*
|
|
775
|
-
* A
|
|
776
|
-
* answer
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
* answer between the first reply landing and its retirement arriving.
|
|
799
|
+
* A later reply that conflicts with an earlier one is a correction, so the
|
|
800
|
+
* latest answer is the one that counts. The server retires the question on
|
|
801
|
+
* the other devices the moment one answers, which makes this rare; it is
|
|
802
|
+
* still reachable, because a device can answer between the first reply
|
|
803
|
+
* landing and its retirement arriving.
|
|
781
804
|
*
|
|
782
|
-
* Ordered by seq, so `replies
|
|
805
|
+
* Ordered by seq, so `replies.at(-1)` is the answer to act on.
|
|
783
806
|
*/
|
|
784
807
|
export function contradictingAnswer(replies: ReplyView[]): string | null {
|
|
785
|
-
const winner = replies
|
|
808
|
+
const winner = replies.at(-1)
|
|
786
809
|
if (winner === undefined || replies.length < 2) return null
|
|
787
|
-
const
|
|
788
|
-
.
|
|
789
|
-
|
|
790
|
-
if (
|
|
791
|
-
const names = [...new Set(
|
|
810
|
+
const key = (reply: ReplyView) =>
|
|
811
|
+
reply.answers.length > 0 ? JSON.stringify(reply.answers) : reply.text
|
|
812
|
+
const corrected = replies.slice(0, -1).filter((reply) => key(reply) !== key(winner))
|
|
813
|
+
if (corrected.length === 0) return null
|
|
814
|
+
const names = [...new Set(corrected.map((reply) => reply.device_name))].join(', ')
|
|
792
815
|
return (
|
|
793
|
-
`note: "${winner.text}" from ${winner.device_name} is the answer that counts —
|
|
794
|
-
|
|
816
|
+
`note: "${winner.text}" from ${winner.device_name} is the answer that counts — a later ` +
|
|
817
|
+
`answer corrects an earlier one. Earlier differing answers from ${names} were superseded.`
|
|
795
818
|
)
|
|
796
819
|
}
|
|
797
820
|
|
|
@@ -1096,17 +1119,119 @@ function rejectedPaths(details: unknown): string[] {
|
|
|
1096
1119
|
}
|
|
1097
1120
|
|
|
1098
1121
|
export interface AskFlags {
|
|
1099
|
-
choice?: string
|
|
1122
|
+
choice?: string[]
|
|
1123
|
+
/** The single question is multi-select: several answers may be chosen. */
|
|
1124
|
+
multi?: boolean
|
|
1125
|
+
/** Long-form markdown context; shown in the app, never on the banner. */
|
|
1126
|
+
detail?: string
|
|
1127
|
+
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
1128
|
+
form?: string
|
|
1100
1129
|
session?: string
|
|
1101
1130
|
}
|
|
1102
1131
|
|
|
1132
|
+
/** The `--form` document: what an agent writes to ask several things at once. */
|
|
1133
|
+
interface AskFormQuestion {
|
|
1134
|
+
text: string
|
|
1135
|
+
choices?: string[]
|
|
1136
|
+
multi?: boolean
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Turn ask input into the question set that will ride the push. Everything is
|
|
1141
|
+
* validated here, at registration, because the push happens inside a hook
|
|
1142
|
+
* where a rejection becomes a stderr note the agent never reads.
|
|
1143
|
+
*/
|
|
1144
|
+
export function buildQuestions(
|
|
1145
|
+
flags: AskFlags,
|
|
1146
|
+
question: string | undefined,
|
|
1147
|
+
): { ok: true; questions: QuestionT[]; detail?: string } | { ok: false; error: string } {
|
|
1148
|
+
if (flags.form !== undefined) {
|
|
1149
|
+
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
1150
|
+
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
|
|
1151
|
+
}
|
|
1152
|
+
let parsed: unknown
|
|
1153
|
+
try {
|
|
1154
|
+
parsed = JSON.parse(flags.form)
|
|
1155
|
+
} catch {
|
|
1156
|
+
return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' }
|
|
1157
|
+
}
|
|
1158
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { questions?: unknown }).questions)) {
|
|
1159
|
+
return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
|
|
1160
|
+
}
|
|
1161
|
+
const form = parsed as { questions: unknown[]; detail?: unknown }
|
|
1162
|
+
if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
|
|
1163
|
+
return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
|
|
1164
|
+
}
|
|
1165
|
+
if (form.detail !== undefined && typeof form.detail !== 'string') {
|
|
1166
|
+
return { ok: false, error: '"detail" must be a markdown string.' }
|
|
1167
|
+
}
|
|
1168
|
+
const questions: QuestionT[] = []
|
|
1169
|
+
const usedIds = new Set<string>()
|
|
1170
|
+
for (const [index, entry] of form.questions.entries()) {
|
|
1171
|
+
if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
|
|
1172
|
+
return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
|
|
1173
|
+
}
|
|
1174
|
+
const spec = entry as AskFormQuestion
|
|
1175
|
+
const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds)
|
|
1176
|
+
if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
|
|
1177
|
+
questions.push(built.question)
|
|
1178
|
+
}
|
|
1179
|
+
return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) }
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (question === undefined || question.trim() === '') {
|
|
1183
|
+
return { ok: false, error: 'The question cannot be empty.' }
|
|
1184
|
+
}
|
|
1185
|
+
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
|
|
1186
|
+
if ('error' in built) return { ok: false, error: built.error }
|
|
1187
|
+
return {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
questions: [built.question],
|
|
1190
|
+
...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function buildOneQuestion(
|
|
1195
|
+
text: string,
|
|
1196
|
+
choiceLabels: string[] | undefined,
|
|
1197
|
+
multi: boolean,
|
|
1198
|
+
index: number,
|
|
1199
|
+
usedIds: Set<string>,
|
|
1200
|
+
): { question: QuestionT } | { error: string } {
|
|
1201
|
+
const trimmed = text.trim()
|
|
1202
|
+
if (trimmed === '') return { error: 'the question text cannot be empty.' }
|
|
1203
|
+
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
1204
|
+
return {
|
|
1205
|
+
error:
|
|
1206
|
+
`a question must be readable where it is answered: keep it within ` +
|
|
1207
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const choices = parseChoices(choiceLabels)
|
|
1211
|
+
if (choices === 'invalid') return { error: CHOICE_USAGE }
|
|
1212
|
+
if (multi && choices === null) {
|
|
1213
|
+
return { error: '--multi needs answers to select between; add --choice.' }
|
|
1214
|
+
}
|
|
1215
|
+
let id = slugify(trimmed)
|
|
1216
|
+
if (id === '' || usedIds.has(id)) id = `q${index + 1}`
|
|
1217
|
+
usedIds.add(id)
|
|
1218
|
+
return {
|
|
1219
|
+
question: {
|
|
1220
|
+
id,
|
|
1221
|
+
text: trimmed,
|
|
1222
|
+
...(choices !== null ? { choices } : {}),
|
|
1223
|
+
...(multi ? { multi: true } : {}),
|
|
1224
|
+
},
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1103
1228
|
/**
|
|
1104
1229
|
* Registers a question for turn-end routing under the user's presence config.
|
|
1105
1230
|
* Returns immediately so the agent can ask in prose and end its turn. With the
|
|
1106
1231
|
* default presence gate, recent keyboard or mouse activity keeps it local;
|
|
1107
1232
|
* `require_idle = false` intentionally permits a push while the user is active.
|
|
1108
1233
|
*/
|
|
1109
|
-
export function askCommand(deps: CommandDeps, question: string, flags: AskFlags): number {
|
|
1234
|
+
export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
|
|
1110
1235
|
// An agent calling this gets no hook payload. Harness-native environment
|
|
1111
1236
|
// markers identify the active owner, while the UserPromptSubmit hook leaves
|
|
1112
1237
|
// a pointer keyed on the project directory with the hook's canonical id.
|
|
@@ -1155,16 +1280,12 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
1155
1280
|
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
1156
1281
|
return EXIT.usage
|
|
1157
1282
|
}
|
|
1158
|
-
if (question.trim() === '') {
|
|
1159
|
-
deps.io.err('The question cannot be empty.')
|
|
1160
|
-
return EXIT.usage
|
|
1161
|
-
}
|
|
1162
1283
|
// Validate here, not at push time. The push happens inside a hook, where a
|
|
1163
1284
|
// rejection becomes a stderr note the agent never reads — so a malformed
|
|
1164
|
-
//
|
|
1165
|
-
const
|
|
1166
|
-
if (
|
|
1167
|
-
deps.io.err(
|
|
1285
|
+
// question set would look like it registered fine and then silently never ask.
|
|
1286
|
+
const built = buildQuestions(flags, question)
|
|
1287
|
+
if (!built.ok) {
|
|
1288
|
+
deps.io.err(built.error)
|
|
1168
1289
|
return EXIT.usage
|
|
1169
1290
|
}
|
|
1170
1291
|
try {
|
|
@@ -1172,8 +1293,9 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
1172
1293
|
sessionId,
|
|
1173
1294
|
deps.env,
|
|
1174
1295
|
{
|
|
1175
|
-
question:
|
|
1176
|
-
|
|
1296
|
+
question: built.questions[0]!.text,
|
|
1297
|
+
questions: built.questions,
|
|
1298
|
+
...(built.detail !== undefined ? { detail: built.detail } : {}),
|
|
1177
1299
|
},
|
|
1178
1300
|
(deps.now ?? Date.now)(),
|
|
1179
1301
|
)
|
|
@@ -1181,14 +1303,20 @@ export function askCommand(deps: CommandDeps, question: string, flags: AskFlags)
|
|
|
1181
1303
|
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
1182
1304
|
return EXIT.failed
|
|
1183
1305
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1306
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
1307
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
1308
|
+
if (entry.choices !== undefined) {
|
|
1309
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
|
|
1310
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
|
|
1311
|
+
} else if (built.questions.length > 1) {
|
|
1312
|
+
deps.io.out(`${prefix}${entry.text} — free text`)
|
|
1313
|
+
}
|
|
1186
1314
|
}
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1315
|
+
deps.io.out(
|
|
1316
|
+
built.questions.length > 1
|
|
1317
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation as usual and end your turn.`
|
|
1318
|
+
: 'Question registered. Ask it in the conversation as usual and end your turn.',
|
|
1319
|
+
)
|
|
1192
1320
|
return EXIT.ok
|
|
1193
1321
|
}
|
|
1194
1322
|
|
|
@@ -1729,7 +1857,7 @@ export async function configSetCommand(
|
|
|
1729
1857
|
* 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
|
|
1730
1858
|
* Keep this immutable and public because the command is printed to users.
|
|
1731
1859
|
*/
|
|
1732
|
-
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.1
|
|
1860
|
+
export const SKILLS_SOURCE = 'Raidiant-io/notifai#v0.2.1'
|
|
1733
1861
|
|
|
1734
1862
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
1735
1863
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -2929,12 +3057,16 @@ function remedyLine(state: ReadinessState): string {
|
|
|
2929
3057
|
* way and is not worth re-deriving. The judgment added here is which failures
|
|
2930
3058
|
* actually stand in the way.
|
|
2931
3059
|
*
|
|
2932
|
-
*
|
|
2933
|
-
*
|
|
2934
|
-
*
|
|
2935
|
-
*
|
|
2936
|
-
*
|
|
2937
|
-
*
|
|
3060
|
+
* Not everything failed is in the way. A pointer that has never been
|
|
3061
|
+
* published is the normal condition of an install thirty seconds old — the
|
|
3062
|
+
* next prompt fixes it and no command can — and OpenCode's inability to
|
|
3063
|
+
* resume an idle turn is a property of that harness rather than a fault in
|
|
3064
|
+
* this setup. Treating those as blocking would mean `init` could never
|
|
3065
|
+
* finish for an OpenCode user, or could only finish after a session had
|
|
3066
|
+
* already run — so they report as things worth knowing rather than things to
|
|
3067
|
+
* fix, and `init` walks on to the states it can actually close, delivery
|
|
3068
|
+
* proof included. Which failures are informational, and what the true remedy
|
|
3069
|
+
* is, is each check's own call (`HookCheck`).
|
|
2938
3070
|
*/
|
|
2939
3071
|
function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
2940
3072
|
const installations = findInstallations(deps.cwd, deps.env)
|
|
@@ -2977,7 +3109,6 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
2977
3109
|
|
|
2978
3110
|
/** Real but not in the way; see the note above. */
|
|
2979
3111
|
const informational = new Set(['hooks (opencode continuation)'])
|
|
2980
|
-
if (active === null) informational.add('hooks (fired)')
|
|
2981
3112
|
return [
|
|
2982
3113
|
...hookChecks(deps).map((check) => ({
|
|
2983
3114
|
id: check.name.replace(/[ ()]+/g, '-').replace(/-$/, ''),
|
|
@@ -2985,15 +3116,24 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
2985
3116
|
check.name === 'hooks' || check.name.startsWith('hooks (active')
|
|
2986
3117
|
? 'Question routing'
|
|
2987
3118
|
: check.name,
|
|
2988
|
-
status: check.ok
|
|
3119
|
+
status: check.ok
|
|
3120
|
+
? 'ready' as const
|
|
3121
|
+
: check.informational === true || informational.has(check.name)
|
|
3122
|
+
? 'optional-gap' as const
|
|
3123
|
+
: 'gap' as const,
|
|
2989
3124
|
detail: check.detail,
|
|
2990
3125
|
...(check.ok
|
|
2991
3126
|
? {}
|
|
2992
3127
|
: {
|
|
2993
3128
|
remedy: {
|
|
2994
3129
|
by: 'user-here' as const,
|
|
2995
|
-
|
|
2996
|
-
|
|
3130
|
+
// The check's own remedy when it has one; the generic reinstall
|
|
3131
|
+
// line was wrong exactly where it mattered (an unfired pointer
|
|
3132
|
+
// needs a prompt, not `hooks install`).
|
|
3133
|
+
...(check.remedy ?? {
|
|
3134
|
+
summary: 'the detail above names what to change',
|
|
3135
|
+
command: 'notifai hooks install',
|
|
3136
|
+
}),
|
|
2997
3137
|
},
|
|
2998
3138
|
}),
|
|
2999
3139
|
})),
|
|
@@ -3001,8 +3141,18 @@ function hookStates(deps: CommandDeps): ReadinessState[] {
|
|
|
3001
3141
|
]
|
|
3002
3142
|
}
|
|
3003
3143
|
|
|
3004
|
-
|
|
3005
|
-
|
|
3144
|
+
interface HookCheck {
|
|
3145
|
+
name: string
|
|
3146
|
+
ok: boolean
|
|
3147
|
+
detail: string
|
|
3148
|
+
/** Real but not in the way: worth a line, never a blocker. */
|
|
3149
|
+
informational?: boolean
|
|
3150
|
+
/** A remedy truer than the generic `notifai hooks install`. */
|
|
3151
|
+
remedy?: { summary: string; command: string }
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
function hookChecks(deps: CommandDeps): HookCheck[] {
|
|
3155
|
+
const checks: HookCheck[] = []
|
|
3006
3156
|
const installations = findInstallations(deps.cwd, deps.env)
|
|
3007
3157
|
|
|
3008
3158
|
// Not having hooks is a setup someone chose, not a fault: `send` works
|
|
@@ -3036,23 +3186,52 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
3036
3186
|
activeInstallations.length > 0
|
|
3037
3187
|
? `active ${active.label} session has a matching hook installation`
|
|
3038
3188
|
: `active ${active.label} session has no matching hook installation — run \`notifai hooks install --harness ${active.harness}\``,
|
|
3189
|
+
...(activeInstallations.length > 0
|
|
3190
|
+
? {}
|
|
3191
|
+
: {
|
|
3192
|
+
remedy: {
|
|
3193
|
+
summary: `install hooks for the active ${active.label} session`,
|
|
3194
|
+
command: `notifai hooks install --harness ${active.harness}`,
|
|
3195
|
+
},
|
|
3196
|
+
}),
|
|
3039
3197
|
})
|
|
3040
3198
|
if (activeInstallations.length > 0) {
|
|
3041
3199
|
const pointer = readProjectSessionPointer(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3200
|
+
if (pointer === null) {
|
|
3201
|
+
// The normal condition of hooks installed moments ago: the pointer
|
|
3202
|
+
// appears when the harness next fires a hook, and no command can
|
|
3203
|
+
// force that. Informational, so `init` walks on to the states it can
|
|
3204
|
+
// actually prove instead of exiting over evidence only time produces.
|
|
3205
|
+
checks.push({
|
|
3206
|
+
name: 'hooks (active session)',
|
|
3207
|
+
ok: false,
|
|
3208
|
+
informational: true,
|
|
3209
|
+
detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
|
|
3210
|
+
remedy: {
|
|
3211
|
+
summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
|
|
3212
|
+
command: 'notifai doctor',
|
|
3213
|
+
},
|
|
3214
|
+
})
|
|
3215
|
+
} else {
|
|
3216
|
+
const matches =
|
|
3217
|
+
pointer.harness === active.harness &&
|
|
3218
|
+
(active.sessionId === undefined || pointer.sessionId === active.sessionId)
|
|
3219
|
+
checks.push({
|
|
3220
|
+
name: 'hooks (active session)',
|
|
3221
|
+
ok: matches,
|
|
3222
|
+
detail: matches
|
|
3223
|
+
? `the project pointer belongs to the active ${active.label} session`
|
|
3224
|
+
: `the project pointer belongs to another ${active.label} session or harness; refusing cross-session routing`,
|
|
3225
|
+
...(matches
|
|
3226
|
+
? {}
|
|
3227
|
+
: {
|
|
3228
|
+
remedy: {
|
|
3229
|
+
summary: `send one ${active.label} prompt in this session so the pointer belongs to it`,
|
|
3230
|
+
command: 'notifai doctor',
|
|
3231
|
+
},
|
|
3232
|
+
}),
|
|
3233
|
+
})
|
|
3234
|
+
}
|
|
3056
3235
|
}
|
|
3057
3236
|
}
|
|
3058
3237
|
|
|
@@ -3145,9 +3324,20 @@ function hookChecks(deps: CommandDeps): { name: string; ok: boolean; detail: str
|
|
|
3145
3324
|
checks.push({
|
|
3146
3325
|
name: 'hooks (fired)',
|
|
3147
3326
|
ok: fired,
|
|
3327
|
+
// Never-fired is evidence about the past, not a fault in the setup, and
|
|
3328
|
+
// the cure is a prompt no command can send.
|
|
3329
|
+
informational: true,
|
|
3148
3330
|
detail: fired
|
|
3149
3331
|
? 'a session in this directory has run them'
|
|
3150
3332
|
: `no session pointer from the last 24 hours — ${hookActivationAdvice(activationInstallations)}`,
|
|
3333
|
+
...(fired
|
|
3334
|
+
? {}
|
|
3335
|
+
: {
|
|
3336
|
+
remedy: {
|
|
3337
|
+
summary: 'send one prompt in a session here, then re-check',
|
|
3338
|
+
command: 'notifai doctor',
|
|
3339
|
+
},
|
|
3340
|
+
}),
|
|
3151
3341
|
})
|
|
3152
3342
|
|
|
3153
3343
|
if (installations.some((installation) => installation.harness === 'opencode')) {
|