@raidiant/notifai 9.2.1 → 9.3.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 +33 -0
- package/README.md +8 -5
- package/dist/claude-wake.js +11 -0
- package/dist/claude-wake.js.map +1 -1
- package/dist/codex-wake.js +9 -0
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-core.d.ts +0 -3
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-doctor.js +41 -2
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-harness-context.d.ts +2 -2
- package/dist/commands-harness-context.js +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.js +66 -15
- 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 +1 -22
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.js +11 -5
- 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.d.ts +1 -0
- package/dist/commands.js +1 -0
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +2 -2
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +8 -8
- package/dist/config.js +6 -9
- package/dist/config.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.js +2 -2
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +26 -50
- package/dist/hooks.js +391 -150
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +7 -28
- package/dist/install-hooks.js +18 -37
- package/dist/install-hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -1
- package/dist/invocation-context.js +2 -2
- package/dist/invocation-context.js.map +1 -1
- package/dist/program.d.ts +4 -1
- package/dist/program.js +33 -15
- 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 +4 -2
- package/dist/send.js +6 -1
- 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/package.json +2 -2
- package/src/claude-wake.test.ts +1 -0
- package/src/claude-wake.ts +10 -0
- package/src/codex-wake.test.ts +1 -0
- package/src/codex-wake.ts +9 -0
- package/src/commands-core.ts +0 -3
- package/src/commands-doctor.ts +48 -4
- package/src/commands-harness-context.ts +2 -2
- package/src/commands-hook-shape.ts +26 -10
- package/src/commands-hooks.ts +62 -16
- package/src/commands-init.ts +11 -1
- package/src/commands-project.ts +47 -0
- package/src/commands-send-support.ts +1 -27
- package/src/commands-send.ts +9 -4
- package/src/commands-skill.test.ts +44 -0
- package/src/commands-skill.ts +57 -0
- package/src/commands.test.ts +47 -17
- package/src/commands.ts +5 -0
- package/src/config-schema.ts +2 -2
- package/src/config.test.ts +2 -3
- package/src/config.ts +12 -15
- package/src/guidance-content.ts +22 -0
- package/src/guidance-render.ts +3 -3
- package/src/harnesses.ts +2 -2
- package/src/hook-adapter.test.ts +27 -8
- package/src/hooks.test.ts +808 -80
- package/src/hooks.ts +525 -181
- package/src/install-hooks.test.ts +8 -12
- package/src/install-hooks.ts +21 -37
- package/src/invocation-context.test.ts +1 -1
- package/src/invocation-context.ts +2 -2
- package/src/program.test.ts +18 -0
- package/src/program.ts +36 -14
- 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 +10 -3
- package/src/session-activation.ts +3 -4
- package/src/skill.test.ts +2 -2
- package/dist/orca-session-title.d.ts +0 -10
- package/dist/orca-session-title.js +0 -99
- package/dist/orca-session-title.js.map +0 -1
- package/src/orca-session-title.test.ts +0 -95
- package/src/orca-session-title.ts +0 -105
package/src/commands-hooks.ts
CHANGED
|
@@ -43,7 +43,6 @@ import {
|
|
|
43
43
|
readSessionState,
|
|
44
44
|
resetCursorStopActivation,
|
|
45
45
|
registerQuestion,
|
|
46
|
-
waiterCeilingSeconds,
|
|
47
46
|
withdrawUnpushedQuestions,
|
|
48
47
|
type EscalationDeliveryRoute,
|
|
49
48
|
type HookContext,
|
|
@@ -51,7 +50,7 @@ import {
|
|
|
51
50
|
type PendingQuestion,
|
|
52
51
|
} from './hooks.js'
|
|
53
52
|
import {
|
|
54
|
-
|
|
53
|
+
NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
55
54
|
HARNESSES,
|
|
56
55
|
applyPlan,
|
|
57
56
|
buildCursorHookConfig,
|
|
@@ -75,14 +74,16 @@ import {
|
|
|
75
74
|
removeCursorHooks,
|
|
76
75
|
removeHooks,
|
|
77
76
|
settingsFile,
|
|
78
|
-
stopHandlerIsDetached,
|
|
79
77
|
withCodexLayerTransaction,
|
|
80
78
|
type Harness,
|
|
81
79
|
type Installation,
|
|
82
80
|
} from './install-hooks.js'
|
|
81
|
+
import { QUESTION_WAITER_CEILING_SECONDS } from './question-timing.js'
|
|
82
|
+
import { inferInvocationContext } from './invocation-context.js'
|
|
83
83
|
import { logConfigResolved, logSettingsFrom } from './logging.js'
|
|
84
84
|
import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
|
|
85
85
|
import { packageVersion } from './release.js'
|
|
86
|
+
import { enableProject, projectBinding, projectEnabled } from './project-enablement.js'
|
|
86
87
|
import { rejectAccidentalEscapedNewlines } from './send.js'
|
|
87
88
|
import {
|
|
88
89
|
cursorStopActivationOutput,
|
|
@@ -176,11 +177,10 @@ export async function hookRunCommand(
|
|
|
176
177
|
// would grant slow setup a second budget and let the harness kill us before
|
|
177
178
|
// an accepted answer is journaled or written to stdout.
|
|
178
179
|
const now = deps.now ?? Date.now
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000
|
|
180
|
+
// One owner lifetime covers startup and the longest answer window. Claude
|
|
181
|
+
// runs it detached; Codex holds the turn. The delivery mechanism does not
|
|
182
|
+
// change how long the exact Agent Session remains reachable.
|
|
183
|
+
const processDeadlineAt = now() + QUESTION_WAITER_CEILING_SECONDS * 1000
|
|
184
184
|
|
|
185
185
|
const logger = log(deps)
|
|
186
186
|
logger.bind({ cmd: `hook ${event}` })
|
|
@@ -253,11 +253,29 @@ export async function hookRunCommand(
|
|
|
253
253
|
const cwd = envelope.cwd ?? deps.cwd
|
|
254
254
|
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
255
255
|
logger.bind({ session: envelope.session_id ?? null })
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
256
|
+
const lifecycleEnabled = (): boolean => {
|
|
257
|
+
try {
|
|
258
|
+
const activationConfig = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
259
|
+
return projectEnabled(projectBinding(cwd, deps.env, activationConfig.project.value))
|
|
260
|
+
} catch (err) {
|
|
261
|
+
logger.error('hook.end', { hook: event, outcome: 'enablement-unavailable', ...failureData(err) })
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Installation only makes lifecycle hooks available. Model-visible
|
|
266
|
+
// activation is a separate User-owned Project decision, checked anew on
|
|
267
|
+
// every run so disabling takes effect without reinstalling anything.
|
|
259
268
|
if (event === 'session-start' || event === 'subagent-start') {
|
|
260
269
|
start({ cwd, source: envelope.source ?? null })
|
|
270
|
+
if (!lifecycleEnabled()) {
|
|
271
|
+
logger.info('hook.end', {
|
|
272
|
+
hook: event,
|
|
273
|
+
outcome: 'ignored',
|
|
274
|
+
reason: 'project-disabled',
|
|
275
|
+
decided: false,
|
|
276
|
+
})
|
|
277
|
+
return EXIT.ok
|
|
278
|
+
}
|
|
261
279
|
if (event === 'session-start' && harness === 'cursor' && envelope.session_id !== undefined) {
|
|
262
280
|
try {
|
|
263
281
|
resetCursorStopActivation(envelope.session_id, deps.env)
|
|
@@ -307,6 +325,15 @@ export async function hookRunCommand(
|
|
|
307
325
|
logger.info('hook.end', { hook: event, outcome: 'unsupported-harness', decided: false })
|
|
308
326
|
return EXIT.ok
|
|
309
327
|
}
|
|
328
|
+
if (!lifecycleEnabled()) {
|
|
329
|
+
logger.info('hook.end', {
|
|
330
|
+
hook: event,
|
|
331
|
+
outcome: 'ignored',
|
|
332
|
+
reason: 'project-disabled',
|
|
333
|
+
decided: false,
|
|
334
|
+
})
|
|
335
|
+
return EXIT.ok
|
|
336
|
+
}
|
|
310
337
|
if (envelope.status === 'aborted') {
|
|
311
338
|
logger.info('hook.end', {
|
|
312
339
|
hook: event,
|
|
@@ -507,7 +534,15 @@ export async function hookRunCommand(
|
|
|
507
534
|
...outcome.log,
|
|
508
535
|
})
|
|
509
536
|
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
510
|
-
if (outcome.stdout !== undefined)
|
|
537
|
+
if (outcome.stdout !== undefined) {
|
|
538
|
+
// No work, await, or diagnostic may sit between this cross-process
|
|
539
|
+
// SessionEnd fence and the irreversible harness stdout write.
|
|
540
|
+
if (outcome.commitStdout === undefined || outcome.commitStdout()) {
|
|
541
|
+
deps.io.out(outcome.stdout)
|
|
542
|
+
} else {
|
|
543
|
+
deps.io.err('notifai: the Agent Session ended before answer delivery; no continuation was written')
|
|
544
|
+
}
|
|
545
|
+
}
|
|
511
546
|
return EXIT.ok
|
|
512
547
|
} catch (err) {
|
|
513
548
|
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
@@ -1028,6 +1063,17 @@ export function askCommand(
|
|
|
1028
1063
|
if (mediaInputError !== null) {
|
|
1029
1064
|
return askFailure(deps, flags, 'invalid_input', 'media', mediaInputError, 'fix the media flags and retry')
|
|
1030
1065
|
}
|
|
1066
|
+
let explicitUseConfig: CliConfig
|
|
1067
|
+
try {
|
|
1068
|
+
explicitUseConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
return askFailure(deps, flags, 'config_invalid', 'configuration', `Question routing configuration is invalid: ${String(err)}`, 'fix the reported Notifai configuration and retry')
|
|
1071
|
+
}
|
|
1072
|
+
const explicitProject = flags.project ?? explicitUseConfig.project.value ?? inferInvocationContext(deps.cwd).project
|
|
1073
|
+
if (explicitProject !== null) {
|
|
1074
|
+
const binding = projectBinding(deps.cwd, deps.env, explicitProject)
|
|
1075
|
+
if (binding !== null) enableProject(binding)
|
|
1076
|
+
}
|
|
1031
1077
|
if (deps.store.load() === null) {
|
|
1032
1078
|
return askFailure(
|
|
1033
1079
|
deps,
|
|
@@ -1124,12 +1170,12 @@ export function askCommand(
|
|
|
1124
1170
|
flags,
|
|
1125
1171
|
'session_identity_missing',
|
|
1126
1172
|
'exact_session',
|
|
1127
|
-
'Could not prove which live
|
|
1128
|
-
'run it from a supported harness with exact
|
|
1173
|
+
'Could not prove which live Agent Session owns this command.',
|
|
1174
|
+
'run it from a supported harness with exact Agent Session identity, or use a blocking `notifai send --reply` question',
|
|
1129
1175
|
)
|
|
1130
1176
|
}
|
|
1131
1177
|
if (!sessionId) {
|
|
1132
|
-
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active
|
|
1178
|
+
return askFailure(deps, flags, 'session_identity_missing', 'exact_session', 'No exact active Agent Session is available.', 'use a blocking `notifai send --reply` question')
|
|
1133
1179
|
}
|
|
1134
1180
|
let routingConfig: CliConfig
|
|
1135
1181
|
try {
|
|
@@ -1732,7 +1778,7 @@ export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags)
|
|
|
1732
1778
|
if (harness === 'opencode') {
|
|
1733
1779
|
return installOpencodePlugin(deps, settingsTarget, {
|
|
1734
1780
|
adapterPath,
|
|
1735
|
-
timeoutSeconds:
|
|
1781
|
+
timeoutSeconds: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1736
1782
|
platform: hookPlatform,
|
|
1737
1783
|
nodePath,
|
|
1738
1784
|
...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
|
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
|
+
}
|
|
@@ -5,42 +5,16 @@ import path from 'node:path'
|
|
|
5
5
|
import { isRetryableReplyPollError, type ApiClient } from './client.js'
|
|
6
6
|
import { fetchMediaUrl } from './url-policy.js'
|
|
7
7
|
import { buildSourceContext, inferInvocationContext } from './invocation-context.js'
|
|
8
|
-
import { readOrcaSessionTitle } from './orca-session-title.js'
|
|
9
8
|
import { type DraftInvocation, type SendFlags } from './send.js'
|
|
10
9
|
import { EXIT, reportError, type CommandDeps } from './commands-core.js'
|
|
11
10
|
import type { ActiveHarnessSession } from './commands-harness-context.js'
|
|
12
11
|
|
|
13
|
-
function managedSessionTitle(
|
|
14
|
-
deps: CommandDeps,
|
|
15
|
-
flags: Pick<SendFlags, 'sessionLabel'>,
|
|
16
|
-
active: ActiveHarnessSession | null,
|
|
17
|
-
): string | undefined {
|
|
18
|
-
if (active?.sessionLabel !== undefined) return active.sessionLabel
|
|
19
|
-
// The Orca worktree title names the workspace, not one harness: every
|
|
20
|
-
// harness session 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
|
-
if (
|
|
24
|
-
active?.sessionId === undefined ||
|
|
25
|
-
flags.sessionLabel !== undefined ||
|
|
26
|
-
deps.env['NOTIFAI_SESSION_LABEL'] !== undefined
|
|
27
|
-
) {
|
|
28
|
-
return undefined
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
return (deps.orcaSessionTitle ?? readOrcaSessionTitle)(deps.env)
|
|
32
|
-
} catch {
|
|
33
|
-
return undefined
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
12
|
export function resolveDraftInvocation(
|
|
38
13
|
deps: CommandDeps,
|
|
39
14
|
flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
|
|
40
15
|
active: ActiveHarnessSession | null,
|
|
41
16
|
): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
|
|
42
17
|
const inferred = inferInvocationContext(deps.cwd)
|
|
43
|
-
const sessionTitle = managedSessionTitle(deps, flags, active)
|
|
44
18
|
const source = buildSourceContext({
|
|
45
19
|
env: deps.env,
|
|
46
20
|
invocation: inferred,
|
|
@@ -52,7 +26,7 @@ export function resolveDraftInvocation(
|
|
|
52
26
|
activeHarness: {
|
|
53
27
|
harness: active.harness,
|
|
54
28
|
...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
|
|
55
|
-
...(
|
|
29
|
+
...(active.sessionLabel === undefined ? {} : { sessionLabel: active.sessionLabel }),
|
|
56
30
|
...(active.sessionLabelPending === true ? { sessionLabelPending: true } : {}),
|
|
57
31
|
},
|
|
58
32
|
}),
|
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,
|
|
@@ -148,9 +149,6 @@ export async function sendCommand(
|
|
|
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,
|
|
@@ -392,7 +397,7 @@ function emitSendWarnings(
|
|
|
392
397
|
}
|
|
393
398
|
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
394
399
|
deps.io.err(
|
|
395
|
-
'Heads up: keep the title to the specific substance. Put notification type in --kind;
|
|
400
|
+
'Heads up: keep the title to the specific substance. Put notification type in --kind; Project identity is inferred separately.',
|
|
396
401
|
)
|
|
397
402
|
}
|
|
398
403
|
if (
|
|
@@ -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',
|
package/src/commands.test.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
codexTrustKey,
|
|
69
69
|
findInstallations,
|
|
70
70
|
handlerEvent,
|
|
71
|
+
QUESTION_STOP_TIMEOUT_SECONDS,
|
|
71
72
|
settingsFile,
|
|
72
73
|
} from './install-hooks.js'
|
|
73
74
|
import { readSessionState, writeProjectSession, writeSessionState } from './hooks.js'
|
|
@@ -78,6 +79,7 @@ import { resetLatestPublishedCliVersionForTest } from './cli-release.js'
|
|
|
78
79
|
import { activeLogPath, createLogger, logsDiskUsage, readLogRecords } from './logging.js'
|
|
79
80
|
import { hookAdapterPath, inspectHookAdapter } from './hook-adapter.js'
|
|
80
81
|
import type { Tone } from './ui/theme.js'
|
|
82
|
+
import { projectBinding, projectEnabled } from './project-enablement.js'
|
|
81
83
|
|
|
82
84
|
afterEach(() => {
|
|
83
85
|
resetLatestPublishedCliVersionForTest()
|
|
@@ -416,6 +418,31 @@ function replyResponse(
|
|
|
416
418
|
}
|
|
417
419
|
|
|
418
420
|
describe('command contracts', () => {
|
|
421
|
+
it('durably enables a valid in-Project send before missing authentication stops it', async () => {
|
|
422
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-explicit-enable-'))
|
|
423
|
+
const io = new CapturedIo()
|
|
424
|
+
const env = isolatedEnv(cwd)
|
|
425
|
+
const deps = {
|
|
426
|
+
...makeDeps(io, {} as ApiClient),
|
|
427
|
+
cwd,
|
|
428
|
+
env,
|
|
429
|
+
store: { load: () => null, save: () => {}, clear: () => {}, describe: () => 'empty store' },
|
|
430
|
+
}
|
|
431
|
+
expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done' })).toBe(EXIT.auth)
|
|
432
|
+
expect(projectEnabled(projectBinding(cwd, env))).toBe(true)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('sends deliberately Projectless without enabling the inferred Project', async () => {
|
|
436
|
+
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-projectless-'))
|
|
437
|
+
const io = new CapturedIo()
|
|
438
|
+
const env = isolatedEnv(cwd)
|
|
439
|
+
let submitted: SubmitNotificationRequestT | undefined
|
|
440
|
+
const client = { submit: async (body: SubmitNotificationRequestT) => ((submitted = body), receipt) } as unknown as ApiClient
|
|
441
|
+
const deps = { ...makeDeps(io, client), cwd, env }
|
|
442
|
+
expect(await sendCommand(deps, { title: 'Ready', body: 'Ready.', kind: 'done', projectless: true })).toBe(EXIT.ok)
|
|
443
|
+
expect(submitted?.draft.project).toBeUndefined()
|
|
444
|
+
expect(projectEnabled(projectBinding(cwd, env))).toBe(false)
|
|
445
|
+
})
|
|
419
446
|
it('shows an actionable no-plan access state', async () => {
|
|
420
447
|
const io = new CapturedIo()
|
|
421
448
|
const client = {
|
|
@@ -829,7 +856,7 @@ describe('command contracts', () => {
|
|
|
829
856
|
expect(io.errLines.join('\n')).not.toContain('opaque-claude-session-42')
|
|
830
857
|
})
|
|
831
858
|
|
|
832
|
-
it('
|
|
859
|
+
it('never promotes an Orca worktree title into an Agent Session label', async () => {
|
|
833
860
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-claude-title-'))
|
|
834
861
|
const io = new CapturedIo()
|
|
835
862
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -852,10 +879,6 @@ describe('command contracts', () => {
|
|
|
852
879
|
...makeDeps(io, client),
|
|
853
880
|
cwd,
|
|
854
881
|
env,
|
|
855
|
-
orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
|
|
856
|
-
expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
|
|
857
|
-
return 'Worker - semantic session implementation'
|
|
858
|
-
},
|
|
859
882
|
}
|
|
860
883
|
|
|
861
884
|
expect(
|
|
@@ -868,12 +891,13 @@ describe('command contracts', () => {
|
|
|
868
891
|
|
|
869
892
|
expect(submitted?.draft.source).toMatchObject({
|
|
870
893
|
session_id: 'orca-claude-session',
|
|
871
|
-
session_label: 'Worker - semantic session implementation',
|
|
872
894
|
harness: 'claude-code',
|
|
873
895
|
})
|
|
896
|
+
expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
897
|
+
expect(submitted?.draft.source?.session_label).not.toBe('Worker - semantic session implementation')
|
|
874
898
|
})
|
|
875
899
|
|
|
876
|
-
it('
|
|
900
|
+
it('never treats an Orca worktree title as a Codex Agent Session label', async () => {
|
|
877
901
|
const cwd = mkdtempSync(path.join(os.tmpdir(), 'notifai-orca-codex-title-'))
|
|
878
902
|
const io = new CapturedIo()
|
|
879
903
|
let submitted: SubmitNotificationRequestT | undefined
|
|
@@ -894,10 +918,6 @@ describe('command contracts', () => {
|
|
|
894
918
|
TERM_PROGRAM: 'Orca',
|
|
895
919
|
ORCA_WORKTREE_ID: worktreeId,
|
|
896
920
|
},
|
|
897
|
-
orcaSessionTitle: (lookupEnv: NodeJS.ProcessEnv) => {
|
|
898
|
-
expect(lookupEnv['ORCA_WORKTREE_ID']).toBe(worktreeId)
|
|
899
|
-
return 'Worker - semantic session implementation'
|
|
900
|
-
},
|
|
901
921
|
}
|
|
902
922
|
|
|
903
923
|
expect(
|
|
@@ -910,9 +930,9 @@ describe('command contracts', () => {
|
|
|
910
930
|
|
|
911
931
|
expect(submitted?.draft.source).toMatchObject({
|
|
912
932
|
session_id: 'orca-codex-thread',
|
|
913
|
-
session_label: 'Worker - semantic session implementation',
|
|
914
933
|
harness: 'codex',
|
|
915
934
|
})
|
|
935
|
+
expect(submitted?.draft.source?.session_label).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
916
936
|
})
|
|
917
937
|
|
|
918
938
|
it('falls back safely when Orca returns a private path as its title', async () => {
|
|
@@ -936,7 +956,6 @@ describe('command contracts', () => {
|
|
|
936
956
|
TERM_PROGRAM: 'Orca',
|
|
937
957
|
ORCA_WORKTREE_ID: `repo-123::${cwd}`,
|
|
938
958
|
},
|
|
939
|
-
orcaSessionTitle: () => '/private/untrusted/worktree',
|
|
940
959
|
}
|
|
941
960
|
|
|
942
961
|
expect(
|
|
@@ -6093,7 +6112,7 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6093
6112
|
expect(readSessionState('codex-orchestrator', env).pending).toBeUndefined()
|
|
6094
6113
|
})
|
|
6095
6114
|
|
|
6096
|
-
it('registers an Orca-managed Claude question
|
|
6115
|
+
it('registers an Orca-managed Claude question without borrowing the worktree title', () => {
|
|
6097
6116
|
const cwd = scratchDir('notifai-orca-claude-ask-')
|
|
6098
6117
|
const io = new CapturedIo()
|
|
6099
6118
|
const env = {
|
|
@@ -6108,7 +6127,6 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6108
6127
|
cwd,
|
|
6109
6128
|
env,
|
|
6110
6129
|
now: () => 42,
|
|
6111
|
-
orcaSessionTitle: () => 'Worker - semantic session implementation',
|
|
6112
6130
|
}
|
|
6113
6131
|
|
|
6114
6132
|
expect(hooksInstallCommand(deps, { harness: 'claude-code', execPath, scriptPath })).toBe(
|
|
@@ -6121,9 +6139,10 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6121
6139
|
expect(askCommand(deps, 'Ship the semantic resolver?', {})).toBe(EXIT.ok)
|
|
6122
6140
|
expect(readSessionState('orca-claude-question', env).pending?.[0]?.source).toMatchObject({
|
|
6123
6141
|
session_id: 'orca-claude-question',
|
|
6124
|
-
session_label: 'Worker - semantic session implementation',
|
|
6125
6142
|
harness: 'claude-code',
|
|
6126
6143
|
})
|
|
6144
|
+
expect(readSessionState('orca-claude-question', env).pending?.[0]?.source?.session_label)
|
|
6145
|
+
.toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
|
6127
6146
|
})
|
|
6128
6147
|
|
|
6129
6148
|
it('judges the fired check against the active harness, not a global installation of another', async () => {
|
|
@@ -6276,6 +6295,16 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6276
6295
|
await doctorCommand(deps, {})
|
|
6277
6296
|
expect(io.outLines.join('\n')).toMatch(/FAIL\s+Codex hook trust.*Stop/is)
|
|
6278
6297
|
expect(io.outLines.join('\n')).toMatch(/best-effort.*never writes.*\/hooks/is)
|
|
6298
|
+
const readiness = await assessReadiness(deps)
|
|
6299
|
+
expect(readiness.states.find((state) => state.id === 'hooks-trust')?.remedy).toMatchObject({
|
|
6300
|
+
command: '/hooks',
|
|
6301
|
+
user_action: {
|
|
6302
|
+
code: 'codex_hook_approval_required',
|
|
6303
|
+
harness: 'codex',
|
|
6304
|
+
action: 'approve_or_enable_notifai_hooks',
|
|
6305
|
+
message: expect.stringContaining('Open `/hooks` in Codex'),
|
|
6306
|
+
},
|
|
6307
|
+
})
|
|
6279
6308
|
})
|
|
6280
6309
|
|
|
6281
6310
|
it('gives doctor the same active-Codex diagnosis as ask', async () => {
|
|
@@ -6650,7 +6679,8 @@ describe('asking before the hooks have ever run', () => {
|
|
|
6650
6679
|
const out = io.outLines.join('\n')
|
|
6651
6680
|
expect(out).toMatch(/FAIL\s+Turn\-end hook shape/)
|
|
6652
6681
|
expect(out).toContain('needs `async: true`')
|
|
6653
|
-
expect(out).toContain('kills the backgrounded waiter
|
|
6682
|
+
expect(out).toContain('kills the backgrounded waiter silently before the complete answer window')
|
|
6683
|
+
expect(out).toContain(`needs an explicit ${QUESTION_STOP_TIMEOUT_SECONDS}s`)
|
|
6654
6684
|
})
|
|
6655
6685
|
|
|
6656
6686
|
/** A configured Claude Code project with one live session claiming `pid`. */
|
package/src/commands.ts
CHANGED
|
@@ -62,4 +62,9 @@ export {
|
|
|
62
62
|
type SetupScope,
|
|
63
63
|
} from './commands-init.js'
|
|
64
64
|
export { assessReadiness, doctorCommand } from './commands-doctor.js'
|
|
65
|
+
export {
|
|
66
|
+
projectDisableCommand,
|
|
67
|
+
projectEnableCommand,
|
|
68
|
+
projectStatusCommand,
|
|
69
|
+
} from './commands-project.js'
|
|
65
70
|
export { realIo } from './commands-io.js'
|
package/src/config-schema.ts
CHANGED
|
@@ -100,7 +100,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
100
100
|
unit: 's',
|
|
101
101
|
summary: 'Optional delay before a question may reach your devices',
|
|
102
102
|
detail:
|
|
103
|
-
'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\
|
|
103
|
+
'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nThis controls when the question reaches devices, not how the harness keeps the Agent Session available afterward. Claude Code waits out of band; Codex holds the asking turn.',
|
|
104
104
|
example: '0',
|
|
105
105
|
},
|
|
106
106
|
|
|
@@ -111,7 +111,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
111
111
|
unit: 's',
|
|
112
112
|
summary: 'How long the service keeps accepting your answer to a question',
|
|
113
113
|
detail:
|
|
114
|
-
'A question stays answerable for this long after it
|
|
114
|
+
'A question stays answerable for this long after it reaches the service. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nQuestion Routing keeps the exact Agent Session available for this complete window. Claude Code waits out of band and wakes the Agent Session; Codex keeps its turn held. This is separate from `--reply-timeout`, which controls how long a direct `send --reply` command blocks.',
|
|
115
115
|
example: '86400',
|
|
116
116
|
},
|
|
117
117
|
|