@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/CHANGELOG.md +48 -0
- package/README.md +7 -4
- package/dist/client.d.ts +2 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +17 -9
- package/dist/commands.js +261 -100
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +9 -9
- package/dist/config-schema.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks.d.ts +12 -7
- package/dist/hooks.js +26 -46
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/main.js +46 -38
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +32 -7
- package/dist/send.js +77 -32
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +46 -0
- package/src/client.ts +13 -0
- package/src/commands.test.ts +333 -37
- package/src/commands.ts +354 -136
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +9 -9
- package/src/config.test.ts +144 -16
- package/src/config.ts +1 -1
- package/src/hooks.test.ts +144 -38
- package/src/hooks.ts +67 -73
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/main.ts +72 -41
- package/src/send.ts +116 -38
- package/src/skill.test.ts +31 -7
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
|
|
587
|
-
deps.io.err(
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
717
|
+
mediaIds.push(uploaded.mediaId)
|
|
644
718
|
}
|
|
645
|
-
|
|
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 (
|
|
883
|
+
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
809
884
|
deps.io.err(
|
|
810
|
-
'Heads up:
|
|
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
|
-
/**
|
|
1644
|
-
|
|
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
|
-
|
|
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
|
|
1659
|
-
* validated
|
|
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[];
|
|
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 {
|
|
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 ||
|
|
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
|
|
1680
|
-
|
|
1681
|
-
|
|
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 (
|
|
1684
|
-
return { ok: false, error: '"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
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
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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.
|
|
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
|
|
3509
|
+
/** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
|
|
3298
3510
|
export function projectSlugFrom(name: string): string {
|
|
3299
|
-
|
|
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
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
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('
|
|
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
|
|