@floless/app 0.7.1 → 0.9.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.
@@ -72,7 +72,7 @@ var require_queue = __commonJS({
72
72
  if (!(_concurrency >= 1)) {
73
73
  throw new Error("fastqueue concurrency must be equal to or greater than 1");
74
74
  }
75
- var cache = reusify(Task);
75
+ var cache2 = reusify(Task);
76
76
  var queueHead = null;
77
77
  var queueTail = null;
78
78
  var _running = 0;
@@ -151,7 +151,7 @@ var require_queue = __commonJS({
151
151
  return _running === 0 && self.length() === 0;
152
152
  }
153
153
  function push(value, done) {
154
- var current = cache.get();
154
+ var current = cache2.get();
155
155
  current.context = context;
156
156
  current.release = release;
157
157
  current.value = value;
@@ -172,7 +172,7 @@ var require_queue = __commonJS({
172
172
  }
173
173
  }
174
174
  function unshift(value, done) {
175
- var current = cache.get();
175
+ var current = cache2.get();
176
176
  current.context = context;
177
177
  current.release = release;
178
178
  current.value = value;
@@ -194,7 +194,7 @@ var require_queue = __commonJS({
194
194
  }
195
195
  function release(holder) {
196
196
  if (holder) {
197
- cache.release(holder);
197
+ cache2.release(holder);
198
198
  }
199
199
  var next = queueHead;
200
200
  if (next && _running <= _concurrency) {
@@ -6621,12 +6621,12 @@ var require_levels = __commonJS({
6621
6621
  function genLsCache(instance) {
6622
6622
  const formatter = instance[formattersSym].level;
6623
6623
  const { labels } = instance.levels;
6624
- const cache = {};
6624
+ const cache2 = {};
6625
6625
  for (const label in labels) {
6626
6626
  const level = formatter(labels[label], Number(label));
6627
- cache[label] = JSON.stringify(level).slice(0, -1);
6627
+ cache2[label] = JSON.stringify(level).slice(0, -1);
6628
6628
  }
6629
- instance[lsCacheSym] = cache;
6629
+ instance[lsCacheSym] = cache2;
6630
6630
  return instance;
6631
6631
  }
6632
6632
  function isStandardLevel(level, useOnlyCustomLevels) {
@@ -25430,7 +25430,7 @@ var require_range = __commonJS({
25430
25430
  parseRange(range) {
25431
25431
  const memoOpts = (this.options.includePrerelease && FLAG_INCLUDE_PRERELEASE) | (this.options.loose && FLAG_LOOSE);
25432
25432
  const memoKey = memoOpts + ":" + range;
25433
- const cached = cache.get(memoKey);
25433
+ const cached = cache2.get(memoKey);
25434
25434
  if (cached) {
25435
25435
  return cached;
25436
25436
  }
@@ -25464,7 +25464,7 @@ var require_range = __commonJS({
25464
25464
  rangeMap.delete("");
25465
25465
  }
25466
25466
  const result = [...rangeMap.values()];
25467
- cache.set(memoKey, result);
25467
+ cache2.set(memoKey, result);
25468
25468
  return result;
25469
25469
  }
25470
25470
  intersects(range, options) {
@@ -25503,7 +25503,7 @@ var require_range = __commonJS({
25503
25503
  };
25504
25504
  module2.exports = Range;
25505
25505
  var LRU = require_lrucache();
25506
- var cache = new LRU();
25506
+ var cache2 = new LRU();
25507
25507
  var parseOptions = require_parse_options();
25508
25508
  var Comparator = require_comparator();
25509
25509
  var debug = require_debug2();
@@ -26445,12 +26445,12 @@ var require_plugin_utils = __commonJS({
26445
26445
  if (display) {
26446
26446
  return display;
26447
26447
  }
26448
- const cache = require.cache;
26449
- if (cache) {
26450
- const keys = Object.keys(cache);
26448
+ const cache2 = require.cache;
26449
+ if (cache2) {
26450
+ const keys = Object.keys(cache2);
26451
26451
  for (let i = 0; i < keys.length; i++) {
26452
26452
  const key = keys[i];
26453
- if (cache[key].exports === func) {
26453
+ if (cache2[key].exports === func) {
26454
26454
  return key;
26455
26455
  }
26456
26456
  }
@@ -42463,10 +42463,10 @@ var require_accept_negotiator = __commonJS({
42463
42463
  }
42464
42464
  const {
42465
42465
  supportedValues = [],
42466
- cache
42466
+ cache: cache2
42467
42467
  } = options && typeof options === "object" && options || {};
42468
42468
  this.supportedValues = supportedValues;
42469
- this.cache = cache;
42469
+ this.cache = cache2;
42470
42470
  }
42471
42471
  Negotiator.prototype.negotiate = function(header) {
42472
42472
  if (typeof header !== "string") {
@@ -51392,6 +51392,49 @@ function parseRunHandle(stdout) {
51392
51392
  function traceLogPath(id, instance, runId, logsRoot = LOGS_DIR) {
51393
51393
  return (0, import_node_path3.join)(logsRoot, id, instance, `${runId}.jsonl`);
51394
51394
  }
51395
+ function latestTracePath(id, logsRoot = LOGS_DIR) {
51396
+ const appDir2 = (0, import_node_path3.join)(logsRoot, id);
51397
+ if (!(0, import_node_fs4.existsSync)(appDir2)) return null;
51398
+ let best = null;
51399
+ let instances;
51400
+ try {
51401
+ instances = (0, import_node_fs4.readdirSync)(appDir2);
51402
+ } catch {
51403
+ return null;
51404
+ }
51405
+ for (const instance of instances) {
51406
+ const instDir = (0, import_node_path3.join)(appDir2, instance);
51407
+ let entries;
51408
+ try {
51409
+ if (!(0, import_node_fs4.statSync)(instDir).isDirectory()) continue;
51410
+ entries = (0, import_node_fs4.readdirSync)(instDir);
51411
+ } catch {
51412
+ continue;
51413
+ }
51414
+ for (const f of entries) {
51415
+ if (!f.endsWith(".jsonl")) continue;
51416
+ const p = (0, import_node_path3.join)(instDir, f);
51417
+ try {
51418
+ const m = (0, import_node_fs4.statSync)(p).mtimeMs;
51419
+ if (!best || m > best.mtimeMs) best = { path: p, mtimeMs: m };
51420
+ } catch {
51421
+ }
51422
+ }
51423
+ }
51424
+ return best ? best.path : null;
51425
+ }
51426
+ function readLatestTrace(id, logsRoot = LOGS_DIR) {
51427
+ const p = latestTracePath(id, logsRoot);
51428
+ if (!p) return null;
51429
+ let text;
51430
+ try {
51431
+ text = (0, import_node_fs4.readFileSync)(p, "utf8");
51432
+ } catch {
51433
+ return null;
51434
+ }
51435
+ const runId = p.split(/[\\/]/).pop()?.replace(/\.jsonl$/, "") ?? "";
51436
+ return { runId, path: p, text };
51437
+ }
51395
51438
  function startTrigger(id, opts = {}) {
51396
51439
  assertId(id);
51397
51440
  const invoker = currentInvoker();
@@ -52050,9 +52093,15 @@ function mapUserStatus(u) {
52050
52093
  function signInUrl() {
52051
52094
  return `${env().webBase}/login?app=floless`;
52052
52095
  }
52096
+ function webBaseUrl() {
52097
+ return env().webBase;
52098
+ }
52053
52099
  function updateApiBase() {
52054
52100
  return `${env().apiBase}/updates/releases`;
52055
52101
  }
52102
+ function apiBaseUrl() {
52103
+ return env().apiBase;
52104
+ }
52056
52105
  async function accessToken() {
52057
52106
  return ensureFreshToken();
52058
52107
  }
@@ -52308,7 +52357,7 @@ function appVersion() {
52308
52357
  return resolveVersion({
52309
52358
  isSea: isSea2(),
52310
52359
  sqVersionXml: readSqVersionXml(),
52311
- define: true ? "0.7.1" : void 0,
52360
+ define: true ? "0.9.0" : void 0,
52312
52361
  pkgVersion: readPkgVersion()
52313
52362
  });
52314
52363
  }
@@ -52318,7 +52367,7 @@ function resolveChannel(s) {
52318
52367
  return "dev";
52319
52368
  }
52320
52369
  function appChannel() {
52321
- return resolveChannel({ isSea: isSea2(), define: true ? "0.7.1" : void 0 });
52370
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.9.0" : void 0 });
52322
52371
  }
52323
52372
 
52324
52373
  // oauth-presets.ts
@@ -53844,6 +53893,150 @@ async function applyUpdate(check, opts) {
53844
53893
  return { applied: true, message: `updating to v${check.targetVersion}\u2026 the app will relaunch` };
53845
53894
  }
53846
53895
 
53896
+ // report-relay.ts
53897
+ var REPORT_TIMEOUT_MS = 15e3;
53898
+ async function reportIssue(input) {
53899
+ let token;
53900
+ try {
53901
+ token = await accessToken();
53902
+ } catch {
53903
+ return { ok: false, error: "offline" };
53904
+ }
53905
+ if (!token) return { ok: false, error: "signed_out" };
53906
+ const url = `${apiBaseUrl()}/issues`;
53907
+ let res;
53908
+ try {
53909
+ res = await authedFetch(url, {
53910
+ method: "POST",
53911
+ headers: { "Content-Type": "application/json" },
53912
+ body: JSON.stringify(input),
53913
+ signal: AbortSignal.timeout(REPORT_TIMEOUT_MS)
53914
+ });
53915
+ } catch {
53916
+ return { ok: false, error: "offline" };
53917
+ }
53918
+ if (res.status === 401) return { ok: false, error: "signed_out" };
53919
+ if (res.status === 429) return { ok: false, error: "rate_limited" };
53920
+ if (!res.ok) return { ok: false, error: "offline" };
53921
+ let ref = "";
53922
+ try {
53923
+ const b = await res.json();
53924
+ if (typeof b.ref === "string") ref = b.ref;
53925
+ } catch {
53926
+ }
53927
+ return { ok: true, ref };
53928
+ }
53929
+
53930
+ // release-notes.ts
53931
+ var FETCH_TIMEOUT_MS = 8e3;
53932
+ var AWARE_REPO = "aware-aeco/aware";
53933
+ var CHANGE_RE = /^[-*]\s+\*\*?(Added|Changed|Fixed|Removed|Security)\*\*?:\s+(.+)$/gim;
53934
+ function parseBulletChanges(body) {
53935
+ const out = [];
53936
+ let m;
53937
+ CHANGE_RE.lastIndex = 0;
53938
+ while ((m = CHANGE_RE.exec(body)) !== null) out.push({ type: m[1].toLowerCase(), description: m[2].trim() });
53939
+ return out;
53940
+ }
53941
+ var cache = /* @__PURE__ */ new Map();
53942
+ var CACHE_TTL_MS = 5 * 6e4;
53943
+ async function getJson(url, headers = {}) {
53944
+ try {
53945
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
53946
+ let json = null;
53947
+ try {
53948
+ json = await res.json();
53949
+ } catch {
53950
+ }
53951
+ return { status: res.status, json };
53952
+ } catch {
53953
+ return null;
53954
+ }
53955
+ }
53956
+ async function getAppReleaseNotes(version, deps = {}) {
53957
+ const useCache = !deps.changelogUrl;
53958
+ const key = `app:${version}`;
53959
+ if (useCache) {
53960
+ const hit = cache.get(key);
53961
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.notes;
53962
+ }
53963
+ const url = deps.changelogUrl ?? `${webBaseUrl()}/changelog.json`;
53964
+ const r = await getJson(url);
53965
+ let notes;
53966
+ if (!r || r.status >= 400 || !Array.isArray(r.json)) {
53967
+ notes = { ok: false, reason: "unavailable" };
53968
+ } else {
53969
+ const e = r.json.find(
53970
+ (x) => typeof x === "object" && x !== null && x.version === version
53971
+ );
53972
+ notes = e ? {
53973
+ ok: true,
53974
+ component: "app",
53975
+ version,
53976
+ title: String(e.title ?? `v${version}`),
53977
+ summary: String(e.description ?? ""),
53978
+ changes: Array.isArray(e.changes) ? e.changes.filter((c) => typeof c === "object" && c !== null).map((c) => ({ type: String(c.type ?? ""), description: String(c.description ?? "") })) : [],
53979
+ url: typeof e.url === "string" ? e.url : `${webBaseUrl()}/changelog#v${version}`
53980
+ } : { ok: false, reason: "not-found" };
53981
+ }
53982
+ if (useCache && (notes.ok || notes.reason === "not-found")) cache.set(key, { at: Date.now(), notes });
53983
+ return notes;
53984
+ }
53985
+ async function getAwareReleaseNotes(version, deps = {}) {
53986
+ const useCache = !deps.apiBase;
53987
+ const key = `aware:${version}`;
53988
+ if (useCache) {
53989
+ const hit = cache.get(key);
53990
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.notes;
53991
+ }
53992
+ const base = deps.apiBase ?? "https://api.github.com";
53993
+ const r = await getJson(`${base}/repos/${AWARE_REPO}/releases/tags/v${version}`, {
53994
+ Accept: "application/vnd.github+json",
53995
+ "User-Agent": "floless.app"
53996
+ });
53997
+ let notes;
53998
+ if (!r || r.status >= 400 || typeof r.json !== "object" || r.json === null) {
53999
+ notes = r && r.status === 404 ? { ok: false, reason: "not-found" } : { ok: false, reason: "unavailable" };
54000
+ } else {
54001
+ const rel = r.json;
54002
+ const body = typeof rel.body === "string" ? rel.body : "";
54003
+ const changes = parseBulletChanges(body);
54004
+ const summary = body.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#") && !l.startsWith("-") && !l.startsWith("*")) ?? "";
54005
+ notes = { ok: true, component: "aware", version, title: rel.name ?? `AWARE ${version}`, summary, changes, url: rel.html_url ?? `https://github.com/${AWARE_REPO}/releases/tag/v${version}` };
54006
+ }
54007
+ if (useCache && (notes.ok || notes.reason === "not-found")) cache.set(key, { at: Date.now(), notes });
54008
+ return notes;
54009
+ }
54010
+
54011
+ // run-summary.ts
54012
+ var clampMsg = (s) => {
54013
+ const t = s.trim();
54014
+ return t.length > 1e3 ? t.slice(0, 1e3) : t;
54015
+ };
54016
+ function summarizeRun(events) {
54017
+ const runEnd = [...events].reverse().find((e) => e.kind === "run-end");
54018
+ const status = runEnd && typeof runEnd.status === "string" ? runEnd.status : events.length ? "unknown" : "none";
54019
+ let errorNodeId = null;
54020
+ let errorMessage = null;
54021
+ for (const e of events) {
54022
+ if (e.kind === "node-error" && typeof e.node === "string") {
54023
+ errorNodeId = e.node;
54024
+ const err = e.error;
54025
+ errorMessage = typeof err === "string" && err.trim() ? clampMsg(err) : null;
54026
+ break;
54027
+ }
54028
+ const data = e.data;
54029
+ const result = data?.result ?? data;
54030
+ if (result && result.ok === false && typeof e.node === "string") {
54031
+ errorNodeId = e.node;
54032
+ const err = result.error;
54033
+ errorMessage = typeof err === "string" && err.trim() ? clampMsg(err) : null;
54034
+ break;
54035
+ }
54036
+ }
54037
+ return { status, errorNodeId, errorMessage };
54038
+ }
54039
+
53847
54040
  // launch.mjs
53848
54041
  var import_node_child_process5 = require("node:child_process");
53849
54042
  var import_node_path14 = require("node:path");
@@ -54321,13 +54514,15 @@ var import_yaml5 = __toESM(require_dist6(), 1);
54321
54514
  // build/ship-skills.mjs
54322
54515
  var PRODUCT_SKILLS = [
54323
54516
  "floless-app-bridge",
54324
- // drive the floless.app CLI / desktop bridge from the user's AI
54517
+ // drive the floless.app CLI / desktop bridge from the user's AI
54325
54518
  "floless-app-onboarding",
54326
- // guided, re-runnable tour of AWARE + floless.app for new users
54519
+ // guided, re-runnable tour of AWARE + floless.app for new users
54520
+ "floless-app-report-issue",
54521
+ // file a diagnosed bug/idea/question to the private log via the floless.io relay
54327
54522
  "floless-app-routines",
54328
- // author event-driven ("on trigger") routines
54523
+ // author event-driven ("on trigger") routines
54329
54524
  "floless-app-workflows"
54330
- // author/run .flo workflows
54525
+ // author/run .flo workflows
54331
54526
  ];
54332
54527
  function selectShippedSkillNames(names) {
54333
54528
  const present = new Set(names);
@@ -56318,7 +56513,12 @@ async function startServer() {
56318
56513
  });
56319
56514
  app.addHook("onRequest", async (req, reply) => {
56320
56515
  if (!req.url.startsWith("/api/")) return;
56321
- if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update")) return;
56516
+ if (req.url.startsWith("/api/health") || req.url.startsWith("/api/license/") || req.url.startsWith("/api/bootstrap/") || req.url.startsWith("/api/autostart") || // Report-an-issue is a support lifeline: a user whose subscription lapsed must still be
56517
+ // able to report "I got locked out". It touches no workspace data and the relay still
56518
+ // requires a valid SESSION token (so it's not open abuse — see report-relay.ts).
56519
+ req.url.startsWith("/api/report-issue") || req.url.startsWith("/api/update") || req.url.startsWith("/api/aware/update") || // Release notes are a read-only display surface for the update pills — a local control,
56520
+ // no workspace data, never spawns `aware`. Exempt like the other update routes.
56521
+ req.url.startsWith("/api/release-notes")) return;
56322
56522
  const { state: state2, signInUrl: signInUrl2 } = await getLicenseStatus();
56323
56523
  if (state2 !== "valid" && state2 !== "offline-grace") {
56324
56524
  return reply.status(402).send({ ok: false, error: "subscription required", state: state2, signInUrl: signInUrl2 });
@@ -56337,6 +56537,8 @@ async function startServer() {
56337
56537
  ok: true,
56338
56538
  appVersion: appVersion(),
56339
56539
  // the installed build (sq.version), so it's scriptable
56540
+ webBase: webBaseUrl(),
56541
+ // channel-correct public site base, for the changelog deep-link
56340
56542
  awareVersion: awareInstalledVersion() ?? bs.awareVersion,
56341
56543
  // fresh (TTL-cached) install version; the boot snapshot is only a fallback
56342
56544
  awareReady: bs.awareReady,
@@ -56355,6 +56557,24 @@ async function startServer() {
56355
56557
  logout();
56356
56558
  return { ok: true };
56357
56559
  });
56560
+ app.post(
56561
+ "/api/report-issue",
56562
+ async (req, reply) => {
56563
+ const b = req.body ?? {};
56564
+ const category = b.category === "idea" || b.category === "question" ? b.category : "bug";
56565
+ const title = typeof b.title === "string" ? b.title.trim() : "";
56566
+ const body = typeof b.body === "string" ? b.body.trim() : "";
56567
+ if (!title || !body) {
56568
+ return reply.status(400).send({ ok: false, error: "title and body are required" });
56569
+ }
56570
+ const clamp = (s, n) => s.length > n ? s.slice(0, n) : s;
56571
+ const context = b.context && typeof b.context === "object" && !Array.isArray(b.context) && JSON.stringify(b.context).length <= 16e3 ? b.context : void 0;
56572
+ const result = await reportIssue({ category, title: clamp(title, 200), body: clamp(body, 12e3), context });
56573
+ if (result.ok) return { ok: true, ref: result.ref };
56574
+ const status = result.error === "signed_out" ? 401 : result.error === "rate_limited" ? 429 : 503;
56575
+ return reply.status(status).send({ ok: false, error: result.error });
56576
+ }
56577
+ );
56358
56578
  app.get("/api/autostart", async () => {
56359
56579
  const supported = autostartSupported();
56360
56580
  return { ok: true, supported, enabled: supported ? autostartPresent() : false };
@@ -56408,6 +56628,13 @@ async function startServer() {
56408
56628
  awareInstallInFlight = false;
56409
56629
  }
56410
56630
  });
56631
+ app.get("/api/release-notes", async (req, reply) => {
56632
+ const component = req.query.component === "aware" ? "aware" : "app";
56633
+ const version = typeof req.query.version === "string" ? req.query.version.trim().replace(/^v/, "") : "";
56634
+ if (!/^\d+\.\d+\.\d+(?:[-+][\w.]+)?$/.test(version)) return reply.status(400).send({ ok: false, reason: "unavailable" });
56635
+ const notes = component === "aware" ? await getAwareReleaseNotes(version) : await getAppReleaseNotes(version);
56636
+ return notes;
56637
+ });
56411
56638
  app.post("/api/bootstrap/retry", async () => {
56412
56639
  const st = getBootstrapState().status;
56413
56640
  if (st === "failed" || st === "idle") {
@@ -56675,6 +56902,18 @@ async function startServer() {
56675
56902
  const running = cancelActiveRun();
56676
56903
  return { ok: true, running };
56677
56904
  });
56905
+ app.get("/api/latest-run/:id", async (req, reply) => {
56906
+ const id = req.params.id;
56907
+ if (!/^[A-Za-z0-9._-]+$/.test(id)) {
56908
+ return reply.status(400).send({ ok: false, error: "invalid app id" });
56909
+ }
56910
+ const latest = readLatestTrace(id);
56911
+ if (!latest) {
56912
+ return { ok: true, id, runId: null, status: "none", errorNodeId: null, errorMessage: null, events: [] };
56913
+ }
56914
+ const events = parseTrace(latest.text);
56915
+ return { ok: true, id, runId: latest.runId, ...summarizeRun(events), events };
56916
+ });
56678
56917
  function resolveArgs(configArgs, inputs) {
56679
56918
  const out = {};
56680
56919
  for (const [k, v] of Object.entries(configArgs)) {
@@ -0,0 +1,127 @@
1
+ ---
2
+ name: floless-app-report-issue
3
+ description: File a diagnosed bug / idea / question from floless.app into the PRIVATE product log via the floless.io relay. Use when the user invokes /floless-app-report-issue, clicks "Report an issue" in the floless.app UI (a queued report or a copied prompt), or says "report this", "file a bug", "something broke in floless", "send feedback / a feature idea". It READS the live run evidence first (latest run trace, failing node + error, the .flo, exec code), offers a workaround when one exists, then composes a maintainer-ready report and sends it — always showing the exact text and confirming first. Read-only: it never re-runs a workflow node.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # floless.app — report an issue
9
+
10
+ You are not a feedback form. You are the **on-device incident responder**: a capable AI already
11
+ running on the user's machine, with its hands on the exact thing that broke — the workflow, the
12
+ run trace, the `.flo`, the exec code. A report you file should arrive **diagnosed, not described**.
13
+
14
+ The destination is floless.app's **private** product log. End users can't file there directly
15
+ (it's a private repo), so the local server **relays** the report to floless.io, which files it
16
+ on their behalf under the signed-in session. You compose + send; you never touch GitHub directly.
17
+
18
+ ## Preconditions
19
+
20
+ - The local server is up: `curl -s http://127.0.0.1:4317/api/health` → `{"ok":true,…}` (fixed port
21
+ 4317; override `$PORT`). It returns `appVersion`, `awareVersion`, and `license`. If it's down,
22
+ the user starts it from the repo (`cd server && npm run dev`).
23
+ - A signed-in session is required to file (the relay authenticates with it). If a send returns
24
+ `signed_out`, tell the user to sign in (menu → the license state in the footer) and retry — do
25
+ not try to work around it.
26
+
27
+ ## Safety contract — READ-ONLY (non-negotiable)
28
+
29
+ Diagnosing must never change the user's model or data.
30
+
31
+ - **Never re-run an individual exec node.** Exec nodes run Roslyn C# against a **live Tekla/Trimble
32
+ host** and can mutate the model. There is no "re-run just this node" — don't invent one.
33
+ - **Only** offer a *full-workflow* re-run when **all** hold: the user explicitly asks, the app is
34
+ `runnable`, and the latest trace shows the previous run was **non-mutating** (read-only nodes).
35
+ Otherwise diagnose from the evidence already on disk.
36
+ - Everything below (`/api/health`, `/api/latest-run/:id`, `/api/app/:id`, reading the `.flo`/exec
37
+ source) is read-only. Stay there.
38
+
39
+ ## Discriminator + routing — keep the trackers clean
40
+
41
+ Everything from a user funnels into the **one private floless log**; the maintainer triages and
42
+ escalates from there. The user never has to decide "is this AWARE or floless?".
43
+
44
+ - File here for: anything in the floless.app experience — the UI, a workflow/app behaving wrong, a
45
+ confusing result, a feature idea, a question.
46
+ - The one exception (maintainers only): if it is *plainly* an AWARE **runtime** defect (a compiler
47
+ panic, a CLI bug) **and** you're operating with repo access, prefer `/aware-log-issue`
48
+ (→ `aware-aeco`). For an ordinary user, still file here — the maintainer escalates.
49
+
50
+ ## Workflow — read, diagnose, confirm, send
51
+
52
+ ```dot
53
+ digraph { "health + identify app" -> "read evidence (READ-ONLY)" -> "root-cause hypothesis" ->
54
+ "offer workaround" -> "compose report" -> "privacy scrub + SHOW + confirm" ->
55
+ "POST /api/report-issue" -> "report the ref" }
56
+ ```
57
+
58
+ 1. **Health + identify the app.** `GET /api/health` for versions + license. Establish which app/
59
+ workflow the report is about (ask if unclear, or take it from a queued UI request).
60
+ 2. **Read the evidence (the killer step — READ-ONLY):**
61
+ - `GET /api/latest-run/<appId>` → structured trace: `runId`, `status`, `errorNodeId`,
62
+ `errorMessage`, `events`. This is the spine of the diagnosis.
63
+ - `GET /api/app/<appId>` → the normalized topology + `.flo` source + each node's `config`
64
+ (incl. exec `code`). Read the failing node's code; pin `file:line`.
65
+ - Form a concrete **root-cause hypothesis** — not "it failed", but *why*.
66
+ 3. **Offer a fix/workaround in the moment** when the evidence supports one. The user often leaves
67
+ already unstuck — that's the point.
68
+ 4. **Compose** the report from the template below: a diagnosed body, problem-first title.
69
+ 5. **Privacy scrub + confirm (ALWAYS).** The private log is still real: include versions + a
70
+ sanitized summary by default. **Ask before** attaching the `.flo`, raw logs, a screenshot, or
71
+ any model/company names. **Show the user the exact title + body**, then wait for an explicit
72
+ yes. Nothing sends without it.
73
+ 6. **Send** via the local relay:
74
+ ```bash
75
+ curl -s -X POST http://127.0.0.1:4317/api/report-issue \
76
+ -H 'Content-Type: application/json' \
77
+ -d '{"category":"bug","title":"…","body":"…","context":{ "appId":"…","appVersion":"…","awareVersion":"…","runId":"…","errorNodeId":"…" }}'
78
+ ```
79
+ → `{ "ok": true, "ref": "#123" }`. Errors: `signed_out` (sign in), `rate_limited` (wait), or
80
+ `offline` (check connection) — relay them plainly; the user's text is not lost.
81
+ 7. **Report** the `ref` to the user, plus any workaround you already gave. Because the repo is
82
+ private, there is no link to open — say so; the maintainer follows up.
83
+
84
+ ### Local API
85
+
86
+ | Call | Purpose |
87
+ |---|---|
88
+ | `GET /api/health` | versions + license state |
89
+ | `GET /api/latest-run/:id` | **read-only** structured trace of the app's most recent run |
90
+ | `GET /api/app/:id` | normalized topology + `.flo` source + node config/exec code |
91
+ | `POST /api/report-issue` | send the composed report through the relay → `{ ok, ref }` |
92
+
93
+ ## Report body template
94
+
95
+ ```
96
+ **Summary** — <one line, problem-first>
97
+
98
+ **Observed** (with evidence)
99
+ - <what happened> — node `<errorNodeId>` · `<errorMessage>`
100
+ - <file:line in the exec code, quoted, when relevant>
101
+
102
+ **Root-cause hypothesis**
103
+ - <why it happened — your diagnosis, marked as hypothesis if unconfirmed>
104
+
105
+ **Repro**
106
+ - app `<appId>` (FloLess v<appVersion>, AWARE v<awareVersion>), run `<runId>`
107
+ - <steps / inputs> ← attach the .flo only with consent
108
+
109
+ **Expected**
110
+ - <what AWARE/floless should have done>
111
+
112
+ **Workaround given** (if any)
113
+ - <what you told the user to do right now>
114
+ ```
115
+
116
+ The local route stamps category → label and the relay adds `user-report` + the reporter id.
117
+
118
+ ## Guardrails
119
+
120
+ - **Read-only.** Diagnose from evidence on disk; never re-run a node (live-host mutation risk).
121
+ - **Relay only.** This skill never calls `gh` — the user has no access to the private repo; the
122
+ relay files on their behalf. (The maintainer `gh` runbook is the separate `floless-app-log-issue`.)
123
+ - **Always confirm** the exact text before sending; default to versions + sanitized summary, and
124
+ ask before attaching anything richer.
125
+ - **Don't invent** node ids, errors, or a fix you can't support from the evidence. A wrong-but-
126
+ confident diagnosis is worse than an honest "couldn't reproduce — here's what I see."
127
+ - **One report per concern.** Don't bundle a bug and a feature idea into one.