@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +8 -5
  3. package/dist/claude-wake.js +11 -0
  4. package/dist/claude-wake.js.map +1 -1
  5. package/dist/codex-wake.js +9 -0
  6. package/dist/codex-wake.js.map +1 -1
  7. package/dist/commands-core.d.ts +0 -3
  8. package/dist/commands-core.js.map +1 -1
  9. package/dist/commands-doctor.js +41 -2
  10. package/dist/commands-doctor.js.map +1 -1
  11. package/dist/commands-harness-context.d.ts +2 -2
  12. package/dist/commands-harness-context.js +1 -1
  13. package/dist/commands-hook-shape.d.ts +4 -3
  14. package/dist/commands-hook-shape.js +19 -10
  15. package/dist/commands-hook-shape.js.map +1 -1
  16. package/dist/commands-hooks.js +66 -15
  17. package/dist/commands-hooks.js.map +1 -1
  18. package/dist/commands-init.js +12 -1
  19. package/dist/commands-init.js.map +1 -1
  20. package/dist/commands-project.d.ts +4 -0
  21. package/dist/commands-project.js +41 -0
  22. package/dist/commands-project.js.map +1 -0
  23. package/dist/commands-send-support.js +1 -22
  24. package/dist/commands-send-support.js.map +1 -1
  25. package/dist/commands-send.js +11 -5
  26. package/dist/commands-send.js.map +1 -1
  27. package/dist/commands-skill.js +58 -0
  28. package/dist/commands-skill.js.map +1 -1
  29. package/dist/commands.d.ts +1 -0
  30. package/dist/commands.js +1 -0
  31. package/dist/commands.js.map +1 -1
  32. package/dist/config-schema.js +2 -2
  33. package/dist/config-schema.js.map +1 -1
  34. package/dist/config.d.ts +8 -8
  35. package/dist/config.js +6 -9
  36. package/dist/config.js.map +1 -1
  37. package/dist/guidance-content.d.ts +3 -0
  38. package/dist/guidance-content.js +20 -0
  39. package/dist/guidance-content.js.map +1 -1
  40. package/dist/guidance-render.js +3 -3
  41. package/dist/guidance-render.js.map +1 -1
  42. package/dist/harnesses.js +2 -2
  43. package/dist/harnesses.js.map +1 -1
  44. package/dist/hooks.d.ts +26 -50
  45. package/dist/hooks.js +391 -150
  46. package/dist/hooks.js.map +1 -1
  47. package/dist/install-hooks.d.ts +7 -28
  48. package/dist/install-hooks.js +18 -37
  49. package/dist/install-hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -1
  51. package/dist/invocation-context.js +2 -2
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/program.d.ts +4 -1
  54. package/dist/program.js +33 -15
  55. package/dist/program.js.map +1 -1
  56. package/dist/project-enablement.d.ts +15 -0
  57. package/dist/project-enablement.js +87 -0
  58. package/dist/project-enablement.js.map +1 -0
  59. package/dist/question-timing.d.ts +46 -0
  60. package/dist/question-timing.js +48 -0
  61. package/dist/question-timing.js.map +1 -0
  62. package/dist/readiness.d.ts +7 -0
  63. package/dist/readiness.js.map +1 -1
  64. package/dist/send.d.ts +4 -2
  65. package/dist/send.js +6 -1
  66. package/dist/send.js.map +1 -1
  67. package/dist/session-activation.d.ts +1 -1
  68. package/dist/session-activation.js +3 -4
  69. package/dist/session-activation.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/claude-wake.test.ts +1 -0
  72. package/src/claude-wake.ts +10 -0
  73. package/src/codex-wake.test.ts +1 -0
  74. package/src/codex-wake.ts +9 -0
  75. package/src/commands-core.ts +0 -3
  76. package/src/commands-doctor.ts +48 -4
  77. package/src/commands-harness-context.ts +2 -2
  78. package/src/commands-hook-shape.ts +26 -10
  79. package/src/commands-hooks.ts +62 -16
  80. package/src/commands-init.ts +11 -1
  81. package/src/commands-project.ts +47 -0
  82. package/src/commands-send-support.ts +1 -27
  83. package/src/commands-send.ts +9 -4
  84. package/src/commands-skill.test.ts +44 -0
  85. package/src/commands-skill.ts +57 -0
  86. package/src/commands.test.ts +47 -17
  87. package/src/commands.ts +5 -0
  88. package/src/config-schema.ts +2 -2
  89. package/src/config.test.ts +2 -3
  90. package/src/config.ts +12 -15
  91. package/src/guidance-content.ts +22 -0
  92. package/src/guidance-render.ts +3 -3
  93. package/src/harnesses.ts +2 -2
  94. package/src/hook-adapter.test.ts +27 -8
  95. package/src/hooks.test.ts +808 -80
  96. package/src/hooks.ts +525 -181
  97. package/src/install-hooks.test.ts +8 -12
  98. package/src/install-hooks.ts +21 -37
  99. package/src/invocation-context.test.ts +1 -1
  100. package/src/invocation-context.ts +2 -2
  101. package/src/program.test.ts +18 -0
  102. package/src/program.ts +36 -14
  103. package/src/project-enablement.test.ts +55 -0
  104. package/src/project-enablement.ts +99 -0
  105. package/src/question-timing.ts +56 -0
  106. package/src/readiness.ts +8 -1
  107. package/src/send.ts +10 -3
  108. package/src/session-activation.ts +3 -4
  109. package/src/skill.test.ts +2 -2
  110. package/dist/orca-session-title.d.ts +0 -10
  111. package/dist/orca-session-title.js +0 -99
  112. package/dist/orca-session-title.js.map +0 -1
  113. package/src/orca-session-title.test.ts +0 -95
  114. package/src/orca-session-title.ts +0 -105
@@ -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
- BLOCKING_STOP_TIMEOUT_SECONDS,
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
- // The waiter may spend a long wall clock exactly when no turn is held open
180
- // for it, which is the same condition the installer used to declare
181
- // `async: true`. One predicate decides it for both.
182
- const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform)
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
- // Session activation is local model context, not authenticated routing. It
257
- // must survive the exact first-run states it exists to repair: no config,
258
- // no credential, no Companion device, and no network.
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) deps.io.out(outcome.stdout)
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 harness session owns this command.',
1128
- 'run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question',
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 session is available.', 'use a blocking `notifai send --reply` question')
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: BLOCKING_STOP_TIMEOUT_SECONDS,
1781
+ timeoutSeconds: NON_ROUTING_BLOCKING_STOP_TIMEOUT_SECONDS,
1736
1782
  platform: hookPlatform,
1737
1783
  nodePath,
1738
1784
  ...(flags.narrate === undefined ? {} : { narrate: flags.narrate }),
@@ -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
- ...(sessionTitle === undefined ? {} : { sessionLabel: sessionTitle }),
29
+ ...(active.sessionLabel === undefined ? {} : { sessionLabel: active.sessionLabel }),
56
30
  ...(active.sessionLabelPending === true ? { sessionLabelPending: true } : {}),
57
31
  },
58
32
  }),
@@ -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; project identity is inferred separately.',
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
+ })
@@ -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',
@@ -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('uses the exact Orca worktree title for an active Claude session send', async () => {
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('uses the exact Orca worktree title for an active Codex session send', async () => {
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 under the semantic worktree title', () => {
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 at its 600s default')
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'
@@ -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\nA timer and nothing more. Whether you happen to be at this keyboard does not change it: waiting no longer holds your terminal, so there is nothing your being here would protect you from.',
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 is asked. The default is a day, so a question that arrives while you are away is still yours to answer when you come back.\n\nThis is not how long anything waits on you. The agent ends its turn immediately; if it is still running when you answer, the answer reaches it directly, and otherwise it is delivered at that session\'s next turn. Shortening this only makes an answer arrive too late to be accepted.',
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