@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/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);
|