@raidiant/notifai 8.0.0 → 9.1.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 (168) hide show
  1. package/CHANGELOG.md +48 -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/command-session.d.ts +14 -0
  16. package/dist/command-session.js +30 -0
  17. package/dist/command-session.js.map +1 -0
  18. package/dist/commands-auth.d.ts +6 -0
  19. package/dist/commands-auth.js +28 -3
  20. package/dist/commands-auth.js.map +1 -1
  21. package/dist/commands-config.js +22 -3
  22. package/dist/commands-config.js.map +1 -1
  23. package/dist/commands-core.d.ts +12 -0
  24. package/dist/commands-core.js +27 -3
  25. package/dist/commands-core.js.map +1 -1
  26. package/dist/commands-doctor.d.ts +1 -0
  27. package/dist/commands-doctor.js +87 -29
  28. package/dist/commands-doctor.js.map +1 -1
  29. package/dist/commands-guidance.d.ts +7 -3
  30. package/dist/commands-guidance.js +40 -31
  31. package/dist/commands-guidance.js.map +1 -1
  32. package/dist/commands-harness-context.js +21 -1
  33. package/dist/commands-harness-context.js.map +1 -1
  34. package/dist/commands-hooks.d.ts +20 -5
  35. package/dist/commands-hooks.js +509 -155
  36. package/dist/commands-hooks.js.map +1 -1
  37. package/dist/commands-init.d.ts +12 -18
  38. package/dist/commands-init.js +149 -47
  39. package/dist/commands-init.js.map +1 -1
  40. package/dist/commands-send-support.d.ts +17 -2
  41. package/dist/commands-send-support.js +61 -5
  42. package/dist/commands-send-support.js.map +1 -1
  43. package/dist/commands-send.d.ts +1 -0
  44. package/dist/commands-send.js +114 -17
  45. package/dist/commands-send.js.map +1 -1
  46. package/dist/commands-setup-proof.d.ts +12 -3
  47. package/dist/commands-setup-proof.js +60 -16
  48. package/dist/commands-setup-proof.js.map +1 -1
  49. package/dist/commands-skill.d.ts +11 -1
  50. package/dist/commands-skill.js +61 -11
  51. package/dist/commands-skill.js.map +1 -1
  52. package/dist/commands.d.ts +2 -2
  53. package/dist/commands.js +1 -1
  54. package/dist/commands.js.map +1 -1
  55. package/dist/config-schema.d.ts +1 -1
  56. package/dist/config-schema.js +25 -0
  57. package/dist/config-schema.js.map +1 -1
  58. package/dist/config.d.ts +24 -3
  59. package/dist/config.js +37 -5
  60. package/dist/config.js.map +1 -1
  61. package/dist/credentials.d.ts +10 -1
  62. package/dist/credentials.js +25 -12
  63. package/dist/credentials.js.map +1 -1
  64. package/dist/guidance-content.d.ts +20 -0
  65. package/dist/guidance-content.js +73 -8
  66. package/dist/guidance-content.js.map +1 -1
  67. package/dist/guidance-render.d.ts +20 -0
  68. package/dist/guidance-render.js +42 -0
  69. package/dist/guidance-render.js.map +1 -0
  70. package/dist/guidance.d.ts +21 -0
  71. package/dist/guidance.js +73 -18
  72. package/dist/guidance.js.map +1 -1
  73. package/dist/hook-adapter.js +37 -3
  74. package/dist/hook-adapter.js.map +1 -1
  75. package/dist/hooks.d.ts +83 -7
  76. package/dist/hooks.js +288 -90
  77. package/dist/hooks.js.map +1 -1
  78. package/dist/install-hooks.d.ts +10 -18
  79. package/dist/install-hooks.js +110 -23
  80. package/dist/install-hooks.js.map +1 -1
  81. package/dist/interactive.d.ts +1 -1
  82. package/dist/interactive.js +1 -1
  83. package/dist/interactive.js.map +1 -1
  84. package/dist/logging.d.ts +8 -1
  85. package/dist/logging.js +43 -2
  86. package/dist/logging.js.map +1 -1
  87. package/dist/main.js +2 -13
  88. package/dist/main.js.map +1 -1
  89. package/dist/native-skills.d.ts +19 -0
  90. package/dist/native-skills.js +30 -8
  91. package/dist/native-skills.js.map +1 -1
  92. package/dist/opencode-plugin.js +25 -1
  93. package/dist/opencode-plugin.js.map +1 -1
  94. package/dist/program.js +34 -20
  95. package/dist/program.js.map +1 -1
  96. package/dist/readiness.d.ts +6 -0
  97. package/dist/readiness.js +37 -0
  98. package/dist/readiness.js.map +1 -1
  99. package/dist/send-attempts.d.ts +26 -0
  100. package/dist/send-attempts.js +139 -0
  101. package/dist/send-attempts.js.map +1 -0
  102. package/dist/send.d.ts +8 -1
  103. package/dist/send.js +17 -1
  104. package/dist/send.js.map +1 -1
  105. package/dist/session-activation.d.ts +11 -0
  106. package/dist/session-activation.js +46 -0
  107. package/dist/session-activation.js.map +1 -0
  108. package/dist/url-policy.d.ts +85 -0
  109. package/dist/url-policy.js +369 -0
  110. package/dist/url-policy.js.map +1 -0
  111. package/package.json +3 -3
  112. package/src/atomic-file.ts +36 -2
  113. package/src/cli-bin.test.ts +38 -0
  114. package/src/cli-bin.ts +67 -0
  115. package/src/cli-release.test.ts +46 -0
  116. package/src/cli-release.ts +78 -0
  117. package/src/client.test.ts +95 -0
  118. package/src/client.ts +16 -3
  119. package/src/command-session.ts +38 -0
  120. package/src/commands-auth.ts +32 -3
  121. package/src/commands-config.ts +25 -2
  122. package/src/commands-core.ts +38 -3
  123. package/src/commands-doctor.ts +117 -41
  124. package/src/commands-guidance.ts +43 -32
  125. package/src/commands-harness-context.ts +24 -1
  126. package/src/commands-hooks.ts +704 -188
  127. package/src/commands-init.ts +180 -57
  128. package/src/commands-send-support.test.ts +156 -0
  129. package/src/commands-send-support.ts +67 -5
  130. package/src/commands-send.ts +135 -16
  131. package/src/commands-setup-proof.test.ts +72 -0
  132. package/src/commands-setup-proof.ts +69 -16
  133. package/src/commands-skill.ts +78 -15
  134. package/src/commands.test.ts +1281 -157
  135. package/src/commands.ts +2 -0
  136. package/src/config-schema.ts +28 -1
  137. package/src/config.test.ts +1 -1
  138. package/src/config.ts +49 -5
  139. package/src/credentials.test.ts +71 -3
  140. package/src/credentials.ts +29 -15
  141. package/src/guidance-content.ts +74 -8
  142. package/src/guidance-render.ts +56 -0
  143. package/src/guidance.test.ts +70 -11
  144. package/src/guidance.ts +100 -18
  145. package/src/hook-adapter.test.ts +113 -1
  146. package/src/hook-adapter.ts +37 -3
  147. package/src/hooks.test.ts +778 -166
  148. package/src/hooks.ts +368 -129
  149. package/src/install-hooks.test.ts +135 -16
  150. package/src/install-hooks.ts +113 -25
  151. package/src/interactive.ts +1 -1
  152. package/src/logging.test.ts +66 -3
  153. package/src/logging.ts +45 -2
  154. package/src/main.ts +2 -14
  155. package/src/native-skills.test.ts +41 -1
  156. package/src/native-skills.ts +40 -6
  157. package/src/opencode-plugin.ts +25 -1
  158. package/src/program.test.ts +37 -2
  159. package/src/program.ts +62 -24
  160. package/src/readiness.ts +44 -0
  161. package/src/send-attempts.test.ts +143 -0
  162. package/src/send-attempts.ts +188 -0
  163. package/src/send.test.ts +28 -0
  164. package/src/send.ts +28 -2
  165. package/src/session-activation.ts +62 -0
  166. package/src/skill.test.ts +125 -11
  167. package/src/trust-policy.test.ts +701 -0
  168. package/src/url-policy.ts +399 -0
@@ -9,7 +9,6 @@ import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
9
9
  import { inspectHookAdapter } from './hook-adapter.js'
10
10
  import {
11
11
  readLiveProjectSessionPointers,
12
- readMatchingProjectSessionPointer,
13
12
  readProjectSessionPointer,
14
13
  readSessionState,
15
14
  } from './hooks.js'
@@ -33,6 +32,7 @@ import { inferInvocationContext } from './invocation-context.js'
33
32
  import type { SkillScope } from './native-skills.js'
34
33
  import {
35
34
  firstBlocker,
35
+ readinessJson,
36
36
  type Readiness,
37
37
  type ReadinessRefresh,
38
38
  type ReadinessState,
@@ -40,9 +40,11 @@ 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,
@@ -56,8 +58,27 @@ 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
+ setupProofProject,
79
+ setupProofIsStale,
80
+ setupProofApplies,
81
+ } from './commands-setup-proof.js'
61
82
  import { skillReadiness } from './commands-skill.js'
62
83
 
63
84
  // ---------------------------------------------------------------------------
@@ -236,10 +257,14 @@ function remoteStatesFrom(previous: Readiness): {
236
257
  return { credential, server, contract, auth, devices, proof }
237
258
  }
238
259
 
239
- function remoteInvalidatedByConfig(previous: Readiness, config: CliConfig): boolean {
240
- if (config.base_url.source === 'default') return false
260
+ function remoteInvalidatedByConfig(
261
+ previous: Readiness,
262
+ config: CliConfig,
263
+ credential: MachineCredential | null,
264
+ ): boolean {
265
+ const origin = resolvedBaseUrl(config, credential)
241
266
  const server = previous.states.find((state) => state.id === 'server')
242
- return server === undefined || !server.detail.includes(config.base_url.value)
267
+ return server === undefined || !server.detail.includes(origin)
243
268
  }
244
269
 
245
270
  /**
@@ -257,9 +282,12 @@ export async function assessReadiness(
257
282
  skillScope?: SkillScope
258
283
  previous?: Readiness
259
284
  refresh?: readonly ReadinessRefresh[]
285
+ json?: boolean
260
286
  } = {},
261
287
  ): Promise<Readiness> {
262
288
  const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
289
+ const credential = deps.store.load()
290
+ if (credential) diagnoseIgnoredOriginOverride(deps.io, config, credential)
263
291
  const previous = options.previous
264
292
  const refresh = options.refresh
265
293
  if (previous !== undefined && refresh !== undefined && refresh.length === 0) return previous
@@ -268,12 +296,13 @@ export async function assessReadiness(
268
296
  previous !== undefined &&
269
297
  refresh !== undefined &&
270
298
  !refresh.includes('remote') &&
271
- !remoteInvalidatedByConfig(previous, config)
299
+ !remoteInvalidatedByConfig(previous, config, credential)
272
300
  if (reuseRemote) {
273
301
  const reused = remoteStatesFrom(previous)
274
302
  if (reused !== null) {
275
303
  return {
276
304
  states: [
305
+ cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform),
277
306
  projectReadiness(deps, config),
278
307
  reused.credential,
279
308
  reused.server,
@@ -292,9 +321,9 @@ export async function assessReadiness(
292
321
  let accountClient: ApiClient | null = null
293
322
  let accountDevices: RoutableDevice[] | null = null
294
323
 
324
+ states.push(cliBinReadiness(deps.env, deps.hookPlatform ?? process.platform))
295
325
  states.push(projectReadiness(deps, config))
296
326
 
297
- const credential = deps.store.load()
298
327
  states.push(
299
328
  credential
300
329
  ? {
@@ -457,10 +486,39 @@ export async function assessReadiness(
457
486
  }
458
487
 
459
488
  states.push(await setupProofState(deps, config, accountClient, accountDevices))
489
+ await applyRegistryRecommendation(deps, options.json === true, states)
460
490
 
461
491
  return { states }
462
492
  }
463
493
 
494
+ async function applyRegistryRecommendation(
495
+ deps: CommandDeps,
496
+ json: boolean,
497
+ states: ReadinessState[],
498
+ ): Promise<void> {
499
+ if (
500
+ !shouldConsultCliRegistry({
501
+ ...(deps.io.interactive === undefined ? {} : { interactive: deps.io.interactive }),
502
+ json,
503
+ env: deps.env,
504
+ })
505
+ ) {
506
+ return
507
+ }
508
+ const latest = await latestPublishedCliVersion(deps.fetchImpl)
509
+ const newer = newerPublishedCli(thisCliVersion(), latest)
510
+ if (newer === null) return
511
+ const contract = states.find((state) => state.id === 'contract')
512
+ if (contract === undefined || contract.status === 'gap') return
513
+ contract.status = 'optional-gap'
514
+ contract.detail = 'A newer Notifai is available.'
515
+ contract.remedy = {
516
+ by: 'user-here',
517
+ summary: 'update Notifai',
518
+ command: UPDATE_CLI_COMMAND,
519
+ }
520
+ }
521
+
464
522
  async function setupProofState(
465
523
  deps: CommandDeps,
466
524
  config: CliConfig,
@@ -486,12 +544,10 @@ async function setupProofState(
486
544
  }
487
545
  }
488
546
 
489
- const proof = readSetupProof(deps)
490
- const target =
491
- proof === null
492
- ? null
493
- : companions.find((device) => device.device_id === proof.device_id)
494
- if (proof === null || proof.project !== config.project.value || target === undefined) {
547
+ const project = setupProofProject(deps, config.project.value)
548
+ const proof = readSetupProof(deps, project)
549
+ const applies = setupProofApplies(proof, project, companions.map((device) => device.device_id))
550
+ if (!applies) {
495
551
  return {
496
552
  id: 'proof',
497
553
  title: 'Delivery proof',
@@ -518,6 +574,20 @@ async function setupProofState(
518
574
  detail: `Companion Receipt (the app's delivery confirmation) observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
519
575
  }
520
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
+ }
521
591
  return {
522
592
  id: 'proof',
523
593
  title: 'Delivery proof',
@@ -549,7 +619,8 @@ export async function doctorCommand(
549
619
  flags: { json?: boolean },
550
620
  options: { readiness?: Readiness } = {},
551
621
  ): Promise<number> {
552
- const readiness = options.readiness ?? (await assessReadiness(deps))
622
+ const readiness =
623
+ options.readiness ?? (await assessReadiness(deps, flags.json === true ? { json: true } : {}))
553
624
  const blocker = firstBlocker(readiness)
554
625
  const ok = blocker === null
555
626
 
@@ -558,7 +629,7 @@ export async function doctorCommand(
558
629
  if (flags.json || deps.io.interactive !== true) {
559
630
  deps.io.out(
560
631
  JSON.stringify(
561
- { ok, exit_code: ok ? EXIT.ok : EXIT.failed, states: readiness.states },
632
+ { ...readinessJson(readiness), ok, exit_code: ok ? EXIT.ok : EXIT.failed },
562
633
  null,
563
634
  2,
564
635
  ),
@@ -834,17 +905,11 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
834
905
  }),
835
906
  })
836
907
  if (activeInstallations.length > 0) {
837
- const pointer =
838
- active.sessionId === undefined
839
- ? null
840
- : readMatchingProjectSessionPointer(
841
- deps.cwd,
842
- deps.env,
843
- (deps.now ?? Date.now)(),
844
- active.sessionId,
845
- active.harness,
846
- )
847
- if (pointer === null) {
908
+ const exactState = active.sessionId === undefined
909
+ ? null
910
+ : readSessionState(active.sessionId, deps.env)
911
+ const activated = exactState?.harness === active.harness && exactState.last_prompt_at !== undefined
912
+ if (!activated) {
848
913
  // The normal condition of hooks installed moments ago: the pointer
849
914
  // appears when the harness next fires a hook, and no command can
850
915
  // force that. Informational, so `init` walks on to the states it can
@@ -853,7 +918,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
853
918
  name: 'hooks (active session)',
854
919
  ok: false,
855
920
  informational: true,
856
- detail: `active ${active.label} session has not published a live pointer — send one ${active.label} prompt, then check again`,
921
+ detail: `active ${active.label} session has not published exact lifecycle state — send one ${active.label} prompt, then check again`,
857
922
  remedy: {
858
923
  summary: `send one ${active.label} prompt — its hook publishes the routing pointer`,
859
924
  command: 'notifai doctor',
@@ -863,7 +928,7 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
863
928
  checks.push({
864
929
  name: 'hooks (active session)',
865
930
  ok: true,
866
- detail: `the concurrent project index contains the active ${active.label} session`,
931
+ detail: `the exact active ${active.label} session has published lifecycle state`,
867
932
  })
868
933
  }
869
934
  }
@@ -879,13 +944,25 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
879
944
  })
880
945
  .map((h) => `${h.event} -> ${handlerEvent(h.command)} in ${i.file}`),
881
946
  )
947
+ const missing = installations.flatMap((installation) => {
948
+ const required = requiredHookEvents(installation.harness)
949
+ const installed = new Set(
950
+ installation.handlers
951
+ .map((handler) => handlerEvent(handler.command))
952
+ .filter((event): event is string => event !== null),
953
+ )
954
+ const absent = required.filter((event) => !installed.has(event))
955
+ return absent.length === 0
956
+ ? []
957
+ : [`${installation.file} is missing ${absent.join(', ')}`]
958
+ })
882
959
  checks.push({
883
960
  name: 'hooks (stale)',
884
- ok: stale.length === 0,
961
+ ok: stale.length === 0 && missing.length === 0,
885
962
  detail:
886
- stale.length === 0
887
- ? 'every installed handler names an event this build serves'
888
- : `${stale.join('; ')} — rerun \`notifai hooks install\` to drop ${stale.length === 1 ? 'it' : 'them'}`,
963
+ stale.length === 0 && missing.length === 0
964
+ ? 'every installed handler names an event this build serves, and every required lifecycle handler is present'
965
+ : `${[...stale, ...missing].join('; ')} — rerun \`notifai hooks install\` to refresh the complete lifecycle set`,
889
966
  })
890
967
 
891
968
  const adapterProblems = installations.flatMap((installation) =>
@@ -940,7 +1017,10 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
940
1017
  // Project and global definitions for one harness both fire. Stable adapter
941
1018
  // identity deliberately makes their command bytes equal, so comparing
942
1019
  // command targets would now hide this duplicate rather than diagnose it.
943
- // Different harnesses remain independent: only the active one runs.
1020
+ // Cursor is the exception to harness independence because it may also load
1021
+ // Claude's user hook file. The shared adapter detects Cursor's hook-only
1022
+ // environment and makes that compatibility copy a no-op; the native Cursor
1023
+ // definition remains the single routing owner.
944
1024
  const duplicated = [...new Set(installations.map((i) => i.harness))]
945
1025
  .map((harness) => ({
946
1026
  harness,
@@ -1016,13 +1096,9 @@ function hookChecks(deps: CommandDeps): HookCheck[] {
1016
1096
  ? (readLiveProjectSessionPointers(deps.cwd, deps.env, (deps.now ?? Date.now)()).find(
1017
1097
  (pointer) => pointer.harness === active.harness,
1018
1098
  ) ?? null)
1019
- : readMatchingProjectSessionPointer(
1020
- deps.cwd,
1021
- deps.env,
1022
- (deps.now ?? Date.now)(),
1023
- active.sessionId,
1024
- active.harness,
1025
- )
1099
+ : readSessionState(active.sessionId, deps.env).harness === active.harness
1100
+ ? { sessionId: active.sessionId, harness: active.harness }
1101
+ : null
1026
1102
  const firedState = firedPointer === null ? null : readSessionState(firedPointer.sessionId, deps.env)
1027
1103
  const promptFired = firedState?.last_prompt_at !== undefined
1028
1104
  const stopFired = firedState?.last_stop_at !== undefined
@@ -1,8 +1,7 @@
1
1
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { describeSource } from './config-schema.js'
4
3
  import { EXIT, type CommandDeps } from './commands-core.js'
5
- import { SHIPPED_GUIDANCE, shippedGuidanceTopic } from './guidance-content.js'
4
+ import { GUIDANCE_TRUST_PREAMBLE, SHIPPED_GUIDANCE, shippedGuidanceTopic } from './guidance-content.js'
6
5
  import {
7
6
  GUIDANCE_TOPIC_MAX_BYTES,
8
7
  GUIDANCE_TOPIC_PATTERN,
@@ -11,40 +10,43 @@ import {
11
10
  personalProjectGuidanceDir,
12
11
  resolveGuidance,
13
12
  } from './guidance.js'
13
+ import { renderGuidance } from './guidance-render.js'
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // guidance show / set / unset
17
17
  // ---------------------------------------------------------------------------
18
18
 
19
19
  /**
20
- * The whole effective guidance, ready to be followed as read. Each topic is
21
- * printed under a provenance comment so a reader can tell the user's standing
22
- * word from the shipped default without the output stopping being Markdown.
20
+ * The whole effective guidance, ready to be followed as read.
21
+ *
22
+ * The trust preamble comes first and belongs to the CLI: it says who supplied
23
+ * each topic and what no topic may ask for. Each topic then follows its own
24
+ * marker, so a reader can tell the User's standing word from a repository's
25
+ * house rules from the shipped default without the output stopping being
26
+ * Markdown.
23
27
  */
24
28
  export function guidanceShowCommand(deps: CommandDeps, flags: { json?: boolean }): number {
25
29
  const topics = resolveGuidance({ cwd: deps.cwd, env: deps.env })
26
30
  if (flags.json) {
27
31
  deps.io.out(
28
32
  JSON.stringify(
29
- topics.map((topic) => ({
30
- name: topic.name,
31
- source: topic.source,
32
- summary: topic.summary,
33
- content: topic.content,
34
- })),
33
+ {
34
+ trust: GUIDANCE_TRUST_PREAMBLE,
35
+ topics: topics.map((topic) => ({
36
+ name: topic.name,
37
+ source: topic.source,
38
+ authority: topic.authority,
39
+ summary: topic.summary,
40
+ content: topic.content,
41
+ })),
42
+ },
35
43
  null,
36
44
  2,
37
45
  ),
38
46
  )
39
47
  return EXIT.ok
40
48
  }
41
- const blocks = topics.map((topic) => {
42
- const { label, path: filePath } = describeSource(topic.source)
43
- const origin = topic.source === 'default' ? 'shipped default' : label
44
- const provenance = filePath === null ? origin : `${origin} · ${filePath}`
45
- return `<!-- ${topic.name} · ${provenance} -->\n${topic.content.trimEnd()}`
46
- })
47
- deps.io.out(blocks.join('\n\n'))
49
+ deps.io.out(renderGuidance(topics))
48
50
  return EXIT.ok
49
51
  }
50
52
 
@@ -72,7 +74,7 @@ async function guidanceMutationTarget(
72
74
  deps: CommandDeps,
73
75
  topic: string,
74
76
  flags: GuidanceMutationFlags,
75
- ): Promise<string | null> {
77
+ ): Promise<{ path: string; layer: GuidanceLayer } | null> {
76
78
  if (!GUIDANCE_TOPIC_PATTERN.test(topic)) {
77
79
  deps.io.err(
78
80
  `"${topic}" is not a topic name. Use lowercase letters, digits, "-" and "_", e.g. ${SHIPPED_GUIDANCE.map((t) => t.name).join(', ')}.`,
@@ -88,9 +90,9 @@ async function guidanceMutationTarget(
88
90
  deps.io.select
89
91
  ) {
90
92
  const selected = await deps.io.select('Where should this guidance live?', [
91
- { value: 'global', label: 'This machine', hint: 'applies across projects' },
92
- { value: 'project', label: 'This project (shared)', hint: '.notifai/guidance/' },
93
- { value: 'local', label: 'This project (personal)', hint: 'stored on this machine, not in the repo' },
93
+ { value: 'global', label: 'This machine', hint: 'your standing word, applies across projects' },
94
+ { value: 'project', label: 'This project (shared)', hint: '.notifai/guidance/ — committed, read as project policy' },
95
+ { value: 'local', label: 'This project (personal)', hint: 'your standing word here, stored outside the repo' },
94
96
  ])
95
97
  if (selected === null) {
96
98
  deps.io.err('No guidance layer selected.')
@@ -98,7 +100,7 @@ async function guidanceMutationTarget(
98
100
  }
99
101
  layer = selected as GuidanceLayer
100
102
  }
101
- return path.join(guidanceLayerDir(deps, layer), `${topic}.md`)
103
+ return { path: path.join(guidanceLayerDir(deps, layer), `${topic}.md`), layer }
102
104
  }
103
105
 
104
106
  /**
@@ -130,19 +132,28 @@ export async function guidanceSetCommand(
130
132
  shipped === undefined
131
133
  ? `Add guidance topic "${topic}"`
132
134
  : `Replace the shipped "${topic}" guidance for this scope`
133
- const confirmed = await deps.io.confirm(`${replaces} at ${target}?`)
135
+ const confirmed = await deps.io.confirm(`${replaces} at ${target.path}?`)
134
136
  if (!confirmed) {
135
137
  deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
136
138
  return EXIT.usage
137
139
  }
138
140
  }
139
141
 
140
- mkdirSync(path.dirname(target), { recursive: true })
141
- writeFileSync(target, text.endsWith('\n') ? text : `${text}\n`)
142
- deps.io.out(`Wrote ${topic} to ${target}`)
142
+ mkdirSync(path.dirname(target.path), { recursive: true })
143
+ writeFileSync(target.path, text.endsWith('\n') ? text : `${text}\n`)
144
+ deps.io.out(`Wrote ${topic} to ${target.path}`)
143
145
  if (shipped !== undefined) {
144
146
  deps.io.out(`This file now replaces the shipped "${topic}" guidance wherever it applies.`)
145
147
  }
148
+ // A shared file is committed and travels to everyone who clones this
149
+ // repository, where it is read as the project's policy rather than as
150
+ // anyone's personal standing word. Worth saying at the moment of writing:
151
+ // the same sentence means something different in the two locations.
152
+ if (target.layer === 'project') {
153
+ deps.io.out(
154
+ '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.',
155
+ )
156
+ }
146
157
  return EXIT.ok
147
158
  }
148
159
 
@@ -153,18 +164,18 @@ export async function guidanceUnsetCommand(
153
164
  ): Promise<number> {
154
165
  const target = await guidanceMutationTarget(deps, topic, flags)
155
166
  if (target === null) return EXIT.usage
156
- if (!existsSync(target)) {
157
- deps.io.out(`${topic} is not set at ${target}`)
167
+ if (!existsSync(target.path)) {
168
+ deps.io.out(`${topic} is not set at ${target.path}`)
158
169
  return EXIT.ok
159
170
  }
160
171
  if (!flags.yes) {
161
- const confirmed = await deps.io.confirm(`Remove ${target}?`)
172
+ const confirmed = await deps.io.confirm(`Remove ${target.path}?`)
162
173
  if (!confirmed) {
163
174
  deps.io.err('Not confirmed. Pass --yes to skip the confirmation gate.')
164
175
  return EXIT.usage
165
176
  }
166
177
  }
167
- rmSync(target, { force: true })
168
- deps.io.out(`Removed ${target}; the inherited guidance now applies.`)
178
+ rmSync(target.path, { force: true })
179
+ deps.io.out(`Removed ${target.path}; the inherited guidance now applies.`)
169
180
  return EXIT.ok
170
181
  }
@@ -1,4 +1,4 @@
1
- import { readLiveProjectSessionPointers } from './hooks.js'
1
+ import { readLiveProjectSessionPointers, readSessionState } from './hooks.js'
2
2
  import { type Harness } from './install-hooks.js'
3
3
 
4
4
  /**
@@ -96,6 +96,29 @@ export function resolveActiveHarness(
96
96
  const first = candidates[0]
97
97
  if (first === undefined) return { active: null, contested: [] }
98
98
  if (candidates.length === 1) return { active: first, contested: [] }
99
+
100
+ // Exact lifecycle state is machine-global and survives a command moving to
101
+ // another linked checkout. Prefer it before the checkout-local compatibility
102
+ // pointer. A unique newest hook event proves which nested harness owns this
103
+ // shell; ties and missing evidence remain contested and fail closed.
104
+ const evidenced = candidates.flatMap((candidate) => {
105
+ if (candidate.sessionId === undefined) return []
106
+ const state = readSessionState(candidate.sessionId, env)
107
+ if (state.harness !== candidate.harness) return []
108
+ const activity = Math.max(
109
+ state.last_prompt_at ?? 0,
110
+ state.last_stop_at ?? 0,
111
+ state.activation_context_emitted_at ?? 0,
112
+ )
113
+ return activity > 0 ? [{ candidate, activity }] : []
114
+ })
115
+ if (evidenced.length > 0) {
116
+ evidenced.sort((left, right) => right.activity - left.activity)
117
+ if (evidenced.length === 1 || evidenced[0]!.activity > evidenced[1]!.activity) {
118
+ return { active: evidenced[0]!.candidate, contested: [] }
119
+ }
120
+ return { active: first, contested: candidates }
121
+ }
99
122
  for (const pointer of readLiveProjectSessionPointers(cwd, env, now)) {
100
123
  const owner = candidates.find(
101
124
  (candidate) =>