@raidiant/notifai 7.0.2 → 9.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 (157) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +20 -13
  3. package/dist/atomic-file.d.ts +14 -0
  4. package/dist/atomic-file.js +27 -2
  5. package/dist/atomic-file.js.map +1 -1
  6. package/dist/cli-bin.d.ts +4 -0
  7. package/dist/cli-bin.js +62 -0
  8. package/dist/cli-bin.js.map +1 -0
  9. package/dist/cli-release.d.ts +10 -0
  10. package/dist/cli-release.js +75 -0
  11. package/dist/cli-release.js.map +1 -0
  12. package/dist/client.d.ts +1 -0
  13. package/dist/client.js +13 -3
  14. package/dist/client.js.map +1 -1
  15. package/dist/commands-auth.d.ts +6 -0
  16. package/dist/commands-auth.js +28 -3
  17. package/dist/commands-auth.js.map +1 -1
  18. package/dist/commands-config.js +22 -3
  19. package/dist/commands-config.js.map +1 -1
  20. package/dist/commands-core.d.ts +12 -0
  21. package/dist/commands-core.js +27 -3
  22. package/dist/commands-core.js.map +1 -1
  23. package/dist/commands-devices.d.ts +2 -1
  24. package/dist/commands-devices.js +20 -8
  25. package/dist/commands-devices.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +85 -27
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +27 -0
  30. package/dist/commands-guidance.js +163 -0
  31. package/dist/commands-guidance.js.map +1 -0
  32. package/dist/commands-hooks.d.ts +16 -5
  33. package/dist/commands-hooks.js +433 -38
  34. package/dist/commands-hooks.js.map +1 -1
  35. package/dist/commands-init.d.ts +10 -18
  36. package/dist/commands-init.js +139 -54
  37. package/dist/commands-init.js.map +1 -1
  38. package/dist/commands-send-support.d.ts +17 -2
  39. package/dist/commands-send-support.js +66 -8
  40. package/dist/commands-send-support.js.map +1 -1
  41. package/dist/commands-send.d.ts +0 -1
  42. package/dist/commands-send.js +105 -39
  43. package/dist/commands-send.js.map +1 -1
  44. package/dist/commands-setup-proof.d.ts +8 -0
  45. package/dist/commands-setup-proof.js +31 -2
  46. package/dist/commands-setup-proof.js.map +1 -1
  47. package/dist/commands-skill.d.ts +11 -1
  48. package/dist/commands-skill.js +61 -11
  49. package/dist/commands-skill.js.map +1 -1
  50. package/dist/commands.d.ts +2 -1
  51. package/dist/commands.js +1 -0
  52. package/dist/commands.js.map +1 -1
  53. package/dist/config-schema.d.ts +1 -1
  54. package/dist/config-schema.js +30 -14
  55. package/dist/config-schema.js.map +1 -1
  56. package/dist/config.d.ts +24 -5
  57. package/dist/config.js +37 -8
  58. package/dist/config.js.map +1 -1
  59. package/dist/credentials.d.ts +10 -1
  60. package/dist/credentials.js +25 -12
  61. package/dist/credentials.js.map +1 -1
  62. package/dist/guidance-content.d.ts +50 -0
  63. package/dist/guidance-content.js +240 -0
  64. package/dist/guidance-content.js.map +1 -0
  65. package/dist/guidance.d.ts +69 -0
  66. package/dist/guidance.js +173 -0
  67. package/dist/guidance.js.map +1 -0
  68. package/dist/hook-adapter.js +37 -3
  69. package/dist/hook-adapter.js.map +1 -1
  70. package/dist/hooks.d.ts +68 -7
  71. package/dist/hooks.js +238 -92
  72. package/dist/hooks.js.map +1 -1
  73. package/dist/install-hooks.d.ts +10 -18
  74. package/dist/install-hooks.js +110 -23
  75. package/dist/install-hooks.js.map +1 -1
  76. package/dist/interactive.d.ts +1 -1
  77. package/dist/interactive.js +14 -9
  78. package/dist/interactive.js.map +1 -1
  79. package/dist/logging.d.ts +8 -1
  80. package/dist/logging.js +43 -2
  81. package/dist/logging.js.map +1 -1
  82. package/dist/main.js +5 -487
  83. package/dist/main.js.map +1 -1
  84. package/dist/native-skills.d.ts +19 -0
  85. package/dist/native-skills.js +30 -8
  86. package/dist/native-skills.js.map +1 -1
  87. package/dist/opencode-plugin.js +14 -1
  88. package/dist/opencode-plugin.js.map +1 -1
  89. package/dist/program.d.ts +49 -0
  90. package/dist/program.js +576 -0
  91. package/dist/program.js.map +1 -0
  92. package/dist/send.d.ts +12 -10
  93. package/dist/send.js +65 -29
  94. package/dist/send.js.map +1 -1
  95. package/dist/session-activation.d.ts +15 -0
  96. package/dist/session-activation.js +35 -0
  97. package/dist/session-activation.js.map +1 -0
  98. package/dist/session-labels.d.ts +9 -3
  99. package/dist/session-labels.js +38 -7
  100. package/dist/session-labels.js.map +1 -1
  101. package/dist/url-policy.d.ts +85 -0
  102. package/dist/url-policy.js +369 -0
  103. package/dist/url-policy.js.map +1 -0
  104. package/package.json +6 -4
  105. package/src/atomic-file.ts +36 -2
  106. package/src/cli-bin.test.ts +38 -0
  107. package/src/cli-bin.ts +67 -0
  108. package/src/cli-release.test.ts +46 -0
  109. package/src/cli-release.ts +78 -0
  110. package/src/client.test.ts +95 -0
  111. package/src/client.ts +16 -3
  112. package/src/commands-auth.ts +32 -3
  113. package/src/commands-config.ts +25 -2
  114. package/src/commands-core.ts +38 -3
  115. package/src/commands-devices.ts +28 -9
  116. package/src/commands-doctor.ts +116 -25
  117. package/src/commands-guidance.ts +216 -0
  118. package/src/commands-hooks.ts +513 -42
  119. package/src/commands-init.ts +170 -60
  120. package/src/commands-send-support.test.ts +156 -0
  121. package/src/commands-send-support.ts +72 -8
  122. package/src/commands-send.ts +123 -38
  123. package/src/commands-setup-proof.ts +36 -2
  124. package/src/commands-skill.ts +78 -15
  125. package/src/commands.test.ts +1490 -197
  126. package/src/commands.ts +6 -0
  127. package/src/config-schema.ts +32 -16
  128. package/src/config.test.ts +57 -2
  129. package/src/config.ts +49 -10
  130. package/src/credentials.test.ts +71 -3
  131. package/src/credentials.ts +29 -15
  132. package/src/guidance-content.ts +256 -0
  133. package/src/guidance.test.ts +299 -0
  134. package/src/guidance.ts +233 -0
  135. package/src/hook-adapter.test.ts +113 -1
  136. package/src/hook-adapter.ts +37 -3
  137. package/src/hooks.test.ts +783 -170
  138. package/src/hooks.ts +306 -131
  139. package/src/install-hooks.test.ts +135 -16
  140. package/src/install-hooks.ts +113 -25
  141. package/src/interactive.ts +14 -9
  142. package/src/logging.test.ts +66 -3
  143. package/src/logging.ts +45 -2
  144. package/src/main.ts +6 -614
  145. package/src/native-skills.test.ts +41 -1
  146. package/src/native-skills.ts +40 -6
  147. package/src/opencode-plugin.ts +14 -1
  148. package/src/program.test.ts +237 -0
  149. package/src/program.ts +751 -0
  150. package/src/send.test.ts +28 -0
  151. package/src/send.ts +81 -34
  152. package/src/session-activation.ts +45 -0
  153. package/src/session-labels.test.ts +120 -3
  154. package/src/session-labels.ts +38 -7
  155. package/src/skill.test.ts +153 -12
  156. package/src/trust-policy.test.ts +701 -0
  157. package/src/url-policy.ts +399 -0
@@ -40,15 +40,17 @@ import {
40
40
  } from './readiness.js'
41
41
  import { packageVersion } from './release.js'
42
42
  import type { Tone } from './ui/theme.js'
43
+ import type { MachineCredential } from './credentials.js'
43
44
  import {
44
45
  EXIT,
45
46
  UPDATE_CLI_COMMAND,
47
+ diagnoseIgnoredOriginOverride,
46
48
  loadLoggedConfig,
47
49
  makeClient,
48
50
  resolvedBaseUrl,
49
51
  type CommandDeps,
50
52
  } from './commands-core.js'
51
- import { deviceInstallRemedy, readyIosDevices, supportPageUrl } from './commands-devices.js'
53
+ import { deviceInstallRemedy, readyCompanionDevices, supportPageUrl } from './commands-devices.js'
52
54
  import {
53
55
  activeHarnessSession,
54
56
  claudeSessionPid,
@@ -56,8 +58,25 @@ import {
56
58
  type ActiveHarnessSession,
57
59
  } from './commands-harness-context.js'
58
60
  import { stopShapeProblems } from './commands-hook-shape.js'
59
- import { HOOK_EVENTS, activeQuestionRouteProblems, hookActivationAdvice } from './commands-hooks.js'
60
- import { observedCompanionReceipt, readSetupProof } from './commands-setup-proof.js'
61
+ import {
62
+ HOOK_EVENTS,
63
+ activeQuestionRouteProblems,
64
+ hookActivationAdvice,
65
+ requiredHookEvents,
66
+ } from './commands-hooks.js'
67
+ import { cliBinReadiness } from './cli-bin.js'
68
+ import {
69
+ latestPublishedCliVersion,
70
+ newerPublishedCli,
71
+ shouldConsultCliRegistry,
72
+ thisCliVersion,
73
+ } from './cli-release.js'
74
+ import {
75
+ SETUP_PROOF_STALE_MS,
76
+ observedCompanionReceipt,
77
+ readSetupProof,
78
+ setupProofIsStale,
79
+ } from './commands-setup-proof.js'
61
80
  import { skillReadiness } from './commands-skill.js'
62
81
 
63
82
  // ---------------------------------------------------------------------------
@@ -236,10 +255,14 @@ function remoteStatesFrom(previous: Readiness): {
236
255
  return { credential, server, contract, auth, devices, proof }
237
256
  }
238
257
 
239
- function remoteInvalidatedByConfig(previous: Readiness, config: CliConfig): boolean {
240
- if (config.base_url.source === 'default') return false
258
+ function remoteInvalidatedByConfig(
259
+ previous: Readiness,
260
+ config: CliConfig,
261
+ credential: MachineCredential | null,
262
+ ): boolean {
263
+ const origin = resolvedBaseUrl(config, credential)
241
264
  const server = previous.states.find((state) => state.id === 'server')
242
- return server === undefined || !server.detail.includes(config.base_url.value)
265
+ return server === undefined || !server.detail.includes(origin)
243
266
  }
244
267
 
245
268
  /**
@@ -257,9 +280,12 @@ export async function assessReadiness(
257
280
  skillScope?: SkillScope
258
281
  previous?: Readiness
259
282
  refresh?: readonly ReadinessRefresh[]
283
+ json?: boolean
260
284
  } = {},
261
285
  ): Promise<Readiness> {
262
286
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
287
+ const credential = deps.store.load()
288
+ if (credential) diagnoseIgnoredOriginOverride(deps.io, config, credential)
263
289
  const previous = options.previous
264
290
  const refresh = options.refresh
265
291
  if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
@@ -268,12 +294,13 @@ export async function assessReadiness(
268
294
  previous !== undefined &&
269
295
  refresh !== undefined &&
270
296
  !refresh.includes('remote') &&
271
- !remoteInvalidatedByConfig(previous, config)
297
+ !remoteInvalidatedByConfig(previous, config, credential)
272
298
  if (reuseRemote) {
273
299
  const reused = remoteStatesFrom(previous)
274
300
  if (reused !== null) {
275
301
  return {
276
302
  states: [
303
+ cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform),
277
304
  projectReadiness(deps, config),
278
305
  reused.credential,
279
306
  reused.server,
@@ -292,9 +319,9 @@ export async function assessReadiness(
292
319
  let accountClient: ApiClient | null = null
293
320
  let accountDevices: RoutableDevice[] | null = null
294
321
 
322
+ states.push(cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform))
295
323
  states.push(projectReadiness(deps, config))
296
324
 
297
- const credential = deps.store.load()
298
325
  states.push(
299
326
  credential
300
327
  ? {
@@ -418,15 +445,17 @@ export async function assessReadiness(
418
445
  states.push({ id: 'devices', title: 'Your devices', status: 'unknown', detail: 'not checked — sign-in failed' })
419
446
  } else {
420
447
  const devices = accountDevices
421
- const iphoneDevices = devices.filter((device) => device.platform === 'ios')
422
- const ready = readyIosDevices(iphoneDevices)
448
+ const companionDevices = devices.filter(
449
+ (device) => device.platform === 'ios' || device.platform === 'android',
450
+ )
451
+ const ready = readyCompanionDevices(companionDevices)
423
452
  states.push(
424
453
  ready.length > 0
425
454
  ? {
426
455
  id: 'devices',
427
456
  title: 'Your devices',
428
457
  status: 'ready',
429
- detail: `${ready.map((d) => d.display_name).join(', ')} ready to receive`,
458
+ detail: `${ready.map((d) => `${d.display_name} (${d.platform})`).join(', ')} ready to receive`,
430
459
  }
431
460
  : {
432
461
  id: 'devices',
@@ -439,15 +468,15 @@ export async function assessReadiness(
439
468
  // permission prompt. The live bridge is /support on the
440
469
  // dashboard origin — not a placeholder, and not typed by hand.
441
470
  detail:
442
- iphoneDevices.length === 0
443
- ? `no iPhone registered yet; install Notifai on iPhone via ${supportPageUrl(baseUrl)}`
444
- : `${iphoneDevices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
471
+ companionDevices.length === 0
472
+ ? `no active Companion device registered yet; install Notifai via ${supportPageUrl(baseUrl)}`
473
+ : `${companionDevices.map((d) => `${d.display_name} (${d.platform}, ${d.permission_status})`).join(', ')} — registered but not able to receive`,
445
474
  remedy: {
446
475
  by: 'user-elsewhere',
447
476
  summary: deviceInstallRemedy({
448
477
  baseUrl,
449
478
  email: accountEmail,
450
- devices: iphoneDevices,
479
+ devices: companionDevices,
451
480
  }),
452
481
  },
453
482
  },
@@ -455,10 +484,39 @@ export async function assessReadiness(
455
484
  }
456
485
 
457
486
  states.push(await setupProofState(deps, config, accountClient, accountDevices))
487
+ await applyRegistryRecommendation(deps, options.json === true, states)
458
488
 
459
489
  return { states }
460
490
  }
461
491
 
492
+ async function applyRegistryRecommendation(
493
+ deps: CommandDeps,
494
+ json: boolean,
495
+ states: ReadinessState[],
496
+ ): Promise<void> {
497
+ if (
498
+ !shouldConsultCliRegistry({
499
+ ...(deps.io.interactive === undefined ? {} : { interactive: deps.io.interactive }),
500
+ json,
501
+ env: deps.env,
502
+ })
503
+ ) {
504
+ return
505
+ }
506
+ const latest = await latestPublishedCliVersion(deps.fetchImpl)
507
+ const newer = newerPublishedCli(thisCliVersion(), latest)
508
+ if (newer === null) return
509
+ const contract = states.find((state) => state.id === 'contract')
510
+ if (contract === undefined || contract.status === 'gap') return
511
+ contract.status = 'optional-gap'
512
+ contract.detail = 'A newer Notifai is available.'
513
+ contract.remedy = {
514
+ by: 'user-here',
515
+ summary: 'update Notifai',
516
+ command: UPDATE_CLI_COMMAND,
517
+ }
518
+ }
519
+
462
520
  async function setupProofState(
463
521
  deps: CommandDeps,
464
522
  config: CliConfig,
@@ -474,18 +532,21 @@ async function setupProofState(
474
532
  }
475
533
  }
476
534
 
477
- const ios = readyIosDevices(devices)
478
- if (ios.length === 0) {
535
+ const companions = readyCompanionDevices(devices)
536
+ if (companions.length === 0) {
479
537
  return {
480
538
  id: 'proof',
481
539
  title: 'Delivery proof',
482
540
  status: 'unknown',
483
- detail: 'not checked — no iPhone is ready',
541
+ detail: 'not checked — no iPhone or Android Companion App is ready',
484
542
  }
485
543
  }
486
544
 
487
545
  const proof = readSetupProof(deps)
488
- const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id)
546
+ const target =
547
+ proof === null
548
+ ? null
549
+ : companions.find((device) => device.device_id === proof.device_id)
489
550
  if (proof === null || proof.project !== config.project.value || target === undefined) {
490
551
  return {
491
552
  id: 'proof',
@@ -513,6 +574,20 @@ async function setupProofState(
513
574
  detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
514
575
  }
515
576
  }
577
+ const now = (deps.now ?? Date.now)()
578
+ if (setupProofIsStale(proof, now)) {
579
+ return {
580
+ id: 'proof',
581
+ title: 'Delivery proof',
582
+ status: 'gap',
583
+ detail: `${proof.request_id} is older than ${SETUP_PROOF_STALE_MS / 36e5}h with no Companion Receipt; init will send a replacement`,
584
+ remedy: {
585
+ by: 'cli',
586
+ summary: 'replace the stale verification notification',
587
+ command: 'notifai init',
588
+ },
589
+ }
590
+ }
516
591
  return {
517
592
  id: 'proof',
518
593
  title: 'Delivery proof',
@@ -544,7 +619,8 @@ export async function doctorCommand(
544
619
  flags: { json?: boolean },
545
620
  options: { readiness?: Readiness } = {},
546
621
  ): Promise<number> {
547
- const readiness = options.readiness ?? (await assessReadiness(deps))
622
+ const readiness =
623
+ options.readiness ?? (await assessReadiness(deps, flags.json === true ? { json: true } : {}))
548
624
  const blocker = firstBlocker(readiness)
549
625
  const ok = blocker === null
550
626
 
@@ -874,13 +950,25 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
874
950
  })
875
951
  .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`),
876
952
  )
953
+ const missing = installations.flatMap((installation) => {
954
+ const required = requiredHookEvents(installation.harness)
955
+ const installed = new Set(
956
+ installation.handlers
957
+ .map((handler) => handlerEvent(handler.command))
958
+ .filter((event): event is string => event !== null),
959
+ )
960
+ const absent = required.filter((event) => !installed.has(event))
961
+ return absent.length === 0
962
+ ? []
963
+ : [`${installation.file} is missing ${absent.join(', ')}`]
964
+ })
877
965
  checks.push({
878
966
  name: 'hooks (stale)',
879
- ok: stale.length === 0,
967
+ ok: stale.length === 0 && missing.length === 0,
880
968
  detail:
881
- stale.length === 0
882
- ? 'every installed handler names an event this build serves'
883
- : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
969
+ stale.length === 0 && missing.length === 0
970
+ ? 'every installed handler names an event this build serves, and every required lifecycle handler is present'
971
+ : `${[...stale, ...missing].join('; ')} — rerun \`notifai hooks install\` to refresh the complete lifecycle set`,
884
972
  })
885
973
 
886
974
  const adapterProblems = installations.flatMap((installation) =>
@@ -935,7 +1023,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
935
1023
  // Project and global definitions for one harness both fire. Stable adapter
936
1024
  // identity deliberately makes their command bytes equal, so comparing
937
1025
  // command targets would now hide this duplicate rather than diagnose it.
938
- // Different harnesses remain independent: only the active one runs.
1026
+ // Cursor is the exception to harness independence because it may also load
1027
+ // Claude's user hook file. The shared adapter detects Cursor's hook-only
1028
+ // environment and makes that compatibility copy a no-op; the native Cursor
1029
+ // definition remains the single routing owner.
939
1030
  const duplicated = [...new Set(installations.map((i) => i.harness))]
940
1031
  .map((harness) => ({
941
1032
  harness,
@@ -0,0 +1,216 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { describeSource } from './config-schema.js'
4
+ import { EXIT, type CommandDeps } from './commands-core.js'
5
+ import {
6
+ GUIDANCE_TRUST_PREAMBLE,
7
+ SHIPPED_GUIDANCE,
8
+ shippedGuidanceTopic,
9
+ } from './guidance-content.js'
10
+ import {
11
+ GUIDANCE_TOPIC_MAX_BYTES,
12
+ GUIDANCE_TOPIC_PATTERN,
13
+ findProjectGuidanceDir,
14
+ globalGuidanceDir,
15
+ personalProjectGuidanceDir,
16
+ resolveGuidance,
17
+ type GuidanceAuthority,
18
+ } from './guidance.js'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // guidance show / set / unset
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** How each authority is named in the marker above a topic. */
25
+ const AUTHORITY_LABEL: Record<GuidanceAuthority, string> = {
26
+ user: 'you',
27
+ repository: 'this repository',
28
+ shipped: 'shipped default',
29
+ }
30
+
31
+ /**
32
+ * The token that makes a marker line ours.
33
+ *
34
+ * Without it, a repository topic could open with a handwritten
35
+ * `<!-- when-to-notify · this machine · /Users/... -->` and everything after
36
+ * it would read as the User's own standing word. The marker therefore carries
37
+ * a fixed token, and any occurrence of that token inside file content is
38
+ * defanged below — so the only lines that can claim to be provenance are the
39
+ * ones this command wrote.
40
+ */
41
+ const MARKER_TOKEN = 'notifai:guidance'
42
+
43
+ function markerSafe(content: string): string {
44
+ return content.replaceAll(MARKER_TOKEN, `${MARKER_TOKEN.replace(':', '-')} [not a provenance marker]`)
45
+ }
46
+
47
+ /**
48
+ * The whole effective guidance, ready to be followed as read.
49
+ *
50
+ * The trust preamble comes first and belongs to the CLI: it says who supplied
51
+ * each topic and what no topic may ask for. Each topic then follows its own
52
+ * marker, so a reader can tell the User's standing word from a repository's
53
+ * house rules from the shipped default without the output stopping being
54
+ * Markdown.
55
+ */
56
+ export function guidanceShowCommand(deps: CommandDeps, flags: { json?: boolean }): number {
57
+ const topics = resolveGuidance({ cwd: deps.cwd, env: deps.env })
58
+ if (flags.json) {
59
+ deps.io.out(
60
+ JSON.stringify(
61
+ {
62
+ trust: GUIDANCE_TRUST_PREAMBLE,
63
+ topics: topics.map((topic) => ({
64
+ name: topic.name,
65
+ source: topic.source,
66
+ authority: topic.authority,
67
+ summary: topic.summary,
68
+ content: topic.content,
69
+ })),
70
+ },
71
+ null,
72
+ 2,
73
+ ),
74
+ )
75
+ return EXIT.ok
76
+ }
77
+ const blocks = topics.map((topic) => {
78
+ const { path: filePath } = describeSource(topic.source)
79
+ const marker =
80
+ `<!-- ${MARKER_TOKEN} topic=${topic.name} from=${AUTHORITY_LABEL[topic.authority]}` +
81
+ `${filePath === null ? '' : ` file=${encodeURIComponent(filePath)}`} -->`
82
+ return `${marker}\n${markerSafe(topic.content).trimEnd()}`
83
+ })
84
+ deps.io.out([GUIDANCE_TRUST_PREAMBLE, ...blocks].join('\n\n'))
85
+ return EXIT.ok
86
+ }
87
+
88
+ type GuidanceMutationFlags = {
89
+ project?: boolean | undefined
90
+ local?: boolean | undefined
91
+ yes?: boolean | undefined
92
+ }
93
+ type GuidanceLayer = 'global' | 'project' | 'local'
94
+
95
+ /**
96
+ * Where a topic file for one layer lives. The project layer prefers an
97
+ * existing `.notifai/guidance` up the tree and otherwise starts one at cwd,
98
+ * the same anchoring `config set --project` uses for its file.
99
+ */
100
+ function guidanceLayerDir(deps: CommandDeps, layer: GuidanceLayer): string {
101
+ if (layer === 'local') return personalProjectGuidanceDir(deps.cwd, deps.env)
102
+ if (layer === 'project') {
103
+ return findProjectGuidanceDir(deps.cwd) ?? path.join(deps.cwd, '.notifai', 'guidance')
104
+ }
105
+ return globalGuidanceDir(deps.env)
106
+ }
107
+
108
+ async function guidanceMutationTarget(
109
+ deps: CommandDeps,
110
+ topic: string,
111
+ flags: GuidanceMutationFlags,
112
+ ): Promise<{ path: string; layer: GuidanceLayer } | null> {
113
+ if (!GUIDANCE_TOPIC_PATTERN.test(topic)) {
114
+ deps.io.err(
115
+ `"${topic}" is not a topic name. Use lowercase letters, digits, "-" and "_", e.g. ${SHIPPED_GUIDANCE.map((t) => t.name).join(', ')}.`,
116
+ )
117
+ return null
118
+ }
119
+ let layer: GuidanceLayer = flags.local ? 'local' : flags.project ? 'project' : 'global'
120
+ if (
121
+ flags.local !== true &&
122
+ flags.project !== true &&
123
+ flags.yes !== true &&
124
+ deps.io.interactive === true &&
125
+ deps.io.select
126
+ ) {
127
+ const selected = await deps.io.select('Where should this guidance live?', [
128
+ { value: 'global', label: 'This machine', hint: 'your standing word, applies across projects' },
129
+ { value: 'project', label: 'This project (shared)', hint: '.notifai/guidance/ — committed, read as project policy' },
130
+ { value: 'local', label: 'This project (personal)', hint: 'your standing word here, stored outside the repo' },
131
+ ])
132
+ if (selected === null) {
133
+ deps.io.err('No guidance layer selected.')
134
+ return null
135
+ }
136
+ layer = selected as GuidanceLayer
137
+ }
138
+ return { path: path.join(guidanceLayerDir(deps, layer), `${topic}.md`), layer }
139
+ }
140
+
141
+ /**
142
+ * Writes one topic at one layer, replacing the shipped topic of that name for
143
+ * this scope. The confirmation gate is the same one `config set` holds: the
144
+ * user owns their standing word, so an agent writes it only with their words
145
+ * and their say-so.
146
+ */
147
+ export async function guidanceSetCommand(
148
+ deps: CommandDeps,
149
+ topic: string,
150
+ text: string,
151
+ flags: GuidanceMutationFlags,
152
+ ): Promise<number> {
153
+ if (text.trim() === '') {
154
+ deps.io.err('Guidance text is empty. Pass the text, or --file <path|-> to read it.')
155
+ return EXIT.usage
156
+ }
157
+ if (text.length > GUIDANCE_TOPIC_MAX_BYTES) {
158
+ deps.io.err(`Guidance for one topic is capped at ${GUIDANCE_TOPIC_MAX_BYTES} bytes.`)
159
+ return EXIT.usage
160
+ }
161
+ const target = await guidanceMutationTarget(deps, topic, flags)
162
+ if (target === null) return EXIT.usage
163
+
164
+ const shipped = shippedGuidanceTopic(topic)
165
+ if (!flags.yes) {
166
+ const replaces =
167
+ shipped === undefined
168
+ ? `Add guidance topic "${topic}"`
169
+ : `Replace the shipped "${topic}" guidance for this scope`
170
+ const confirmed = await deps.io.confirm(`${replaces} at ${target.path}?`)
171
+ if (!confirmed) {
172
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
173
+ return EXIT.usage
174
+ }
175
+ }
176
+
177
+ mkdirSync(path.dirname(target.path), { recursive: true })
178
+ writeFileSync(target.path, text.endsWith('\n') ? text : `${text}\n`)
179
+ deps.io.out(`Wrote ${topic} to ${target.path}`)
180
+ if (shipped !== undefined) {
181
+ deps.io.out(`This file now replaces the shipped "${topic}" guidance wherever it applies.`)
182
+ }
183
+ // A shared file is committed and travels to everyone who clones this
184
+ // repository, where it is read as the project's policy rather than as
185
+ // anyone's personal standing word. Worth saying at the moment of writing:
186
+ // the same sentence means something different in the two locations.
187
+ if (target.layer === 'project') {
188
+ deps.io.out(
189
+ 'This is shared project policy: it is committed, everyone working here reads it, and it is never read as the User\'s own standing word. Use --local for a personal preference.',
190
+ )
191
+ }
192
+ return EXIT.ok
193
+ }
194
+
195
+ export async function guidanceUnsetCommand(
196
+ deps: CommandDeps,
197
+ topic: string,
198
+ flags: GuidanceMutationFlags,
199
+ ): Promise<number> {
200
+ const target = await guidanceMutationTarget(deps, topic, flags)
201
+ if (target === null) return EXIT.usage
202
+ if (!existsSync(target.path)) {
203
+ deps.io.out(`${topic} is not set at ${target.path}`)
204
+ return EXIT.ok
205
+ }
206
+ if (!flags.yes) {
207
+ const confirmed = await deps.io.confirm(`Remove ${target.path}?`)
208
+ if (!confirmed) {
209
+ deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
210
+ return EXIT.usage
211
+ }
212
+ }
213
+ rmSync(target.path, { force: true })
214
+ deps.io.out(`Removed ${target.path}; the inherited guidance now applies.`)
215
+ return EXIT.ok
216
+ }