@raidiant/notifai 3.0.1 → 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 +27 -0
- package/README.md +7 -4
- 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/commands.test.ts +325 -21
- 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 +143 -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/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
8
8
|
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
|
9
9
|
and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
|
|
10
10
|
|
|
11
|
+
## [4.0.0](https://github.com/Raidiant-io/notifai/compare/v3.0.1...v4.0.0) (2026-08-15)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### ⚠ BREAKING CHANGES
|
|
15
|
+
|
|
16
|
+
* **cli:** `--detail` and `--detail-file` are removed; the one canonical Markdown body is `--body` / `--body-file`. `--session` is removed, replaced by `--session-id` and `--session-label`. `--image` is now a repeatable ordered collection (max 8) with `--image-alt` for alt text.
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* add unified notification content and source context ([3e6479a](https://github.com/Raidiant-io/notifai/commit/3e6479ae22c64c6886c1e919ba3183486e408596))
|
|
21
|
+
* add unified notification content and source context ([2473ed5](https://github.com/Raidiant-io/notifai/commit/2473ed51df42b56652435c611a57f3751fa484f5))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* attribute the unified-content break and propagate workspace dependency bumps ([cb438ce](https://github.com/Raidiant-io/notifai/commit/cb438cec76de9b393d7673554dc440a764887f76))
|
|
27
|
+
* **cli:** document replacements for the removed send flags ([44af905](https://github.com/Raidiant-io/notifai/commit/44af90536a1e6fa5af2ec6b58a19ea68f2d76b96))
|
|
28
|
+
* print server rejection details on command failures ([884fc78](https://github.com/Raidiant-io/notifai/commit/884fc78b62382ad23a49e1c5b92772dd1b8d574b))
|
|
29
|
+
* print server rejection details on command failures ([c9ec4e4](https://github.com/Raidiant-io/notifai/commit/c9ec4e45130784fff7b8ab4da560899eaddffcc5))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Dependencies
|
|
33
|
+
|
|
34
|
+
* The following workspace dependencies were updated
|
|
35
|
+
* dependencies
|
|
36
|
+
* @raidiant/notifai-protocol bumped from 0.6.0 to 1.0.0
|
|
37
|
+
|
|
11
38
|
## [3.0.1](https://github.com/Raidiant-io/notifai/compare/v3.0.0...v3.0.1) (2026-08-14)
|
|
12
39
|
|
|
13
40
|
|
package/README.md
CHANGED
|
@@ -32,10 +32,13 @@ project or the machine. An unattended caller must select one explicitly:
|
|
|
32
32
|
notifai send --title "Deploy finished" --body "staging, 2m14s" --kind done
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Write
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
Write a brief title whose substance is immediately understandable; type lives
|
|
36
|
+
in `--kind`, and Project identity is inferred from the invocation directory.
|
|
37
|
+
The one body is always Markdown. Native banners show a bounded readable
|
|
38
|
+
plain-text excerpt from it, while Companion Apps render the complete body. Use
|
|
39
|
+
`--body-file <path|->` for long content. Repeat `--image` for an ordered image
|
|
40
|
+
collection (up to eight), pair each with `--image-alt` for alt text, and use
|
|
41
|
+
`media:1`, `media:2`, and so on for inline references.
|
|
39
42
|
|
|
40
43
|
## Asking you a question
|
|
41
44
|
|
package/dist/commands.d.ts
CHANGED
|
@@ -205,21 +205,29 @@ export interface AskFlags {
|
|
|
205
205
|
choice?: string[];
|
|
206
206
|
/** The single question is multi-select: several answers may be chosen. */
|
|
207
207
|
multi?: boolean;
|
|
208
|
-
/**
|
|
209
|
-
|
|
208
|
+
/** Optional Markdown context appended after the question block. */
|
|
209
|
+
body?: string;
|
|
210
210
|
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
211
211
|
form?: string;
|
|
212
|
-
|
|
212
|
+
image?: string[];
|
|
213
|
+
imageAlt?: string[];
|
|
214
|
+
project?: string;
|
|
215
|
+
sessionId?: string;
|
|
216
|
+
sessionLabel?: string;
|
|
217
|
+
}
|
|
218
|
+
export interface BuiltQuestions {
|
|
219
|
+
questions: QuestionT[];
|
|
220
|
+
/** Canonical Markdown body: question block first, optional context second. */
|
|
221
|
+
body: string;
|
|
213
222
|
}
|
|
214
223
|
/**
|
|
215
|
-
* Turn ask input into
|
|
216
|
-
* validated
|
|
217
|
-
* where a rejection becomes a stderr note the agent never reads.
|
|
224
|
+
* Turn ask input into questions plus their canonical body. Everything is
|
|
225
|
+
* validated at registration because a later hook failure is easy to miss.
|
|
218
226
|
*/
|
|
219
227
|
export declare function buildQuestions(flags: AskFlags, question: string | undefined): {
|
|
220
228
|
ok: true;
|
|
221
229
|
questions: QuestionT[];
|
|
222
|
-
|
|
230
|
+
body: string;
|
|
223
231
|
} | {
|
|
224
232
|
ok: false;
|
|
225
233
|
error: string;
|
|
@@ -229,7 +237,7 @@ export declare function buildQuestions(flags: AskFlags, question: string | undef
|
|
|
229
237
|
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
230
238
|
* for `ask_grace_seconds` before it reaches any device.
|
|
231
239
|
*/
|
|
232
|
-
export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number
|
|
240
|
+
export declare function askCommand(deps: CommandDeps, question: string | undefined, flags: AskFlags): number | Promise<number>;
|
|
233
241
|
/** Record the one Agent Acknowledgement associated with a replied-to request. */
|
|
234
242
|
export declare function acknowledgeCommand(deps: CommandDeps, requestId: string, flags: {
|
|
235
243
|
text?: string;
|
|
@@ -314,7 +322,7 @@ export declare function logsCommand(deps: CommandDeps, flags: LogsFlags): number
|
|
|
314
322
|
* Null when the build cannot establish its version; see `./release.js`.
|
|
315
323
|
*/
|
|
316
324
|
export declare const SKILLS_SOURCE: string | null;
|
|
317
|
-
/** Derive a contract-valid project slug
|
|
325
|
+
/** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
|
|
318
326
|
export declare function projectSlugFrom(name: string): string;
|
|
319
327
|
export interface InitFlags {
|
|
320
328
|
projectId?: string;
|
package/dist/commands.js
CHANGED
|
@@ -21,7 +21,8 @@ import { claudeWakeRoute, inspectClaudeInbox, systemClaudeWakeAdapters, } from '
|
|
|
21
21
|
import { codexWakeRoute, inspectCodexResume, } from './codex-wake.js';
|
|
22
22
|
import { inspectHookAdapter, installHookAdapter, isNpxAdapterTarget, } from './hook-adapter.js';
|
|
23
23
|
import { isOurOpencodePlugin, opencodePluginSource, } from './opencode-plugin.js';
|
|
24
|
-
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, } from './send.js';
|
|
24
|
+
import { CHOICE_USAGE, buildDraft, formatReceipt, parseChoices, receiptExitCode, slugify, validateMediaInputs, } from './send.js';
|
|
25
|
+
import { buildSourceContext, inferInvocationContext, projectSlugFrom as inferredProjectSlugFrom, } from './invocation-context.js';
|
|
25
26
|
import { openUrl } from './platform.js';
|
|
26
27
|
/** The invocation's logger, or one that discards everything. */
|
|
27
28
|
export function log(deps) {
|
|
@@ -79,6 +80,18 @@ function reportError(deps, err, context = {}) {
|
|
|
79
80
|
});
|
|
80
81
|
if (err instanceof ApiCallError) {
|
|
81
82
|
deps.io.err(`${err.code}: ${err.message}`);
|
|
83
|
+
// The server's field-level details were already being logged above, but
|
|
84
|
+
// stderr is the one surface an agent's next turn actually reads — so the
|
|
85
|
+
// diagnosis has to be on it, the same way the hook path prints it.
|
|
86
|
+
const paths = rejectedPaths(err.details);
|
|
87
|
+
if (paths.length > 0)
|
|
88
|
+
deps.io.err(`the server rejected: ${paths.join(', ')}`);
|
|
89
|
+
// A 422 on a request this CLI built is not a user error: the two sides
|
|
90
|
+
// disagree about the contract, in whichever direction. `doctor` says which.
|
|
91
|
+
if (err.status === 422) {
|
|
92
|
+
deps.io.err('this build sent a field the server did not accept — the CLI and server ' +
|
|
93
|
+
'disagree about the contract; check with `notifai doctor`');
|
|
94
|
+
}
|
|
82
95
|
if (err.nextAction)
|
|
83
96
|
deps.io.err(`next: ${err.nextAction}`);
|
|
84
97
|
if (err.code === 'auth_required' || err.code === 'machine_revoked')
|
|
@@ -324,12 +337,42 @@ export async function capabilitiesCommand(deps, flags) {
|
|
|
324
337
|
// ---------------------------------------------------------------------------
|
|
325
338
|
// send / status
|
|
326
339
|
// ---------------------------------------------------------------------------
|
|
340
|
+
function resolveDraftInvocation(deps, flags, active) {
|
|
341
|
+
const inferred = inferInvocationContext(deps.cwd);
|
|
342
|
+
const source = buildSourceContext({
|
|
343
|
+
env: deps.env,
|
|
344
|
+
invocation: inferred,
|
|
345
|
+
...(flags.sessionId !== undefined ? { sessionId: flags.sessionId } : {}),
|
|
346
|
+
...(flags.sessionLabel !== undefined ? { sessionLabel: flags.sessionLabel } : {}),
|
|
347
|
+
...(active === null
|
|
348
|
+
? {}
|
|
349
|
+
: {
|
|
350
|
+
activeHarness: {
|
|
351
|
+
harness: active.harness,
|
|
352
|
+
...(active.sessionId === undefined ? {} : { sessionId: active.sessionId }),
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
});
|
|
356
|
+
if (!source.ok)
|
|
357
|
+
return source;
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
360
|
+
invocation: {
|
|
361
|
+
inferredProject: inferred.project,
|
|
362
|
+
...(source.source === undefined ? {} : { source: source.source }),
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
327
366
|
export async function sendCommand(deps, flags) {
|
|
328
367
|
const hasReplyChoice = Array.isArray(flags.replyChoice)
|
|
329
368
|
? flags.replyChoice.length > 0
|
|
330
369
|
: flags.replyChoice !== undefined;
|
|
331
|
-
if (flags.reply && flags.kind
|
|
332
|
-
deps.io.err(
|
|
370
|
+
if (flags.reply && flags.kind !== undefined && flags.kind !== 'question') {
|
|
371
|
+
deps.io.err(`--kind ${flags.kind} cannot be combined with --reply; a reply request is a question.`);
|
|
372
|
+
return EXIT.usage;
|
|
373
|
+
}
|
|
374
|
+
if (!flags.reply && flags.kind === 'question') {
|
|
375
|
+
deps.io.err('--kind question requires --reply so the question can be answered.');
|
|
333
376
|
return EXIT.usage;
|
|
334
377
|
}
|
|
335
378
|
if (!flags.reply &&
|
|
@@ -366,23 +409,39 @@ export async function sendCommand(deps, flags) {
|
|
|
366
409
|
deps.io.err(`--reply-window must be an integer from 60 to ${REPLY_MAX_WINDOW_SECONDS} seconds.`);
|
|
367
410
|
return EXIT.usage;
|
|
368
411
|
}
|
|
412
|
+
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
|
|
413
|
+
if (mediaInputError !== null) {
|
|
414
|
+
deps.io.err(mediaInputError);
|
|
415
|
+
return EXIT.usage;
|
|
416
|
+
}
|
|
369
417
|
const config = loadLoggedConfig(deps, {
|
|
370
418
|
cwd: deps.cwd,
|
|
371
419
|
env: deps.env,
|
|
372
420
|
flags: { base_url: flags.baseUrl, wait_seconds: flags.wait },
|
|
373
421
|
});
|
|
422
|
+
const source = resolveDraftInvocation(deps, flags, activeHarnessSession(deps.env, deps.cwd, (deps.now ?? Date.now)()));
|
|
423
|
+
if (!source.ok) {
|
|
424
|
+
deps.io.err(source.error);
|
|
425
|
+
return EXIT.usage;
|
|
426
|
+
}
|
|
374
427
|
const authed = authedClient(deps, config);
|
|
375
428
|
if (!authed)
|
|
376
429
|
return EXIT.auth;
|
|
377
|
-
|
|
378
|
-
|
|
430
|
+
const mediaIds = [];
|
|
431
|
+
for (const image of flags.image ?? []) {
|
|
432
|
+
if (image.startsWith('med_')) {
|
|
433
|
+
mediaIds.push(image);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const uploaded = await uploadImage(deps, authed.client, image);
|
|
379
437
|
if (!uploaded.ok) {
|
|
380
438
|
deps.io.err(uploaded.error);
|
|
381
439
|
return uploaded.exit;
|
|
382
440
|
}
|
|
383
|
-
|
|
441
|
+
mediaIds.push(uploaded.mediaId);
|
|
384
442
|
}
|
|
385
|
-
|
|
443
|
+
flags = { ...flags, image: mediaIds };
|
|
444
|
+
const build = buildDraft(config, flags, source.invocation);
|
|
386
445
|
if (!build.ok) {
|
|
387
446
|
deps.io.err(build.error);
|
|
388
447
|
return EXIT.usage;
|
|
@@ -529,12 +588,8 @@ function emitSendWarnings(deps, flags, config) {
|
|
|
529
588
|
if (flags.title.length > 40) {
|
|
530
589
|
deps.io.err(`Heads up: this title is ${flags.title.length} characters; notification titles work best around 40 characters or fewer.`);
|
|
531
590
|
}
|
|
532
|
-
if (
|
|
533
|
-
deps.io.err('Heads up:
|
|
534
|
-
}
|
|
535
|
-
const effectiveKind = flags.reply ? 'question' : (flags.kind ?? 'update');
|
|
536
|
-
if (effectiveKind === 'update' && /^(done|failed)\b/i.test(flags.title.trim())) {
|
|
537
|
-
deps.io.err(`Heads up: this title announces completion but the notification kind is update. Use --kind done for finished work.`);
|
|
591
|
+
if (/^(?:update|done|question|failed|blocked)\s*(?:[·:—-]|$)/i.test(flags.title.trim())) {
|
|
592
|
+
deps.io.err('Heads up: keep the title to the specific substance. Put notification type in --kind; project identity is inferred separately.');
|
|
538
593
|
}
|
|
539
594
|
if (flags.collapseKey === undefined &&
|
|
540
595
|
config.collapse_key.value !== null &&
|
|
@@ -545,9 +600,6 @@ function emitSendWarnings(deps, flags, config) {
|
|
|
545
600
|
deps.io.err('Heads up: this explicit --ttl is longer than 72 hours; stale notifications may arrive after they are useful.');
|
|
546
601
|
}
|
|
547
602
|
}
|
|
548
|
-
function looksLikeMarkdown(value) {
|
|
549
|
-
return /(?:^|\n)\s{0,3}(?:#{1,6}\s|[-+*]\s|>\s|\d+\.\s|```)|(?:\*\*|__|~~|`)[^\n]+(?:\*\*|__|~~|`)|\[[^\]]+\]\([^)]+\)/m.test(value);
|
|
550
|
-
}
|
|
551
603
|
export async function repliesCommand(deps, requestedId, flags) {
|
|
552
604
|
const waitSeconds = flags.wait ?? 0;
|
|
553
605
|
const afterSeq = flags.after ?? 0;
|
|
@@ -1251,9 +1303,8 @@ function rejectedPaths(details) {
|
|
|
1251
1303
|
.slice(0, 5);
|
|
1252
1304
|
}
|
|
1253
1305
|
/**
|
|
1254
|
-
* Turn ask input into
|
|
1255
|
-
* validated
|
|
1256
|
-
* where a rejection becomes a stderr note the agent never reads.
|
|
1306
|
+
* Turn ask input into questions plus their canonical body. Everything is
|
|
1307
|
+
* validated at registration because a later hook failure is easy to miss.
|
|
1257
1308
|
*/
|
|
1258
1309
|
export function buildQuestions(flags, question) {
|
|
1259
1310
|
if (flags.form !== undefined) {
|
|
@@ -1265,21 +1316,41 @@ export function buildQuestions(flags, question) {
|
|
|
1265
1316
|
parsed = JSON.parse(flags.form);
|
|
1266
1317
|
}
|
|
1267
1318
|
catch {
|
|
1268
|
-
return {
|
|
1319
|
+
return {
|
|
1320
|
+
ok: false,
|
|
1321
|
+
error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
|
|
1322
|
+
};
|
|
1269
1323
|
}
|
|
1270
|
-
if (typeof parsed !== 'object' || parsed === null ||
|
|
1324
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
1271
1325
|
return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
|
|
1272
1326
|
}
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1327
|
+
const record = parsed;
|
|
1328
|
+
const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body');
|
|
1329
|
+
if (unknownKeys.length > 0) {
|
|
1330
|
+
return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
if (!Array.isArray(record['questions'])) {
|
|
1336
|
+
return { ok: false, error: '--form needs a "questions" array (1-4 entries).' };
|
|
1337
|
+
}
|
|
1338
|
+
const formQuestions = record['questions'];
|
|
1339
|
+
if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
if (record['body'] !== undefined && typeof record['body'] !== 'string') {
|
|
1346
|
+
return { ok: false, error: '"body" must be a Markdown string.' };
|
|
1276
1347
|
}
|
|
1277
|
-
if (
|
|
1278
|
-
return { ok: false, error: '"
|
|
1348
|
+
if (flags.body !== undefined && record['body'] !== undefined) {
|
|
1349
|
+
return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' };
|
|
1279
1350
|
}
|
|
1280
1351
|
const questions = [];
|
|
1281
1352
|
const usedIds = new Set();
|
|
1282
|
-
for (const [index, entry] of
|
|
1353
|
+
for (const [index, entry] of formQuestions.entries()) {
|
|
1283
1354
|
if (typeof entry !== 'object' || entry === null || typeof entry.text !== 'string') {
|
|
1284
1355
|
return { ok: false, error: `Question ${index + 1} needs a "text" string.` };
|
|
1285
1356
|
}
|
|
@@ -1289,7 +1360,15 @@ export function buildQuestions(flags, question) {
|
|
|
1289
1360
|
return { ok: false, error: `Question ${index + 1}: ${built.error}` };
|
|
1290
1361
|
questions.push(built.question);
|
|
1291
1362
|
}
|
|
1292
|
-
|
|
1363
|
+
const context = flags.body ?? record['body'];
|
|
1364
|
+
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n');
|
|
1365
|
+
return {
|
|
1366
|
+
ok: true,
|
|
1367
|
+
questions,
|
|
1368
|
+
body: context !== undefined && context.trim() !== ''
|
|
1369
|
+
? `${questionBlock}\n\n${context}`
|
|
1370
|
+
: questionBlock,
|
|
1371
|
+
};
|
|
1293
1372
|
}
|
|
1294
1373
|
if (question === undefined || question.trim() === '') {
|
|
1295
1374
|
return { ok: false, error: 'The question cannot be empty.' };
|
|
@@ -1297,10 +1376,13 @@ export function buildQuestions(flags, question) {
|
|
|
1297
1376
|
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set());
|
|
1298
1377
|
if ('error' in built)
|
|
1299
1378
|
return { ok: false, error: built.error };
|
|
1379
|
+
const context = flags.body;
|
|
1300
1380
|
return {
|
|
1301
1381
|
ok: true,
|
|
1302
1382
|
questions: [built.question],
|
|
1303
|
-
|
|
1383
|
+
body: context !== undefined && context.trim() !== ''
|
|
1384
|
+
? `${built.question.text}\n\n${context}`
|
|
1385
|
+
: built.question.text,
|
|
1304
1386
|
};
|
|
1305
1387
|
}
|
|
1306
1388
|
function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
@@ -1310,7 +1392,7 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
|
1310
1392
|
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
1311
1393
|
return {
|
|
1312
1394
|
error: `a question must be readable where it is answered: keep it within ` +
|
|
1313
|
-
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in
|
|
1395
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
|
|
1314
1396
|
};
|
|
1315
1397
|
}
|
|
1316
1398
|
const choices = parseChoices(choiceLabels);
|
|
@@ -1332,6 +1414,108 @@ function buildOneQuestion(text, choiceLabels, multi, index, usedIds) {
|
|
|
1332
1414
|
},
|
|
1333
1415
|
};
|
|
1334
1416
|
}
|
|
1417
|
+
function buildAskDraft(config, built, flags, invocation, mediaIds) {
|
|
1418
|
+
const result = buildDraft(config, {
|
|
1419
|
+
title: built.questions[0].text,
|
|
1420
|
+
body: built.body,
|
|
1421
|
+
...(flags.project !== undefined ? { project: flags.project } : {}),
|
|
1422
|
+
...(mediaIds.length > 0 ? { image: mediaIds } : {}),
|
|
1423
|
+
...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
|
|
1424
|
+
reply: true,
|
|
1425
|
+
questions: built.questions,
|
|
1426
|
+
}, invocation);
|
|
1427
|
+
if (!result.ok)
|
|
1428
|
+
return result;
|
|
1429
|
+
const capabilities = CAPABILITIES_V1.describe(result.platform);
|
|
1430
|
+
if (capabilities === null)
|
|
1431
|
+
return { ok: false, error: 'No iOS capability contract is available.' };
|
|
1432
|
+
const validation = validateDraft(result.draft, capabilities);
|
|
1433
|
+
if (!validation.ok) {
|
|
1434
|
+
return {
|
|
1435
|
+
ok: false,
|
|
1436
|
+
error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
return { ok: true, draft: result.draft };
|
|
1440
|
+
}
|
|
1441
|
+
function recordRegisteredQuestion(deps, sessionId, built, draft) {
|
|
1442
|
+
try {
|
|
1443
|
+
registerQuestion(sessionId, deps.env, {
|
|
1444
|
+
question: built.questions[0].text,
|
|
1445
|
+
questions: built.questions,
|
|
1446
|
+
body: draft.presentation.body,
|
|
1447
|
+
...(draft.project !== undefined ? { project: draft.project } : {}),
|
|
1448
|
+
...(draft.source !== undefined ? { source: draft.source } : {}),
|
|
1449
|
+
...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
|
|
1450
|
+
}, (deps.now ?? Date.now)());
|
|
1451
|
+
}
|
|
1452
|
+
catch (err) {
|
|
1453
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) });
|
|
1454
|
+
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
|
|
1455
|
+
return EXIT.failed;
|
|
1456
|
+
}
|
|
1457
|
+
log(deps).info('ask.registered', {
|
|
1458
|
+
ok: true,
|
|
1459
|
+
session: sessionId,
|
|
1460
|
+
questions: built.questions.length,
|
|
1461
|
+
text: built.questions[0].text,
|
|
1462
|
+
choices: built.questions[0].choices?.length ?? 0,
|
|
1463
|
+
media: draft.presentation.media?.length ?? 0,
|
|
1464
|
+
});
|
|
1465
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
1466
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
1467
|
+
if (entry.choices !== undefined) {
|
|
1468
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
|
|
1469
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
|
|
1470
|
+
}
|
|
1471
|
+
else if (built.questions.length > 1) {
|
|
1472
|
+
deps.io.out(`${prefix}${entry.text} — free text`);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
deps.io.out(built.questions.length > 1
|
|
1476
|
+
? `${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.`
|
|
1477
|
+
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
|
|
1478
|
+
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
1479
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
1480
|
+
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
1481
|
+
if (entry.choices !== undefined) {
|
|
1482
|
+
for (const choice of entry.choices) {
|
|
1483
|
+
deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
|
|
1484
|
+
}
|
|
1485
|
+
deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
deps.io.out('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.');
|
|
1492
|
+
deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
|
|
1493
|
+
return EXIT.ok;
|
|
1494
|
+
}
|
|
1495
|
+
async function uploadAskMedia(deps, config, sessionId, built, flags, invocation) {
|
|
1496
|
+
const authed = authedClient(deps, config);
|
|
1497
|
+
if (!authed)
|
|
1498
|
+
return EXIT.auth;
|
|
1499
|
+
const mediaIds = [];
|
|
1500
|
+
for (const image of flags.image ?? []) {
|
|
1501
|
+
if (image.startsWith('med_')) {
|
|
1502
|
+
mediaIds.push(image);
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
const uploaded = await uploadImage(deps, authed.client, image);
|
|
1506
|
+
if (!uploaded.ok) {
|
|
1507
|
+
deps.io.err(uploaded.error);
|
|
1508
|
+
return uploaded.exit;
|
|
1509
|
+
}
|
|
1510
|
+
mediaIds.push(uploaded.mediaId);
|
|
1511
|
+
}
|
|
1512
|
+
const ready = buildAskDraft(config, built, flags, invocation, mediaIds);
|
|
1513
|
+
if (!ready.ok) {
|
|
1514
|
+
deps.io.err(ready.error);
|
|
1515
|
+
return EXIT.usage;
|
|
1516
|
+
}
|
|
1517
|
+
return recordRegisteredQuestion(deps, sessionId, built, ready.draft);
|
|
1518
|
+
}
|
|
1335
1519
|
/**
|
|
1336
1520
|
* Registers a question for turn-end routing. Returns immediately so the agent
|
|
1337
1521
|
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
@@ -1346,6 +1530,11 @@ export function askCommand(deps, question, flags) {
|
|
|
1346
1530
|
deps.io.err(built.error);
|
|
1347
1531
|
return EXIT.usage;
|
|
1348
1532
|
}
|
|
1533
|
+
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt);
|
|
1534
|
+
if (mediaInputError !== null) {
|
|
1535
|
+
deps.io.err(mediaInputError);
|
|
1536
|
+
return EXIT.usage;
|
|
1537
|
+
}
|
|
1349
1538
|
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env });
|
|
1350
1539
|
if (!routingConfig.ask_notifications.value) {
|
|
1351
1540
|
deps.io.err('Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.');
|
|
@@ -1383,10 +1572,6 @@ export function askCommand(deps, question, flags) {
|
|
|
1383
1572
|
}
|
|
1384
1573
|
return EXIT.usage;
|
|
1385
1574
|
}
|
|
1386
|
-
if (flags.session !== undefined && flags.session !== active.sessionId) {
|
|
1387
|
-
deps.io.err(`Question routing is not ready: --session ${flags.session} does not match the exact active ${active.label} session ${active.sessionId}; refusing cross-session routing.`);
|
|
1388
|
-
return EXIT.usage;
|
|
1389
|
-
}
|
|
1390
1575
|
const routeProblems = activeQuestionRouteProblems(deps, active, installations);
|
|
1391
1576
|
if (routeProblems.length > 0) {
|
|
1392
1577
|
for (const problem of routeProblems)
|
|
@@ -1410,57 +1595,27 @@ export function askCommand(deps, question, flags) {
|
|
|
1410
1595
|
deps.io.err(line);
|
|
1411
1596
|
return EXIT.usage;
|
|
1412
1597
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
...(built.detail !== undefined ? { detail: built.detail } : {}),
|
|
1418
|
-
}, (deps.now ?? Date.now)());
|
|
1598
|
+
const source = resolveDraftInvocation(deps, flags, active);
|
|
1599
|
+
if (!source.ok) {
|
|
1600
|
+
deps.io.err(source.error);
|
|
1601
|
+
return EXIT.usage;
|
|
1419
1602
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
return EXIT.failed;
|
|
1603
|
+
if (source.invocation.source?.session_id !== sessionId) {
|
|
1604
|
+
deps.io.err(`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`);
|
|
1605
|
+
return EXIT.usage;
|
|
1424
1606
|
}
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
text: built.questions[0].text,
|
|
1433
|
-
choices: built.questions[0].choices?.length ?? 0,
|
|
1434
|
-
});
|
|
1435
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1436
|
-
const prefix = built.questions.length > 1 ? `${index + 1}. ` : '';
|
|
1437
|
-
if (entry.choices !== undefined) {
|
|
1438
|
-
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered';
|
|
1439
|
-
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`);
|
|
1440
|
-
}
|
|
1441
|
-
else if (built.questions.length > 1) {
|
|
1442
|
-
deps.io.out(`${prefix}${entry.text} — free text`);
|
|
1443
|
-
}
|
|
1607
|
+
// Placeholders let every body, source, project, media, and payload limit fail
|
|
1608
|
+
// before an upload starts. The real ids replace them only after this passes.
|
|
1609
|
+
const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`);
|
|
1610
|
+
const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders);
|
|
1611
|
+
if (!preflight.ok) {
|
|
1612
|
+
deps.io.err(preflight.error);
|
|
1613
|
+
return EXIT.usage;
|
|
1444
1614
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.');
|
|
1448
|
-
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:');
|
|
1449
|
-
for (const [index, entry] of built.questions.entries()) {
|
|
1450
|
-
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : '';
|
|
1451
|
-
if (entry.choices !== undefined) {
|
|
1452
|
-
for (const choice of entry.choices) {
|
|
1453
|
-
deps.io.out(`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`);
|
|
1454
|
-
}
|
|
1455
|
-
deps.io.out(`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`);
|
|
1456
|
-
}
|
|
1457
|
-
else {
|
|
1458
|
-
deps.io.out(`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`);
|
|
1459
|
-
}
|
|
1615
|
+
if ((flags.image?.length ?? 0) > 0) {
|
|
1616
|
+
return uploadAskMedia(deps, routingConfig, sessionId, built, flags, source.invocation);
|
|
1460
1617
|
}
|
|
1461
|
-
deps
|
|
1462
|
-
deps.io.out('A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.');
|
|
1463
|
-
return EXIT.ok;
|
|
1618
|
+
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft);
|
|
1464
1619
|
}
|
|
1465
1620
|
/**
|
|
1466
1621
|
* Every harness marker present in this environment, in declared order.
|
|
@@ -2599,14 +2754,9 @@ async function skillReadiness(deps, selectedScope) {
|
|
|
2599
2754
|
},
|
|
2600
2755
|
};
|
|
2601
2756
|
}
|
|
2602
|
-
/** Derive a contract-valid project slug
|
|
2757
|
+
/** Derive a contract-valid project slug; init alone needs a non-empty fallback. */
|
|
2603
2758
|
export function projectSlugFrom(name) {
|
|
2604
|
-
|
|
2605
|
-
.toLowerCase()
|
|
2606
|
-
.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
2607
|
-
.replaceAll(/^[^a-z0-9]+|[^a-z0-9._-]+$/g, '')
|
|
2608
|
-
.slice(0, 64);
|
|
2609
|
-
return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
|
|
2759
|
+
return inferredProjectSlugFrom(name) ?? 'project';
|
|
2610
2760
|
}
|
|
2611
2761
|
/** Long enough for a first TestFlight install; keep-waiting extends another budget. */
|
|
2612
2762
|
const DEVICE_BRIDGE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
@@ -3265,29 +3415,40 @@ async function contractCheck(client) {
|
|
|
3265
3415
|
}
|
|
3266
3416
|
}
|
|
3267
3417
|
function projectReadiness(deps, config) {
|
|
3268
|
-
const
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
? {
|
|
3418
|
+
const configured = config.project.value;
|
|
3419
|
+
if (configured !== null) {
|
|
3420
|
+
return {
|
|
3272
3421
|
id: 'project',
|
|
3273
3422
|
title: 'Project identity',
|
|
3274
3423
|
status: 'ready',
|
|
3275
|
-
detail: `"${
|
|
3276
|
-
}
|
|
3277
|
-
|
|
3424
|
+
detail: `"${configured}" (${config.project.source})`,
|
|
3425
|
+
};
|
|
3426
|
+
}
|
|
3427
|
+
const inferred = inferInvocationContext(deps.cwd).project;
|
|
3428
|
+
if (inferred !== null) {
|
|
3429
|
+
return {
|
|
3278
3430
|
id: 'project',
|
|
3279
3431
|
title: 'Project identity',
|
|
3280
|
-
// Not a blocker: a send without a project simply carries no project
|
|
3281
|
-
// identity. init always sets one because it is free and reversible,
|
|
3282
|
-
// but an unlabelled setup works, so this must not go red.
|
|
3283
3432
|
status: 'optional-gap',
|
|
3284
|
-
detail: `
|
|
3433
|
+
detail: `"${inferred}" is inferred for each send; init can stamp it into shared config`,
|
|
3285
3434
|
remedy: {
|
|
3286
3435
|
by: 'cli',
|
|
3287
|
-
summary: '
|
|
3436
|
+
summary: 'make the inferred Project identity explicit for every checkout',
|
|
3288
3437
|
command: 'notifai init',
|
|
3289
3438
|
},
|
|
3290
3439
|
};
|
|
3440
|
+
}
|
|
3441
|
+
return {
|
|
3442
|
+
id: 'project',
|
|
3443
|
+
title: 'Project identity',
|
|
3444
|
+
status: 'optional-gap',
|
|
3445
|
+
detail: 'the directory name has no characters a Project identifier can use',
|
|
3446
|
+
remedy: {
|
|
3447
|
+
by: 'cli',
|
|
3448
|
+
summary: 'choose an explicit Project identifier',
|
|
3449
|
+
command: 'notifai init --project-id my-project',
|
|
3450
|
+
},
|
|
3451
|
+
};
|
|
3291
3452
|
}
|
|
3292
3453
|
function remoteStatesFrom(previous) {
|
|
3293
3454
|
const pick = (id) => previous.states.find((state) => state.id === id);
|