@usenaive-sdk/cli 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/dist/client.js +6 -1
  3. package/dist/client.js.map +1 -1
  4. package/dist/commands/agents.d.ts +2 -0
  5. package/dist/commands/agents.js +916 -0
  6. package/dist/commands/agents.js.map +1 -0
  7. package/dist/commands/billing.js +53 -12
  8. package/dist/commands/billing.js.map +1 -1
  9. package/dist/commands/brain.js +40 -8
  10. package/dist/commands/brain.js.map +1 -1
  11. package/dist/commands/cron-jobs.js +37 -0
  12. package/dist/commands/cron-jobs.js.map +1 -1
  13. package/dist/commands/init.d.ts +1 -1
  14. package/dist/commands/init.js +9 -56
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/register.js +9 -5
  17. package/dist/commands/register.js.map +1 -1
  18. package/dist/commands/sandbox.js +15 -15
  19. package/dist/commands/sandbox.js.map +1 -1
  20. package/dist/commands/status.js +28 -2
  21. package/dist/commands/status.js.map +1 -1
  22. package/dist/commands/support.d.ts +2 -0
  23. package/dist/commands/support.js +134 -0
  24. package/dist/commands/support.js.map +1 -0
  25. package/dist/commands/teams.d.ts +3 -3
  26. package/dist/commands/teams.js +5 -5
  27. package/dist/commands/up.d.ts +398 -0
  28. package/dist/commands/up.js +444 -6
  29. package/dist/commands/up.js.map +1 -1
  30. package/dist/commands/verify-email.d.ts +19 -0
  31. package/dist/commands/verify-email.js +112 -0
  32. package/dist/commands/verify-email.js.map +1 -0
  33. package/dist/commands/webhooks.d.ts +13 -0
  34. package/dist/commands/webhooks.js +119 -0
  35. package/dist/commands/webhooks.js.map +1 -0
  36. package/dist/index.js +8 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/lib/config-errors.d.ts +4 -0
  39. package/dist/lib/config-errors.js +13 -0
  40. package/dist/lib/config-errors.js.map +1 -0
  41. package/dist/lib/starter-config.d.ts +13 -0
  42. package/dist/lib/starter-config.js +71 -0
  43. package/dist/lib/starter-config.js.map +1 -0
  44. package/dist/lib/teams-catalog.js +81 -5
  45. package/dist/lib/teams-catalog.js.map +1 -1
  46. package/dist/output.js +22 -4
  47. package/dist/output.js.map +1 -1
  48. package/package.json +1 -1
@@ -2,8 +2,9 @@ import { Command } from "commander";
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import { join, relative } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import { agentOutput, agentError } from "../output.js";
5
+ import { agentOutput, agentOutputExit, agentError } from "../output.js";
6
6
  import { apiRequest, handleApiError } from "../client.js";
7
+ import { isConfigAuthoringError } from "../lib/config-errors.js";
7
8
  const WEB_IGNORE = new Set([
8
9
  "node_modules", ".next", ".git", ".vercel", ".ready", "dist", ".turbo", ".cache", "data", ".DS_Store",
9
10
  ]);
@@ -81,6 +82,15 @@ async function loadConfig(path) {
81
82
  return { ok: true, config: cfg };
82
83
  }
83
84
  catch (e) {
85
+ // Config authoring errors (e.g. system.root !== "ceo") must not look like a
86
+ // missing dependency — surface as invalid_input with the author's message.
87
+ if (isConfigAuthoringError(e)) {
88
+ return {
89
+ ok: false,
90
+ reason: "invalid",
91
+ message: e instanceof Error ? e.message : String(e),
92
+ };
93
+ }
84
94
  // The config itself threw. Almost always a NAMED define-time refusal, whose
85
95
  // message is written to be read by the person who wrote the config — so it
86
96
  // is surfaced verbatim rather than replaced with a guess.
@@ -161,6 +171,175 @@ export function brainReport(brains) {
161
171
  ...(brains.notes?.length ? { notes: brains.notes } : {}),
162
172
  };
163
173
  }
174
+ /**
175
+ * How a loop's cadence PRINTS — the one place that decides, so the four lines
176
+ * below cannot disagree about it.
177
+ *
178
+ * An interval is spelled `every 3600s` rather than as a bare number: `(3600)`
179
+ * next to a name reads as an id, and the whole point of this line is that an
180
+ * operator can tell at a glance what is about to spend on a timer.
181
+ *
182
+ * The empty fallback is reachable only for an {@link AppliedLoop} carrying
183
+ * neither arm, which the fold refuses (`loop_cadence_invalid`) — so it exists to
184
+ * keep the report printable rather than to describe anything real.
185
+ */
186
+ export function loopCadence(l) {
187
+ if (typeof l.everySeconds === "number")
188
+ return `every ${l.everySeconds}s`;
189
+ return l.cron ?? "no cadence";
190
+ }
191
+ /**
192
+ * Every loop this apply touched on a team, BOTH LANES FOLDED.
193
+ *
194
+ * A registered loop is real, recurring work the apply just scheduled, and
195
+ * reporting nothing about it made a firing schedule invisible — the reason this
196
+ * projection exists at all. It read `hermes?.agents` only, which was correct
197
+ * exactly as long as the durable lane registered nothing: a durable role's
198
+ * `loop` came back in `unconsumed` and there was no timer to print. Now that
199
+ * `registerDurableTeamLoops` (packages/api/src/services/system-staffing.ts)
200
+ * registers it against the runtime's own alarm, reading one lane would make a
201
+ * schedule the apply had just created invisible on the OTHER runtime — the same
202
+ * silence, moved.
203
+ *
204
+ * Folded rather than `durable ?? hermes` for the reason `agents` is: a MIXED
205
+ * team is reported by both materializers and preferring one drops the other's
206
+ * half.
207
+ */
208
+ export function projectLoops(hermes, durable) {
209
+ return [...(hermes?.agents ?? []), ...(durable?.agents ?? [])]
210
+ .filter((a) => a.loop !== undefined)
211
+ .map((a) => ({ role: a.role, ...a.loop }));
212
+ }
213
+ /**
214
+ * One hint line per loop, plus one per loop this apply cancelled.
215
+ *
216
+ * 🔴 THE LIST COMMAND IS LANE-DEPENDENT AND NAMING THE WRONG ONE IS WORSE THAN
217
+ * NAMING NONE. `naive cron list` reads `cron_jobs`, which has no row for a
218
+ * durable tenant — an operator who followed it after a durable loop registered
219
+ * would find nothing and conclude the line above it had lied. A mixed team has
220
+ * both lanes at once, so the lookup is per ROLE and falls back to the team's.
221
+ */
222
+ export function loopHints(t) {
223
+ const lines = (t.loops ?? []).map((l) => {
224
+ const lane = t.role_runtimes?.[l.role] ?? t.runtime;
225
+ const list = lane === "durable" ? `\`naive teams schedules ${t.name}\` shows it` : "`naive cron list` shows it";
226
+ const why = l.reason ? ` — ${l.reason}` : "";
227
+ const cadence = loopCadence(l);
228
+ return l.status === "registered"
229
+ ? `loop "${l.name}" (${cadence}) registered for ${t.name}/${l.role} — ${list}.${why}`
230
+ : l.status === "queued"
231
+ ? `loop "${l.name}" (${cadence}) for ${t.name}/${l.role} is QUEUED — nothing is firing on this cadence yet${why || " — the runtime could not take it yet; it lands when the runtime is reachable."}`
232
+ // THE UNSCHEDULABLE LINE NO LONGER ASSERTS *WHY*. It used to say the
233
+ // cadence "is not a cron expression the scheduler can compile", which
234
+ // was the only reason that existed when the only arm was cron. An
235
+ // interval loop on a hermes role is now also unschedulable, for a
236
+ // completely different reason, and that reason arrives in `reason`.
237
+ // Stating a cause the field no longer implies would make the report
238
+ // wrong exactly when it matters.
239
+ : `loop "${l.name}" (${cadence}) for ${t.name}/${l.role} is UNSCHEDULABLE — nothing was registered.${why || ` "${cadence}" is not a cadence this lane's scheduler can compile.`}`;
240
+ });
241
+ // A CANCELLED TIMER IS WORK THE APPLY DID. Printed for the same reason
242
+ // `archived` is: the config stopped declaring it, so something had to happen
243
+ // to it, and silence is the reading that leaves an operator unsure whether it
244
+ // is still spending.
245
+ return [
246
+ ...lines,
247
+ ...(t.loops_unscheduled ?? []).map((id) => `loop "${id}" UNSCHEDULED on ${t.name} — this config no longer declares it, so the runtime is no longer firing it.`),
248
+ ];
249
+ }
250
+ /**
251
+ * 🔴 WHAT THE CONTROL PLANE PUT IN THE DIGEST THAT THE CONFIG DID NOT.
252
+ *
253
+ * `projectTeamRecord` grants a durable team's LEAD the built-in `delegate` when
254
+ * the lane has more than one member. The grant is narrow and argued for at
255
+ * length where it is made, and it was reported NOWHERE: `naive up` prints the
256
+ * declared `can:`, and so do `/plan` and `/roster`, so the authority surface an
257
+ * operator reviews before running `naive teams apply … --expected-digest …`
258
+ * contained one capability more than anything on naive listed. A digest that
259
+ * moves for a reason no surface names is the same silence the manifest report
260
+ * itself exists to end, one field deeper.
261
+ *
262
+ * SAID PER TEAM AND ONLY WHEN THERE IS SOMETHING TO SAY. A one-member durable
263
+ * team gets no grant and no line.
264
+ */
265
+ export function grantHints(t) {
266
+ if (!t.manifest_grants?.length)
267
+ return [];
268
+ return [
269
+ `team "${t.name}" manifest carries ${t.manifest_grants.length} capability the config did not declare: ` +
270
+ `${t.manifest_grants.join(", ")} — added by the control plane so the planner can hand sub-tasks to its ` +
271
+ `own members and wait for the result. It is IN the digest you review and bind, so it is named here ` +
272
+ `rather than left to be discovered in the record.`,
273
+ ];
274
+ }
275
+ /**
276
+ * One hint line per declared trigger, one per trigger this apply cancelled, and
277
+ * ONE RETAINED LINE for standing schedules this config did not author.
278
+ *
279
+ * A sibling of {@link loopHints} rather than a branch inside it, printed
280
+ * immediately after it, and the split is not cosmetic: a trigger has no ROLE.
281
+ * `agent({ loop })` reserves every firing to the member that declared it and
282
+ * every loop line says `team/role`; `team({ on })` fires UNRESERVED and the
283
+ * board hands the card to whoever is next. A line that named a role would name
284
+ * one that does not exist.
285
+ *
286
+ * 🔴 THE RETAINED LINE IS THE HALF THAT WOULD OTHERWISE BE INVISIBLE, and it is
287
+ * the reason this function exists at all. `registerTeamTriggers` refuses to
288
+ * cancel a schedule it cannot prove it wrote — the one destructive failure that
289
+ * cannot be undone by re-running anything — and then says nothing about it. A
290
+ * correct decision nobody is told about is the same defect one level up: the
291
+ * operator still has a timer spending on a cadence no config declares, and now
292
+ * also has no evidence it exists. The sentence is copied in shape from the
293
+ * brains one this file already prints, down to "remove one deliberately", so an
294
+ * operator meets one vocabulary for drift rather than two.
295
+ *
296
+ * 🔴 AND WHERE A FIRING WILL NOT SHOW UP, said once per team that declares any.
297
+ * A durable schedule's firing reaches NEITHER existing observability surface:
298
+ * `GET /v1/triggers/ledger` reads the `events` table, whose sources are
299
+ * `email|objective|cron|task|approval|company_channel` and which a durable alarm
300
+ * never writes, and `routeEvent` writes `trigger_deliveries` over a disjoint
301
+ * vocabulary it also never writes. The board is the only place the work appears.
302
+ * Naming that here is the cheap mitigation; reconciling the two ledgers is its
303
+ * own piece of work.
304
+ */
305
+ export function triggerHints(t) {
306
+ const declared = t.triggers ?? [];
307
+ const lines = declared.map((tr) => {
308
+ const why = tr.reason ? ` — ${tr.reason}` : "";
309
+ switch (tr.status) {
310
+ case "registered":
311
+ return `trigger "${tr.key}" registered for ${t.name} as "${tr.name}" (${tr.cron}, UTC) — \`naive teams schedules ${t.name}\` shows it.${why}`;
312
+ case "blocked_unbound":
313
+ // NOT `queued`. The repair is a command a human runs, and the runtime
314
+ // will refuse every registration until they run it.
315
+ return `trigger "${tr.key}" (${tr.cron}, UTC) for ${t.name} is BLOCKED — the team's manifest is not bound, so the runtime refuses to schedule against it and NOTHING was registered${why || " — bind the manifest, then re-run `naive up`."}`;
316
+ case "queued":
317
+ return `trigger "${tr.key}" (${tr.cron}, UTC) for ${t.name} is QUEUED — nothing is firing on this cadence yet${why || " — the runtime could not take it yet; it lands when the runtime is reachable."}`;
318
+ default:
319
+ return `trigger "${tr.key}" for ${t.name} is UNSCHEDULABLE — nothing was registered.${why}`;
320
+ }
321
+ });
322
+ return [
323
+ ...lines,
324
+ ...(t.triggers_unscheduled ?? []).map((id) => `trigger "${id}" UNSCHEDULED on ${t.name} — this config no longer declares it, so the runtime is no longer firing it.`),
325
+ // The drift half. One line, all ids, mirroring the brains sentence verbatim
326
+ // in shape — including the fact that apply did NOT touch them.
327
+ ...(t.schedules_retained?.length
328
+ ? [
329
+ `schedules on ${t.name} RETAINED, not declared by this config: ${t.schedules_retained.join(", ")} — apply never cancels a schedule it cannot prove it wrote; remove one deliberately with \`naive teams unschedule ${t.name} <id>\`.`,
330
+ ]
331
+ : []),
332
+ ...(declared.length
333
+ ? [
334
+ // NAMED AS A ROUTE AND NOT AS A COMMAND, deliberately: there is no
335
+ // `naive triggers` verb on this build, and pointing an operator at a
336
+ // command that does not exist is the defect this line exists to
337
+ // avoid, not a smaller version of it.
338
+ `a declared trigger's firing appears on ${t.name}'s board and in NO trigger ledger — \`GET /v1/triggers/ledger\` reads the \`events\` table, which a durable alarm never writes to.`,
339
+ ]
340
+ : []),
341
+ ];
342
+ }
164
343
  /**
165
344
  * The per-team accept-and-drop warning, one line per team that declared any.
166
345
  *
@@ -293,6 +472,189 @@ export function refusalNextStep(t) {
293
472
  }
294
473
  return null;
295
474
  }
475
+ /**
476
+ * A team whose manifest is NOT bound, so its tick dispatches nothing.
477
+ *
478
+ * `refused` — the runtime (or the projection) refused the record itself.
479
+ * `delivered_not_applied` — the record stored, the bind was refused. The team
480
+ * is placed, addressable, and inert.
481
+ * `unreachable` — the runtime never answered, so nothing was delivered.
482
+ *
483
+ * `skipped` IS DELIBERATELY ABSENT, and that is the whole reason this is a
484
+ * named constant rather than an inline list. The API emits `skipped` only when
485
+ * placement already failed — there was nowhere to deliver to — and that team is
486
+ * ALREADY in `refusedTeams` and already printed with its placement refusal.
487
+ * Including it here would print one failure twice under two names and imply two
488
+ * things went wrong, which is the report lying in the generous direction.
489
+ *
490
+ * `applied` and `noop` are the bound states. `noop` in particular is a SUCCESS:
491
+ * the runtime already held that exact digest and rewrote nothing.
492
+ */
493
+ const UNBOUND_MANIFEST_STATUSES = ["refused", "delivered_not_applied", "unreachable"];
494
+ /**
495
+ * The `manifest` half of one team's report line, from the API's own team row.
496
+ *
497
+ * A SIBLING OF `placement`, NEVER NESTED INSIDE IT. The API's comment on the
498
+ * field this reads is explicit about why: "the tenant routes to the durable
499
+ * runtime" and "the runtime enforces what the config declared" are different
500
+ * facts, and a placed team with a refused manifest is exactly the state that
501
+ * must not read as success. Nesting one inside the other would collapse a
502
+ * distinction the API deliberately kept, and would make the refusal look like a
503
+ * detail of a successful placement.
504
+ *
505
+ * Absent when the API reported nothing — a hermes team has no manifest at all,
506
+ * and an older server does not send the field. An empty object rather than
507
+ * `{ manifest: undefined }`, so the printed envelope has no key for a fact
508
+ * nobody stated.
509
+ */
510
+ export function projectManifest(durable) {
511
+ const m = durable?.manifest;
512
+ if (!m)
513
+ return {};
514
+ return {
515
+ manifest: {
516
+ status: m.status,
517
+ ...(m.digest ? { digest: m.digest } : {}),
518
+ ...(m.code ? { code: m.code } : {}),
519
+ ...(m.error ? { error: m.error } : {}),
520
+ },
521
+ };
522
+ }
523
+ /**
524
+ * WHAT `naive up` SAYS ABOUT MANIFEST DELIVERY, AND WHETHER IT MAY STILL EXIT 0.
525
+ *
526
+ * Extracted for the same reason `brainReport` is: the claim — "a refused
527
+ * manifest is reported in the runtime's own words and the apply exits
528
+ * non-zero" — is otherwise only assertable against a live API.
529
+ *
530
+ * Delivering the manifest is the step that turns a placed tenant into a working
531
+ * one. Before this existed the CLI decoded the placement and dropped the
532
+ * delivery, so a team that routed correctly and enforced NOTHING printed
533
+ * exactly like one that worked: `placement: { status: "placed" }`, `applied:
534
+ * true`, exit 0.
535
+ *
536
+ * 🔴 A `next_step` IS NOW OFFERED FOR `delivered_not_applied`, AND FOR NOTHING
537
+ * ELSE. This block previously offered none at all, under this reasoning:
538
+ *
539
+ * The obvious candidate is `naive teams apply <team> --expected-digest
540
+ * <digest>` and it is wrong TWICE on this build: `teamsCmd` registers no
541
+ * `apply` subcommand, and the control plane routes `POST …/apply` through
542
+ * `REFUSED_WRITES` to a 501.
543
+ *
544
+ * Both halves are now false, and the FIRST ONE WAS NEVER TRUE. `teams.ts`
545
+ * declares no subcommands at all — it walks `lib/teams-catalog.ts` — and that
546
+ * catalog has carried an `apply` row, with a REQUIRED `--expected-digest` that
547
+ * defaults to nothing, since before this branch was written. Verified by
548
+ * running the built CLI rather than by reading for it:
549
+ *
550
+ * $ naive teams --help
551
+ * apply [options] <team> Record that an operator applied this exact
552
+ * manifest digest. Refuses a stale one
553
+ *
554
+ * The second half is false as of this change: `POST …/apply` is a
555
+ * `DURABLE_WRITES` row and reaches the runtime's own `apply` verb. So the
556
+ * command exists, the route exists, and the digest is already in hand — which
557
+ * is exactly the condition {@link refusalNextStep} sets for emitting one.
558
+ *
559
+ * `--tenant` IS SPELLED OUT, AND IT IS NOT OPTIONAL. `naive teams` requires it
560
+ * on every subcommand and has NO default, deliberately: "a command that guesses
561
+ * the tenant is a command that acts on the wrong tenant exactly once". A durable
562
+ * team's subject is its OWN — `ensureTeamSubject` mints one per declared team —
563
+ * so `--tenant @self` would address the operator's subject and bind a digest on
564
+ * a tenant that is not this team. The apply response carries the right id on
565
+ * `subject.tenant_user_id` and that is what is printed. If it is somehow absent
566
+ * the next_step is WITHHELD rather than emitted with a placeholder, because a
567
+ * command an operator has to guess a field in is the same dead end as a command
568
+ * that 501s.
569
+ *
570
+ * STILL NO next_step FOR `refused` OR `unreachable`, and the distinction is the
571
+ * point. `delivered_not_applied` means the record IS stored and only the bind
572
+ * failed — a retryable act with a known digest. `refused` means the runtime
573
+ * would not take the record at all, so there is nothing to apply and the fix is
574
+ * in the config; `unreachable` means nothing was attempted. Offering an apply
575
+ * for either would send an operator to run a command that cannot succeed.
576
+ */
577
+ export function manifestReport(teams) {
578
+ const unbound = teams.filter((t) => t.manifest !== undefined && UNBOUND_MANIFEST_STATUSES.includes(t.manifest.status));
579
+ const hints = unbound.map((t) => {
580
+ const m = t.manifest;
581
+ // The refusing side's own sentence, verbatim. The runtime names the slug it
582
+ // could not resolve and lists what this deploy does ship; rewording that
583
+ // into "manifest delivery failed" throws away the only part an operator can
584
+ // act on.
585
+ const said = m.error ? `: ${m.error}` : "";
586
+ const code = m.code ? ` (${m.code})` : "";
587
+ return (`team "${t.name}" manifest ${m.status.toUpperCase()}${code}${said}` +
588
+ (m.status === "delivered_not_applied"
589
+ ? ` — the record IS stored at digest ${m.digest ?? "(none reported)"}; it is the BIND that was refused, ` +
590
+ `so this team routes, accepts requests and dispatches nothing until that digest is applied.`
591
+ : m.status === "unreachable"
592
+ ? ` — nothing was delivered, so the runtime is enforcing whatever it held before this apply.`
593
+ : ` — nothing is bound, so this team's tick dispatches nothing.`));
594
+ });
595
+ const nextSteps = unbound.flatMap((t) => {
596
+ const m = t.manifest;
597
+ const tenantId = t.subject?.tenant_user_id;
598
+ // Every condition must hold: the record must be STORED (so there is
599
+ // something to bind), the digest must be KNOWN (the flag is required and
600
+ // has no default), and the team's own subject must be in hand.
601
+ if (m.status !== "delivered_not_applied" || !m.digest || !tenantId)
602
+ return [];
603
+ return [
604
+ {
605
+ command: `naive teams apply ${t.name} --tenant ${tenantId} --expected-digest ${m.digest}`,
606
+ description: `team "${t.name}": the record is stored and the BIND was refused, so this team routes and ` +
607
+ `dispatches nothing. Review the authority surface that digest names, then bind it. The digest ` +
608
+ `is passed explicitly because applying whatever is current is not a review — a stale one is refused.`,
609
+ },
610
+ ];
611
+ });
612
+ return { unbound, hints, nextSteps };
613
+ }
614
+ /**
615
+ * THE ONE PARAGRAPH SAID ABOUT UNBOUND MANIFESTS AS A GROUP, RATHER THAN PER
616
+ * TEAM — and it is emitted only for the teams {@link manifestReport} could NOT
617
+ * hand a repair command to.
618
+ *
619
+ * 🔴 IT USED TO CONTRADICT THE NEXT_STEP PRINTED THREE LINES ABOVE IT. Written
620
+ * inline in the command's action closure by parity 0, it read:
621
+ *
622
+ * No `naive` command binds a manifest on this build: `naive teams`
623
+ * registers no `apply` subcommand, and the control plane answers
624
+ * `POST /v1/teams/.../apply` with 501.
625
+ *
626
+ * Both halves are false at HEAD and the first was never true — the full
627
+ * accounting is in {@link manifestReport}'s own comment, which emits
628
+ * `naive teams apply <team> --tenant … --expected-digest …` for
629
+ * `delivered_not_applied`. That is the SAME status that put a team in
630
+ * `unbound`, so an operator was handed a command and then told, one line later,
631
+ * that no such command exists. It survived because it was a string inside a
632
+ * 500-line closure that nothing imports and nothing could call.
633
+ *
634
+ * So it is a function now, for the reason `manifestReport` is one: a claim that
635
+ * cannot be called cannot be tested, and this one was wrong for two commits.
636
+ *
637
+ * WHAT IT SAYS NOW is the fact that is actually true of the teams it fires for:
638
+ * `naive teams apply` binds a digest the runtime has ALREADY STORED, so it can
639
+ * repair `delivered_not_applied` and nothing else. `refused` means the record
640
+ * was never taken; `unreachable` means it was never delivered. For both, the
641
+ * runtime's own sentence is printed above and re-running `naive up` re-delivers.
642
+ *
643
+ * The condition is "an unbound team that got no next_step", not "any unbound
644
+ * team": a team whose repair is printed does not need to be told there is none.
645
+ */
646
+ export function unbindableNote(report) {
647
+ if (report.unbound.length <= report.nextSteps.length)
648
+ return [];
649
+ return [
650
+ "A manifest above is unbound with NO bind to offer. `naive teams apply <team> --tenant <id> " +
651
+ "--expected-digest <digest>` exists and works, but it binds a digest the runtime has already STORED — " +
652
+ "for `refused` nothing was stored, and for `unreachable` nothing was even delivered, so there is no " +
653
+ "digest to review. The refusing side's own sentence is printed above and names what it would not take. " +
654
+ "Re-running `naive up` re-delivers, which is the only self-serve path and only helps if the cause was " +
655
+ "transient.",
656
+ ];
657
+ }
296
658
  /** Team names declared in the config, without evaluating anything the API will re-derive. */
297
659
  function declaredTeamNames(config) {
298
660
  const teams = config.teams;
@@ -405,13 +767,39 @@ export const upCmd = new Command("up")
405
767
  });
406
768
  return;
407
769
  }
408
- // Fallback: the TOOLCHAIN is missing (or --local was asked for), so the config
409
- // could not be evaluated at all — local, regex-only validation.
770
+ /* ── Fallback: the TOOLCHAIN is missing (or --local was asked for) ─────────
771
+ *
772
+ * The config could not be evaluated at all, so all that is left is local,
773
+ * regex-only validation. The PAYLOAD is deliberately unchanged — extracting
774
+ * what can be read and naming the install is genuinely the most useful thing
775
+ * to say, and the `unloadable` half of the union stays a soft outcome.
776
+ *
777
+ * 🔴 WHAT CHANGED IS THE EXIT CODE, AND ONLY WHEN THE USER DID NOT ASK FOR
778
+ * LOCAL-ONLY. `naive up` was asked to plan AND apply; on this branch it did
779
+ * neither, and it still exited 0. Measured on the published CLI (0.13.1), a
780
+ * config importing a package that does not resolve produced
781
+ * `success: true`, `applied: false`, exit 0 — so `naive up && echo deployed`
782
+ * printed "deployed" over a deployment that was never even planned. That is
783
+ * the defect docs/COVERAGE-TRUTH.md §5 recorded as "`naive up` exits 0 with
784
+ * `success:true` when it cannot evaluate the config"; the `invalid` half was
785
+ * closed above, this is the half that was left.
786
+ *
787
+ * Exit 3 is this CLI's OWN ladder for exactly this shape — see
788
+ * agentOutputExit in output.ts: "success, and the terminal state is not
789
+ * success", written because "a CI script reading 0 as 'the work is done'
790
+ * would otherwise count an `unverified` task as a pass". Nothing about the
791
+ * envelope's content moves, so an agent still gets the extraction and the
792
+ * install step; a shell script stops.
793
+ *
794
+ * `--local` keeps exit 0. There the user asked for local validation only and
795
+ * got precisely that — the terminal state IS the success.
796
+ */
410
797
  if (!load || !load.ok) {
411
798
  const source = readFileSync(found, "utf8");
412
799
  const projectMatch = /project\s*:\s*["'`]([^"'`]+)["'`]/.exec(source);
413
800
  const project = projectMatch?.[1] ?? "(unknown)";
414
- agentOutput({
801
+ agentOutputExit({
802
+ exitCode: opts.local ? undefined : 3,
415
803
  action: "up",
416
804
  result: {
417
805
  config: found,
@@ -431,6 +819,16 @@ export const upCmd = new Command("up")
431
819
  opts.local
432
820
  ? `Local validation only for "${project}".`
433
821
  : `Validated ${found} locally, but could not evaluate it (install @usenaive-sdk/iac + jiti, or commit a naive.config.json).`,
822
+ // Said in the envelope as well as in the exit code, because an agent
823
+ // reads this and a shell script reads that, and neither should have to
824
+ // infer it from `applied: false`.
825
+ ...(opts.local
826
+ ? []
827
+ : [
828
+ "NOTHING WAS PLANNED AND NOTHING WAS APPLIED — the config never evaluated, so no plan was " +
829
+ "requested and no apply was sent. Exit code 3 (`success, and the terminal state is not " +
830
+ "success`); do not read this as a completed deploy.",
831
+ ]),
434
832
  // Only ever printed for a real resolution failure now — an invalid config
435
833
  // took the branch above. Said out loud so the two are never conflated again.
436
834
  ...(load ? [`Module resolution failed: ${load.message}`] : []),
@@ -539,6 +937,7 @@ export const upCmd = new Command("up")
539
937
  const lanes = t.role_runtimes;
540
938
  const label = (role, status) => lanes?.[role] ? `${role} (${status}, ${lanes[role]})` : `${role} (${status})`;
541
939
  const agents = [...(hermes?.agents ?? []), ...(durable?.agents ?? [])].map((a) => label(a.role, a.provision_status));
940
+ const loops = projectLoops(hermes, durable);
542
941
  return {
543
942
  name: t.name,
544
943
  runtime: t.runtime,
@@ -546,6 +945,17 @@ export const upCmd = new Command("up")
546
945
  ...(lanes ? { role_runtimes: lanes } : {}),
547
946
  ...(durable?.subject ? { subject: durable.subject } : {}),
548
947
  agents,
948
+ ...(loops.length ? { loops } : {}),
949
+ ...(durable?.loops_unscheduled?.length ? { loops_unscheduled: durable.loops_unscheduled } : {}),
950
+ // The trigger half, carried WHOLE rather than counted. Each of these is
951
+ // a fact an operator may have to act on: a blocked trigger needs a
952
+ // manifest bound, a retained schedule needs a human decision, and a
953
+ // cancelled one is work this apply already did.
954
+ ...(durable?.triggers?.length ? { triggers: durable.triggers } : {}),
955
+ ...(durable?.triggers_unscheduled?.length ? { triggers_unscheduled: durable.triggers_unscheduled } : {}),
956
+ ...(durable?.schedules_retained?.length ? { schedules_retained: durable.schedules_retained } : {}),
957
+ // The authority the config did not declare — see {@link grantHints}.
958
+ ...(durable?.manifest_grants?.length ? { manifest_grants: durable.manifest_grants } : {}),
549
959
  archived: [...(hermes?.archived ?? []), ...(durable?.archived ?? [])],
550
960
  ...(durable?.placement
551
961
  ? {
@@ -556,9 +966,13 @@ export const upCmd = new Command("up")
556
966
  },
557
967
  }
558
968
  : {}),
969
+ // A SIBLING OF `placement`, NEVER NESTED INSIDE IT — see
970
+ // `projectManifest`, which is where the reason is written down.
971
+ ...projectManifest(durable),
559
972
  };
560
973
  });
561
974
  const refusedTeams = teams.filter((t) => t.placement?.status === "refused");
975
+ const manifests = manifestReport(teams);
562
976
  const brains = brainReport(data.brains);
563
977
  // Deploy cloud.web source to the provisioned web project(s) (Vercel builds + hosts).
564
978
  const webDeploys = {};
@@ -608,10 +1022,24 @@ export const upCmd = new Command("up")
608
1022
  next_steps: [
609
1023
  ...needsAction.map((r) => ({ command: `naive status`, description: `${r.name}: ${r.requirement ?? r.status}` })),
610
1024
  ...refusedTeams.map(refusalNextStep).filter((s) => s !== null),
1025
+ // The repair for a team that was placed and never bound. See
1026
+ // `manifestReport` for why this is emitted only for
1027
+ // `delivered_not_applied` and why `--tenant` is spelled out.
1028
+ ...manifests.nextSteps,
611
1029
  { command: `naive agent-profiles provision <name> --user <tenant>`, description: "Provision an agent profile for a tenant" },
612
1030
  ],
613
1031
  hints: [
614
1032
  `Deployment "${project}" is ${deployment?.status ?? "?"}.`,
1033
+ ...teams.flatMap(loopHints),
1034
+ // IMMEDIATELY ALONGSIDE THE LOOP LINES, because they are the same kind
1035
+ // of fact: a standing timer this apply either created, refused, or found
1036
+ // and left alone. Separating them by half a screen would make "what
1037
+ // recurs on this team after this apply" two reads instead of one.
1038
+ ...teams.flatMap(triggerHints),
1039
+ // BESIDE THE MANIFEST LINES, not the schedule ones: this is about the
1040
+ // authority surface a digest carries, and the operator reading it is
1041
+ // about to run `naive teams apply … --expected-digest …`.
1042
+ ...teams.flatMap(grantHints),
615
1043
  ...unconsumedWarnings(teamPlan),
616
1044
  ...(legacyDeprecationNotice(config) ? [legacyDeprecationNotice(config)] : []),
617
1045
  ...(agentsRenameNotice(config) ? [agentsRenameNotice(config)] : []),
@@ -645,6 +1073,12 @@ export const upCmd = new Command("up")
645
1073
  ? `team "${t.name}" PLACED on the durable runtime (${t.agents.length} agent(s)). Its jurisdiction is fixed permanently by the first control request.`
646
1074
  : `team "${t.name}" was already on the durable runtime (${t.agents.length} agent(s)); nothing was rewritten.`),
647
1075
  ...refusedTeams.map((t) => `team "${t.name}" REFUSED (${t.placement?.code}): ${t.placement?.reason}`),
1076
+ // Placement and delivery are reported separately because they fail
1077
+ // separately: a PLACED team can still be inert. See `manifestReport`.
1078
+ ...manifests.hints,
1079
+ // Said once, not per team, exactly as the placement paragraph below is.
1080
+ // See {@link unbindableNote} for what it now says and what it used to.
1081
+ ...unbindableNote(manifests),
648
1082
  // Said once, not per team. `naive runtime migrate` does not exist in this
649
1083
  // CLI or any released one — see refusalNextStep — so a refusal with no
650
1084
  // next_step gets the reason it has none instead of a command that 404s.
@@ -659,8 +1093,12 @@ export const upCmd = new Command("up")
659
1093
  ],
660
1094
  });
661
1095
  // The apply did real work and said so; it must not ALSO report success for
662
- // the teams it refused. Non-zero, so a CI pipeline stops here.
663
- if (refusedTeams.length > 0)
1096
+ // the teams it refused, NOR for the teams it placed and left inert. Both are
1097
+ // "the apply ran and part of it did not take", which is exit 1 on this
1098
+ // command's ladder — 3 is reserved for "the config could not be evaluated at
1099
+ // all, so nothing was even planned", and here the infra, the pools and the
1100
+ // hermes teams really were provisioned. Non-zero, so a CI pipeline stops.
1101
+ if (refusedTeams.length > 0 || manifests.unbound.length > 0)
664
1102
  process.exitCode = 1;
665
1103
  });
666
1104
  export const downCmd = new Command("down")