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 +36 -0
- package/README.md +4 -4
- package/dist/index.js +181 -50
- package/dist/lib/chat-line.js +44 -0
- package/dist/lib/template-check.js +4 -2
- package/package.json +1 -1
- package/templates/starter/flows/tools/browse.flow.yaml +7 -0
- package/templates/starter/flows/tools/browse.locale.ar.yaml +1 -0
- package/templates/starter/manifest.yaml +1 -1
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` |
|
|
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
|
|
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] #
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
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] #
|
|
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
|
-
|
|
5322
|
-
reference
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
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
|
|
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
|
-
|
|
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,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.
|
|
@@ -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
|
|
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.
|