@raidiant/notifai 9.2.2 → 10.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-agent-sessions.d.ts +6 -0
- package/dist/commands-agent-sessions.js +51 -0
- package/dist/commands-agent-sessions.js.map +1 -0
- package/dist/commands-config.js +11 -4
- package/dist/commands-config.js.map +1 -1
- package/dist/commands-core.d.ts +1 -1
- package/dist/commands-doctor.js +83 -6
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +15 -2
- package/dist/commands-harness-context.js +32 -1
- package/dist/commands-harness-context.js.map +1 -1
- package/dist/commands-hook-shape.d.ts +4 -3
- package/dist/commands-hook-shape.js +19 -10
- package/dist/commands-hook-shape.js.map +1 -1
- package/dist/commands-hooks.d.ts +4 -3
- package/dist/commands-hooks.js +80 -23
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.js +12 -1
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-project.d.ts +4 -0
- package/dist/commands-project.js +41 -0
- package/dist/commands-project.js.map +1 -0
- package/dist/commands-send-support.js +0 -4
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +12 -6
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +58 -0
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-sounds.d.ts +12 -0
- package/dist/commands-sounds.js +35 -0
- package/dist/commands-sounds.js.map +1 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +3 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +7 -4
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +6 -7
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +4 -1
- package/dist/credentials.js.map +1 -1
- package/dist/guidance-content.d.ts +3 -0
- package/dist/guidance-content.js +20 -0
- package/dist/guidance-content.js.map +1 -1
- package/dist/guidance-render.js +3 -3
- package/dist/guidance-render.js.map +1 -1
- package/dist/harnesses.d.ts +49 -7
- package/dist/harnesses.js +90 -24
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +28 -52
- package/dist/hooks.js +393 -152
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +17 -39
- package/dist/install-hooks.js +21 -41
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +2 -2
- package/dist/orca-session-title.d.ts +2 -2
- package/dist/orca-session-title.js +3 -4
- package/dist/orca-session-title.js.map +1 -1
- package/dist/program.d.ts +6 -1
- package/dist/program.js +46 -5
- package/dist/program.js.map +1 -1
- package/dist/project-enablement.d.ts +15 -0
- package/dist/project-enablement.js +87 -0
- package/dist/project-enablement.js.map +1 -0
- package/dist/question-timing.d.ts +46 -0
- package/dist/question-timing.js +48 -0
- package/dist/question-timing.js.map +1 -0
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.js.map +1 -1
- package/dist/send.d.ts +2 -0
- package/dist/send.js +17 -15
- package/dist/send.js.map +1 -1
- package/dist/session-activation.d.ts +1 -1
- package/dist/session-activation.js +3 -4
- package/dist/session-activation.js.map +1 -1
- package/dist/session-labels.d.ts +18 -2
- package/dist/session-labels.js +79 -8
- package/dist/session-labels.js.map +1 -1
- package/dist/sound-ref.d.ts +23 -0
- package/dist/sound-ref.js +28 -0
- package/dist/sound-ref.js.map +1 -0
- package/package.json +3 -3
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +8 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-agent-sessions.ts +72 -0
- package/src/commands-config.ts +10 -4
- package/src/commands-core.ts +1 -1
- package/src/commands-doctor.ts +96 -8
- package/src/commands-harness-context.ts +48 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +95 -31
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +0 -4
- package/src/commands-send.ts +10 -5
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands-sounds.test.ts +106 -0
- package/src/commands-sounds.ts +49 -0
- package/src/commands.test.ts +386 -23
- package/src/commands.ts +10 -0
- package/src/config-schema.test.ts +1 -0
- package/src/config-schema.ts +7 -4
- package/src/config.test.ts +44 -23
- package/src/config.ts +13 -15
- package/src/credentials.test.ts +20 -0
- package/src/credentials.ts +4 -1
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harness-integration.test.ts +212 -0
- package/src/harnesses.test.ts +107 -8
- package/src/harnesses.ts +111 -30
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +812 -83
- package/src/hooks.ts +529 -185
- package/src/install-hooks.test.ts +13 -16
- package/src/install-hooks.ts +40 -58
- package/src/invocation-context.ts +2 -2
- package/src/orca-session-title.test.ts +8 -49
- package/src/orca-session-title.ts +3 -5
- package/src/program.test.ts +45 -0
- package/src/program.ts +60 -4
- package/src/project-enablement.test.ts +55 -0
- package/src/project-enablement.ts +99 -0
- package/src/question-timing.ts +56 -0
- package/src/readiness.ts +8 -1
- package/src/send.ts +18 -20
- package/src/session-activation.ts +3 -4
- package/src/session-labels.test.ts +58 -9
- package/src/session-labels.ts +95 -12
- package/src/skill.test.ts +14 -3
- package/src/sound-ref.test.ts +31 -0
- package/src/sound-ref.ts +34 -0
- package/src/ui/config-view.test.ts +1 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS,
|
|
2
|
+
NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
4
3
|
handlerEvent,
|
|
4
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
5
5
|
stopHandlerIsDetached,
|
|
6
6
|
type Installation,
|
|
7
7
|
} from './install-hooks.js'
|
|
@@ -17,15 +17,15 @@ import {
|
|
|
17
17
|
* also declare a `timeout`, because the harness default is 600 s and the
|
|
18
18
|
* kill is silent — the backgrounded waiter vanishes and the answer the
|
|
19
19
|
* user already gave is never delivered.
|
|
20
|
-
* - Codex
|
|
21
|
-
*
|
|
22
|
-
* - Everything else
|
|
20
|
+
* - Codex must declare the same full-window timeout. That changes its trusted
|
|
21
|
+
* definition and deliberately requires the User to approve it once.
|
|
22
|
+
* - Everything else cannot own asynchronous Question Routing; it receives
|
|
23
|
+
* only the shorter bounded cleanup/refusal timeout.
|
|
23
24
|
*/
|
|
24
25
|
export function stopShapeProblems(
|
|
25
26
|
installation: Installation,
|
|
26
27
|
platform: NodeJS.Platform = process.platform,
|
|
27
28
|
): string[] {
|
|
28
|
-
if (installation.harness === 'codex') return []
|
|
29
29
|
const problems: string[] = []
|
|
30
30
|
for (const handler of installation.handlers.filter(
|
|
31
31
|
(entry) => handlerEvent(entry.command) === 'stop',
|
|
@@ -36,16 +36,32 @@ export function stopShapeProblems(
|
|
|
36
36
|
`${installation.file} declares a blocking Stop handler; the Claude Code wake route needs \`async: true\` so the turn ends while the waiter runs`,
|
|
37
37
|
)
|
|
38
38
|
}
|
|
39
|
-
if (handler.timeout === undefined || handler.timeout <
|
|
39
|
+
if (handler.timeout === undefined || handler.timeout < QUESTION_STOP_TIMEOUT_SECONDS) {
|
|
40
40
|
problems.push(
|
|
41
|
-
`${installation.file} gives Stop ${handler.timeout ?? 'no'} declared seconds; Claude Code then kills the backgrounded waiter
|
|
41
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'} declared seconds; Claude Code then kills the backgrounded waiter silently before the complete answer window, so it needs an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s`,
|
|
42
42
|
)
|
|
43
43
|
}
|
|
44
44
|
continue
|
|
45
45
|
}
|
|
46
|
-
if (
|
|
46
|
+
if (installation.harness === 'codex' || installation.harness === 'claude-code') {
|
|
47
|
+
if (handler.async === true) {
|
|
48
|
+
problems.push(
|
|
49
|
+
`${installation.file} declares an asynchronous ${installation.harness} Stop handler, but this platform needs the blocking continuation so the harness consumes the answer output`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
if (handler.timeout === undefined || handler.timeout < QUESTION_STOP_TIMEOUT_SECONDS) {
|
|
53
|
+
problems.push(
|
|
54
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but ${installation.harness} Question Routing requires ${QUESTION_STOP_TIMEOUT_SECONDS}s to own the complete answer window`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (
|
|
60
|
+
handler.timeout === undefined ||
|
|
61
|
+
handler.timeout < NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS
|
|
62
|
+
) {
|
|
47
63
|
problems.push(
|
|
48
|
-
`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the blocking
|
|
64
|
+
`${installation.file} gives Stop ${handler.timeout ?? 'no'}s, but the non-routing blocking handler requires ${NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS}s`,
|
|
49
65
|
)
|
|
50
66
|
}
|
|
51
67
|
}
|
package/src/commands-hooks.ts
CHANGED
|
@@ -21,7 +21,14 @@ import { ApiCallError } from './client.js'
|
|
|
21
21
|
import { codexWakeRoute } from './codex-wake.js'
|
|
22
22
|
import { loadConfig, type CliConfig } from './config.js'
|
|
23
23
|
import { withTargetFileLock } from './file-lock.js'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
HARNESS_LABELS,
|
|
26
|
+
HERMES_QUESTION_ROUTING_UNAVAILABLE,
|
|
27
|
+
HOOK_INSTALLABLE_HARNESSES,
|
|
28
|
+
isHookInstallableHarness,
|
|
29
|
+
questionRoutingCapability,
|
|
30
|
+
type HookInstallableHarness,
|
|
31
|
+
} from './harnesses.js'
|
|
25
32
|
import {
|
|
26
33
|
inspectHookAdapter,
|
|
27
34
|
installHookAdapter,
|
|
@@ -43,7 +50,6 @@ import {
|
|
|
43
50
|
readSessionState,
|
|
44
51
|
resetCursorStopActivation,
|
|
45
52
|
registerQuestion,
|
|
46
|
-
waiterCeilingSeconds,
|
|
47
53
|
withdrawUnpushedQuestions,
|
|
48
54
|
type EscalationDeliveryRoute,
|
|
49
55
|
type HookContext,
|
|
@@ -51,8 +57,7 @@ import {
|
|
|
51
57
|
type PendingQuestion,
|
|
52
58
|
} from './hooks.js'
|
|
53
59
|
import {
|
|
54
|
-
|
|
55
|
-
HARNESSES,
|
|
60
|
+
NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
56
61
|
applyPlan,
|
|
57
62
|
buildCursorHookConfig,
|
|
58
63
|
buildHookConfig,
|
|
@@ -75,14 +80,15 @@ import {
|
|
|
75
80
|
removeCursorHooks,
|
|
76
81
|
removeHooks,
|
|
77
82
|
settingsFile,
|
|
78
|
-
stopHandlerIsDetached,
|
|
79
83
|
withCodexLayerTransaction,
|
|
80
|
-
type Harness,
|
|
81
84
|
type Installation,
|
|
82
85
|
} from './install-hooks.js'
|
|
86
|
+
import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
|
|
87
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
83
88
|
import { logConfigResolved, logSettingsFrom } from './logging.js'
|
|
84
89
|
import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
|
|
85
90
|
import { packageVersion } from './release.js'
|
|
91
|
+
import { enableProject, projectBinding, projectEnabled } from './project-enablement.js'
|
|
86
92
|
import { rejectAccidentalEscapedNewlines } from './send.js'
|
|
87
93
|
import {
|
|
88
94
|
cursorStopActivationOutput,
|
|
@@ -137,7 +143,7 @@ export const HOOK_EVENTS = [
|
|
|
137
143
|
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
138
144
|
|
|
139
145
|
/** Lifecycle handlers one installed harness must carry in this CLI build. */
|
|
140
|
-
export function requiredHookEvents(harness:
|
|
146
|
+
export function requiredHookEvents(harness: HookInstallableHarness): readonly HookEvent[] {
|
|
141
147
|
if (harness === 'opencode') return []
|
|
142
148
|
return harness === 'cursor'
|
|
143
149
|
? ['session-start', 'activation-stop', 'user-prompt-submit', 'stop', 'session-end']
|
|
@@ -176,11 +182,10 @@ export async function hookRunCommand(
|
|
|
176
182
|
// would grant slow setup a second budget and let the harness kill us before
|
|
177
183
|
// an accepted answer is journaled or written to stdout.
|
|
178
184
|
const now = deps.now ?? Date.now
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000
|
|
185
|
+
// One owner lifetime covers startup and the longest answer window. Claude
|
|
186
|
+
// runs it detached; Codex holds the turn. The delivery mechanism does not
|
|
187
|
+
// change how long the exact Agent Session remains reachable.
|
|
188
|
+
const processDeadlineAt = now() + QUESTION_WAITER_CEILING_SECONDS * 1000
|
|
184
189
|
|
|
185
190
|
const logger = log(deps)
|
|
186
191
|
logger.bind({ cmd: `hook ${event}` })
|
|
@@ -253,11 +258,29 @@ export async function hookRunCommand(
|
|
|
253
258
|
const cwd = envelope.cwd ?? deps.cwd
|
|
254
259
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
255
260
|
logger.bind({ session: envelope.session_id ?? null })
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
261
|
+
const lifecycleEnabled = (): boolean => {
|
|
262
|
+
try {
|
|
263
|
+
const activationConfig = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
264
|
+
return projectEnabled(projectBinding(cwd, deps.env, activationConfig.project.value))
|
|
265
|
+
} catch (err) {
|
|
266
|
+
logger.error('hook.end', { hook: event, outcome: 'enablement-unavailable', ...failureData(err) })
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Installation only makes lifecycle hooks available. Model-visible
|
|
271
|
+
// activation is a separate User-owned Project decision, checked anew on
|
|
272
|
+
// every run so disabling takes effect without reinstalling anything.
|
|
259
273
|
if (event === 'session-start' || event === 'subagent-start') {
|
|
260
274
|
start({ cwd, source: envelope.source ?? null })
|
|
275
|
+
if (!lifecycleEnabled()) {
|
|
276
|
+
logger.info('hook.end', {
|
|
277
|
+
hook: event,
|
|
278
|
+
outcome: 'ignored',
|
|
279
|
+
reason: 'project-disabled',
|
|
280
|
+
decided: false,
|
|
281
|
+
})
|
|
282
|
+
return EXIT.ok
|
|
283
|
+
}
|
|
261
284
|
if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
|
|
262
285
|
try {
|
|
263
286
|
resetCursorStopActivation(envelope.session_id, deps.env)
|
|
@@ -307,6 +330,15 @@ export async function hookRunCommand(
|
|
|
307
330
|
logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false })
|
|
308
331
|
return EXIT.ok
|
|
309
332
|
}
|
|
333
|
+
if (!lifecycleEnabled()) {
|
|
334
|
+
logger.info('hook.end', {
|
|
335
|
+
hook: event,
|
|
336
|
+
outcome: 'ignored',
|
|
337
|
+
reason: 'project-disabled',
|
|
338
|
+
decided: false,
|
|
339
|
+
})
|
|
340
|
+
return EXIT.ok
|
|
341
|
+
}
|
|
310
342
|
if (envelope.status === 'aborted') {
|
|
311
343
|
logger.info('hook.end', {
|
|
312
344
|
hook: event,
|
|
@@ -507,7 +539,15 @@ export async function hookRunCommand(
|
|
|
507
539
|
...outcome.log,
|
|
508
540
|
})
|
|
509
541
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
510
|
-
if (outcome.stdout !== undefined)
|
|
542
|
+
if (outcome.stdout !== undefined) {
|
|
543
|
+
// No work, await, or diagnostic may sit between this cross-process
|
|
544
|
+
// SessionEnd fence and the irreversible harness stdout write.
|
|
545
|
+
if (outcome.commitStdout === undefined || outcome.commitStdout()) {
|
|
546
|
+
deps.io.out(outcome.stdout)
|
|
547
|
+
} else {
|
|
548
|
+
deps.io.err('notifai: the Agent Session ended before answer delivery; no continuation was written')
|
|
549
|
+
}
|
|
550
|
+
}
|
|
511
551
|
return EXIT.ok
|
|
512
552
|
} catch (err) {
|
|
513
553
|
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
@@ -537,7 +577,7 @@ export async function hookRunCommand(
|
|
|
537
577
|
*/
|
|
538
578
|
function stopWakeRoute(
|
|
539
579
|
deps: CommandDeps,
|
|
540
|
-
harness:
|
|
580
|
+
harness: HookInstallableHarness | undefined,
|
|
541
581
|
sessionId: string | undefined,
|
|
542
582
|
cwd: string,
|
|
543
583
|
): EscalationDeliveryRoute | undefined {
|
|
@@ -1028,6 +1068,17 @@ export function askCommand(
|
|
|
1028
1068
|
if (mediaInputError !== null) {
|
|
1029
1069
|
return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry')
|
|
1030
1070
|
}
|
|
1071
|
+
let explicitUseConfig: CliConfig
|
|
1072
|
+
try {
|
|
1073
|
+
explicitUseConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
return askFailure(deps, flags, 'config_invalid', 'configuration', `Question routing configuration is invalid: ${String(err)}`, 'fix the reported Notifai configuration and retry')
|
|
1076
|
+
}
|
|
1077
|
+
const explicitProject = flags.project ?? explicitUseConfig.project.value ?? inferInvocationContext(deps.cwd).project
|
|
1078
|
+
if (explicitProject !== null) {
|
|
1079
|
+
const binding = projectBinding(deps.cwd, deps.env, explicitProject)
|
|
1080
|
+
if (binding !== null) enableProject(binding)
|
|
1081
|
+
}
|
|
1031
1082
|
if (deps.store.load() === null) {
|
|
1032
1083
|
return askFailure(
|
|
1033
1084
|
deps,
|
|
@@ -1056,6 +1107,17 @@ export function askCommand(
|
|
|
1056
1107
|
'run the ask from a shell with one exact active harness session',
|
|
1057
1108
|
)
|
|
1058
1109
|
}
|
|
1110
|
+
if (!isHookInstallableHarness(active.harness)) {
|
|
1111
|
+
const capability = HERMES_QUESTION_ROUTING_UNAVAILABLE
|
|
1112
|
+
return askFailure(
|
|
1113
|
+
deps,
|
|
1114
|
+
flags,
|
|
1115
|
+
'question_routing_unavailable',
|
|
1116
|
+
'hook_contract',
|
|
1117
|
+
`${active.label}: ${capability.deliveryContract}`,
|
|
1118
|
+
'use a blocking `notifai send --reply` question',
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1059
1121
|
const exactState = active.sessionId === undefined
|
|
1060
1122
|
? null
|
|
1061
1123
|
: readSessionState(active.sessionId, deps.env)
|
|
@@ -1207,7 +1269,9 @@ export function activeQuestionRouteProblems(
|
|
|
1207
1269
|
`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
|
|
1208
1270
|
)
|
|
1209
1271
|
}
|
|
1210
|
-
const capability =
|
|
1272
|
+
const capability = isHookInstallableHarness(active.harness)
|
|
1273
|
+
? questionRoutingCapability(active.harness)
|
|
1274
|
+
: HERMES_QUESTION_ROUTING_UNAVAILABLE
|
|
1211
1275
|
if (capability.stopContinuation === 'unsupported') {
|
|
1212
1276
|
problems.push(`${active.label}: ${capability.deliveryContract}`)
|
|
1213
1277
|
}
|
|
@@ -1637,7 +1701,7 @@ function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags):
|
|
|
1637
1701
|
return { execPath, scriptPath }
|
|
1638
1702
|
}
|
|
1639
1703
|
|
|
1640
|
-
function printHooksInstallClose(deps: CommandDeps, harness:
|
|
1704
|
+
function printHooksInstallClose(deps: CommandDeps, harness: HookInstallableHarness, file: string): void {
|
|
1641
1705
|
const label = HARNESS_LABELS[harness]
|
|
1642
1706
|
const activation =
|
|
1643
1707
|
harness === 'codex'
|
|
@@ -1659,7 +1723,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1659
1723
|
if (flags.harness === undefined) {
|
|
1660
1724
|
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
1661
1725
|
if (detected.length === 0) {
|
|
1662
|
-
deps.io.err(`Could not tell which harness you mean — pass --harness <${
|
|
1726
|
+
deps.io.err(`Could not tell which harness you mean — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
|
|
1663
1727
|
return EXIT.usage
|
|
1664
1728
|
}
|
|
1665
1729
|
let ok = true
|
|
@@ -1732,7 +1796,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1732
1796
|
if (harness === 'opencode') {
|
|
1733
1797
|
return installOpencodePlugin(deps, settingsTarget, {
|
|
1734
1798
|
adapterPath,
|
|
1735
|
-
timeoutSeconds:
|
|
1799
|
+
timeoutSeconds: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1736
1800
|
platform: hookPlatform,
|
|
1737
1801
|
nodePath,
|
|
1738
1802
|
...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
|
|
@@ -2003,17 +2067,17 @@ function assertOwnedRegularFile(file: string): void {
|
|
|
2003
2067
|
}
|
|
2004
2068
|
}
|
|
2005
2069
|
|
|
2006
|
-
function resolveHarness(deps: CommandDeps, requested: string | undefined):
|
|
2070
|
+
function resolveHarness(deps: CommandDeps, requested: string | undefined): HookInstallableHarness | null {
|
|
2007
2071
|
if (requested !== undefined) {
|
|
2008
|
-
if ((
|
|
2072
|
+
if ((HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(requested)) return requested as HookInstallableHarness
|
|
2009
2073
|
deps.io.err(
|
|
2010
|
-
`Unknown harness "${requested}". Supported: ${
|
|
2074
|
+
`Unknown harness "${requested}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`,
|
|
2011
2075
|
)
|
|
2012
2076
|
return null
|
|
2013
2077
|
}
|
|
2014
2078
|
const detected = detectHarness(deps.cwd, deps.env)
|
|
2015
2079
|
if (!detected) {
|
|
2016
|
-
deps.io.err(`Could not tell which harness to install for — pass --harness <${
|
|
2080
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>.`)
|
|
2017
2081
|
return null
|
|
2018
2082
|
}
|
|
2019
2083
|
return detected
|
|
@@ -2027,7 +2091,7 @@ function resolveHarness(deps: CommandDeps, requested: string | undefined): Harne
|
|
|
2027
2091
|
export async function pickHarnessesToInstall(
|
|
2028
2092
|
deps: CommandDeps,
|
|
2029
2093
|
requested?: string,
|
|
2030
|
-
): Promise<
|
|
2094
|
+
): Promise<HookInstallableHarness[] | null> {
|
|
2031
2095
|
if (requested !== undefined) {
|
|
2032
2096
|
const harness = resolveHarness(deps, requested)
|
|
2033
2097
|
return harness === null ? null : [harness]
|
|
@@ -2037,7 +2101,7 @@ export async function pickHarnessesToInstall(
|
|
|
2037
2101
|
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
2038
2102
|
const picked = await deps.io.multiselect(
|
|
2039
2103
|
'Which agent harnesses should Notifai wire here?',
|
|
2040
|
-
|
|
2104
|
+
HOOK_INSTALLABLE_HARNESSES.map((name) => ({
|
|
2041
2105
|
value: name,
|
|
2042
2106
|
label: HARNESS_LABELS[name],
|
|
2043
2107
|
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
@@ -2045,16 +2109,16 @@ export async function pickHarnessesToInstall(
|
|
|
2045
2109
|
detected,
|
|
2046
2110
|
)
|
|
2047
2111
|
if (picked === null) return null
|
|
2048
|
-
const unknown = picked.filter((name) => !(
|
|
2112
|
+
const unknown = picked.filter((name) => !(HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(name))
|
|
2049
2113
|
if (unknown.length > 0) {
|
|
2050
|
-
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${
|
|
2114
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`)
|
|
2051
2115
|
return null
|
|
2052
2116
|
}
|
|
2053
|
-
return picked as
|
|
2117
|
+
return picked as HookInstallableHarness[]
|
|
2054
2118
|
}
|
|
2055
2119
|
if (detected.length === 0) {
|
|
2056
2120
|
deps.io.err(
|
|
2057
|
-
`Could not tell which harness to wire. Run: notifai hooks install --harness <${
|
|
2121
|
+
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HOOK_INSTALLABLE_HARNESSES.join('|')}>`,
|
|
2058
2122
|
)
|
|
2059
2123
|
return null
|
|
2060
2124
|
}
|
package/src/commands-init.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
writeSetupProof,
|
|
44
44
|
} from './commands-setup-proof.js'
|
|
45
45
|
import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js'
|
|
46
|
+
import { enableProject, projectBinding } from './project-enablement.js'
|
|
46
47
|
|
|
47
48
|
// ---------------------------------------------------------------------------
|
|
48
49
|
// init
|
|
@@ -139,6 +140,14 @@ async function closeGap(
|
|
|
139
140
|
return 'closed'
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
if (state.id === 'project-enablement') {
|
|
144
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
145
|
+
const binding = projectBinding(deps.cwd, deps.env, config.project.value)
|
|
146
|
+
if (binding === null) return 'failed'
|
|
147
|
+
enableProject(binding)
|
|
148
|
+
return 'closed'
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
if (state.id === 'hooks') {
|
|
143
152
|
const harnesses = await pickHarnessesToInstall(deps)
|
|
144
153
|
if (harnesses === null || harnesses.length === 0) return 'failed'
|
|
@@ -534,7 +543,7 @@ async function runSetupProof(deps: CommandDeps): Promise<GapCloseResult> {
|
|
|
534
543
|
|
|
535
544
|
/** Closing a local gap cannot have changed the service, the keychain, or devices. */
|
|
536
545
|
function refreshAfterClose(id: string): readonly ReadinessRefresh[] | undefined {
|
|
537
|
-
return id === 'project' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
546
|
+
return id === 'project' || id === 'project-enablement' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
|
|
538
547
|
? ['local']
|
|
539
548
|
: undefined
|
|
540
549
|
}
|
|
@@ -548,6 +557,7 @@ function wantsOptional(deps: CommandDeps, state: ReadinessState, flags: InitFlag
|
|
|
548
557
|
// nothing, and is undone by editing one line — so it is done rather than
|
|
549
558
|
// asked about, for a human and an agent alike.
|
|
550
559
|
if (state.id === 'project') return Promise.resolve(true)
|
|
560
|
+
if (state.id === 'project-enablement') return Promise.resolve(true)
|
|
551
561
|
const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined
|
|
552
562
|
if (explicit !== undefined) return Promise.resolve(explicit)
|
|
553
563
|
// An agent is never asked, and never assumed into a change it did not
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { loadLoggedConfig, EXIT, type CommandDeps } from './commands-core.js'
|
|
2
|
+
import {
|
|
3
|
+
disableProject,
|
|
4
|
+
enableProject,
|
|
5
|
+
projectBinding,
|
|
6
|
+
projectEnabled,
|
|
7
|
+
} from './project-enablement.js'
|
|
8
|
+
|
|
9
|
+
function bindingFor(deps: CommandDeps) {
|
|
10
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
11
|
+
return projectBinding(deps.cwd, deps.env, config.project.value)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function projectEnableCommand(deps: CommandDeps): number {
|
|
15
|
+
const binding = bindingFor(deps)
|
|
16
|
+
if (binding === null) {
|
|
17
|
+
deps.io.err('No Project can be inferred here. Run inside a Project or configure an explicit Project id.')
|
|
18
|
+
return EXIT.usage
|
|
19
|
+
}
|
|
20
|
+
enableProject(binding)
|
|
21
|
+
deps.io.out(`Notifai lifecycle behavior is enabled for Project "${binding.project}".`)
|
|
22
|
+
return EXIT.ok
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function projectDisableCommand(deps: CommandDeps): number {
|
|
26
|
+
const binding = bindingFor(deps)
|
|
27
|
+
if (binding === null) {
|
|
28
|
+
deps.io.err('No Project can be inferred here. Run inside a Project or configure an explicit Project id.')
|
|
29
|
+
return EXIT.usage
|
|
30
|
+
}
|
|
31
|
+
disableProject(binding)
|
|
32
|
+
deps.io.out(`Notifai lifecycle behavior is disabled for Project "${binding.project}".`)
|
|
33
|
+
return EXIT.ok
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function projectStatusCommand(deps: CommandDeps, json = false): number {
|
|
37
|
+
const binding = bindingFor(deps)
|
|
38
|
+
const enabled = projectEnabled(binding)
|
|
39
|
+
if (json) {
|
|
40
|
+
deps.io.out(JSON.stringify({ project: binding?.project ?? null, enabled }))
|
|
41
|
+
} else if (binding === null) {
|
|
42
|
+
deps.io.out('No Project can be inferred here; lifecycle behavior is disabled.')
|
|
43
|
+
} else {
|
|
44
|
+
deps.io.out(`Project "${binding.project}": lifecycle behavior ${enabled ? 'enabled' : 'disabled'}.`)
|
|
45
|
+
}
|
|
46
|
+
return EXIT.ok
|
|
47
|
+
}
|
|
@@ -16,10 +16,6 @@ function managedSessionTitle(
|
|
|
16
16
|
active: ActiveHarnessSession | null,
|
|
17
17
|
): string | undefined {
|
|
18
18
|
if (active?.sessionLabel !== undefined) return active.sessionLabel
|
|
19
|
-
// The Orca worktree title names the workspace, not one harness: every
|
|
20
|
-
// Agent Session that Orca starts inside that worktree inherits the same
|
|
21
|
-
// environment selector, so any active session with an identity to label
|
|
22
|
-
// may use it. An explicit label still wins without paying for the lookup.
|
|
23
19
|
if (
|
|
24
20
|
active?.sessionId === undefined ||
|
|
25
21
|
flags.sessionLabel !== undefined ||
|
package/src/commands-send.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import { ApiCallError, NetworkError } from './client.js'
|
|
10
10
|
import type { FlagOverrides, loadConfig } from './config.js'
|
|
11
11
|
import { MIN_REPLY_WINDOW_SECONDS, readSessionState } from './hooks.js'
|
|
12
|
+
import { enableProject, projectBinding } from './project-enablement.js'
|
|
12
13
|
import {
|
|
13
14
|
buildDraft,
|
|
14
15
|
formatReceipt,
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
reportError,
|
|
27
28
|
type CommandDeps,
|
|
28
29
|
} from './commands-core.js'
|
|
29
|
-
import {
|
|
30
|
+
import { sourceContextHarnessSession } from './commands-harness-context.js'
|
|
30
31
|
import { resolveCommandSession } from './command-session.js'
|
|
31
32
|
import {
|
|
32
33
|
beginSendAttempt,
|
|
@@ -142,15 +143,12 @@ export async function sendCommand(
|
|
|
142
143
|
const source = resolveDraftInvocation(
|
|
143
144
|
deps,
|
|
144
145
|
flags,
|
|
145
|
-
|
|
146
|
+
sourceContextHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
|
|
146
147
|
)
|
|
147
148
|
if (!source.ok) {
|
|
148
149
|
deps.io.err(source.error)
|
|
149
150
|
return EXIT.usage
|
|
150
151
|
}
|
|
151
|
-
const authed = authedClient(deps, config)
|
|
152
|
-
if (!authed) return EXIT.auth
|
|
153
|
-
const credential = deps.store.load()!
|
|
154
152
|
let semanticImages: string[]
|
|
155
153
|
try {
|
|
156
154
|
semanticImages = semanticMediaIds(flags.image ?? [], deps.cwd)
|
|
@@ -167,6 +165,13 @@ export async function sendCommand(
|
|
|
167
165
|
deps.io.err(semanticBuild.error)
|
|
168
166
|
return EXIT.usage
|
|
169
167
|
}
|
|
168
|
+
if (semanticBuild.draft.project !== undefined) {
|
|
169
|
+
const binding = projectBinding(deps.cwd, deps.env, semanticBuild.draft.project)
|
|
170
|
+
if (binding !== null) enableProject(binding)
|
|
171
|
+
}
|
|
172
|
+
const authed = authedClient(deps, config)
|
|
173
|
+
if (!authed) return EXIT.auth
|
|
174
|
+
const credential = deps.store.load()!
|
|
170
175
|
const attempt = beginSendAttempt({
|
|
171
176
|
env: deps.env,
|
|
172
177
|
credential,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import type { CommandDeps } from './commands-core.js'
|
|
6
|
+
import { skillReadiness, SKILLS_SOURCE } from './commands-skill.js'
|
|
7
|
+
|
|
8
|
+
describe('development CLI skill parity', () => {
|
|
9
|
+
it('reports an exact gap instead of calling a stale released skill ready', async () => {
|
|
10
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-dev-skill-'))
|
|
11
|
+
const installedPath = path.join(root, 'skills', 'notifai')
|
|
12
|
+
mkdirSync(installedPath, { recursive: true })
|
|
13
|
+
writeFileSync(path.join(installedPath, 'SKILL.md'), '# stale released skill\n')
|
|
14
|
+
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE ?? '')
|
|
15
|
+
if (match === null) throw new Error('test build has no skill release pin')
|
|
16
|
+
const deps = {
|
|
17
|
+
cwd: root,
|
|
18
|
+
env: {},
|
|
19
|
+
io: { out() {}, err() {}, confirm: async () => false, openUrl() {} },
|
|
20
|
+
store: { load: () => null, save() {}, clear() {}, describe: () => 'test' },
|
|
21
|
+
nativeSkills: {
|
|
22
|
+
list: async (scope: 'project' | 'global') => ({
|
|
23
|
+
skills: scope === 'global'
|
|
24
|
+
? [{ name: 'notifai', scope, path: installedPath, source: match[1]!, sourceType: 'github' as const, sourceUrl: `https://github.com/${match[1]}.git`, ref: match[2]! }]
|
|
25
|
+
: [],
|
|
26
|
+
}),
|
|
27
|
+
add: async () => 0,
|
|
28
|
+
remove: async () => 0,
|
|
29
|
+
},
|
|
30
|
+
} satisfies CommandDeps
|
|
31
|
+
|
|
32
|
+
const state = await skillReadiness(deps)
|
|
33
|
+
expect(state).toMatchObject({
|
|
34
|
+
status: 'gap',
|
|
35
|
+
technical: {
|
|
36
|
+
resolution: 'development-cli-skill-mismatch',
|
|
37
|
+
ref: match[2],
|
|
38
|
+
checkout_digest: expect.stringMatching(/^sha256:/),
|
|
39
|
+
installed_digest: expect.stringMatching(/^sha256:/),
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
expect(state.remedy).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
})
|
package/src/commands-skill.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { SKILLS_INSTALLER_SPEC, type NativeSkill, type SkillScope } from './native-skills.js'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
2
6
|
import { type ReadinessState } from './readiness.js'
|
|
3
7
|
import { skillsSource } from './release.js'
|
|
4
8
|
import type { CommandDeps } from './commands-core.js'
|
|
@@ -20,6 +24,40 @@ const SKILLS_SOURCE_LABEL = SKILLS_SOURCE ?? 'the public release tag matching th
|
|
|
20
24
|
|
|
21
25
|
const SKILL_SCOPES: readonly SkillScope[] = ['project', 'global']
|
|
22
26
|
|
|
27
|
+
function markdownTreeDigest(root: string): string | null {
|
|
28
|
+
if (!existsSync(root)) return null
|
|
29
|
+
const files: string[] = []
|
|
30
|
+
const walk = (directory: string): void => {
|
|
31
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
32
|
+
const absolute = path.join(directory, entry.name)
|
|
33
|
+
if (entry.isDirectory()) walk(absolute)
|
|
34
|
+
else if (entry.isFile() && entry.name.endsWith('.md')) files.push(absolute)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
walk(root)
|
|
39
|
+
const hash = createHash('sha256')
|
|
40
|
+
for (const file of files.sort()) {
|
|
41
|
+
hash.update(path.relative(root, file))
|
|
42
|
+
hash.update('\0')
|
|
43
|
+
hash.update(readFileSync(file))
|
|
44
|
+
hash.update('\0')
|
|
45
|
+
}
|
|
46
|
+
return `sha256:${hash.digest('hex')}`
|
|
47
|
+
} catch {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function developmentSkillMismatch(skill: NativeSkill): { checkout: string; installed: string } | null {
|
|
53
|
+
const checkoutRoot = fileURLToPath(new URL('../../../skills/notifai/', import.meta.url))
|
|
54
|
+
const checkout = markdownTreeDigest(checkoutRoot)
|
|
55
|
+
const installed = markdownTreeDigest(skill.path)
|
|
56
|
+
return checkout !== null && installed !== null && checkout !== installed
|
|
57
|
+
? { checkout, installed }
|
|
58
|
+
: null
|
|
59
|
+
}
|
|
60
|
+
|
|
23
61
|
function skillSourceParts(): { source: string; ref: string } | null {
|
|
24
62
|
if (SKILLS_SOURCE === null) return null
|
|
25
63
|
const match = /^([^#]+)#(.+)$/.exec(SKILLS_SOURCE)
|
|
@@ -118,6 +156,25 @@ export async function skillReadiness(
|
|
|
118
156
|
|
|
119
157
|
const current = installed.find(expectedSkill)
|
|
120
158
|
if (current !== undefined) {
|
|
159
|
+
const mismatch = developmentSkillMismatch(current)
|
|
160
|
+
if (mismatch !== null) {
|
|
161
|
+
return {
|
|
162
|
+
id: 'skill',
|
|
163
|
+
title: 'Agent guidance skill',
|
|
164
|
+
status: 'gap',
|
|
165
|
+
detail:
|
|
166
|
+
`the active development CLI's shipped guidance differs from the installer-managed ${skillPin(current)} skill. ` +
|
|
167
|
+
'Released installs remain immutable; publish a new CLI/skill release before treating this combination as ready.',
|
|
168
|
+
technical: {
|
|
169
|
+
resolution: 'development-cli-skill-mismatch',
|
|
170
|
+
scope: current.scope,
|
|
171
|
+
ref: current.ref,
|
|
172
|
+
path: current.path,
|
|
173
|
+
checkout_digest: mismatch.checkout,
|
|
174
|
+
installed_digest: mismatch.installed,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
121
178
|
return {
|
|
122
179
|
id: 'skill',
|
|
123
180
|
title: 'Agent guidance skill',
|