@raidiant/notifai 2.0.2 → 4.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.
package/src/commands.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  type EvidenceSnapshot,
14
14
  type AccountAccessResponse,
15
15
  type ListRepliesResponse,
16
+ type NotificationDraftT,
16
17
  type Platform,
17
18
  type QuestionT,
18
19
  type ReplyView,
@@ -153,8 +154,15 @@ import {
153
154
  parseChoices,
154
155
  receiptExitCode,
155
156
  slugify,
157
+ validateMediaInputs,
158
+ type DraftInvocation,
156
159
  type SendFlags,
157
160
  } from './send.js'
161
+ import {
162
+ buildSourceContext,
163
+ inferInvocationContext,
164
+ projectSlugFrom as inferredProjectSlugFrom,
165
+ } from './invocation-context.js'
158
166
  import type { NativeSkill, NativeSkills, SkillScope } from './native-skills.js'
159
167
  import { openUrl } from './platform.js'
160
168
 
@@ -304,6 +312,19 @@ function reportError(
304
312
  })
305
313
  if (err instanceof ApiCallError) {
306
314
  deps.io.err(`${err.code}: ${err.message}`)
315
+ // The server's field-level details were already being logged above, but
316
+ // stderr is the one surface an agent's next turn actually reads — so the
317
+ // diagnosis has to be on it, the same way the hook path prints it.
318
+ const paths = rejectedPaths(err.details)
319
+ if (paths.length > 0) deps.io.err(`the server rejected: ${paths.join(', ')}`)
320
+ // A 422 on a request this CLI built is not a user error: the two sides
321
+ // disagree about the contract, in whichever direction. `doctor` says which.
322
+ if (err.status === 422) {
323
+ deps.io.err(
324
+ 'this build sent a field the server did not accept — the CLI and server ' +
325
+ 'disagree about the contract; check with `notifai doctor`',
326
+ )
327
+ }
307
328
  if (err.nextAction) deps.io.err(`next: ${err.nextAction}`)
308
329
  if (err.code === 'auth_required' || err.code === 'machine_revoked') return EXIT.auth
309
330
  return err.status >= 500 ? EXIT.network : EXIT.failed
@@ -568,6 +589,36 @@ export async function capabilitiesCommand(
568
589
  // send / status
569
590
  // ---------------------------------------------------------------------------
570
591
 
592
+ function resolveDraftInvocation(
593
+ deps: CommandDeps,
594
+ flags: Pick<SendFlags, 'sessionId' | 'sessionLabel'>,
595
+ active: ActiveHarnessSession | null,
596
+ ): { ok: true; invocation: DraftInvocation } | { ok: false; error: string } {
597
+ const inferred = inferInvocationContext(deps.cwd)
598
+ const source = buildSourceContext({
599
+ env: deps.env,
600
+ invocation: inferred,
601
+ ...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
602
+ ...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
603
+ ...(active === null
604
+ ? {}
605
+ : {
606
+ activeHarness: {
607
+ harness: active.harness,
608
+ ...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
609
+ },
610
+ }),
611
+ })
612
+ if (!source.ok) return source
613
+ return {
614
+ ok: true,
615
+ invocation: {
616
+ inferredProject: inferred.project,
617
+ ...(source.source === undefined ? {} : { source: source.source }),
618
+ },
619
+ }
620
+ }
621
+
571
622
  export async function sendCommand(
572
623
  deps: CommandDeps,
573
624
  flags: SendFlags & {
@@ -583,8 +634,12 @@ export async function sendCommand(
583
634
  const hasReplyChoice = Array.isArray(flags.replyChoice)
584
635
  ? flags.replyChoice.length > 0
585
636
  : flags.replyChoice !== undefined
586
- if (flags.reply && flags.kind === 'done') {
587
- deps.io.err('--kind done cannot be combined with --reply; a reply request is a question.')
637
+ if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
638
+ deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`)
639
+ return EXIT.usage
640
+ }
641
+ if (!flags.reply && flags.kind === 'question') {
642
+ deps.io.err('--kind question requires --reply so the question can be answered.')
588
643
  return EXIT.usage
589
644
  }
590
645
  if (
@@ -627,22 +682,42 @@ export async function sendCommand(
627
682
  deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`)
628
683
  return EXIT.usage
629
684
  }
685
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
686
+ if (mediaInputError !== null) {
687
+ deps.io.err(mediaInputError)
688
+ return EXIT.usage
689
+ }
630
690
  const config = loadLoggedConfig(deps, {
631
691
  cwd: deps.cwd,
632
692
  env: deps.env,
633
693
  flags: { base_url: flags.baseUrl, wait_seconds: flags.wait } as FlagOverrides,
634
694
  })
695
+ const source = resolveDraftInvocation(
696
+ deps,
697
+ flags,
698
+ activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()),
699
+ )
700
+ if (!source.ok) {
701
+ deps.io.err(source.error)
702
+ return EXIT.usage
703
+ }
635
704
  const authed = authedClient(deps, config)
636
705
  if (!authed) return EXIT.auth
637
- if (flags.image !== undefined && !flags.image.startsWith('med_')) {
638
- const uploaded = await uploadImage(deps, authed.client, flags.image)
706
+ const mediaIds: string[] = []
707
+ for (const image of flags.image ?? []) {
708
+ if (image.startsWith('med_')) {
709
+ mediaIds.push(image)
710
+ continue
711
+ }
712
+ const uploaded = await uploadImage(deps, authed.client, image)
639
713
  if (!uploaded.ok) {
640
714
  deps.io.err(uploaded.error)
641
715
  return uploaded.exit
642
716
  }
643
- flags = { ...flags, image: uploaded.mediaId }
717
+ mediaIds.push(uploaded.mediaId)
644
718
  }
645
- const build = buildDraft(config, flags)
719
+ flags = { ...flags, image: mediaIds }
720
+ const build = buildDraft(config, flags, source.invocation)
646
721
  if (!build.ok) {
647
722
  deps.io.err(build.error)
648
723
  return EXIT.usage
@@ -805,15 +880,9 @@ function emitSendWarnings(
805
880
  `Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`,
806
881
  )
807
882
  }
808
- if (looksLikeMarkdown(flags.body)) {
883
+ if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
809
884
  deps.io.err(
810
- 'Heads up: --body looks like Markdown, but banners show plain text. Put long-form Markdown in --detail or --detail-file.',
811
- )
812
- }
813
- const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update')
814
- if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
815
- deps.io.err(
816
- `Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`,
885
+ 'Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.',
817
886
  )
818
887
  }
819
888
  if (
@@ -832,12 +901,6 @@ function emitSendWarnings(
832
901
  }
833
902
  }
834
903
 
835
- function looksLikeMarkdown(value: string): boolean {
836
- return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(
837
- value,
838
- )
839
- }
840
-
841
904
  export async function repliesCommand(
842
905
  deps: CommandDeps,
843
906
  requestedId: string | undefined,
@@ -1640,11 +1703,15 @@ export interface AskFlags {
1640
1703
  choice?: string[]
1641
1704
  /** The single question is multi-select: several answers may be chosen. */
1642
1705
  multi?: boolean
1643
- /** Long-form markdown context; shown in the app, never on the banner. */
1644
- detail?: string
1706
+ /** Optional Markdown context appended after the question block. */
1707
+ body?: string
1645
1708
  /** Raw JSON for a multi-question form; replaces the positional question. */
1646
1709
  form?: string
1647
- session?: string
1710
+ image?: string[]
1711
+ imageAlt?: string[]
1712
+ project?: string
1713
+ sessionId?: string
1714
+ sessionLabel?: string
1648
1715
  }
1649
1716
 
1650
1717
  /** The `--form` document: what an agent writes to ask several things at once. */
@@ -1654,15 +1721,20 @@ interface AskFormQuestion {
1654
1721
  multi?: boolean
1655
1722
  }
1656
1723
 
1724
+ export interface BuiltQuestions {
1725
+ questions: QuestionT[]
1726
+ /** Canonical Markdown body: question block first, optional context second. */
1727
+ body: string
1728
+ }
1729
+
1657
1730
  /**
1658
- * Turn ask input into the question set that will ride the push. Everything is
1659
- * validated here, at registration, because the push happens inside a hook
1660
- * where a rejection becomes a stderr note the agent never reads.
1731
+ * Turn ask input into questions plus their canonical body. Everything is
1732
+ * validated at registration because a later hook failure is easy to miss.
1661
1733
  */
1662
1734
  export function buildQuestions(
1663
1735
  flags: AskFlags,
1664
1736
  question: string | undefined,
1665
- ): { ok: true; questions: QuestionT[]; detail?: string } | { ok: false; error: string } {
1737
+ ): { ok: true; questions: QuestionT[]; body: string } | { ok: false; error: string } {
1666
1738
  if (flags.form !== undefined) {
1667
1739
  if (question !== undefined || flags.choice?.length || flags.multi) {
1668
1740
  return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
@@ -1671,21 +1743,41 @@ export function buildQuestions(
1671
1743
  try {
1672
1744
  parsed = JSON.parse(flags.form)
1673
1745
  } catch {
1674
- return { ok: false, error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "detail"?}.' }
1746
+ return {
1747
+ ok: false,
1748
+ error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
1749
+ }
1675
1750
  }
1676
- if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { questions?: unknown }).questions)) {
1751
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1677
1752
  return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1678
1753
  }
1679
- const form = parsed as { questions: unknown[]; detail?: unknown }
1680
- if (form.questions.length < 1 || form.questions.length > REPLY_MAX_QUESTIONS) {
1681
- return { ok: false, error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${form.questions.length}.` }
1754
+ const record = parsed as Record<string, unknown>
1755
+ const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
1756
+ if (unknownKeys.length > 0) {
1757
+ return {
1758
+ ok: false,
1759
+ error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
1760
+ }
1761
+ }
1762
+ if (!Array.isArray(record['questions'])) {
1763
+ return { ok: false, error: '--form needs a "questions" array (1-4 entries).' }
1764
+ }
1765
+ const formQuestions = record['questions']
1766
+ if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
1767
+ return {
1768
+ ok: false,
1769
+ error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
1770
+ }
1771
+ }
1772
+ if (record['body'] !== undefined && typeof record['body'] !== 'string') {
1773
+ return { ok: false, error: '"body" must be a Markdown string.' }
1682
1774
  }
1683
- if (form.detail !== undefined && typeof form.detail !== 'string') {
1684
- return { ok: false, error: '"detail" must be a markdown string.' }
1775
+ if (flags.body !== undefined && record['body'] !== undefined) {
1776
+ return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' }
1685
1777
  }
1686
1778
  const questions: QuestionT[] = []
1687
1779
  const usedIds = new Set<string>()
1688
- for (const [index, entry] of form.questions.entries()) {
1780
+ for (const [index, entry] of formQuestions.entries()) {
1689
1781
  if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
1690
1782
  return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
1691
1783
  }
@@ -1694,7 +1786,16 @@ export function buildQuestions(
1694
1786
  if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
1695
1787
  questions.push(built.question)
1696
1788
  }
1697
- return { ok: true, questions, ...(form.detail !== undefined ? { detail: form.detail } : {}) }
1789
+ const context = flags.body ?? (record['body'] as string | undefined)
1790
+ const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
1791
+ return {
1792
+ ok: true,
1793
+ questions,
1794
+ body:
1795
+ context !== undefined && context.trim() !== ''
1796
+ ? `${questionBlock}\n\n${context}`
1797
+ : questionBlock,
1798
+ }
1698
1799
  }
1699
1800
 
1700
1801
  if (question === undefined || question.trim() === '') {
@@ -1702,10 +1803,14 @@ export function buildQuestions(
1702
1803
  }
1703
1804
  const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
1704
1805
  if ('error' in built) return { ok: false, error: built.error }
1806
+ const context = flags.body
1705
1807
  return {
1706
1808
  ok: true,
1707
1809
  questions: [built.question],
1708
- ...(flags.detail !== undefined && flags.detail.trim() !== '' ? { detail: flags.detail } : {}),
1810
+ body:
1811
+ context !== undefined && context.trim() !== ''
1812
+ ? `${built.question.text}\n\n${context}`
1813
+ : built.question.text,
1709
1814
  }
1710
1815
  }
1711
1816
 
@@ -1722,7 +1827,7 @@ function buildOneQuestion(
1722
1827
  return {
1723
1828
  error:
1724
1829
  `a question must be readable where it is answered: keep it within ` +
1725
- `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in detail.`,
1830
+ `${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
1726
1831
  }
1727
1832
  }
1728
1833
  const choices = parseChoices(choiceLabels)
@@ -1743,12 +1848,154 @@ function buildOneQuestion(
1743
1848
  }
1744
1849
  }
1745
1850
 
1851
+ function buildAskDraft(
1852
+ config: CliConfig,
1853
+ built: BuiltQuestions,
1854
+ flags: AskFlags,
1855
+ invocation: DraftInvocation,
1856
+ mediaIds: string[],
1857
+ ): { ok: true; draft: NotificationDraftT } | { ok: false; error: string } {
1858
+ const result = buildDraft(
1859
+ config,
1860
+ {
1861
+ title: built.questions[0]!.text,
1862
+ body: built.body,
1863
+ ...(flags.project !== undefined ? { project: flags.project } : {}),
1864
+ ...(mediaIds.length > 0 ? { image: mediaIds } : {}),
1865
+ ...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
1866
+ reply: true,
1867
+ questions: built.questions,
1868
+ },
1869
+ invocation,
1870
+ )
1871
+ if (!result.ok) return result
1872
+ const capabilities = CAPABILITIES_V1.describe(result.platform)
1873
+ if (capabilities === null) return { ok: false, error: 'No iOS capability contract is available.' }
1874
+ const validation = validateDraft(result.draft, capabilities)
1875
+ if (!validation.ok) {
1876
+ return {
1877
+ ok: false,
1878
+ error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
1879
+ }
1880
+ }
1881
+ return { ok: true, draft: result.draft }
1882
+ }
1883
+
1884
+ function recordRegisteredQuestion(
1885
+ deps: CommandDeps,
1886
+ sessionId: string,
1887
+ built: BuiltQuestions,
1888
+ draft: NotificationDraftT,
1889
+ ): number {
1890
+ try {
1891
+ registerQuestion(
1892
+ sessionId,
1893
+ deps.env,
1894
+ {
1895
+ question: built.questions[0]!.text,
1896
+ questions: built.questions,
1897
+ body: draft.presentation.body,
1898
+ ...(draft.project !== undefined ? { project: draft.project } : {}),
1899
+ ...(draft.source !== undefined ? { source: draft.source } : {}),
1900
+ ...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
1901
+ },
1902
+ (deps.now ?? Date.now)(),
1903
+ )
1904
+ } catch (err) {
1905
+ log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
1906
+ deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
1907
+ return EXIT.failed
1908
+ }
1909
+ log(deps).info('ask.registered', {
1910
+ ok: true,
1911
+ session: sessionId,
1912
+ questions: built.questions.length,
1913
+ text: built.questions[0]!.text,
1914
+ choices: built.questions[0]!.choices?.length ?? 0,
1915
+ media: draft.presentation.media?.length ?? 0,
1916
+ })
1917
+ for (const [index, entry] of built.questions.entries()) {
1918
+ const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1919
+ if (entry.choices !== undefined) {
1920
+ const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
1921
+ deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
1922
+ } else if (built.questions.length > 1) {
1923
+ deps.io.out(`${prefix}${entry.text} — free text`)
1924
+ }
1925
+ }
1926
+ deps.io.out(
1927
+ built.questions.length > 1
1928
+ ? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1929
+ : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
1930
+ )
1931
+ deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
1932
+ for (const [index, entry] of built.questions.entries()) {
1933
+ const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
1934
+ if (entry.choices !== undefined) {
1935
+ for (const choice of entry.choices) {
1936
+ deps.io.out(
1937
+ `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
1938
+ )
1939
+ }
1940
+ deps.io.out(
1941
+ `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
1942
+ )
1943
+ } else {
1944
+ deps.io.out(
1945
+ `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
1946
+ )
1947
+ }
1948
+ }
1949
+ deps.io.out(
1950
+ 'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
1951
+ )
1952
+ deps.io.out(
1953
+ 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
1954
+ )
1955
+ return EXIT.ok
1956
+ }
1957
+
1958
+ async function uploadAskMedia(
1959
+ deps: CommandDeps,
1960
+ config: CliConfig,
1961
+ sessionId: string,
1962
+ built: BuiltQuestions,
1963
+ flags: AskFlags,
1964
+ invocation: DraftInvocation,
1965
+ ): Promise<number> {
1966
+ const authed = authedClient(deps, config)
1967
+ if (!authed) return EXIT.auth
1968
+ const mediaIds: string[] = []
1969
+ for (const image of flags.image ?? []) {
1970
+ if (image.startsWith('med_')) {
1971
+ mediaIds.push(image)
1972
+ continue
1973
+ }
1974
+ const uploaded = await uploadImage(deps, authed.client, image)
1975
+ if (!uploaded.ok) {
1976
+ deps.io.err(uploaded.error)
1977
+ return uploaded.exit
1978
+ }
1979
+ mediaIds.push(uploaded.mediaId)
1980
+ }
1981
+ const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
1982
+ if (!ready.ok) {
1983
+ deps.io.err(ready.error)
1984
+ return EXIT.usage
1985
+ }
1986
+ return recordRegisteredQuestion(deps, sessionId, built, ready.draft)
1987
+ }
1988
+
1746
1989
  /**
1747
1990
  * Registers a question for turn-end routing. Returns immediately so the agent
1748
1991
  * can ask in prose and end its turn; the terminal keeps the question to itself
1749
1992
  * for `ask_grace_seconds` before it reaches any device.
1750
1993
  */
1751
- export function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number {
1994
+ export function askCommand(
1995
+ deps: CommandDeps,
1996
+ question: string | undefined,
1997
+ flags: AskFlags,
1998
+ ): number | Promise<number> {
1752
1999
  // Validate before route discovery. A malformed question belongs to the
1753
2000
  // caller and should not be hidden behind whichever harness setup issue
1754
2001
  // happens to exist on this machine.
@@ -1757,6 +2004,11 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1757
2004
  deps.io.err(built.error)
1758
2005
  return EXIT.usage
1759
2006
  }
2007
+ const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
2008
+ if (mediaInputError !== null) {
2009
+ deps.io.err(mediaInputError)
2010
+ return EXIT.usage
2011
+ }
1760
2012
  const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
1761
2013
  if (!routingConfig.ask_notifications.value) {
1762
2014
  deps.io.err(
@@ -1816,12 +2068,6 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1816
2068
  }
1817
2069
  return EXIT.usage
1818
2070
  }
1819
- if (flags.session !== undefined && flags.session !== active.sessionId) {
1820
- deps.io.err(
1821
- `Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`,
1822
- )
1823
- return EXIT.usage
1824
- }
1825
2071
  const routeProblems = activeQuestionRouteProblems(deps, active, installations)
1826
2072
  if (routeProblems.length > 0) {
1827
2073
  for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
@@ -1845,71 +2091,37 @@ export function askCommand(deps: CommandDeps, question: string | undefined, flag
1845
2091
  for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
1846
2092
  return EXIT.usage
1847
2093
  }
1848
- try {
1849
- registerQuestion(
1850
- sessionId,
1851
- deps.env,
1852
- {
1853
- question: built.questions[0]!.text,
1854
- questions: built.questions,
1855
- ...(built.detail !== undefined ? { detail: built.detail } : {}),
1856
- },
1857
- (deps.now ?? Date.now)(),
2094
+ const source = resolveDraftInvocation(deps, flags, active)
2095
+ if (!source.ok) {
2096
+ deps.io.err(source.error)
2097
+ return EXIT.usage
2098
+ }
2099
+ if (source.invocation.source?.session_id !== sessionId) {
2100
+ deps.io.err(
2101
+ `Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`,
1858
2102
  )
1859
- } catch (err) {
1860
- log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
1861
- deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
1862
- return EXIT.failed
2103
+ return EXIT.usage
1863
2104
  }
1864
- // `ask` returns immediately and the push happens later inside a hook, so this
1865
- // is the only local evidence that the question was ever registered — the
1866
- // starting point for "the agent asked, and nothing ever reached my phone".
1867
- log(deps).info('ask.registered', {
1868
- ok: true,
1869
- session: sessionId,
1870
- questions: built.questions.length,
1871
- text: built.questions[0]!.text,
1872
- choices: built.questions[0]!.choices?.length ?? 0,
1873
- })
1874
- for (const [index, entry] of built.questions.entries()) {
1875
- const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
1876
- if (entry.choices !== undefined) {
1877
- const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
1878
- deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
1879
- } else if (built.questions.length > 1) {
1880
- deps.io.out(`${prefix}${entry.text} — free text`)
1881
- }
2105
+
2106
+ // Placeholders let every body, source, project, media, and payload limit fail
2107
+ // before an upload starts. The real ids replace them only after this passes.
2108
+ const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
2109
+ const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
2110
+ if (!preflight.ok) {
2111
+ deps.io.err(preflight.error)
2112
+ return EXIT.usage
1882
2113
  }
1883
- deps.io.out(
1884
- built.questions.length > 1
1885
- ? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
1886
- : 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
1887
- )
1888
- deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
1889
- for (const [index, entry] of built.questions.entries()) {
1890
- const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
1891
- if (entry.choices !== undefined) {
1892
- for (const choice of entry.choices) {
1893
- deps.io.out(
1894
- `- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
1895
- )
1896
- }
1897
- deps.io.out(
1898
- `- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
1899
- )
1900
- } else {
1901
- deps.io.out(
1902
- `- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
1903
- )
1904
- }
2114
+ if ((flags.image?.length ?? 0) > 0) {
2115
+ return uploadAskMedia(
2116
+ deps,
2117
+ routingConfig,
2118
+ sessionId,
2119
+ built,
2120
+ flags,
2121
+ source.invocation,
2122
+ )
1905
2123
  }
1906
- deps.io.out(
1907
- 'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
1908
- )
1909
- deps.io.out(
1910
- 'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
1911
- )
1912
- return EXIT.ok
2124
+ return recordRegisteredQuestion(deps, sessionId, built, preflight.draft)
1913
2125
  }
1914
2126
 
1915
2127
  /**
@@ -3294,14 +3506,9 @@ async function skillReadiness(
3294
3506
  }
3295
3507
  }
3296
3508
 
3297
- /** Derive a contract-valid project slug from a directory name. */
3509
+ /** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
3298
3510
  export function projectSlugFrom(name: string): string {
3299
- const slug = name
3300
- .toLowerCase()
3301
- .replaceAll(/[^a-z0-9._-]+/g, '-')
3302
- .replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
3303
- .slice(0, 64)
3304
- return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project'
3511
+ return inferredProjectSlugFrom(name) ?? 'project'
3305
3512
  }
3306
3513
 
3307
3514
  export interface InitFlags {
@@ -4106,29 +4313,40 @@ async function contractCheck(client: ApiClient): Promise<{ name: string; ok: boo
4106
4313
  }
4107
4314
 
4108
4315
  function projectReadiness(deps: CommandDeps, config: CliConfig): ReadinessState {
4109
- const configPath = path.join(deps.cwd, '.notifai', 'config.toml')
4110
- const projectSlug = config.project.value
4111
- return projectSlug !== null
4112
- ? {
4113
- id: 'project',
4114
- title: 'Project identity',
4115
- status: 'ready',
4116
- detail: `"${projectSlug}" (${config.project.source})`,
4117
- }
4118
- : {
4119
- id: 'project',
4120
- title: 'Project identity',
4121
- // Not a blocker: a send without a project simply carries no project
4122
- // identity. init always sets one because it is free and reversible,
4123
- // but an unlabelled setup works, so this must not go red.
4124
- status: 'optional-gap',
4125
- detail: `not set in ${configPath} — sends from here carry no project identity`,
4126
- remedy: {
4127
- by: 'cli',
4128
- summary: 'name this project after its directory',
4129
- command: 'notifai init',
4130
- },
4131
- }
4316
+ const configured = config.project.value
4317
+ if (configured !== null) {
4318
+ return {
4319
+ id: 'project',
4320
+ title: 'Project identity',
4321
+ status: 'ready',
4322
+ detail: `"${configured}" (${config.project.source})`,
4323
+ }
4324
+ }
4325
+ const inferred = inferInvocationContext(deps.cwd).project
4326
+ if (inferred !== null) {
4327
+ return {
4328
+ id: 'project',
4329
+ title: 'Project identity',
4330
+ status: 'optional-gap',
4331
+ detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
4332
+ remedy: {
4333
+ by: 'cli',
4334
+ summary: 'make the inferred Project identity explicit for every checkout',
4335
+ command: 'notifai init',
4336
+ },
4337
+ }
4338
+ }
4339
+ return {
4340
+ id: 'project',
4341
+ title: 'Project identity',
4342
+ status: 'optional-gap',
4343
+ detail: 'the directory name has no characters a Project identifier can use',
4344
+ remedy: {
4345
+ by: 'cli',
4346
+ summary: 'choose an explicit Project identifier',
4347
+ command: 'notifai init --project-id my-project',
4348
+ },
4349
+ }
4132
4350
  }
4133
4351
 
4134
4352
  function remoteStatesFrom(previous: Readiness): {
@@ -86,7 +86,7 @@ describe('formatValue', () => {
86
86
  it('explains what an unset value means instead of printing null', () => {
87
87
  // `sound = null` is accurate and tells the reader nothing about what will
88
88
  // happen; the whole complaint about the old output in one line.
89
- expect(formatValue('sound', null)).toBe('each notification uses the sound for its kind')
89
+ expect(formatValue('sound', null)).toBe('not specified; the destination uses its normal behavior')
90
90
  expect(formatValue('devices', null)).toBe('every device that can receive')
91
91
  })
92
92