@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.
Files changed (149) hide show
  1. package/CHANGELOG.md +45 -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/client.d.ts +3 -1
  6. package/dist/client.js +2 -0
  7. package/dist/client.js.map +1 -1
  8. package/dist/codex-wake.js +9 -0
  9. package/dist/codex-wake.js.map +1 -1
  10. package/dist/commands-agent-sessions.d.ts +6 -0
  11. package/dist/commands-agent-sessions.js +51 -0
  12. package/dist/commands-agent-sessions.js.map +1 -0
  13. package/dist/commands-config.js +11 -4
  14. package/dist/commands-config.js.map +1 -1
  15. package/dist/commands-core.d.ts +1 -1
  16. package/dist/commands-doctor.js +83 -6
  17. package/dist/commands-doctor.js.map +1 -1
  18. package/dist/commands-harness-context.d.ts +15 -2
  19. package/dist/commands-harness-context.js +32 -1
  20. package/dist/commands-harness-context.js.map +1 -1
  21. package/dist/commands-hook-shape.d.ts +4 -3
  22. package/dist/commands-hook-shape.js +19 -10
  23. package/dist/commands-hook-shape.js.map +1 -1
  24. package/dist/commands-hooks.d.ts +4 -3
  25. package/dist/commands-hooks.js +80 -23
  26. package/dist/commands-hooks.js.map +1 -1
  27. package/dist/commands-init.js +12 -1
  28. package/dist/commands-init.js.map +1 -1
  29. package/dist/commands-project.d.ts +4 -0
  30. package/dist/commands-project.js +41 -0
  31. package/dist/commands-project.js.map +1 -0
  32. package/dist/commands-send-support.js +0 -4
  33. package/dist/commands-send-support.js.map +1 -1
  34. package/dist/commands-send.js +12 -6
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +58 -0
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-sounds.d.ts +12 -0
  39. package/dist/commands-sounds.js +35 -0
  40. package/dist/commands-sounds.js.map +1 -0
  41. package/dist/commands.d.ts +3 -0
  42. package/dist/commands.js +3 -0
  43. package/dist/commands.js.map +1 -1
  44. package/dist/config-schema.js +7 -4
  45. package/dist/config-schema.js.map +1 -1
  46. package/dist/config.d.ts +6 -7
  47. package/dist/config.js +9 -9
  48. package/dist/config.js.map +1 -1
  49. package/dist/credentials.js +4 -1
  50. package/dist/credentials.js.map +1 -1
  51. package/dist/guidance-content.d.ts +3 -0
  52. package/dist/guidance-content.js +20 -0
  53. package/dist/guidance-content.js.map +1 -1
  54. package/dist/guidance-render.js +3 -3
  55. package/dist/guidance-render.js.map +1 -1
  56. package/dist/harnesses.d.ts +49 -7
  57. package/dist/harnesses.js +90 -24
  58. package/dist/harnesses.js.map +1 -1
  59. package/dist/hooks.d.ts +28 -52
  60. package/dist/hooks.js +393 -152
  61. package/dist/hooks.js.map +1 -1
  62. package/dist/install-hooks.d.ts +17 -39
  63. package/dist/install-hooks.js +21 -41
  64. package/dist/install-hooks.js.map +1 -1
  65. package/dist/invocation-context.d.ts +2 -2
  66. package/dist/orca-session-title.d.ts +2 -2
  67. package/dist/orca-session-title.js +3 -4
  68. package/dist/orca-session-title.js.map +1 -1
  69. package/dist/program.d.ts +6 -1
  70. package/dist/program.js +46 -5
  71. package/dist/program.js.map +1 -1
  72. package/dist/project-enablement.d.ts +15 -0
  73. package/dist/project-enablement.js +87 -0
  74. package/dist/project-enablement.js.map +1 -0
  75. package/dist/question-timing.d.ts +46 -0
  76. package/dist/question-timing.js +48 -0
  77. package/dist/question-timing.js.map +1 -0
  78. package/dist/readiness.d.ts +7 -0
  79. package/dist/readiness.js.map +1 -1
  80. package/dist/send.d.ts +2 -0
  81. package/dist/send.js +17 -15
  82. package/dist/send.js.map +1 -1
  83. package/dist/session-activation.d.ts +1 -1
  84. package/dist/session-activation.js +3 -4
  85. package/dist/session-activation.js.map +1 -1
  86. package/dist/session-labels.d.ts +18 -2
  87. package/dist/session-labels.js +79 -8
  88. package/dist/session-labels.js.map +1 -1
  89. package/dist/sound-ref.d.ts +23 -0
  90. package/dist/sound-ref.js +28 -0
  91. package/dist/sound-ref.js.map +1 -0
  92. package/package.json +3 -3
  93. package/src/claude-wake.test.ts +1 -0
  94. package/src/claude-wake.ts +10 -0
  95. package/src/client.test.ts +12 -0
  96. package/src/client.ts +8 -0
  97. package/src/codex-wake.test.ts +1 -0
  98. package/src/codex-wake.ts +9 -0
  99. package/src/commands-agent-sessions.ts +72 -0
  100. package/src/commands-config.ts +10 -4
  101. package/src/commands-core.ts +1 -1
  102. package/src/commands-doctor.ts +96 -8
  103. package/src/commands-harness-context.ts +48 -2
  104. package/src/commands-hook-shape.ts +26 -10
  105. package/src/commands-hooks.ts +95 -31
  106. package/src/commands-init.ts +11 -1
  107. package/src/commands-project.ts +47 -0
  108. package/src/commands-send-support.ts +0 -4
  109. package/src/commands-send.ts +10 -5
  110. package/src/commands-skill.test.ts +44 -0
  111. package/src/commands-skill.ts +57 -0
  112. package/src/commands-sounds.test.ts +106 -0
  113. package/src/commands-sounds.ts +49 -0
  114. package/src/commands.test.ts +386 -23
  115. package/src/commands.ts +10 -0
  116. package/src/config-schema.test.ts +1 -0
  117. package/src/config-schema.ts +7 -4
  118. package/src/config.test.ts +44 -23
  119. package/src/config.ts +13 -15
  120. package/src/credentials.test.ts +20 -0
  121. package/src/credentials.ts +4 -1
  122. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +50 -0
  123. package/src/guidance-content.ts +22 -0
  124. package/src/guidance-render.ts +3 -3
  125. package/src/harness-integration.test.ts +212 -0
  126. package/src/harnesses.test.ts +107 -8
  127. package/src/harnesses.ts +111 -30
  128. package/src/hook-adapter.test.ts +27 -8
  129. package/src/hooks.test.ts +812 -83
  130. package/src/hooks.ts +529 -185
  131. package/src/install-hooks.test.ts +13 -16
  132. package/src/install-hooks.ts +40 -58
  133. package/src/invocation-context.ts +2 -2
  134. package/src/orca-session-title.test.ts +8 -49
  135. package/src/orca-session-title.ts +3 -5
  136. package/src/program.test.ts +45 -0
  137. package/src/program.ts +60 -4
  138. package/src/project-enablement.test.ts +55 -0
  139. package/src/project-enablement.ts +99 -0
  140. package/src/question-timing.ts +56 -0
  141. package/src/readiness.ts +8 -1
  142. package/src/send.ts +18 -20
  143. package/src/session-activation.ts +3 -4
  144. package/src/session-labels.test.ts +58 -9
  145. package/src/session-labels.ts +95 -12
  146. package/src/skill.test.ts +14 -3
  147. package/src/sound-ref.test.ts +31 -0
  148. package/src/sound-ref.ts +34 -0
  149. package/src/ui/config-view.test.ts +1 -0
@@ -1,7 +1,7 @@
1
1
  import {
2
- BLOCKING_STOP_TIMEOUT_SECONDS,
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 owns its Stop timeout. Declaring one changes the definition it
21
- * hashes into `trusted_hash`, and an untrusted handler is simply not run.
22
- * - Everything else blocks its turn and needs a ceiling above the wait.
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 < CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS) {
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 at its 600s default without reporting anything, so it needs an explicit ${CLAUDE_ASYNC_STOP_TIMEOUT_SECONDS}s`,
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 (handler.timeout === undefined || handler.timeout < BLOCKING_STOP_TIMEOUT_SECONDS) {
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 answer owner requires ${BLOCKING_STOP_TIMEOUT_SECONDS}s`,
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
  }
@@ -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 { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
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
- BLOCKING_STOP_TIMEOUT_SECONDS,
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: Harness): readonly HookEvent[] {
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
- // 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
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
- // 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.
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) deps.io.out(outcome.stdout)
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: Harness | undefined,
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 = HARNESS_CAPABILITIES[active.harness]
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: Harness, file: string): void {
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 <${HARNESSES.join('|')}>.`)
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: BLOCKING_STOP_TIMEOUT_SECONDS,
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): Harness | null {
2070
+ function resolveHarness(deps: CommandDeps, requested: string | undefined): HookInstallableHarness | null {
2007
2071
  if (requested !== undefined) {
2008
- if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
2072
+ if ((HOOK_INSTALLABLE_HARNESSES as readonly string[]).includes(requested)) return requested as HookInstallableHarness
2009
2073
  deps.io.err(
2010
- `Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`,
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 <${HARNESSES.join('|')}>.`)
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<Harness[] | null> {
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
- HARNESSES.map((name) => ({
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) => !(HARNESSES as readonly string[]).includes(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: ${HARNESSES.join(', ')}.`)
2114
+ deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HOOK_INSTALLABLE_HARNESSES.join(', ')}.`)
2051
2115
  return null
2052
2116
  }
2053
- return picked as Harness[]
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 <${HARNESSES.join('|')}>`,
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
  }
@@ -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 ||
@@ -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 { activeHarnessSession } from './commands-harness-context.js'
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
- activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
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
+ })
@@ -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',