@raidiant/notifai 10.1.0 → 10.1.2

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 (204) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +3 -0
  3. package/dist/cli-bin.d.ts +21 -1
  4. package/dist/cli-bin.js +141 -10
  5. package/dist/cli-bin.js.map +1 -1
  6. package/dist/cli-update-contract.d.ts +7 -0
  7. package/dist/cli-update-contract.js +10 -0
  8. package/dist/cli-update-contract.js.map +1 -0
  9. package/dist/client.d.ts +6 -1
  10. package/dist/client.js +1 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/codex-wake.d.ts +2 -0
  13. package/dist/codex-wake.js +2 -1
  14. package/dist/codex-wake.js.map +1 -1
  15. package/dist/commands-auth.d.ts +15 -1
  16. package/dist/commands-auth.js +29 -11
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-core.d.ts +9 -2
  19. package/dist/commands-core.js +11 -4
  20. package/dist/commands-core.js.map +1 -1
  21. package/dist/commands-devices.d.ts +10 -1
  22. package/dist/commands-devices.js +17 -13
  23. package/dist/commands-devices.js.map +1 -1
  24. package/dist/commands-doctor.js +93 -49
  25. package/dist/commands-doctor.js.map +1 -1
  26. package/dist/commands-hooks.js +47 -10
  27. package/dist/commands-hooks.js.map +1 -1
  28. package/dist/commands-init.d.ts +16 -0
  29. package/dist/commands-init.js +254 -93
  30. package/dist/commands-init.js.map +1 -1
  31. package/dist/commands-send-support.js +3 -0
  32. package/dist/commands-send-support.js.map +1 -1
  33. package/dist/commands-send.d.ts +1 -1
  34. package/dist/commands-send.js +121 -35
  35. package/dist/commands-send.js.map +1 -1
  36. package/dist/commands-skill.js +72 -54
  37. package/dist/commands-skill.js.map +1 -1
  38. package/dist/commands-update.d.ts +10 -0
  39. package/dist/commands-update.js +141 -0
  40. package/dist/commands-update.js.map +1 -0
  41. package/dist/commands.d.ts +2 -1
  42. package/dist/commands.js +2 -1
  43. package/dist/commands.js.map +1 -1
  44. package/dist/hook-adapter.d.ts +2 -0
  45. package/dist/hook-adapter.js +12 -1
  46. package/dist/hook-adapter.js.map +1 -1
  47. package/dist/hooks.d.ts +43 -2
  48. package/dist/hooks.js +163 -21
  49. package/dist/hooks.js.map +1 -1
  50. package/dist/invocation-context.d.ts +1 -0
  51. package/dist/invocation-context.js +9 -1
  52. package/dist/invocation-context.js.map +1 -1
  53. package/dist/main-run.d.ts +1 -0
  54. package/dist/main-run.js +42 -0
  55. package/dist/main-run.js.map +1 -0
  56. package/dist/main.js +10 -31
  57. package/dist/main.js.map +1 -1
  58. package/dist/native-skills.d.ts +13 -3
  59. package/dist/native-skills.js +51 -12
  60. package/dist/native-skills.js.map +1 -1
  61. package/dist/node-floor.d.ts +19 -0
  62. package/dist/node-floor.js +39 -0
  63. package/dist/node-floor.js.map +1 -0
  64. package/dist/orca-session-title.d.ts +5 -4
  65. package/dist/orca-session-title.js +32 -19
  66. package/dist/orca-session-title.js.map +1 -1
  67. package/dist/platform.d.ts +11 -17
  68. package/dist/platform.js +20 -23
  69. package/dist/platform.js.map +1 -1
  70. package/dist/program.d.ts +2 -1
  71. package/dist/program.js +22 -5
  72. package/dist/program.js.map +1 -1
  73. package/dist/question-settlement-process.d.ts +15 -0
  74. package/dist/question-settlement-process.js +35 -0
  75. package/dist/question-settlement-process.js.map +1 -0
  76. package/dist/readiness.d.ts +25 -1
  77. package/dist/readiness.js +32 -2
  78. package/dist/readiness.js.map +1 -1
  79. package/dist/release.d.ts +5 -5
  80. package/dist/release.js +5 -5
  81. package/dist/setup-destinations.d.ts +23 -0
  82. package/dist/setup-destinations.js +44 -0
  83. package/dist/setup-destinations.js.map +1 -0
  84. package/dist/skill-integrity.d.ts +49 -0
  85. package/dist/skill-integrity.js +154 -0
  86. package/dist/skill-integrity.js.map +1 -0
  87. package/dist/skill-source/manifest.json +16 -0
  88. package/dist/skill-source/notifai/SKILL.md +390 -0
  89. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  90. package/package.json +11 -14
  91. package/src/atomic-file.ts +0 -198
  92. package/src/claude-wake.test.ts +0 -321
  93. package/src/claude-wake.ts +0 -499
  94. package/src/cli-bin.test.ts +0 -38
  95. package/src/cli-bin.ts +0 -67
  96. package/src/cli-release.test.ts +0 -46
  97. package/src/cli-release.ts +0 -78
  98. package/src/client.test.ts +0 -390
  99. package/src/client.ts +0 -345
  100. package/src/codex-wake.test.ts +0 -254
  101. package/src/codex-wake.ts +0 -297
  102. package/src/command-session.ts +0 -38
  103. package/src/commands-agent-sessions.ts +0 -72
  104. package/src/commands-auth.ts +0 -205
  105. package/src/commands-config.ts +0 -336
  106. package/src/commands-core.ts +0 -298
  107. package/src/commands-devices.test.ts +0 -15
  108. package/src/commands-devices.ts +0 -173
  109. package/src/commands-doctor.ts +0 -1440
  110. package/src/commands-guidance.ts +0 -181
  111. package/src/commands-harness-context.ts +0 -207
  112. package/src/commands-hook-shape.ts +0 -69
  113. package/src/commands-hooks.ts +0 -2268
  114. package/src/commands-init.ts +0 -878
  115. package/src/commands-io.ts +0 -108
  116. package/src/commands-logs.ts +0 -243
  117. package/src/commands-project.ts +0 -47
  118. package/src/commands-send-support.test.ts +0 -156
  119. package/src/commands-send-support.ts +0 -311
  120. package/src/commands-send.ts +0 -769
  121. package/src/commands-setup-proof.test.ts +0 -72
  122. package/src/commands-setup-proof.ts +0 -124
  123. package/src/commands-skill.test.ts +0 -44
  124. package/src/commands-skill.ts +0 -204
  125. package/src/commands-sounds.test.ts +0 -106
  126. package/src/commands-sounds.ts +0 -49
  127. package/src/commands.test.ts +0 -7828
  128. package/src/commands.ts +0 -75
  129. package/src/config-schema.test.ts +0 -153
  130. package/src/config-schema.ts +0 -349
  131. package/src/config.test.ts +0 -581
  132. package/src/config.ts +0 -475
  133. package/src/credentials.test.ts +0 -403
  134. package/src/credentials.ts +0 -403
  135. package/src/file-lock-process-worker.test.ts +0 -64
  136. package/src/file-lock.test.ts +0 -320
  137. package/src/file-lock.ts +0 -393
  138. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  139. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  140. package/src/generated-session-label.ts +0 -155
  141. package/src/guidance-content.ts +0 -278
  142. package/src/guidance-render.ts +0 -56
  143. package/src/guidance.test.ts +0 -327
  144. package/src/guidance.ts +0 -233
  145. package/src/harness-integration.test.ts +0 -294
  146. package/src/harnesses.test.ts +0 -149
  147. package/src/harnesses.ts +0 -164
  148. package/src/hook-adapter.test.ts +0 -496
  149. package/src/hook-adapter.ts +0 -410
  150. package/src/hook-input.test.ts +0 -39
  151. package/src/hook-input.ts +0 -72
  152. package/src/hooks.test.ts +0 -5549
  153. package/src/hooks.ts +0 -3759
  154. package/src/install-hooks.test.ts +0 -1614
  155. package/src/install-hooks.ts +0 -1501
  156. package/src/interactive.test.ts +0 -387
  157. package/src/interactive.ts +0 -858
  158. package/src/invocation-context.test.ts +0 -352
  159. package/src/invocation-context.ts +0 -197
  160. package/src/logging-process-worker.test.ts +0 -45
  161. package/src/logging.test.ts +0 -544
  162. package/src/logging.ts +0 -698
  163. package/src/main.ts +0 -36
  164. package/src/native-skills.test.ts +0 -115
  165. package/src/native-skills.ts +0 -162
  166. package/src/onboarding-docs.test.ts +0 -25
  167. package/src/openclaw-plugin.ts +0 -409
  168. package/src/opencode-plugin.ts +0 -298
  169. package/src/orca-session-title.test.ts +0 -54
  170. package/src/orca-session-title.ts +0 -103
  171. package/src/pack-feedback-slice.test.ts +0 -212
  172. package/src/pack-feedback-slice.ts +0 -187
  173. package/src/platform.test.ts +0 -152
  174. package/src/platform.ts +0 -173
  175. package/src/program.test.ts +0 -282
  176. package/src/program.ts +0 -818
  177. package/src/project-enablement.test.ts +0 -55
  178. package/src/project-enablement.ts +0 -99
  179. package/src/question-caps.test.ts +0 -49
  180. package/src/question-timing.ts +0 -56
  181. package/src/readiness.test.ts +0 -25
  182. package/src/readiness.ts +0 -179
  183. package/src/release.ts +0 -51
  184. package/src/send-attempts.test.ts +0 -143
  185. package/src/send-attempts.ts +0 -188
  186. package/src/send.test.ts +0 -28
  187. package/src/send.ts +0 -436
  188. package/src/session-activation.ts +0 -61
  189. package/src/session-end-log-lock-worker.test.ts +0 -35
  190. package/src/session-labels.test.ts +0 -490
  191. package/src/session-labels.ts +0 -474
  192. package/src/skill.test.ts +0 -415
  193. package/src/sound-ref.test.ts +0 -31
  194. package/src/sound-ref.ts +0 -34
  195. package/src/trust-policy.test.ts +0 -701
  196. package/src/ui/banner.ts +0 -95
  197. package/src/ui/config-view.test.ts +0 -90
  198. package/src/ui/config-view.ts +0 -138
  199. package/src/ui/help.test.ts +0 -16
  200. package/src/ui/help.ts +0 -128
  201. package/src/ui/theme.test.ts +0 -92
  202. package/src/ui/theme.ts +0 -265
  203. package/src/url-policy.ts +0 -399
  204. package/tsconfig.json +0 -10
@@ -7,17 +7,20 @@ import { ensurePrivateDirectory } from './atomic-file.js';
7
7
  import { ApiCallError, NetworkError } from './client.js';
8
8
  import { personalProjectConfigPath } from './config.js';
9
9
  import { projectSlugFrom as inferredProjectSlugFrom } from './invocation-context.js';
10
- import { SKILLS_INSTALLER_SPEC } from './native-skills.js';
11
- import { firstBlocker, questionRoutingReady, readinessJson, } from './readiness.js';
10
+ import {} from './native-skills.js';
11
+ import { firstRequiredBlocker, isOptionalAutomation, isOptionalSetup, questionRoutingReady, readinessJson, } from './readiness.js';
12
12
  import { buildDraft, formatReceipt } from './send.js';
13
13
  import { loginCommand } from './commands-auth.js';
14
- import { EXIT, SETUP_COMMAND, UPDATE_CLI_COMMAND, authedClient, loadLoggedConfig, reportError, } from './commands-core.js';
15
- import { readyCompanionDevices, supportPageUrl } from './commands-devices.js';
14
+ import { EXIT, SETUP_COMMAND, authedClient, loadLoggedConfig, reportError, updateCliCommand, } from './commands-core.js';
15
+ import { readyCompanionDevices } from './commands-devices.js';
16
+ import { companionPlatformLabel, setupCompanionUrl, } from './setup-destinations.js';
16
17
  import { assessReadiness, remedyLine } from './commands-doctor.js';
17
18
  import { hooksInstallCommand, pickHarnessesToInstall } from './commands-hooks.js';
18
19
  import { observedCompanionReceipt, readSetupProof, setupProofProject, setupProofApplies, setupProofIsStale, writeSetupProof, } from './commands-setup-proof.js';
19
20
  import { listScopedNotifaiSkills, SKILLS_SOURCE } from './commands-skill.js';
20
21
  import { enableProject, projectBinding } from './project-enablement.js';
22
+ import { inspectCliInstallations } from './cli-bin.js';
23
+ import { installHookAdapter } from './hook-adapter.js';
21
24
  // ---------------------------------------------------------------------------
22
25
  // init
23
26
  // ---------------------------------------------------------------------------
@@ -36,8 +39,10 @@ function formatWaitBudget(milliseconds) {
36
39
  }
37
40
  async function closeGap(deps, state, flags) {
38
41
  if (state.id === 'project') {
39
- if (flags.setupScope === undefined)
40
- return 'failed';
42
+ // Naming this checkout is local bookkeeping, not skill or hook placement,
43
+ // so it does not wait on the scope question. Without an explicit answer the
44
+ // name lands in the checkout it describes, which is the answer a User who
45
+ // never sees the question would have given.
41
46
  const slug = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
42
47
  const configPath = flags.setupScope === 'global'
43
48
  ? personalProjectConfigPath(deps.cwd, deps.env)
@@ -73,13 +78,27 @@ async function closeGap(deps, state, flags) {
73
78
  }
74
79
  return ok ? 'closed' : 'failed';
75
80
  }
81
+ if (state.id === 'hooks-adapter') {
82
+ const effective = inspectCliInstallations(deps.env, deps.hookPlatform ?? process.platform).effective;
83
+ if (effective === null || effective.artifact_path === null)
84
+ return 'failed';
85
+ try {
86
+ installHookAdapter({ execPath: process.execPath, scriptPath: effective.artifact_path }, deps.hookAdapterHome, deps.hookPlatform ?? process.platform);
87
+ return 'closed';
88
+ }
89
+ catch (err) {
90
+ deps.io.err(`Could not retarget Question Routing: ${String(err)}`);
91
+ return 'failed';
92
+ }
93
+ }
76
94
  if (state.id === 'skill') {
77
95
  if (deps.nativeSkills === undefined) {
78
96
  deps.io.err('Skill installation failed — the native `npx skills` flow is unavailable.');
79
97
  return 'failed';
80
98
  }
81
- // Refuse rather than reach for a mutable ref: installing the skill from a
82
- // moving branch is the one outcome the pin exists to prevent.
99
+ // Refuse rather than guess a release identity. The production adapter uses
100
+ // this only as a human label; it verifies and stages npm's bundled skill at
101
+ // a project-relative local path before it invokes the installer.
83
102
  if (SKILLS_SOURCE === null) {
84
103
  deps.io.err('Skill installation failed — this build cannot determine its own version, so there is no release tag to install from.');
85
104
  return 'failed';
@@ -104,16 +123,18 @@ async function closeGap(deps, state, flags) {
104
123
  }
105
124
  }
106
125
  deps.io.out(`Starting the native npx skills setup for the notifai agent skill (${installScope} scope)...`);
107
- const code = await deps.nativeSkills.add({
126
+ const operation = await deps.nativeSkills.add({
108
127
  source: SKILLS_SOURCE,
109
128
  skill: 'notifai',
110
129
  cwd: deps.cwd,
111
130
  env: deps.env,
112
131
  scope: installScope,
113
132
  });
133
+ const code = typeof operation === 'number' ? operation : operation.code;
114
134
  if (code !== 0) {
115
- deps.io.err('Skill installation failed — run it manually with:');
116
- deps.io.err(` npx -y ${SKILLS_INSTALLER_SPEC} add ${SKILLS_SOURCE} --skill notifai${installScope === 'global' ? ' --global' : ''} --yes`);
135
+ deps.io.err(typeof operation === 'number'
136
+ ? 'Skill installation failed — rerun `notifai init --skills` after checking the network connection.'
137
+ : `Skill installation refused — ${operation.error}.`);
117
138
  }
118
139
  return code === 0 ? 'closed' : 'failed';
119
140
  }
@@ -133,12 +154,42 @@ function deviceBridgeMessage(devices) {
133
154
  return `Waiting for ${undecided.display_name} to allow the notification prompt…`;
134
155
  return 'Waiting for a Companion device to become ready…';
135
156
  }
157
+ /** How many Companion Apps this Account has registered, on any platform. */
158
+ async function registeredCompanions(client) {
159
+ try {
160
+ const response = await client.listDevices();
161
+ return response.devices.filter((device) => device.platform === 'ios' || device.platform === 'android').length;
162
+ }
163
+ catch {
164
+ // Unreachable here means unreachable for the wait that follows; ask, so a
165
+ // User with nothing installed still gets the platform-specific steps.
166
+ return 0;
167
+ }
168
+ }
169
+ /**
170
+ * Which phone the notifications should arrive on.
171
+ *
172
+ * Asked in the User's words, here, so the destination this terminal opens is
173
+ * already about their phone rather than an index they have to choose from
174
+ * again on the other screen. A run with no human gets nothing — it opens no
175
+ * browser and answers no question — so there is no prompt to hang on.
176
+ */
177
+ async function askCompanionPlatform(deps) {
178
+ if (deps.io.interactive !== true || deps.io.select === undefined)
179
+ return null;
180
+ const selected = await deps.io.select('Where do you want to receive notifications?', [
181
+ { value: 'iphone', label: 'iPhone' },
182
+ { value: 'android', label: 'Android' },
183
+ ]);
184
+ return selected === 'iphone' || selected === 'android' ? selected : null;
185
+ }
136
186
  /**
137
187
  * Observe the supported Device Installation path while the user finishes the
138
- * app-side work. The live bridge is the dashboard `/support` page (controlled
139
- * Companion installation steps). Interactive runs offer to open it so the user
140
- * never types the URL; non-interactive/agent paths only print plain text and
141
- * never wait on a prompt.
188
+ * app-side work. The bridge is one focused setup destination for the platform
189
+ * they just named — not the omnibus help page, whose own next step is to go
190
+ * somewhere else. Interactive runs offer to open it so the user never types
191
+ * the URL; non-interactive/agent paths only print plain text and never wait on
192
+ * a prompt.
142
193
  *
143
194
  * The wait budget is stated up front. On expiry we say the *timer* expired —
144
195
  * not the setup — and offer another budget when a human is present. Agents
@@ -156,20 +207,29 @@ async function waitForReadyDevice(deps, state) {
156
207
  return 'failed';
157
208
  }
158
209
  const budgetLabel = formatWaitBudget(DEVICE_BRIDGE_TIMEOUT_MS);
159
- const supportUrl = supportPageUrl(authed.baseUrl);
210
+ // A phone that is registered but silent needs its permission allowed, not
211
+ // install steps — and asking which phone would be asking about one they are
212
+ // already holding. Only a User with nothing registered has a platform still
213
+ // to name, and that is read from the account rather than from the wording of
214
+ // the state that got us here.
215
+ const platform = (await registeredCompanions(authed.client)) === 0
216
+ ? await askCompanionPlatform(deps)
217
+ : null;
218
+ const setupUrl = setupCompanionUrl(authed.baseUrl, platform ?? undefined);
219
+ const stepsLabel = platform === null ? 'Setup steps (no typing)' : `${companionPlatformLabel(platform)} steps (no typing)`;
160
220
  await deps.io.note?.([
161
221
  state.detail,
162
222
  remedy.summary,
163
- `Install steps (no typing): ${supportUrl}`,
164
- `I will wait up to ${budgetLabel} for a Companion device to become ready.`,
165
- ].join('\n'), 'Finish setup on your Companion device');
166
- // Open the real support page so the user never has to type the URL. Decline
167
- // is fine — the URL remains in the note and in the Next: line if they leave.
168
- if (await deps.io.confirm('Open install instructions in your browser?', true)) {
169
- deps.io.openUrl(supportUrl);
170
- }
171
- if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
172
- deps.io.out(`OK — finish device setup when you can (install steps: ${supportUrl}), then re-run \`notifai init\`.`);
223
+ `${stepsLabel}: ${setupUrl}`,
224
+ `I will wait up to ${budgetLabel} for a Companion App to become ready.`,
225
+ ].join('\n'), 'Finish setup on your phone');
226
+ // Open the destination so the user never has to type the URL. Decline is
227
+ // fine — the URL remains in the note and in the Next: line if they leave.
228
+ if (await deps.io.confirm('Open those steps in your browser?', true)) {
229
+ deps.io.openUrl(setupUrl);
230
+ }
231
+ if (!(await deps.io.confirm('Wait here while you finish that on your phone?', true))) {
232
+ deps.io.out(`OK — finish that when you can (steps: ${setupUrl}), then re-run \`notifai init\`.`);
173
233
  return 'pending';
174
234
  }
175
235
  const now = deps.now ?? Date.now;
@@ -207,30 +267,48 @@ async function waitForReadyDevice(deps, state) {
207
267
  spinner?.error(`The ${budgetLabel} wait timer expired`);
208
268
  deps.io.err(`The ${budgetLabel} wait timer expired — setup is not finished, only this wait.`);
209
269
  deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
210
- deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (install steps: ${supportUrl}).`);
270
+ deps.io.err(`Re-run \`notifai init\` later and it will pick up from here (steps: ${setupUrl}).`);
211
271
  const keepWaiting = await deps.io.confirm(`Keep waiting for another ${budgetLabel}?`, false);
212
272
  if (!keepWaiting) {
213
273
  deps.io.out('Stopping the wait. Companion setup can continue; re-run `notifai init` when ready.');
214
274
  return 'pending';
215
275
  }
216
- spinner?.message(`Waiting another ${budgetLabel} for a Companion device…`);
276
+ spinner?.message(`Waiting another ${budgetLabel} for a Companion App…`);
217
277
  deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
218
278
  }
219
279
  }
280
+ /**
281
+ * The first notification a User ever receives from Notifai.
282
+ *
283
+ * It is the payoff of the whole setup, and it used to be written as an
284
+ * internal receipt: "This real notification completed setup verification" — a
285
+ * sentence about the check rather than about them — delivered silently, at
286
+ * `passive`, with the sound turned off. So the one arrival that proves their
287
+ * agents can reach them landed in Notification Center with no banner and no
288
+ * sound, where a first-time User has no reason to look and every reason to
289
+ * conclude that nothing came.
290
+ *
291
+ * It now says what is true for them and arrives the way their notifications
292
+ * will. `done` is the honest kind — a body of work finished successfully — and
293
+ * it is also what lets the server pick the sound through its own kind, Project
294
+ * and Account layers instead of this command stamping one and skipping them.
295
+ */
220
296
  function setupProofDraft(config, device) {
221
297
  const project = config.project.value;
222
298
  return buildDraft(config, {
223
- title: 'Notifai is ready',
299
+ title: 'Your agents can reach you',
224
300
  body: project === null
225
- ? 'This real notification completed setup verification.'
226
- : `This real notification completed setup verification for ${project}.`,
227
- kind: 'update',
301
+ ? 'Notifai setup is finished. Notifications from your agents will arrive like this one.'
302
+ : `Notifai setup is finished for ${project}. Notifications from your agents will arrive like this one.`,
303
+ kind: 'done',
228
304
  platform: device.platform,
229
305
  device: [device.device_id],
230
- sound: 'none',
306
+ // Noticeable, not intrusive: a normal banner and sound. Deliberately not
307
+ // `time_sensitive` — a setup confirmation has no business breaking a Focus.
308
+ //
231
309
  // Android has no caller-selected interruption level. Explicit null keeps an
232
310
  // Apple preference in config from leaking into this Android-only proof.
233
- level: device.platform === 'ios' ? 'passive' : null,
311
+ level: device.platform === 'ios' ? 'active' : null,
234
312
  collapseKey: 'notifai-setup-verification',
235
313
  });
236
314
  }
@@ -395,7 +473,7 @@ async function runSetupProof(deps) {
395
473
  }
396
474
  /** Closing a local gap cannot have changed the service, the keychain, or devices. */
397
475
  function refreshAfterClose(id) {
398
- return id === 'project' || id === 'project-enablement' || id === 'hooks' || id === 'skill' || id === 'question-routing-settings'
476
+ return id === 'project' || id === 'project-enablement' || id === 'hooks' || id.startsWith('hooks-') || id === 'skill' || id === 'question-routing-settings'
399
477
  ? ['local']
400
478
  : undefined;
401
479
  }
@@ -412,7 +490,12 @@ function wantsOptional(deps, state, flags) {
412
490
  return Promise.resolve(true);
413
491
  if (state.id === 'project-enablement')
414
492
  return Promise.resolve(true);
415
- const explicit = state.id === 'hooks' ? flags.hooks : state.id === 'skill' ? flags.skills : undefined;
493
+ // Every other hook sub-state is a report line about routing, not an errand
494
+ // with a yes/no question of its own; offering the hooks question for one of
495
+ // them would ask about something the answer does not change.
496
+ if (state.id !== 'hooks' && state.id !== 'skill')
497
+ return Promise.resolve(false);
498
+ const explicit = state.id === 'hooks' ? flags.hooks : flags.skills;
416
499
  if (explicit !== undefined)
417
500
  return Promise.resolve(explicit);
418
501
  // An agent is never asked, and never assumed into a change it did not
@@ -424,26 +507,19 @@ function wantsOptional(deps, state, flags) {
424
507
  : 'Install/update the agent guidance skill through the native npx skills flow?';
425
508
  return deps.io.confirm(question, true);
426
509
  }
427
- /**
428
- * Setup as one step at a time.
429
- *
430
- * The old version ran five steps in a fixed order and ended with a list of
431
- * everything still outstanding. That is a report, and a report is the wrong
432
- * output here: someone handed five things to do does none of them, and the
433
- * order was the script's rather than the dependency graph's — it offered to
434
- * install hooks after a sign-in that had just failed.
435
- *
436
- * So this closes what it can, then surfaces exactly one thing, the first that
437
- * stands in the way. Re-running advances by one. Idempotence stops being a
438
- * property to preserve and becomes the mechanism: every decision is derived
439
- * from observed state, so a partial run, a second project, a fresh worktree
440
- * and a revoked credential are the same code path arriving at different
441
- * states rather than four branches to enumerate.
442
- */
510
+ /** The two states whose remedy actually places files the scope question is about. */
511
+ function needsInstallScope(state) {
512
+ return state.id === 'hooks' || state.id === 'skill';
513
+ }
443
514
  function isSetupScope(value) {
444
515
  return value === 'project' || value === 'global';
445
516
  }
446
- async function resolveSetupScope(deps, flags) {
517
+ /**
518
+ * Scope flags are checked before anything runs, because a contradiction is a
519
+ * usage error and reporting it after half a setup is worse than reporting it
520
+ * now. Answering the question is a separate step (`scopeForLocalInstall`).
521
+ */
522
+ function checkSetupScopeFlags(deps, flags) {
447
523
  if (flags.setupScope !== undefined && !isSetupScope(flags.setupScope)) {
448
524
  deps.io.err('Invalid setup scope. Choose `project` or `global`.');
449
525
  return 'usage';
@@ -462,11 +538,35 @@ async function resolveSetupScope(deps, flags) {
462
538
  deps.io.err('`--skills-scope` requires `--skills`. Use `--setup-scope` to choose project or machine for the whole setup.');
463
539
  return 'usage';
464
540
  }
541
+ const fromFlags = flags.setupScope ?? flags.skillsScope;
542
+ // Asking for an install nobody can be asked about is a usage error, and it
543
+ // is knowable from the flags alone — so it is reported before a setup runs
544
+ // half way rather than after.
545
+ if (fromFlags === undefined &&
546
+ deps.io.interactive !== true &&
547
+ (flags.skills === true || flags.hooks === true)) {
548
+ deps.io.err('Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.');
549
+ return 'usage';
550
+ }
551
+ return fromFlags;
552
+ }
553
+ /**
554
+ * Where a skill or hook install should land, asked at the moment one is about
555
+ * to happen and not before.
556
+ *
557
+ * It used to be the first thing the product ever said, in front of the
558
+ * assessment, sign-in and every other gate — a question about skill, hook and
559
+ * shared-config placement put to someone who had met none of those words and
560
+ * might never reach the step it decides. Worse, project identity and Project
561
+ * Enablement were gated on the answer, so an unattended run that could not
562
+ * produce one advanced nothing at all.
563
+ *
564
+ * Now nothing depends on it except the two installs it actually describes.
565
+ */
566
+ async function scopeForLocalInstall(deps, flags) {
465
567
  const fromFlags = flags.setupScope ?? flags.skillsScope;
466
568
  if (fromFlags !== undefined)
467
569
  return fromFlags;
468
- if (flags.skills === false && flags.hooks === false)
469
- return undefined;
470
570
  if (deps.io.interactive === true && deps.io.select) {
471
571
  const selected = await deps.io.select('Should this Notifai setup apply to this project only, or to every project on this machine?', [
472
572
  {
@@ -483,14 +583,27 @@ async function resolveSetupScope(deps, flags) {
483
583
  if (selected === 'project' || selected === 'global')
484
584
  return selected;
485
585
  deps.io.err('No setup scope selected. Pass `--setup-scope project` or `--setup-scope global`.');
486
- return 'usage';
487
- }
488
- if (flags.skills === true || flags.hooks === true) {
489
- deps.io.err('Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.');
490
- return 'usage';
586
+ return 'declined';
491
587
  }
492
- return undefined;
588
+ deps.io.err('Unattended setup changes require an explicit scope: pass `--setup-scope project` or `--setup-scope global`.');
589
+ return 'declined';
493
590
  }
591
+ /**
592
+ * Setup as one step at a time.
593
+ *
594
+ * The old version ran five steps in a fixed order and ended with a list of
595
+ * everything still outstanding. That is a report, and a report is the wrong
596
+ * output here: someone handed five things to do does none of them, and the
597
+ * order was the script's rather than the dependency graph's — it offered to
598
+ * install hooks after a sign-in that had just failed.
599
+ *
600
+ * So this closes what it can, then surfaces exactly one thing, the first that
601
+ * stands in the way. Re-running advances by one. Idempotence stops being a
602
+ * property to preserve and becomes the mechanism: every decision is derived
603
+ * from observed state, so a partial run, a second project, a fresh worktree
604
+ * and a revoked credential are the same code path arriving at different
605
+ * states rather than four branches to enumerate.
606
+ */
494
607
  export async function initCommand(deps, flags) {
495
608
  const workingDeps = flags.json === true
496
609
  ? {
@@ -504,12 +617,14 @@ export async function initCommand(deps, flags) {
504
617
  },
505
618
  }
506
619
  : deps;
507
- const setupScope = await resolveSetupScope(workingDeps, flags);
508
- if (setupScope === 'usage')
620
+ const scopeFromFlags = checkSetupScopeFlags(workingDeps, flags);
621
+ if (scopeFromFlags === 'usage')
509
622
  return EXIT.usage;
510
- const resolved = {
623
+ let resolved = {
511
624
  ...flags,
512
- ...(setupScope === undefined ? {} : { setupScope, skillsScope: flags.skillsScope ?? setupScope }),
625
+ ...(scopeFromFlags === undefined
626
+ ? {}
627
+ : { setupScope: scopeFromFlags, skillsScope: flags.skillsScope ?? scopeFromFlags }),
513
628
  };
514
629
  await workingDeps.io.intro?.('Notifai setup');
515
630
  const skillOpts = resolved.setupScope === undefined ? {} : { skillScope: resolved.setupScope };
@@ -520,6 +635,11 @@ export async function initCommand(deps, flags) {
520
635
  ...(refresh === undefined ? {} : { previous: readiness, refresh }),
521
636
  });
522
637
  let failed = false;
638
+ // What actually stopped a sign-in, when something did. Without it the close
639
+ // renders the state as it was before the attempt — "not paired … run
640
+ // `notifai init`" — which contradicts the correct line printed moments
641
+ // earlier and sends the reader back into the command that just failed.
642
+ let loginBlocker = null;
523
643
  const attempted = new Set();
524
644
  // Re-assess after every successful action. This is how a browser approval or
525
645
  // companion registration can unlock the next state while the user is still
@@ -530,24 +650,37 @@ export async function initCommand(deps, flags) {
530
650
  for (const state of readiness.states) {
531
651
  if (state.status === 'ready')
532
652
  continue;
653
+ // Question Routing is optional automation, so it is reported and never
654
+ // waited on. `doctor` still fails on a broken route; `init` walks past
655
+ // it, because a hook diagnostic standing in front of the delivery proof
656
+ // means a setup that can already send never proves that it can.
657
+ const optional = isOptionalSetup(state.id);
658
+ const halt = () => {
659
+ if (!optional)
660
+ stop = true;
661
+ return !optional;
662
+ };
533
663
  if (state.status === 'unknown') {
534
- stop = true;
535
- break;
664
+ if (halt())
665
+ break;
666
+ continue;
536
667
  }
537
668
  const remedy = state.remedy;
538
669
  if (remedy === undefined || attempted.has(state.id)) {
539
- if (state.status === 'gap')
540
- stop = true;
541
- if (stop)
670
+ if (state.status === 'gap' && halt())
542
671
  break;
543
672
  continue;
544
673
  }
545
674
  if (state.status === 'optional-gap') {
546
675
  if (remedy.by !== 'cli' || !(await wantsOptional(workingDeps, state, resolved)))
547
676
  continue;
548
- if (resolved.setupScope === undefined && state.id === 'project') {
549
- stop = true;
550
- break;
677
+ if (needsInstallScope(state)) {
678
+ const scope = await scopeForLocalInstall(workingDeps, resolved);
679
+ if (scope === 'declined') {
680
+ attempted.add(state.id);
681
+ continue;
682
+ }
683
+ resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope };
551
684
  }
552
685
  attempted.add(state.id);
553
686
  const result = await closeGap(workingDeps, state, resolved);
@@ -559,25 +692,33 @@ export async function initCommand(deps, flags) {
559
692
  break;
560
693
  }
561
694
  if (result !== 'closed') {
562
- stop = true;
563
- break;
695
+ if (halt())
696
+ break;
697
+ continue;
564
698
  }
565
699
  readiness = await reassess(refreshAfterClose(state.id));
566
700
  advanced = true;
567
701
  break;
568
702
  }
569
703
  if (remedy.by === 'cli') {
570
- if (resolved.setupScope === undefined && state.id === 'project') {
571
- stop = true;
572
- break;
704
+ if (needsInstallScope(state)) {
705
+ const scope = await scopeForLocalInstall(workingDeps, resolved);
706
+ if (scope === 'declined') {
707
+ attempted.add(state.id);
708
+ if (halt())
709
+ break;
710
+ continue;
711
+ }
712
+ resolved = { ...resolved, setupScope: scope, skillsScope: resolved.skillsScope ?? scope };
573
713
  }
574
714
  attempted.add(state.id);
575
715
  const result = await closeGap(workingDeps, state, resolved);
576
716
  if (result === 'failed')
577
717
  failed = true;
578
718
  if (result !== 'closed') {
579
- stop = true;
580
- break;
719
+ if (halt())
720
+ break;
721
+ continue;
581
722
  }
582
723
  readiness = await reassess(refreshAfterClose(state.id));
583
724
  advanced = true;
@@ -590,7 +731,7 @@ export async function initCommand(deps, flags) {
590
731
  workingDeps.io.interactive === true) {
591
732
  attempted.add(state.id);
592
733
  workingDeps.io.out('Opening your browser to approve this machine — Ctrl-C to stop.');
593
- if ((await loginCommand(workingDeps, {})) !== EXIT.ok) {
734
+ if ((await loginCommand(workingDeps, {}, (blocker) => (loginBlocker = blocker))) !== EXIT.ok) {
594
735
  failed = true;
595
736
  stop = true;
596
737
  break;
@@ -615,50 +756,68 @@ export async function initCommand(deps, flags) {
615
756
  break;
616
757
  }
617
758
  // A human-only remedy is the first blocker for an unattended agent.
618
- stop = true;
619
- break;
759
+ if (halt())
760
+ break;
761
+ continue;
620
762
  }
621
763
  if (stop || !advanced)
622
764
  break;
623
765
  }
766
+ const blocker = loginBlocker ?? firstRequiredBlocker(readiness);
624
767
  if (flags.json === true)
625
768
  deps.io.out(JSON.stringify(readinessJson(readiness), null, 2));
626
769
  else
627
- await printInitClose(deps, readiness, resolved);
628
- const blocker = firstBlocker(readiness);
770
+ await printInitClose(deps, readiness, resolved, loginBlocker);
629
771
  if (blocker === null)
630
772
  return failed ? EXIT.failed : EXIT.ok;
631
773
  return failed || workingDeps.io.interactive !== true ? EXIT.failed : EXIT.ok;
632
774
  }
633
- function isHookSubstate(id) {
634
- return id === 'hooks' || id.startsWith('hooks-') || id === 'question-routing-settings';
635
- }
636
775
  function leftoverOptionals(readiness, flags) {
637
776
  return readiness.states.filter((state) => {
638
777
  if (state.status !== 'optional-gap')
639
778
  return false;
640
- if (isHookSubstate(state.id))
779
+ if (isOptionalAutomation(state.id))
780
+ return false;
781
+ // Not an install anyone declined, so "Optional, not set up" would misread
782
+ // it; it gets its own line below.
783
+ if (state.id === 'cli-bin')
641
784
  return false;
642
785
  if (state.id === 'skill' && flags.skills === false)
643
786
  return false;
644
787
  return true;
645
788
  });
646
789
  }
790
+ /**
791
+ * A `notifai` the reader cannot type.
792
+ *
793
+ * Said on the way out of every run, successful or not, because every next step
794
+ * this command prints — here, in the skill, in the README — names a command
795
+ * that will not be found until it is fixed.
796
+ */
797
+ function printMissingCliBin(deps, readiness) {
798
+ const state = readiness.states.find((candidate) => candidate.id === 'cli-bin');
799
+ if (state?.status !== 'optional-gap')
800
+ return;
801
+ deps.io.out(`Heads up: ${state.detail}. ${remedyLine(state)}`);
802
+ }
647
803
  function printOptionalLeftovers(deps, leftovers) {
648
804
  for (const state of leftovers) {
649
805
  deps.io.out(`Optional, not set up — ${remedyLine(state)}`);
650
806
  }
651
807
  }
652
- async function printInitClose(deps, readiness, flags) {
653
- const blocker = firstBlocker(readiness);
808
+ async function printInitClose(deps, readiness, flags, stoppedBy = null) {
809
+ const blocker = stoppedBy ?? firstRequiredBlocker(readiness);
654
810
  const canSend = readiness.states.find((state) => state.id === 'devices')?.status === 'ready';
655
811
  const questions = questionRoutingReady(readiness);
656
812
  const leftovers = leftoverOptionals(readiness, flags).filter((state) => state.id !== 'contract');
657
813
  if (blocker?.id === 'contract') {
658
814
  deps.io.out(blocker.detail);
659
- deps.io.out(UPDATE_CLI_COMMAND);
815
+ deps.io.out(blocker.remedy?.by !== 'user-elsewhere' && blocker.remedy?.command !== undefined
816
+ ? blocker.remedy.command
817
+ : updateCliCommand(deps));
660
818
  return;
661
819
  }
820
+ printMissingCliBin(deps, readiness);
662
821
  if (deps.io.interactive === true) {
663
822
  if (blocker === null) {
664
823
  const lines = [
@@ -675,7 +834,9 @@ async function printInitClose(deps, readiness, flags) {
675
834
  await deps.io.outro?.('All set ✨');
676
835
  return;
677
836
  }
678
- await deps.io.note?.(`${blocker.title} — ${blocker.detail}\n${remedyLine(blocker)}`, 'Next');
837
+ // No framed copy of what the next three lines already say. The "Ready"
838
+ // note above adds something the close does not; this one only made a
839
+ // stopped visit state its one step twice, box then text.
679
840
  deps.io.out(`Next: ${blocker.title} — ${blocker.detail}`);
680
841
  deps.io.out(` ${remedyLine(blocker)}`);
681
842
  if (shouldRestateInit(blocker)) {