octwin-cli 0.8.12 → 0.8.13

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 CHANGED
@@ -7,6 +7,42 @@ repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut th
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.13] - 2026-09-27
11
+
12
+ ### Added
13
+ - **`octwin records archive <id>` / `restore <id>`.** Archive is how a record leaves use — test data
14
+ included: it drops out of every list, count and report and fires the entity's `on.archived`
15
+ reactions, exactly like the console's Archive; `restore` undoes it. There is no delete, on the
16
+ platform or here, and `octwin records --help` now says so.
17
+
18
+ ### Fixed
19
+ - **`octwin chat` said the AI Agent wrote every message.** A reminder that fired mid `--script` (a
20
+ `schedule_notify`, a reaction) printed as `← <body>`, was taken for the turn's reply, and ended
21
+ the turn on it — so the real reply looked lost while `octwin logs` showed the flow had worked.
22
+ Each line now says who sent it: `← [notify] …` (not the turn's answer — the wait for the real
23
+ reply goes on), `← [operator: Name] …` (a person in Live chat), `← [platform] …` (a handoff
24
+ joining or returning); plain `← …` is the AI Agent. After the last turn the stream stays open
25
+ 3 s more, so a late message prints (marked "after the last turn") instead of reaching a closed
26
+ connection. Needs a platform that stamps `source: 'notify'` on web renders; an older one just
27
+ prints notifies unlabelled, as before.
28
+ - **The "no such template" hint named a file the platform never loads.** It said
29
+ `templates/<name>.yaml`; pack templates load only as `templates/<name>.template.yaml`, so a
30
+ template saved as the hint said was silently ignored.
31
+
32
+ ### Changed
33
+ - **`octwin validate` runs the platform's FULL check by default — and needs no login for it.** The
34
+ offline checks are a subset, and a subset that passes read as "deploy will pass": it passed a pack
35
+ the full check refused (a scaffold flow left behind with Arabic copy only, in a pack that ships
36
+ English). Now `validate` runs the offline checks, then the full one: through your workspace when you
37
+ have a token AND a `--project` (which also confirms the project exists), otherwise through the
38
+ platform's public validate route with no credential. When it cannot reach the full check — no url,
39
+ offline, rate-limited, an older platform — the LAST line says *"only the OFFLINE check ran — a
40
+ PARTIAL check"* and why, and it exits 0 as before. `--remote` now means "the full check is
41
+ required" (not reaching it is an error); the new `--offline` runs only the subset.
42
+ - **The scaffold's example `browse` flow says it is an example.** Its header, its copy file and its
43
+ `manifest.yaml` line now say to replace it, or delete the flow, every `browse.locale.*.yaml` and the
44
+ `flows:` line together — a leftover never loads, but a second language makes it fail validation.
45
+
10
46
  ## [0.8.12] - 2026-09-25
11
47
 
12
48
  ### Changed
package/README.md CHANGED
@@ -65,15 +65,15 @@ octwin status # "✓ live and current" once it's warm
65
65
  | Command | What it does |
66
66
  | --- | --- |
67
67
  | `octwin init <dir>` | Scaffold a new pure-YAML pack into `<dir>` (writes a starter `manifest.yaml`, flow, prompt, `.gitignore` and `README.md` — pack content only). Options: `--id`, `--description`, `--display-name`. |
68
- | `octwin validate` | Check the pack locally (structure + pure-YAML rules). `--remote` runs **the same validation the platform runs to load your pack** — every declaration file (`xrm.yaml`, `scheduling.yaml`, `worklist.yaml`, `roles.yaml`, `automation.yaml`, `integrations.yaml`, `surveys.yaml`, `taps.yaml`, `commands.yaml`, `journeys/`, `messages.<lang>.yaml`), the cross-file checks between them, and the **flow lint** — conventions the schema can't express, like an `assign:` whose value is a quoted literal or a `$t()` key with no namespace. These need template expansion and the platform's own schemas, which only the server has, so a local ✓ does not cover them. A `--remote` ✓ and a successful deploy are now the same answer by construction, not by convention. |
68
+ | `octwin validate` | Fast offline checks (structure + pure-YAML rules + the checks the pulled KB drives), then — **by default, with no login needed** — **the same validation the platform runs to load your pack**. Without a token and a `--project` it asks the platform's public validate route; with both it goes through your workspace and also confirms the project exists. When it cannot reach the platform, the LAST line says *"only the OFFLINE check ran — a PARTIAL check"* and why. `--remote` makes the full check required (not reaching it is an error); `--offline` runs only the subset. The full check covers every declaration file (`xrm.yaml`, `scheduling.yaml`, `worklist.yaml`, `roles.yaml`, `automation.yaml`, `integrations.yaml`, `surveys.yaml`, `taps.yaml`, `commands.yaml`, `journeys/`, `messages.<lang>.yaml`), the cross-file checks between them, and the **flow lint** — conventions the schema can't express, like an `assign:` whose value is a quoted literal or a `$t()` key with no namespace. These need template expansion and the platform's own schemas, which only the server has, so an offline ✓ does not cover them. A full-check ✓ and a successful deploy are the same answer by construction, not by convention. |
69
69
  | `octwin login` | Sign in to a platform URL **and make it the default target** (both stored in `~/.octwin/credentials.json`). With no `--token` it prints a link to approve in the console, then collects a 30-day token itself — nothing is pasted. The wait is bounded and resumable: run it again to keep waiting on the same request. `--url`, `--token`, `--wait <seconds>`, `--json`. |
70
70
  | `octwin whoami` | Verify the saved/passed token is valid for a tenant. `--url`, `--tenant`. |
71
71
  | `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed. Says **nothing** about the public marketplace unless asked: `--request-listing` submits the pack for operator review (needs `listing.public: true` in the manifest too), `--withdraw-listing` retracts. Reports the **listing verdict** whenever one exists. |
72
72
  | `octwin status` | Report what the platform has live for this pack — installed version, the **content sha** the instance loaded vs. the one the catalog holds (a redeploy of the *same* version changes it), its flows, and whether it is live on the public marketplace. |
73
73
  | `octwin pull <packId>` | Write a **deployed** pack's source back to disk — the inverse of `deploy`, and how a pack pushed from one machine is recovered. Defaults to the version installed on the target project; `--version` overrides, `--dir` defaults to `./<packId>`, a non-empty dir needs `--force`. You may pull a pack your tenant **owns**. |
74
- | `octwin chat "msg"` | Drive a turn through the dev web channel and print **every render with its tap ids**. `--as <handle>` picks the test user; `--tap "<tap-id>"` presses a rendered button/list row; `--json` dumps the raw envelopes. |
74
+ | `octwin chat "msg"` | Drive a turn through the dev web channel and print **every render with its tap ids**. `--as <handle>` picks the test user; `--tap "<tap-id>"` presses a rendered button/list row; `--json` dumps the raw envelopes. Each reply line says who sent it: plain `← …` is the AI Agent, `← [notify] …` a reminder or notification (never taken for the turn's reply), `← [operator: Name] …` a person in Live chat, `← [platform] …` a handoff line. |
75
75
  | `octwin logs` | List recent conversations (handle, status, last activity; `--as` filters), or show one conversation's full event timeline — including what each turn rendered. `--json` for raw payloads. |
76
- | `octwin records` | Inspect the pack's XRM data (needs a `records:read` token). No args = list entities. |
76
+ | `octwin records` | Inspect the pack's XRM data (needs a `records:read` token). No args = list entities. Writes (`records:write`): `create`, `patch`, `stage`, `note`, and `archive <id>` / `restore <id>` — there is no delete; archive takes a record (test data too) out of every list and count, and restore brings it back. |
77
77
  | `octwin work` | The work inbox: worked records across queues, one item + its timeline and decisions, or `--queues` for queue keys + open counts. Casework is one shape of it — the command spans every entity a pack works. |
78
78
  | `octwin projects` | The `--project <slug>` values this token can name, with the plan's project cap. `--archived` includes archived ones. A `pack:deploy` token reaches it — it names a project in every other command, so this turns "guess the slug" into "read the list". |
79
79
  | `octwin agents` | The agent roster with each agent's **effective** model / history window and **which layer set it** (project override → platform default → pack manifest) — an operator platform default can override what your manifest declares. `--prompt` prints the exact system prompt the LLM sees. Needs `agents:read`. |
@@ -88,7 +88,7 @@ octwin status # "✓ live and current" once it's warm
88
88
  | `octwin usage` | Model calls, tokens and cost, by model / kind / agent / channel. Project when one is pinned, workspace otherwise. Needs no particular scope. **Model spend only** — WhatsApp/Meta message billing is operator-only and no API token can read it. |
89
89
  | `octwin media generate "<prompt>"` | AI-generate an image, store it as a public asset, and print its `MEDIA-` handle + serve URL. `--out` downloads the bytes (WhatsApp renders only `.png`/`.jpg`); `--json`. Pairs with `octwin chat --media` to drive media-collect flows. Needs `media:generate`. |
90
90
  | `octwin platform-kb pull` | Pull the platform's capability reference into `.octwin/platform-kb/` for the **`octwin-pack`** Claude Code authoring plugin: guides as markdown, plus **one JSON file per capability** (`primitives/record_list.json`, `render-intents/carousel.json`, `declarations/xrm.json`, …) and **three maps** — `INDEX.md` (the corpus, by family and size), `SYMBOLS.md` (every name → its exact file; grep this), `OUTLINE.md` (every heading with its line number). **No token needed** — the reference is served anonymously. `--if-stale` skips the download when your copy is current (cheap enough for every session); `--check` writes nothing and exits 0 current / 2 stale / 1 could-not-tell. |
91
- | `octwin test` | Alias for `octwin validate --remote` — the platform's full manifest + flow-DSL check. |
91
+ | `octwin test` | Alias for `octwin validate --remote` — the platform's full check, required (a skipped full check is an error). |
92
92
  | `octwin feedback` | Submit this pack's `FEEDBACK.md` to the platform team, with the pack version, your CLI version and the `content_hash` of the capability reference you pulled — the two facts that separate a real platform gap from something already fixed or a stale KB. |
93
93
  | `octwin help` | Show usage. Every subcommand also answers `--help`. |
94
94
 
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * octwin --version | -v # print the CLI version (+ any upgrade notice)
11
11
  * octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
12
- * octwin validate [--dir .] [--remote] [--require-kb] # --remote → the platform's FULL schema check + lint, all errors at once
12
+ * octwin validate [--dir .] [--remote | --offline] [--require-kb] # the platform's FULL check by default (no login needed); --offline = the partial subset
13
13
  * octwin login --url <platformUrl> [--token oct_…] # no token → approve in a browser
14
14
  * octwin whoami [--url <url>] [--tenant <slug>]
15
15
  * octwin projects [--archived] # the --project slugs this token can name
@@ -69,7 +69,7 @@ import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding }
69
69
  import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding, findArgWarnings, describeArgWarning, } from './lib/args-check.js';
70
70
  import { yamlLineOf } from './lib/yaml-pos.js';
71
71
  import { localhostFamilyHint } from './lib/net-hint.js';
72
- import { authHeaders as buildAuthHeaders } from './lib/client-headers.js';
72
+ import { authHeaders as buildAuthHeaders, clientReportHeaders } from './lib/client-headers.js';
73
73
  import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from './lib/builtin-check.js';
74
74
  import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
75
75
  import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
@@ -78,6 +78,7 @@ import { findPlatformKbDir } from './lib/kb-path.js';
78
78
  import { reportChecks } from './lib/outcome.js';
79
79
  import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
80
80
  import { readPage, morePageHint } from './lib/page.js';
81
+ import { chatLabel, answersTheTurn } from './lib/chat-line.js';
81
82
  import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
82
83
  import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
83
84
  import { readPending, writePending, clearPending, isPendingExpired, secondsLeft, interpretPoll, clientLabel, EXIT_STILL_PENDING, } from './lib/device-login.js';
@@ -199,6 +200,8 @@ const VERB_REQUIREMENTS = {
199
200
  'records patch': { scope: 'records:write', feature: 'records' },
200
201
  'records stage': { scope: 'records:write', feature: 'records' },
201
202
  'records note': { scope: 'records:write', feature: 'records' },
203
+ 'records archive': { scope: 'records:write', feature: 'records' },
204
+ 'records restore': { scope: 'records:write', feature: 'records' },
202
205
  'records tasks': { scope: 'records:read', feature: 'tasks' },
203
206
  'records task': { scope: 'records:write', feature: 'tasks' },
204
207
  'work assign': { scope: 'work:write', feature: 'work' },
@@ -872,7 +875,8 @@ function exitNow(code) {
872
875
  * `platform-kb` refreshes the reference itself, so it needs no nudge). */
873
876
  function commandTouchesPlatform(command, flags) {
874
877
  switch (command) {
875
- case 'validate': return flags.remote === true; // offline validate stays offline
878
+ // `validate` runs the FULL check against the platform by default now; only `--offline` stays offline.
879
+ case 'validate': return flags.offline !== true;
876
880
  case 'deploy':
877
881
  case 'status':
878
882
  case 'test':
@@ -1192,36 +1196,62 @@ async function cmdValidate(flags) {
1192
1196
  if (skipped.length && flags['require-kb'] === true) {
1193
1197
  die(`--require-kb: ${skipped.length} check${skipped.length === 1 ? '' : 's'} could not run (${skipped.join(', ')})`);
1194
1198
  }
1195
- if (flags.remote !== true) {
1196
- // The LAST line carries the skip. A reader who sees a ✓ and stops there is the
1197
- // failure mode; a caveat printed ABOVE the ✓ does not fix it.
1198
- if (skipped.length) {
1199
- console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.length} skipped: ${skipped.join(', ')}.`);
1200
- console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
1201
- console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
1202
- }
1203
- else {
1204
- console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
1205
- console.log(' (all errors at once) before you deploy.');
1206
- }
1207
- return;
1208
- }
1209
- // Remote: the SAME validation the deploy route runs — manifest `.strict()` +
1210
- // every flow (schema/expression/structure) — returning ALL errors at once.
1211
- const t = resolveTarget(flags);
1212
- const { url } = t;
1213
- console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
1199
+ /**
1200
+ * ── The FULL check is the DEFAULT — the one deploy runs ─────────────────────────
1201
+ *
1202
+ * The checks above are a SUBSET: they replay what the KB publishes, file by file. On
1203
+ * 2026-09-25 they passed a pack the platform refused — a starter flow left behind with
1204
+ * Arabic copy only, in a pack that ships English — and "the offline check passed" had read
1205
+ * as "deploy will pass". So `validate` now asks the platform unless told not to, and says
1206
+ * PARTIAL, on its last line, whenever it could not.
1207
+ *
1208
+ * Two doors, one check (`routes/packs/_validate-bundle.ts` on the platform):
1209
+ * • a token AND a named project (`--project` / `PACK_PROJECT`) → the logged-in door,
1210
+ * which also confirms the project exists — the right pre-flight right before a deploy;
1211
+ * • anything else → `POST /api/public/packs/validate`, which needs no login and no
1212
+ * project. An unpinned token with no project would get a 400 on the other door, and a
1213
+ * default must not fail for a config gap the check itself does not care about.
1214
+ *
1215
+ * `--remote` REQUIRES the full check (a failure to reach it is an error); `--offline` skips it.
1216
+ */
1217
+ if (flags.offline === true)
1218
+ return printPartialTrailer(id, version, skipped, 'you passed --offline');
1219
+ const t = readTarget(flags);
1220
+ if (!t.url) {
1221
+ if (flags.remote === true)
1222
+ die('--remote needs a platform url — pass --url, set PACK_PLATFORM_URL, or `octwin login --url <url>`');
1223
+ return printPartialTrailer(id, version, skipped, 'no platform url is set — pass --url, set PACK_PLATFORM_URL, or `octwin login --url <url>`');
1224
+ }
1225
+ const loggedInDoor = !!t.token && !!t.project;
1226
+ const endpoint = loggedInDoor ? `${t.url}/api/self/p/packs/validate` : `${t.url}/api/public/packs/validate`;
1227
+ const headers = loggedInDoor
1228
+ ? authHeaders(t)
1229
+ // The public door takes no credential — but it still gets this run's client report, so a
1230
+ // stale reference is flagged exactly as it is for a logged-in author.
1231
+ : clientReportHeaders({ cliVersion: VERSION, kbContentHash: LOCAL_KB?.content_hash ?? null });
1232
+ console.log(`→ Running the platform's full check${loggedInDoor ? ` against ${targetLabel(t)}` : ' (no login needed)'} @ ${t.url} …`);
1214
1233
  // `--strict-primitives` (opt-in): the server additionally type-checks LITERAL
1215
1234
  // `args:` values against each primitive's per-key input schema. Expression
1216
1235
  // strings are always exempt — this judges only plain YAML scalars.
1217
- const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
1218
- method: 'POST',
1219
- headers: { 'content-type': 'application/json', ...authHeaders(t) },
1220
- body: JSON.stringify({
1221
- files, blobs,
1222
- ...(flags['strict-primitives'] === true ? { strict_primitives: true } : {}),
1223
- }),
1224
- }, 'remote validate');
1236
+ let res;
1237
+ try {
1238
+ res = await fetch(endpoint, {
1239
+ method: 'POST',
1240
+ headers: { 'content-type': 'application/json', ...headers },
1241
+ body: JSON.stringify({
1242
+ files, blobs,
1243
+ ...(flags['strict-primitives'] === true ? { strict_primitives: true } : {}),
1244
+ }),
1245
+ });
1246
+ }
1247
+ catch (err) {
1248
+ const why = `platform unreachable at ${t.url} (${err?.message ?? err})`;
1249
+ if (flags.remote === true) {
1250
+ const hint = localhostFamilyHint(t.url, err);
1251
+ die(`full check — ${why}${hint ? `\n ${hint}` : ''}`);
1252
+ }
1253
+ return printPartialTrailer(id, version, skipped, why);
1254
+ }
1225
1255
  const text = await res.text();
1226
1256
  let json;
1227
1257
  try {
@@ -1230,6 +1260,14 @@ async function cmdValidate(flags) {
1230
1260
  catch {
1231
1261
  json = text;
1232
1262
  }
1263
+ // A platform older than the public door answers Fastify's default 404 — degrade to the
1264
+ // subset (loudly), or fail when the full check was REQUIRED.
1265
+ if (!loggedInDoor && res.status === 404 && (typeof json !== 'object' || json === null || json.error === 'Not Found')) {
1266
+ const why = `the platform at ${t.url} has no public validate yet — log in and pass --project for the full check`;
1267
+ if (flags.remote === true)
1268
+ die(why);
1269
+ return printPartialTrailer(id, version, skipped, why);
1270
+ }
1233
1271
  if (!res.ok) {
1234
1272
  // A 404 here is AMBIGUOUS and must not be collapsed. The route resolves the
1235
1273
  // tenant and the project BEFORE it validates anything, so a 404 is usually an
@@ -1250,8 +1288,13 @@ async function cmdValidate(flags) {
1250
1288
  die('this platform has no /packs/validate endpoint yet (older version) — deploy runs the full check');
1251
1289
  die(`remote validate${errDetail(json)} — check --tenant/--project (or PACK_TENANT/PACK_PROJECT); \`octwin projects\` lists what this token can reach`);
1252
1290
  }
1253
- console.error(`✗ remote validate failed (HTTP ${res.status})`);
1254
- printAuthHint(res.status, url);
1291
+ // The public door is rate-limited per caller. Hitting it is not a verdict on the pack.
1292
+ if (res.status === 429 && flags.remote !== true) {
1293
+ return printPartialTrailer(id, version, skipped, `the platform's no-login check is rate-limited right now — retry shortly, or log in and pass --project`);
1294
+ }
1295
+ console.error(`✗ full check failed (HTTP ${res.status})`);
1296
+ if (loggedInDoor)
1297
+ printAuthHint(res.status, t.url);
1255
1298
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1256
1299
  exitNow(1);
1257
1300
  }
@@ -1280,6 +1323,19 @@ async function cmdValidate(flags) {
1280
1323
  }
1281
1324
  exitNow(1);
1282
1325
  }
1326
+ /**
1327
+ * The LAST lines when only the offline SUBSET ran. A reader who sees a ✓ and stops there is the
1328
+ * failure mode, and a caveat printed ABOVE the ✓ does not fix it — so the word is PARTIAL, it is
1329
+ * last, and it says why. Exit 0, as offline `validate` always has: the subset found nothing, and
1330
+ * a config gap is not a finding (`--remote` is for when the full check must not be skipped).
1331
+ */
1332
+ function printPartialTrailer(id, version, skipped, why) {
1333
+ console.log(`\n⚠ ${id}@${version}: only the OFFLINE check ran — a PARTIAL check. Deploy runs more, and can still refuse this pack.`);
1334
+ console.log(` Why: ${why}.`);
1335
+ if (skipped.length) {
1336
+ console.log(` Also skipped offline (${skipped.length}): ${skipped.join(', ')} — \`octwin platform-kb pull\` once at your repo root restores them.`);
1337
+ }
1338
+ }
1283
1339
  /**
1284
1340
  * Two ways in, one saved result.
1285
1341
  *
@@ -2437,7 +2493,7 @@ function pagingQs(flags, defaultLimit = 50) {
2437
2493
  * guessed) so the ambiguity is documented rather than emergent — a pack that
2438
2494
  * declares an entity actually called `note` reaches it via `--entity note`.
2439
2495
  */
2440
- const RECORD_VERBS = new Set(['create', 'patch', 'stage', 'note', 'tasks', 'task']);
2496
+ const RECORD_VERBS = new Set(['create', 'patch', 'stage', 'note', 'tasks', 'task', 'archive', 'restore']);
2441
2497
  /** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
2442
2498
  async function cmdRecords(flags) {
2443
2499
  // A leading reserved word is a write. To READ an entity whose name collides
@@ -2574,6 +2630,24 @@ async function cmdRecordsWrite(flags) {
2574
2630
  console.log(`✓ #${json?.record?.record_number ?? '?'} is now at '${json?.record?.stage ?? to}'`);
2575
2631
  return;
2576
2632
  }
2633
+ // There is no delete — on the platform or here. Archive is how a record leaves use (test data
2634
+ // included): it drops out of every list, count and report, fires the entity's `on.archived`
2635
+ // reactions exactly as the console's Archive does, and `restore` brings it back.
2636
+ if (verb === 'archive' || verb === 'restore') {
2637
+ const archived = verb === 'archive';
2638
+ // `||`, not `??`: an EMPTY id (a failed `$(…)` in a script) must not reach the route.
2639
+ const id = arg || die(`usage: octwin records ${verb} <recordId>`);
2640
+ console.log(`→ ${archived ? 'Archiving' : 'Restoring'} ${id} in ${targetLabel(t)} …`);
2641
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/archive`, { archived }, t);
2642
+ if (status === 404)
2643
+ die(`record '${id}' not found`);
2644
+ if (status !== 200)
2645
+ writeFail(`${verb} record ${id}`, status, json, url, true);
2646
+ console.log(archived
2647
+ ? `✓ Archived #${json?.record?.record_number ?? '?'} — out of every list and count. Undo: octwin records restore ${id}`
2648
+ : `✓ Restored #${json?.record?.record_number ?? '?'} — back in use.`);
2649
+ return;
2650
+ }
2577
2651
  if (verb === 'note') {
2578
2652
  const id = arg ?? die('usage: octwin records note <recordId> "the note text"');
2579
2653
  const note = flags._[2] ?? die('octwin records note needs the note text as the last argument');
@@ -2958,6 +3032,7 @@ class SseFrameReader {
2958
3032
  const REPLAY_SETTLE_MS = 400; // quiet gap that marks the end of the connect replay burst
2959
3033
  const TURN_SETTLE_MS = 2_000; // quiet gap after a render = the turn finished sending
2960
3034
  const REPLY_TIMEOUT_MS = 45_000; // hard cap waiting for the first render of the turn
3035
+ const LATE_DRAIN_MS = 3_000; // after the last turn: stay connected for late pushes (`drainLateRenders`)
2961
3036
  /**
2962
3037
  * Parse a `--script` file — ONE TURN PER LINE, in order:
2963
3038
  *
@@ -3069,20 +3144,55 @@ async function runChatTurn(args) {
3069
3144
  if (!asJson)
3070
3145
  console.log(heldLine(held));
3071
3146
  }
3147
+ // Only an ANSWER ends the wait. A notify (a reminder firing mid-script) is printed, labelled,
3148
+ // and the wait goes on — it used to count as the reply, end the turn on the reminder, and leave
3149
+ // the real reply looking lost (`lib/chat-line.ts`).
3072
3150
  if (asJson) {
3073
3151
  console.log(JSON.stringify(f.ev));
3074
- if (f.ev?.kind === 'render')
3152
+ if (f.ev?.kind === 'render' && answersTheTurn(f.ev))
3075
3153
  rendersSeen++;
3076
3154
  continue;
3077
3155
  }
3078
3156
  if (f.ev?.kind !== 'render')
3079
3157
  continue; // status/typing noise
3080
- rendersSeen++;
3081
- console.log(`← ${f.ev.body ?? '(no text body)'}${f.ev.hint?.type && f.ev.hint.type !== 'text' ? ` (render: ${f.ev.hint.type})` : ''}`);
3082
- printHint(f.ev.hint);
3158
+ if (answersTheTurn(f.ev))
3159
+ rendersSeen++;
3160
+ printRender(f.ev);
3083
3161
  }
3084
3162
  return { boundary, rendersSeen, held };
3085
3163
  }
3164
+ /** One render line — `← <label><body>` — and its interactive content. */
3165
+ function printRender(ev, note = '') {
3166
+ console.log(`← ${chatLabel(ev)}${ev.body ?? '(no text body)'}${ev.hint?.type && ev.hint.type !== 'text' ? ` (render: ${ev.hint.type})` : ''}${note}`);
3167
+ printHint(ev.hint);
3168
+ }
3169
+ /**
3170
+ * After the LAST turn, keep reading for `LATE_DRAIN_MS` and print what arrives — a notify, a
3171
+ * slow post-turn line — before closing the stream. A render the platform pushes to a stream the
3172
+ * CLI has already closed reaches no one and is recorded as undelivered ("the visitor has no open
3173
+ * chat connection"), which reads like a platform fault when it is only the CLI leaving early.
3174
+ */
3175
+ async function drainLateRenders(frames, boundary, asJson) {
3176
+ const deadline = Date.now() + LATE_DRAIN_MS;
3177
+ for (;;) {
3178
+ const remaining = deadline - Date.now();
3179
+ if (remaining <= 0)
3180
+ return;
3181
+ const f = await frames.next(remaining);
3182
+ if (f === 'timeout' || f === 'done')
3183
+ return;
3184
+ if (f.id != null && f.id <= boundary)
3185
+ continue;
3186
+ if (f.id != null)
3187
+ boundary = f.id;
3188
+ if (asJson) {
3189
+ console.log(JSON.stringify(f.ev));
3190
+ continue;
3191
+ }
3192
+ if (f.ev?.kind === 'render')
3193
+ printRender(f.ev, ' (after the last turn)');
3194
+ }
3195
+ }
3086
3196
  /** `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--script <file>] [--json]`
3087
3197
  * — drive a turn (or a whole scripted conversation) through the dev web channel
3088
3198
  * and print everything it rendered.
@@ -3201,6 +3311,7 @@ async function cmdChat(flags) {
3201
3311
  exitNow(1);
3202
3312
  }
3203
3313
  }
3314
+ await drainLateRenders(frames, boundary, asJson);
3204
3315
  await cancel();
3205
3316
  if (totalRenders === 0 && !anyHeld) {
3206
3317
  console.error(` no reply after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — the pack may not be warm yet, or the turn produced no render.`);
@@ -5258,7 +5369,7 @@ function help() {
5258
5369
 
5259
5370
  octwin --version # print the CLI version (+ any upgrade notice)
5260
5371
  octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5261
- octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
5372
+ octwin validate [--dir .] [--remote | --offline] [--require-kb] # the platform's FULL check by default — no login needed
5262
5373
  octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
5263
5374
  octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
5264
5375
  octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
@@ -5290,7 +5401,7 @@ function help() {
5290
5401
  octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
5291
5402
 
5292
5403
  Writes — exercise the state your pack creates (each needs the matching :write scope):
5293
- octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
5404
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…", archive|restore <id>
5294
5405
  octwin records tasks | task complete <taskId> [--outcome done|cancelled]
5295
5406
  octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
5296
5407
  octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
@@ -5317,15 +5428,22 @@ Per-command usage: octwin <command> --help`);
5317
5428
  const COMMAND_HELP = {
5318
5429
  init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5319
5430
  Scaffold a pure-YAML starter pack into <dir>.`,
5320
- validate: `octwin validate [--dir .] [--remote] [--require-kb] [--strict-primitives]
5321
- Offline structural check, plus two checks driven by the pulled capability
5322
- reference (render-intent fields, primitive arguments). Those two SKIP when the
5323
- reference is missing — the run says so, and --require-kb turns the skip into a
5324
- failure for CI. --remote additionally runs the platform's FULL manifest +
5325
- flow-DSL validation and its flow lint (all errors at once) — same check as deploy.
5326
- --strict-primitives (with --remote) additionally type-checks LITERAL args:
5327
- values against each primitive's declared input schema; expression strings
5328
- ('$found.id', '{$t(…)}') are always exempt.`,
5431
+ validate: `octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives]
5432
+ First the fast OFFLINE checks (structure, YAML, and the checks driven by the
5433
+ pulled capability reference — those SKIP when the reference is missing, and
5434
+ --require-kb turns the skip into a failure for CI). Then, BY DEFAULT, the
5435
+ platform's FULL validation — the same check deploy runs, all errors at once.
5436
+
5437
+ The full check needs NO login: without a token + --project it goes to the
5438
+ platform's public validate route. With a token AND --project (or PACK_PROJECT)
5439
+ it goes through your workspace, which also confirms that project exists.
5440
+
5441
+ When the platform cannot be reached (no url, offline, rate-limited) the run
5442
+ ends with "only the OFFLINE check ran — a PARTIAL check" and exits 0.
5443
+ --remote REQUIRES the full check: not reaching it is an error. --offline runs
5444
+ only the partial subset. --strict-primitives additionally type-checks LITERAL
5445
+ args: values against each primitive's declared input schema; expression
5446
+ strings ('$found.id', '{$t(…)}') are always exempt.`,
5329
5447
  login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
5330
5448
  Sign in to a platform and make that url the DEFAULT deploy target for every
5331
5449
  later command.
@@ -5403,14 +5521,20 @@ octwin projects rm <slug> [--yes]
5403
5521
  octwin records patch <recordId> --entity <entity> --set field=value
5404
5522
  octwin records stage <recordId> --to <stage> [--note "..."]
5405
5523
  octwin records note <recordId> "the note text"
5524
+ octwin records archive <recordId> # out of every list and count (test data too)
5525
+ octwin records restore <recordId> # undo an archive
5406
5526
  octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
5407
5527
  octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
5408
5528
 
5529
+ There is NO delete — on the platform or here. \`archive\` is how a record leaves use:
5530
+ it fires the entity's \`on.archived\` reactions, exactly like the console's Archive,
5531
+ and \`restore\` brings it back.
5532
+
5409
5533
  --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
5410
5534
  sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
5411
5535
  \`patch\` needs --entity even though it has an id: the route resolves the field
5412
- validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
5413
- VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
5536
+ validator from it. A leading \`create/patch/stage/note/archive/restore/tasks/task\` is
5537
+ read as a VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
5414
5538
  work: `octwin work [recordId] [--queues] [--unrouted] [--limit 50] [--offset n] [--json]
5415
5539
  Inspect the work inbox — every entity the pack declares worked (cases, orders
5416
5540
  needing review, applications, …): the inbox, one item + its timeline
@@ -5449,6 +5573,13 @@ octwin projects rm <slug> [--yes]
5449
5573
  running media-collect flow (e.g. activate-app).
5450
5574
  --json dumps the raw SSE envelopes for the turn.
5451
5575
 
5576
+ Each reply line says who sent it. Plain \`← …\` is the AI Agent. \`← [notify] …\` is a
5577
+ reminder or notification (a \`schedule_notify\`, a reaction) — NOT the turn's answer,
5578
+ so the wait for the real reply goes on. \`← [operator: Name] …\` is a person in
5579
+ Live chat; \`← [platform] …\` is a platform line (a handoff joining or returning).
5580
+ After the last turn the stream stays open ${LATE_DRAIN_MS / 1000}s more, so a late message
5581
+ still prints (marked "after the last turn") instead of reaching a closed connection.
5582
+
5452
5583
  --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
5453
5584
  process over one connection — waiting for each turn to settle before sending
5454
5585
  the next. Use this for any multi-step flow: chaining shell invocations races
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Who sent a render on the chat stream — and whether it answers the turn.
3
+ *
4
+ * `octwin chat` reads ONE stream for a conversation, and more than the AI Agent writes to it:
5
+ *
6
+ * - a `notify` send — a `schedule_notify` reminder, a reaction's notification, a queued
7
+ * briefing — which answers nothing the author just sent (`source: 'notify'` on the web
8
+ * envelope);
9
+ * - a person holding the conversation in Live chat (`author.kind: 'human'`, with a name);
10
+ * - a platform line such as a handoff's join / return copy (`author.kind: 'system'`).
11
+ *
12
+ * Every one of them used to print as `← <body>`, exactly like the AI Agent's reply. The case
13
+ * that cost an author real time (chughtai, 2026-09-23): a reminder fired mid `--script`, the CLI
14
+ * took it for the turn's reply and ended the turn on it, and the real reply — which `octwin logs`
15
+ * showed the flow had produced correctly — looked lost.
16
+ *
17
+ * Kept free of I/O so the rules can be tested without a stream.
18
+ */
19
+ export function chatSender(ev) {
20
+ if (ev.source === 'notify')
21
+ return 'notify';
22
+ if (ev.author?.kind === 'human')
23
+ return 'operator';
24
+ if (ev.author?.kind === 'system')
25
+ return 'platform';
26
+ return 'agent';
27
+ }
28
+ /** The label printed before the body — empty for the AI Agent, whose replies stay `← …`. */
29
+ export function chatLabel(ev) {
30
+ switch (chatSender(ev)) {
31
+ case 'notify': return '[notify] ';
32
+ case 'operator': return ev.author?.name ? `[operator: ${ev.author.name}] ` : '[operator] ';
33
+ case 'platform': return '[platform] ';
34
+ default: return '';
35
+ }
36
+ }
37
+ /**
38
+ * Whether this render is an ANSWER to the message the author just sent — what ends a turn's wait.
39
+ * A notify is not: it would have been sent whether or not the author wrote anything. An operator's
40
+ * line and a platform line are: in a held conversation they ARE the reply.
41
+ */
42
+ export function answersTheTurn(ev) {
43
+ return chatSender(ev) !== 'notify';
44
+ }
@@ -9,7 +9,7 @@
9
9
  * platform's own `approve_apply` is the common case: `confirm_title` misspelled
10
10
  * silently falls back to the hardcoded Arabic default.
11
11
  *
12
- * PACK TEMPLATES SHADOW PLATFORM ONES. A pack may ship `templates/<name>.yaml`
12
+ * PACK TEMPLATES SHADOW PLATFORM ONES. A pack may ship `templates/<name>.template.yaml`
13
13
  * of its own, and a pack template of the same name wins. So the caller passes
14
14
  * the pack's own template names and this check stays silent about them entirely
15
15
  * — it has no schema for a template it did not publish, and inventing one would
@@ -96,7 +96,9 @@ export function describeTemplateFinding(f) {
96
96
  switch (f.kind) {
97
97
  case 'unknown':
98
98
  return `${f.file}: \`use: ${f.template}\` — no such template. Platform templates: ${f.known.join(', ')}`
99
- + `. (A template of your own must live at templates/${f.template}.yaml in the pack.)`;
99
+ // `.template.yaml`, not `.yaml`: the platform loads only that suffix, so a template saved
100
+ // as this hint used to say was silently ignored and the `use:` stayed unresolved.
101
+ + `. (A template of your own must live at templates/${f.template}.template.yaml in the pack.)`;
100
102
  case 'unknown-param':
101
103
  return `${f.file}: \`use: ${f.template}\` passes ${f.keys.map(k => `'${k}'`).join(', ')}, which the template declares no param for `
102
104
  + `— dropped in silence, and the body sees undefined. Params: ${f.params.join(', ')}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,13 @@
1
+ # EXAMPLE — replace it or delete it; do not leave it behind.
1
2
  # An example capability the home hub invokes. Replace this with your real tool
2
3
  # (record_list + a carousel, a collect: intake, a case_open, …). Kept trivial here
3
4
  # so the scaffold is chattable end-to-end: home menu → tap "browse" → this renders.
5
+ #
6
+ # If you do not keep it, delete THREE things together: this file, every
7
+ # `browse.locale.<lang>.yaml` beside it, and the `browse` line in manifest.yaml
8
+ # `flows:` (plus the home menu row that invokes it). A file left here with its
9
+ # manifest line removed never loads — but its copy is still checked, so a pack that
10
+ # adds a second language fails validation over a flow nobody uses.
4
11
  flow_id: browse
5
12
  version: 1
6
13
  description: An example capability, reached from the home menu. Replace with your real tool.
@@ -1,3 +1,4 @@
1
+ # EXAMPLE copy for the example `browse` flow — delete it together with browse.flow.yaml.
1
2
  flow_id: browse
2
3
 
3
4
  strings:
@@ -33,7 +33,7 @@ default_settings:
33
33
  # front door; add each new tool here as you build it.
34
34
  flows:
35
35
  - home # the menu / navigation hub (see references/ux-patterns.md)
36
- - browse # an example tool the hub invokes — replace with your real capability
36
+ - browse # an EXAMPLE tool the hub invokes — replace it, or delete this line AND flows/tools/browse.*
37
37
 
38
38
  # One agent. The platform's universal agent protocol is auto-appended by
39
39
  # `createPackAgents` — `instructions:` carries only pack-supplied parts.