issue-flow 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/agent-OX3KPHE4.js +30 -0
  2. package/dist/{analyze-WS47UYGN.js → analyze-WIND2SEB.js} +21 -18
  3. package/dist/{analyze-WS47UYGN.js.map → analyze-WIND2SEB.js.map} +1 -1
  4. package/dist/{apply-OS62UWRN.js → apply-JNOI2LR4.js} +14 -11
  5. package/dist/{apply-OS62UWRN.js.map → apply-JNOI2LR4.js.map} +1 -1
  6. package/dist/{bench-SA3BOME5.js → bench-B4VZON7E.js} +30 -26
  7. package/dist/{bench-SA3BOME5.js.map → bench-B4VZON7E.js.map} +1 -1
  8. package/dist/{chunk-PIVGJM6K.js → chunk-2IQ6M4ZE.js} +11 -11
  9. package/dist/{chunk-QCXPNSEL.js → chunk-3BWZO4YI.js} +2 -2
  10. package/dist/chunk-3WFZETIM.js +163 -0
  11. package/dist/chunk-3WFZETIM.js.map +1 -0
  12. package/dist/{chunk-6FDFRNXZ.js → chunk-67YIU2VB.js} +25 -20
  13. package/dist/chunk-67YIU2VB.js.map +1 -0
  14. package/dist/{chunk-TM67EHSV.js → chunk-6BENHR2I.js} +19 -19
  15. package/dist/chunk-6BENHR2I.js.map +1 -0
  16. package/dist/{chunk-IYV6KNVF.js → chunk-6QYMSNVA.js} +66 -240
  17. package/dist/chunk-6QYMSNVA.js.map +1 -0
  18. package/dist/{chunk-IV5SS4JN.js → chunk-76MMPJ53.js} +9 -7
  19. package/dist/{chunk-IV5SS4JN.js.map → chunk-76MMPJ53.js.map} +1 -1
  20. package/dist/{chunk-LZ6GFQAG.js → chunk-B72RQZBV.js} +35 -25
  21. package/dist/chunk-B72RQZBV.js.map +1 -0
  22. package/dist/{chunk-KQHB7ZT3.js → chunk-BEZ5BA5U.js} +82 -16
  23. package/dist/chunk-BEZ5BA5U.js.map +1 -0
  24. package/dist/{chunk-BQDQTIM7.js → chunk-BRCNQXYQ.js} +4 -4
  25. package/dist/{chunk-ARFVHC2F.js → chunk-CGKLE6WR.js} +115 -23
  26. package/dist/chunk-CGKLE6WR.js.map +1 -0
  27. package/dist/{chunk-BHJCGWQX.js → chunk-F6TOYSYY.js} +9 -9
  28. package/dist/chunk-G5EK2XRN.js +25 -0
  29. package/dist/chunk-G5EK2XRN.js.map +1 -0
  30. package/dist/{chunk-TX5WIVYP.js → chunk-HAKDLR6N.js} +6 -1
  31. package/dist/chunk-HAKDLR6N.js.map +1 -0
  32. package/dist/{chunk-DIG5XKQQ.js → chunk-J3SOTUVI.js} +2 -2
  33. package/dist/{chunk-SNTTOQAD.js → chunk-JN6D3MY6.js} +2 -2
  34. package/dist/{chunk-MIVHK5YI.js → chunk-KMRFRXIT.js} +3 -3
  35. package/dist/{chunk-5TB6KLC2.js → chunk-KT7JKOXB.js} +8 -8
  36. package/dist/{chunk-NEZZ6KDR.js → chunk-LBGHI4S2.js} +49 -2
  37. package/dist/chunk-LBGHI4S2.js.map +1 -0
  38. package/dist/{chunk-3XLCNVMD.js → chunk-LEW4QM74.js} +2 -2
  39. package/dist/{chunk-IHIEXUTC.js → chunk-LFEQIZLB.js} +85 -10
  40. package/dist/chunk-LFEQIZLB.js.map +1 -0
  41. package/dist/{chunk-TVN2JVOF.js → chunk-OOVG2KBY.js} +3 -3
  42. package/dist/{chunk-7KD66LIM.js → chunk-OR43364P.js} +3 -3
  43. package/dist/{chunk-J62QSVP6.js → chunk-PBTA3QQX.js} +8 -7
  44. package/dist/{chunk-J62QSVP6.js.map → chunk-PBTA3QQX.js.map} +1 -1
  45. package/dist/{chunk-664Y3NDX.js → chunk-PHW2YV6N.js} +8 -8
  46. package/dist/{chunk-PV4OX5TC.js → chunk-PZTWU277.js} +11 -11
  47. package/dist/{chunk-HJGGNLWI.js → chunk-QD4VOILZ.js} +9 -9
  48. package/dist/{chunk-CNBYCMSU.js → chunk-QJ5UZAH5.js} +27 -18
  49. package/dist/{chunk-CNBYCMSU.js.map → chunk-QJ5UZAH5.js.map} +1 -1
  50. package/dist/{chunk-HQAHNIAR.js → chunk-QK7WUO7A.js} +14 -14
  51. package/dist/{chunk-UJ7XWK7J.js → chunk-QZVKRDJY.js} +16 -14
  52. package/dist/{chunk-UJ7XWK7J.js.map → chunk-QZVKRDJY.js.map} +1 -1
  53. package/dist/{chunk-FZDR7CGH.js → chunk-REO3PXXO.js} +3 -3
  54. package/dist/{chunk-UOCPC2Y2.js → chunk-RVO7L4FX.js} +4 -4
  55. package/dist/{chunk-QXDNBYQF.js → chunk-SZMHCEYV.js} +21 -10
  56. package/dist/chunk-SZMHCEYV.js.map +1 -0
  57. package/dist/chunk-TYRNTSPJ.js +243 -0
  58. package/dist/chunk-TYRNTSPJ.js.map +1 -0
  59. package/dist/{agent-BXRIEKVM.js → chunk-UJ3L2MS7.js} +22 -22
  60. package/dist/chunk-UJ3L2MS7.js.map +1 -0
  61. package/dist/{chunk-37N2RJW2.js → chunk-V7MO5EDS.js} +3 -3
  62. package/dist/{chunk-HEF2RMKN.js → chunk-WB2X37MO.js} +18 -16
  63. package/dist/{chunk-HEF2RMKN.js.map → chunk-WB2X37MO.js.map} +1 -1
  64. package/dist/{chunk-OPNGPUKJ.js → chunk-X44FKNKP.js} +133 -42
  65. package/dist/chunk-X44FKNKP.js.map +1 -0
  66. package/dist/cli.js +43 -40
  67. package/dist/cli.js.map +1 -1
  68. package/dist/{config-MEM2IWWN.js → config-HMFMCY2M.js} +12 -9
  69. package/dist/{conventions-WV4NDXGH.js → conventions-NJF2OPVQ.js} +14 -11
  70. package/dist/{conventions-WV4NDXGH.js.map → conventions-NJF2OPVQ.js.map} +1 -1
  71. package/dist/diagnostics-THH42ZMP.js +23 -0
  72. package/dist/execute-KFNNZZHI.js +35 -0
  73. package/dist/{generate-MVQI7A45.js → generate-FW5C6NP7.js} +23 -20
  74. package/dist/{generate-MVQI7A45.js.map → generate-FW5C6NP7.js.map} +1 -1
  75. package/dist/{git-YRVRUN3C.js → git-W72QQ6GT.js} +4 -2
  76. package/dist/init-HTN4V67Q.js +24 -0
  77. package/dist/{operations-LU6JA3BX.js → operations-BCFOBH42.js} +17 -14
  78. package/dist/{operations-LU6JA3BX.js.map → operations-BCFOBH42.js.map} +1 -1
  79. package/dist/{permissions-7NMDFU27.js → permissions-4AKNQZHK.js} +4 -4
  80. package/dist/plan-SIZLGA4V.js +34 -0
  81. package/dist/{policy-JSYEK5B2.js → policy-DT6Q6ZVM.js} +13 -10
  82. package/dist/{policy-JSYEK5B2.js.map → policy-DT6Q6ZVM.js.map} +1 -1
  83. package/dist/pr-OMWCGUOW.js +39 -0
  84. package/dist/pr-review-N24CZPKR.js +32 -0
  85. package/dist/prd-5OFTZAHJ.js +33 -0
  86. package/dist/{ps-TNE44R5V.js → ps-RFORTCUD.js} +11 -8
  87. package/dist/{ps-TNE44R5V.js.map → ps-RFORTCUD.js.map} +1 -1
  88. package/dist/{recorder-XGF55WXI.js → recorder-KCCPMKQG.js} +5 -4
  89. package/dist/registry-GLPXOMFG.js +15 -0
  90. package/dist/{resume-QMEPMSZW.js → resume-M5RLKP4I.js} +44 -40
  91. package/dist/{resume-QMEPMSZW.js.map → resume-M5RLKP4I.js.map} +1 -1
  92. package/dist/review-YM4JAABY.js +35 -0
  93. package/dist/{routing-2ZUMZPJB.js → routing-NNT5WKGX.js} +13 -10
  94. package/dist/{routing-2ZUMZPJB.js.map → routing-NNT5WKGX.js.map} +1 -1
  95. package/dist/run-PZUKHPQK.js +54 -0
  96. package/dist/run-PZUKHPQK.js.map +1 -0
  97. package/dist/session-publisher-T7EI3CIP.js +11 -0
  98. package/dist/session-publisher-T7EI3CIP.js.map +1 -0
  99. package/dist/{state-manager-X3PT3FGT.js → state-manager-GQH32C44.js} +3 -3
  100. package/dist/state-manager-GQH32C44.js.map +1 -0
  101. package/dist/{usage-VCFLDA5Y.js → usage-V2CHE5PY.js} +13 -10
  102. package/dist/{usage-VCFLDA5Y.js.map → usage-V2CHE5PY.js.map} +1 -1
  103. package/dist/{web-XVHLX62B.js → web-EA3KGNFN.js} +18 -14
  104. package/dist/{web-XVHLX62B.js.map → web-EA3KGNFN.js.map} +1 -1
  105. package/package.json +1 -1
  106. package/web/AGENTS.md +150 -11
  107. package/web/public/app.css +343 -110
  108. package/web/public/app.js +408 -18
  109. package/web/public/index.html +44 -1
  110. package/dist/agent-BXRIEKVM.js.map +0 -1
  111. package/dist/chunk-6FDFRNXZ.js.map +0 -1
  112. package/dist/chunk-ARFVHC2F.js.map +0 -1
  113. package/dist/chunk-IHIEXUTC.js.map +0 -1
  114. package/dist/chunk-IYV6KNVF.js.map +0 -1
  115. package/dist/chunk-KQHB7ZT3.js.map +0 -1
  116. package/dist/chunk-LZ6GFQAG.js.map +0 -1
  117. package/dist/chunk-NEZZ6KDR.js.map +0 -1
  118. package/dist/chunk-OPNGPUKJ.js.map +0 -1
  119. package/dist/chunk-QXDNBYQF.js.map +0 -1
  120. package/dist/chunk-TM67EHSV.js.map +0 -1
  121. package/dist/chunk-TX5WIVYP.js.map +0 -1
  122. package/dist/execute-NITIHHCK.js +0 -31
  123. package/dist/init-OKWEC3EV.js +0 -21
  124. package/dist/plan-LEJJF3DE.js +0 -31
  125. package/dist/pr-HPVUZ7S3.js +0 -36
  126. package/dist/pr-review-R2QZBT3F.js +0 -29
  127. package/dist/prd-5O6GYCC6.js +0 -30
  128. package/dist/registry-LGPE53QD.js +0 -15
  129. package/dist/review-YKWD6SNN.js +0 -32
  130. package/dist/run-YU4TR62R.js +0 -50
  131. /package/dist/{config-MEM2IWWN.js.map → agent-OX3KPHE4.js.map} +0 -0
  132. /package/dist/{chunk-PIVGJM6K.js.map → chunk-2IQ6M4ZE.js.map} +0 -0
  133. /package/dist/{chunk-QCXPNSEL.js.map → chunk-3BWZO4YI.js.map} +0 -0
  134. /package/dist/{chunk-BQDQTIM7.js.map → chunk-BRCNQXYQ.js.map} +0 -0
  135. /package/dist/{chunk-BHJCGWQX.js.map → chunk-F6TOYSYY.js.map} +0 -0
  136. /package/dist/{chunk-DIG5XKQQ.js.map → chunk-J3SOTUVI.js.map} +0 -0
  137. /package/dist/{chunk-SNTTOQAD.js.map → chunk-JN6D3MY6.js.map} +0 -0
  138. /package/dist/{chunk-MIVHK5YI.js.map → chunk-KMRFRXIT.js.map} +0 -0
  139. /package/dist/{chunk-5TB6KLC2.js.map → chunk-KT7JKOXB.js.map} +0 -0
  140. /package/dist/{chunk-3XLCNVMD.js.map → chunk-LEW4QM74.js.map} +0 -0
  141. /package/dist/{chunk-TVN2JVOF.js.map → chunk-OOVG2KBY.js.map} +0 -0
  142. /package/dist/{chunk-7KD66LIM.js.map → chunk-OR43364P.js.map} +0 -0
  143. /package/dist/{chunk-664Y3NDX.js.map → chunk-PHW2YV6N.js.map} +0 -0
  144. /package/dist/{chunk-PV4OX5TC.js.map → chunk-PZTWU277.js.map} +0 -0
  145. /package/dist/{chunk-HJGGNLWI.js.map → chunk-QD4VOILZ.js.map} +0 -0
  146. /package/dist/{chunk-HQAHNIAR.js.map → chunk-QK7WUO7A.js.map} +0 -0
  147. /package/dist/{chunk-FZDR7CGH.js.map → chunk-REO3PXXO.js.map} +0 -0
  148. /package/dist/{chunk-UOCPC2Y2.js.map → chunk-RVO7L4FX.js.map} +0 -0
  149. /package/dist/{chunk-37N2RJW2.js.map → chunk-V7MO5EDS.js.map} +0 -0
  150. /package/dist/{execute-NITIHHCK.js.map → config-HMFMCY2M.js.map} +0 -0
  151. /package/dist/{git-YRVRUN3C.js.map → diagnostics-THH42ZMP.js.map} +0 -0
  152. /package/dist/{init-OKWEC3EV.js.map → execute-KFNNZZHI.js.map} +0 -0
  153. /package/dist/{permissions-7NMDFU27.js.map → git-W72QQ6GT.js.map} +0 -0
  154. /package/dist/{plan-LEJJF3DE.js.map → init-HTN4V67Q.js.map} +0 -0
  155. /package/dist/{pr-HPVUZ7S3.js.map → permissions-4AKNQZHK.js.map} +0 -0
  156. /package/dist/{pr-review-R2QZBT3F.js.map → plan-SIZLGA4V.js.map} +0 -0
  157. /package/dist/{prd-5O6GYCC6.js.map → pr-OMWCGUOW.js.map} +0 -0
  158. /package/dist/{recorder-XGF55WXI.js.map → pr-review-N24CZPKR.js.map} +0 -0
  159. /package/dist/{registry-LGPE53QD.js.map → prd-5OFTZAHJ.js.map} +0 -0
  160. /package/dist/{review-YKWD6SNN.js.map → recorder-KCCPMKQG.js.map} +0 -0
  161. /package/dist/{run-YU4TR62R.js.map → registry-GLPXOMFG.js.map} +0 -0
  162. /package/dist/{state-manager-X3PT3FGT.js.map → review-YM4JAABY.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/web/session-directory.ts","../src/commands/web.ts"],"sourcesContent":["import { readdir, readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { type JournalEntry, parseJournal } from '../core/journal.js';\nimport type { ValidatedSessionSnapshot } from '../schemas.js';\nimport {\n EVENTS_FILENAME,\n type GetGlobalRootOptions,\n getGlobalRoot,\n ISSUES_DIR_NAME,\n PROJECTS_DIR_NAME,\n ROTATED_EVENTS_FILENAME,\n SESSION_FILENAME,\n} from '../storage/paths.js';\nimport { readSessionFile } from '../storage/session-file.js';\n\n/**\n * Multi-session discovery for the web monitoring server (US-003).\n *\n * The single-instance server (`web/lock.ts`) runs decoupled from any one\n * pipeline invocation, so it cannot hold a `SessionPublisher` in memory for\n * \"the\" run being monitored — there may be zero, one or several running at\n * once, each in its own process. Instead this module polls every\n * `~/.issue-flow/projects/<project>/issues/<n>/session.json` on disk (the\n * same file `FilePublisher` already writes) and keeps an in-memory map of the\n * ones that are both well-formed and recently updated.\n *\n * Polling rather than `fs.watch` is a deliberate choice, not a placeholder:\n * `fs.watch`'s `recursive` option is only reliable on macOS and Windows (Linux\n * needs a manual per-directory watch tree, and the whole thing still degrades\n * to polling on network filesystems), while a `~/.issue-flow` tree is small\n * and local — a cheap poll of it is simpler and behaves identically on every\n * platform, which is what the US-003 acceptance criteria ask to validate.\n */\n\n/** How often the directory tree is rescanned, unless overridden. */\nexport const DEFAULT_POLL_INTERVAL_MS = 3000;\n\n/**\n * A session stops being reported once its file has gone this long without an\n * update. `FilePublisher` touches a live session every 10s without rewriting\n * its content. Three missed heartbeats plus ample scheduling/filesystem slack\n * avoid flapping while still removing an abruptly killed run promptly.\n */\nexport const DEFAULT_STALE_AFTER_MS = 90_000;\n\nexport interface ActiveSession {\n /** Directory the session.json was read from, one level up from the file. */\n issueDir: string;\n filePath: string;\n snapshot: ValidatedSessionSnapshot;\n /** mtime of the file at the last successful read, in epoch ms. */\n updatedAtMs: number;\n}\n\nexport interface SessionDirectoryOptions extends GetGlobalRootOptions {\n /** Poll interval, in ms. Default {@link DEFAULT_POLL_INTERVAL_MS}. */\n pollIntervalMs?: number;\n /** Staleness threshold, in ms. Default {@link DEFAULT_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Invoked (at most once) when a poll fails unexpectedly. Best-effort logging only. */\n onWarn?: (message: string) => void;\n}\n\nexport interface SessionDirectoryHandle {\n /** Snapshot of every session currently considered active. */\n sessions(): ActiveSession[];\n /** A single active session by id, or undefined. */\n getSession(sessionId: string): ActiveSession | undefined;\n /** Journal entries for one active session, oldest generation first. */\n events(sessionId: string): Promise<JournalEntry[] | undefined>;\n /** Force an immediate rescan instead of waiting for the next tick. Never throws. */\n refresh(): Promise<void>;\n /** Stop polling. Idempotent. */\n close(): void;\n}\n\nasync function listSubdirectories(dir: string): Promise<string[]> {\n try {\n const entries = await readdir(dir, { withFileTypes: true });\n // Symlinks are skipped on purpose, same discipline as the storage\n // migration walk: following one could pull session data from outside the\n // global tree into the listing.\n return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);\n } catch {\n // Missing/unreadable projects (or issues) directory reads as \"none yet\" —\n // this is routinely the case before the first --web run ever happens.\n return [];\n }\n}\n\n/** Every `session.json` path currently on disk, one per project/issue pair. */\nasync function discoverSessionFiles(root: string): Promise<string[]> {\n const projectsDir = join(root, PROJECTS_DIR_NAME);\n const projectIds = await listSubdirectories(projectsDir);\n\n const perProject = await Promise.all(\n projectIds.map(async (projectId) => {\n const issuesDir = join(projectsDir, projectId, ISSUES_DIR_NAME);\n const issueIds = await listSubdirectories(issuesDir);\n return issueIds.map((issueId) => join(issuesDir, issueId, SESSION_FILENAME));\n }),\n );\n\n return perProject.flat();\n}\n\nasync function readJournalFile(filePath: string): Promise<JournalEntry[]> {\n try {\n return parseJournal(await readFile(filePath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * Start polling the global storage tree for active sessions.\n *\n * Returns a handle immediately; the first scan happens synchronously in the\n * background and `sessions()` answers `[]` until it completes, same as \"no\n * sessions found yet\" — there is nothing to await here that would not also\n * delay the server's very first bind.\n */\nexport function watchSessionDirectory(\n options: SessionDirectoryOptions = {},\n): SessionDirectoryHandle {\n const root = getGlobalRoot(options);\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;\n const warn = options.onWarn;\n\n // Keyed by sessionId, not by file path: a session's identity is the id\n // inside its content, and that is what /api/status?session=<id> looks up.\n let sessions = new Map<string, ActiveSession>();\n let warned = false;\n\n async function scan(): Promise<void> {\n let files: string[];\n try {\n files = await discoverSessionFiles(root);\n } catch (err) {\n if (!warned) {\n warned = true;\n warn?.(\n `issue-flow: web monitor could not scan the session directory (will keep retrying silently): ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n return;\n }\n\n const now = Date.now();\n const next = new Map<string, ActiveSession>();\n for (const filePath of files) {\n const result = await readSessionFile(filePath);\n if (result === null) continue;\n if (now - result.updatedAtMs > staleAfterMs) continue; // stopped updating: not active\n const sessionId = result.snapshot.sessionId as string;\n next.set(sessionId, {\n issueDir: join(filePath, '..'),\n filePath,\n snapshot: result.snapshot,\n updatedAtMs: result.updatedAtMs,\n });\n }\n sessions = next;\n }\n\n // The first scan runs immediately (not just after the first interval tick)\n // so a server started right after a session begins does not report \"no\n // sessions\" for a whole poll interval.\n void scan();\n const timer = setInterval(() => void scan(), pollIntervalMs);\n timer.unref();\n\n return {\n sessions: () => [...sessions.values()],\n getSession: (sessionId: string) => sessions.get(sessionId),\n events: async (sessionId: string) => {\n const session = sessions.get(sessionId);\n if (session === undefined) return undefined;\n const [rotated, current] = await Promise.all([\n readJournalFile(join(session.issueDir, ROTATED_EVENTS_FILENAME)),\n readJournalFile(join(session.issueDir, EVENTS_FILENAME)),\n ]);\n return [...rotated, ...current];\n },\n refresh: scan,\n close: () => clearInterval(timer),\n };\n}\n","import { loadWebConfig } from '../config.js';\nimport type { WebConfig } from '../schemas.js';\nimport { printError, printInfo, printWarning } from '../ui/logger.js';\nimport { ensureSingleWebServer, getWebLockFile, readWebLock, removeWebLock } from '../web/lock.js';\nimport { watchSessionDirectory } from '../web/session-directory.js';\n\n/**\n * `issue-flow web serve` / `issue-flow web stop` (US-002).\n *\n * `serve` is the process `ensureWebMonitor()` (`web/lock.ts`) spawns detached\n * — it is not meant to be run interactively, though nothing stops a user from\n * doing so to watch every session on the machine without going through a\n * pipeline command first. It never exits on its own: the server stays bound\n * (see `unref: false` in `web/server.ts`) until `stop` sends it `SIGTERM`, at\n * which point `startWebServer`'s own signal handler closes it (removing the\n * lock) and re-raises the signal for the default termination behavior.\n */\n\nconst STOP_POLL_INTERVAL_MS = 100;\nconst STOP_TIMEOUT_MS = 5000;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport interface RunWebServeOptions {\n port?: number;\n host?: string;\n refresh?: number;\n}\n\n/**\n * Bind (or defer to) the single web monitor instance and keep the process\n * alive for as long as it stays bound. Logging is intentionally silent on the\n * happy path: this process is spawned with `stdio: 'ignore'`, so anything\n * printed here goes nowhere — the caller (`ensureWebMonitor`) is the one that\n * tells the user the server is up.\n */\nexport async function runWebServe(options: RunWebServeOptions): Promise<number> {\n // Built conditionally, not `{ port: options.port, ... }`: loadWebConfig()\n // spreads this object directly over the lower-precedence layers, and an\n // explicit `undefined` value would overwrite an env/.issue-flow.json\n // setting instead of falling through to it (only an *absent* key does).\n const cli: Partial<WebConfig> = {};\n if (options.port !== undefined) cli.port = options.port;\n if (options.host !== undefined) cli.host = options.host;\n if (options.refresh !== undefined) cli.refreshSeconds = options.refresh;\n const webConfig = await loadWebConfig({ cli });\n\n const sessions = watchSessionDirectory();\n\n const noop = (): void => {};\n const handle = await ensureSingleWebServer({\n sessions,\n port: webConfig.port,\n host: webConfig.host,\n refreshSeconds: webConfig.refreshSeconds,\n unref: false,\n info: noop,\n warn: noop,\n });\n\n if (handle === null) {\n sessions.close();\n return 1;\n }\n\n if (handle.server === undefined) {\n // Another instance already won the race (or was already running by the\n // time this process got here): nothing to serve, so it exits right away\n // instead of idling as a redundant detached process.\n sessions.close();\n return 0;\n }\n\n const originalClose = handle.close;\n handle.close = async () => {\n await originalClose();\n sessions.close();\n };\n\n return 0;\n}\n\n/** Stop the single running web monitor instance, if any. */\nexport async function runWebStop(): Promise<number> {\n const lockFile = getWebLockFile();\n const lock = await readWebLock(lockFile);\n if (lock === null) {\n printInfo('No web monitor is currently running.');\n return 0;\n }\n\n try {\n process.kill(lock.pid, 'SIGTERM');\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ESRCH') {\n printWarning(\n 'The web monitor lock referenced a process that is no longer running; removing it.',\n );\n await removeWebLock(lockFile);\n return 0;\n }\n printError(\n `Failed to stop the web monitor: ${err instanceof Error ? err.message : String(err)}`,\n );\n return 1;\n }\n\n const deadline = Date.now() + STOP_TIMEOUT_MS;\n while (Date.now() < deadline) {\n if ((await readWebLock(lockFile)) === null) {\n printInfo(`Web monitor (pid ${lock.pid}) stopped.`);\n return 0;\n }\n await sleep(STOP_POLL_INTERVAL_MS);\n }\n\n printWarning('Sent the stop signal, but the web monitor did not confirm shutdown in time.');\n return 0;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/web/session-directory.ts","../src/commands/web.ts"],"sourcesContent":["import { readdir, readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { type JournalEntry, parseJournal } from '../core/journal.js';\nimport type { ValidatedSessionSnapshot } from '../schemas.js';\nimport {\n EVENTS_FILENAME,\n type GetGlobalRootOptions,\n getGlobalRoot,\n ISSUES_DIR_NAME,\n PROJECTS_DIR_NAME,\n ROTATED_EVENTS_FILENAME,\n SESSION_FILENAME,\n} from '../storage/paths.js';\nimport { readSessionFile } from '../storage/session-file.js';\n\n/**\n * Multi-session discovery for the web monitoring server (US-003).\n *\n * The single-instance server (`web/lock.ts`) runs decoupled from any one\n * pipeline invocation, so it cannot hold a `SessionPublisher` in memory for\n * \"the\" run being monitored — there may be zero, one or several running at\n * once, each in its own process. Instead this module polls every\n * `~/.issue-flow/projects/<project>/issues/<n>/session.json` on disk (the\n * same file `FilePublisher` already writes) and keeps an in-memory map of the\n * ones that are both well-formed and recently updated.\n *\n * Polling rather than `fs.watch` is a deliberate choice, not a placeholder:\n * `fs.watch`'s `recursive` option is only reliable on macOS and Windows (Linux\n * needs a manual per-directory watch tree, and the whole thing still degrades\n * to polling on network filesystems), while a `~/.issue-flow` tree is small\n * and local — a cheap poll of it is simpler and behaves identically on every\n * platform, which is what the US-003 acceptance criteria ask to validate.\n */\n\n/** How often the directory tree is rescanned, unless overridden. */\nexport const DEFAULT_POLL_INTERVAL_MS = 3000;\n\n/**\n * A session stops being reported once its file has gone this long without an\n * update. `FilePublisher` touches a live session every 10s without rewriting\n * its content. Three missed heartbeats plus ample scheduling/filesystem slack\n * avoid flapping while still removing an abruptly killed run promptly.\n */\nexport const DEFAULT_STALE_AFTER_MS = 90_000;\n\nexport interface ActiveSession {\n /** Directory the session.json was read from, one level up from the file. */\n issueDir: string;\n filePath: string;\n snapshot: ValidatedSessionSnapshot;\n /** mtime of the file at the last successful read, in epoch ms. */\n updatedAtMs: number;\n}\n\nexport interface SessionDirectoryOptions extends GetGlobalRootOptions {\n /** Poll interval, in ms. Default {@link DEFAULT_POLL_INTERVAL_MS}. */\n pollIntervalMs?: number;\n /** Staleness threshold, in ms. Default {@link DEFAULT_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Invoked (at most once) when a poll fails unexpectedly. Best-effort logging only. */\n onWarn?: (message: string) => void;\n}\n\nexport interface SessionDirectoryHandle {\n /** Snapshot of every session currently considered active. */\n sessions(): ActiveSession[];\n /** A single active session by id, or undefined. */\n getSession(sessionId: string): ActiveSession | undefined;\n /** Journal entries for one active session, oldest generation first. */\n events(sessionId: string): Promise<JournalEntry[] | undefined>;\n /** Force an immediate rescan instead of waiting for the next tick. Never throws. */\n refresh(): Promise<void>;\n /** Stop polling. Idempotent. */\n close(): void;\n}\n\nasync function listSubdirectories(dir: string): Promise<string[]> {\n try {\n const entries = await readdir(dir, { withFileTypes: true });\n // Symlinks are skipped on purpose, same discipline as the storage\n // migration walk: following one could pull session data from outside the\n // global tree into the listing.\n return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);\n } catch {\n // Missing/unreadable projects (or issues) directory reads as \"none yet\" —\n // this is routinely the case before the first --web run ever happens.\n return [];\n }\n}\n\n/** Every `session.json` path currently on disk, one per project/issue pair. */\nasync function discoverSessionFiles(root: string): Promise<string[]> {\n const projectsDir = join(root, PROJECTS_DIR_NAME);\n const projectIds = await listSubdirectories(projectsDir);\n\n const perProject = await Promise.all(\n projectIds.map(async (projectId) => {\n const issuesDir = join(projectsDir, projectId, ISSUES_DIR_NAME);\n const issueIds = await listSubdirectories(issuesDir);\n return issueIds.map((issueId) => join(issuesDir, issueId, SESSION_FILENAME));\n }),\n );\n\n return perProject.flat();\n}\n\nasync function readJournalFile(filePath: string): Promise<JournalEntry[]> {\n try {\n return parseJournal(await readFile(filePath, 'utf-8'));\n } catch {\n return [];\n }\n}\n\n/**\n * Start polling the global storage tree for active sessions.\n *\n * Returns a handle immediately; the first scan happens synchronously in the\n * background and `sessions()` answers `[]` until it completes, same as \"no\n * sessions found yet\" — there is nothing to await here that would not also\n * delay the server's very first bind.\n */\nexport function watchSessionDirectory(\n options: SessionDirectoryOptions = {},\n): SessionDirectoryHandle {\n const root = getGlobalRoot(options);\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;\n const warn = options.onWarn;\n\n // Keyed by sessionId, not by file path: a session's identity is the id\n // inside its content, and that is what /api/status?session=<id> looks up.\n let sessions = new Map<string, ActiveSession>();\n let warned = false;\n\n async function scan(): Promise<void> {\n let files: string[];\n try {\n files = await discoverSessionFiles(root);\n } catch (err) {\n if (!warned) {\n warned = true;\n warn?.(\n `issue-flow: web monitor could not scan the session directory (will keep retrying silently): ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n return;\n }\n\n const now = Date.now();\n const next = new Map<string, ActiveSession>();\n for (const filePath of files) {\n const result = await readSessionFile(filePath);\n if (result === null) continue;\n if (now - result.updatedAtMs > staleAfterMs) continue; // stopped updating: not active\n const sessionId = result.snapshot.sessionId as string;\n next.set(sessionId, {\n issueDir: join(filePath, '..'),\n filePath,\n snapshot: result.snapshot,\n updatedAtMs: result.updatedAtMs,\n });\n }\n sessions = next;\n }\n\n // The first scan runs immediately (not just after the first interval tick)\n // so a server started right after a session begins does not report \"no\n // sessions\" for a whole poll interval.\n void scan();\n const timer = setInterval(() => void scan(), pollIntervalMs);\n timer.unref();\n\n return {\n sessions: () => [...sessions.values()],\n getSession: (sessionId: string) => sessions.get(sessionId),\n events: async (sessionId: string) => {\n const session = sessions.get(sessionId);\n if (session === undefined) return undefined;\n const [rotated, current] = await Promise.all([\n readJournalFile(join(session.issueDir, ROTATED_EVENTS_FILENAME)),\n readJournalFile(join(session.issueDir, EVENTS_FILENAME)),\n ]);\n return [...rotated, ...current];\n },\n refresh: scan,\n close: () => clearInterval(timer),\n };\n}\n","import { loadWebConfig } from '../config.js';\nimport type { WebConfig } from '../schemas.js';\nimport { printError, printInfo, printWarning } from '../ui/logger.js';\nimport { ensureSingleWebServer, getWebLockFile, readWebLock, removeWebLock } from '../web/lock.js';\nimport { watchSessionDirectory } from '../web/session-directory.js';\n\n/**\n * `issue-flow web serve` / `issue-flow web stop` (US-002).\n *\n * `serve` is the process `ensureWebMonitor()` (`web/lock.ts`) spawns detached\n * — it is not meant to be run interactively, though nothing stops a user from\n * doing so to watch every session on the machine without going through a\n * pipeline command first. It never exits on its own: the server stays bound\n * (see `unref: false` in `web/server.ts`) until `stop` sends it `SIGTERM`, at\n * which point `startWebServer`'s own signal handler closes it (removing the\n * lock) and re-raises the signal for the default termination behavior.\n */\n\nconst STOP_POLL_INTERVAL_MS = 100;\nconst STOP_TIMEOUT_MS = 5000;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport interface RunWebServeOptions {\n port?: number;\n host?: string;\n refresh?: number;\n}\n\n/**\n * Bind (or defer to) the single web monitor instance and keep the process\n * alive for as long as it stays bound. Logging is intentionally silent on the\n * happy path: this process is spawned with `stdio: 'ignore'`, so anything\n * printed here goes nowhere — the caller (`ensureWebMonitor`) is the one that\n * tells the user the server is up.\n */\nexport async function runWebServe(options: RunWebServeOptions): Promise<number> {\n // Built conditionally, not `{ port: options.port, ... }`: loadWebConfig()\n // spreads this object directly over the lower-precedence layers, and an\n // explicit `undefined` value would overwrite an env/.issue-flow.json\n // setting instead of falling through to it (only an *absent* key does).\n const cli: Partial<WebConfig> = {};\n if (options.port !== undefined) cli.port = options.port;\n if (options.host !== undefined) cli.host = options.host;\n if (options.refresh !== undefined) cli.refreshSeconds = options.refresh;\n const webConfig = await loadWebConfig({ cli });\n\n const sessions = watchSessionDirectory();\n\n const noop = (): void => {};\n const handle = await ensureSingleWebServer({\n sessions,\n port: webConfig.port,\n host: webConfig.host,\n refreshSeconds: webConfig.refreshSeconds,\n unref: false,\n info: noop,\n warn: noop,\n });\n\n if (handle === null) {\n sessions.close();\n return 1;\n }\n\n if (handle.server === undefined) {\n // Another instance already won the race (or was already running by the\n // time this process got here): nothing to serve, so it exits right away\n // instead of idling as a redundant detached process.\n sessions.close();\n return 0;\n }\n\n const originalClose = handle.close;\n handle.close = async () => {\n await originalClose();\n sessions.close();\n };\n\n return 0;\n}\n\n/** Stop the single running web monitor instance, if any. */\nexport async function runWebStop(): Promise<number> {\n const lockFile = getWebLockFile();\n const lock = await readWebLock(lockFile);\n if (lock === null) {\n printInfo('No web monitor is currently running.');\n return 0;\n }\n\n try {\n process.kill(lock.pid, 'SIGTERM');\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ESRCH') {\n printWarning(\n 'The web monitor lock referenced a process that is no longer running; removing it.',\n );\n await removeWebLock(lockFile);\n return 0;\n }\n printError(\n `Failed to stop the web monitor: ${err instanceof Error ? err.message : String(err)}`,\n );\n return 1;\n }\n\n const deadline = Date.now() + STOP_TIMEOUT_MS;\n while (Date.now() < deadline) {\n if ((await readWebLock(lockFile)) === null) {\n printInfo(`Web monitor (pid ${lock.pid}) stopped.`);\n return 0;\n }\n await sleep(STOP_POLL_INTERVAL_MS);\n }\n\n printWarning('Sent the stop signal, but the web monitor did not confirm shutdown in time.');\n return 0;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issue-flow",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Unified CLI for orchestrating the full issue-flow pipeline via Claude Code, Codex CLI, Cursor CLI or Antigravity CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/web/AGENTS.md CHANGED
@@ -36,8 +36,8 @@ do snapshot.
36
36
 
37
37
  ## Abas, Kanban e drawer
38
38
 
39
- O painel tem três abas ("Execução", "Kanban" e "Histórico") e um drawer de detalhes por
40
- story. Três regras seguram esse conjunto:
39
+ O painel tem três abas ("Execução", "Kanban" e "Histórico") e um único drawer de detalhes
40
+ para fases e stories (inclusive cards do Kanban). Três regras seguram esse conjunto:
41
41
 
42
42
  - **Acesso a story sempre por `getStoryById()` / `getStories()`.** Elas são a
43
43
  camada de leitura: normalizam num lugar só o que pode faltar num
@@ -45,7 +45,7 @@ story. Três regras seguram esse conjunto:
45
45
  `acceptanceCriteria` → `[]`, `description` → `''`) e são o ponto onde uma
46
46
  futura camada de escrita entra. Nenhum consumidor varre `snapshot.stories`
47
47
  por conta própria, ou a normalização se espalha.
48
- - **Estado de UI vive em `state`** (`activeTab`, `selectedStoryId`), junto de
48
+ - **Estado de UI vive em `state`** (`activeTab`, `selectedDetail`), junto de
49
49
  `logFilter`, nunca em variável solta ou em referência a nó do DOM. O drawer
50
50
  guarda o **id** da story, não o card: `render()` recria o Kanban a cada poll,
51
51
  então uma referência guardada na abertura apontaria para um nó fora do
@@ -66,14 +66,153 @@ grid`/`flex` da regra base vence o atributo `hidden`. E o overlay/drawer ficam
66
66
  em `z-index` 20/21 para cobrir o `.banner` de desconexão, que é `sticky` com
67
67
  `z-index: 10`.
68
68
 
69
- ## Escrita: o que ainda não existe
70
-
71
- A interface é somente leitura por contrato (`snapshot.readOnly === true`,
72
- `capabilities: []`), e o servidor não registra nenhuma rota de escrita — o
73
- comentário em `src/web/server.ts` reserva `POST /api/control/*` para isso. O
74
- Kanban e o drawer foram desenhados para que a escrita caiba depois sem
75
- redesenhar a interação: o drawer é o lugar onde uma story é inspecionada por
76
- id, e `getStoryById()`é o único ponto por onde ela é lida.
69
+ ## Paleta e tema
70
+
71
+ As cores do `app.css` são **tokens nomeados por papel**, não por local de uso:
72
+ superfície (`--surface-page`, `--surface`, `--surface-sunken`), texto (`--text`,
73
+ `--text-muted`, `--text-subtle`), borda (`--border`, `--border-strong`), acento
74
+ (`--accent`, `--accent-text`), estado (`--state-ok|run|warn|error` e o
75
+ `--state-*-surface` que acompanha cada um) e `--focus-ring`. Um componente novo
76
+ escolhe o papel que existe em vez de inventar uma cor.
77
+
78
+ **Regra dura: nunca defina uma cor só dentro de um `@media` ou de um
79
+ `[data-theme]`.** `:root` carrega a paleta clara inteira; os blocos escuros
80
+ apenas redefinem o que muda. Um token que só existe num deles some no outro
81
+ tema, e o sintoma aparece longe da causa.
82
+
83
+ O tema escuro vive em **dois blocos gêmeos** com a mesma lista de overrides:
84
+ `@media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { … } }`
85
+ e `:root[data-theme='dark'] { … }`. Mexeu em um, mexa no outro. O guarda
86
+ `:not([data-theme='light'])` é o que faz a escolha manual vencer o sistema nos
87
+ dois sentidos. Cada bloco declara seu próprio `color-scheme` (e `:root`, o
88
+ `light`): é ele — não um `<meta name="color-scheme">`, que foi removido do
89
+ `index.html` justamente por isso — que faz `<select>`, `<progress>` e as barras
90
+ de rolagem acompanharem o tema **efetivo** em vez do tema do SO.
91
+
92
+ O antigo `--idle-bg` (badge inerte, contagem de coluna do Kanban, hover do
93
+ fechar do drawer, trilha da barra de progresso) virou **`--surface-sunken`**, e
94
+ não um `--state-neutral-surface`: metade dos usos não é badge de estado, e um
95
+ recesso neutro é a mesma coisa nos dois casos. "Sem estado" não é um estado.
96
+
97
+ O tema é aplicado **antes do primeiro paint** por um `<script>` inline no
98
+ `<head>` do `index.html`, colocado antes do `<link>` do `app.css`: ele lê
99
+ `issue-flow:theme` do `localStorage` e define `data-theme` na raiz. Fora dali
100
+ o reload piscaria a paleta do SO até o `app.js` rodar. Ele é à prova de
101
+ exceção (`try`/`catch`) e não referencia nada do `app.js` — que só carrega no
102
+ fim do `<body>`. Por isso a leitura da chave é **duplicada** entre os dois, com
103
+ comentário nos dois lugares; mudou o formato do valor, mude nos dois.
104
+
105
+ O controle do tema é um `<select>` de três opções (Sistema/Claro/Escuro)
106
+ **duplicado nos dois headers** (`theme-select` e `theme-select-dashboard`),
107
+ como já acontece com o de intervalo — mudar num reflete no outro via
108
+ `syncThemeSelects()`. As opções são estáticas no HTML (ao contrário de
109
+ `fillRefreshSelect`, que monta as suas); o JS só sincroniza `.value`. A
110
+ preferência vive em `state.theme`, nunca em variável solta, e `'system'`
111
+ **remove** o `data-theme` da raiz em vez de gravar `'system'`: é a ausência do
112
+ atributo que devolve a decisão ao `@media`.
113
+
114
+ A escolha é persistida em `issue-flow:theme` (`'system' | 'light' | 'dark'`)
115
+ por `readStoredTheme()` / `storeTheme()`, que copiam a forma de
116
+ `readStoredRefresh()` / `storeRefresh()`: leitura e escrita em `try`/`catch`,
117
+ valor ausente ou desconhecido caindo para `'system'`, e **nenhum wrapper
118
+ genérico de storage** — duas chaves não justificam uma abstração. Com o
119
+ armazenamento bloqueado o painel carrega no modo sistema e o `<select>`
120
+ continua alternando o tema na sessão; só não sobrevive ao reload.
121
+
122
+ No modo `'system'` — e **só** nele — um listener de
123
+ `matchMedia('(prefers-color-scheme: dark)')` fica anexado, para a troca de tema
124
+ do SO chegar ao painel sem reload; `setTheme()` o desanexa quando a escolha
125
+ passa a ser forçada e o reanexa quando volta a `'system'`. O repaint das cores
126
+ em si é do `@media`, que o navegador reavalia sozinho: o listener sincroniza o
127
+ lado JS (raiz e o `.value` dos seletores).
128
+
129
+ Consequência para o servidor: `baseHeaders()` em `src/web/server.ts` hoje não
130
+ define `Content-Security-Policy`. Se um CSP for adicionado, ele precisa
131
+ contemplar esse script inline (`'unsafe-inline'` em `script-src` ou, melhor, um
132
+ hash/nonce), senão o painel volta a piscar — e um `script-src` estrito sem essa
133
+ provisão quebra a aplicação do tema silenciosamente.
134
+
135
+ ### Contraste: os pares medidos
136
+
137
+ Os valores abaixo são calculados (WCAG 2.x, luminância relativa), não estimados
138
+ no olho. **Trocar qualquer um destes tokens exige recalcular a linha
139
+ correspondente** — a maior parte da paleta clara passa com pouca folga.
140
+
141
+ | Frente | Fundo | Mínimo | Claro | Escuro |
142
+ | --------------- | ----------------------- | ------ | ----- | ------ |
143
+ | `--text` | `--surface-page` | 4,5:1 | 15,17 | 15,40 |
144
+ | `--text` | `--surface` | 4,5:1 | 16,55 | 14,04 |
145
+ | `--text` | `--surface-sunken` | 4,5:1 | 13,36 | 11,38 |
146
+ | `--text-muted` | `--surface-page` | 4,5:1 | 6,93 | 7,21 |
147
+ | `--text-muted` | `--surface` | 4,5:1 | 7,56 | 6,58 |
148
+ | `--text-muted` | `--surface-sunken` | 4,5:1 | 6,10 | 5,33 |
149
+ | `--text-subtle` | `--surface-page` | 4,5:1 | 5,24 | 6,37 |
150
+ | `--text-subtle` | `--surface` | 4,5:1 | 5,72 | 5,81 |
151
+ | `--text-subtle` | `--surface-sunken` | 4,5:1 | 4,62 | 4,71 |
152
+ | `--state-ok` | `--state-ok-surface` | 4,5:1 | 4,57 | 8,19 |
153
+ | `--state-run` | `--state-run-surface` | 4,5:1 | 5,49 | 5,68 |
154
+ | `--state-warn` | `--state-warn-surface` | 4,5:1 | 4,51 | 8,05 |
155
+ | `--state-error` | `--state-error-surface` | 4,5:1 | 5,30 | 5,63 |
156
+ | `--focus-ring` | `--surface-page` | 3:1 | 5,76 | 6,29 |
157
+ | `--focus-ring` | `--surface` | 3:1 | 6,29 | 5,73 |
158
+ | `--focus-ring` | `--surface-sunken` | 3:1 | 5,08 | 4,65 |
159
+ | `--accent-text` | `--accent` | 4,5:1 | 6,29 | 6,29 |
160
+ | `--accent-text` | `--state-error` | 4,5:1 | 6,47 | 6,78 |
161
+
162
+ O limiar dos badges de estado é **4,5:1 e não 3:1** porque `.badge` é
163
+ `font-size: 0.78rem; font-weight: 600` — abaixo do que a WCAG chama de texto
164
+ grande. Já `--focus-ring` é um componente gráfico, não texto: 3:1 basta.
165
+
166
+ No tema claro as quatro cores de estado ficam no nível 700 da escala — é o tom
167
+ mais claro que ainda atende 4,5:1 sobre a superfície do próprio badge; `--state-ok`
168
+ (4,57) e `--state-warn` (4,51) passam por pouco. No tema escuro os
169
+ preenchimentos sólidos são claros, então `--accent-text` inverte para
170
+ `#0f1218`: era branco sobre `--state-error` no banner de desconexão, 2,98:1.
171
+
172
+ Hover e foco por teclado precisam ser **distinguíveis um do outro**. Em
173
+ `.dashboard-card` o hover só acende a borda (`--accent`); o foco desenha
174
+ `outline: 2px solid var(--focus-ring)` com `outline-offset: 2px`, que continua
175
+ visível em cima do hover. É a única regra do `app.css` que mexe em `outline` —
176
+ antes ela o suprimia e dava a mesma aparência aos dois estados.
177
+
178
+ ## Como verificar uma mudança aqui
179
+
180
+ Nada nesta pasta é coberto por teste automatizado, então a verificação é um
181
+ navegador com **dados de verdade**. `python3 -m http.server` dentro de
182
+ `web/public/` basta para inspecionar o CSS, mas o painel fica no estado
183
+ desconectado — não dá para exercitar alertas, Kanban, drawer nem métricas.
184
+
185
+ Para isso, sirva o servidor real de um `ISSUE_FLOW_HOME` descartável:
186
+
187
+ 1. Escreva um ou mais `session.json` (o schema é `sessionSnapshotSchema` em
188
+ `src/schemas.ts`) em `<home>/projects/<projeto>/issues/<n>/session.json`,
189
+ com `events.jsonl` ao lado no formato `{ seq, event }` da aba Histórico.
190
+ **Duas** sessões abrem o dashboard; uma só abre direto no detalhe.
191
+ 2. `npm run build` e `ISSUE_FLOW_HOME=<home> node dist/cli.js web serve --port
192
+ <p> --host 127.0.0.1`. O servidor lê os assets de `web/public/` (não uma
193
+ cópia em `dist/`), então basta reiniciá-lo para pegar uma edição.
194
+ 3. Uma sessão some após **90s** sem heartbeat: `touch` periódico no
195
+ `session.json` a mantém viva pelo tempo da verificação.
196
+
197
+ Os estados que só aparecem sob condição se forçam do console: o `.banner` de
198
+ desconexão, substituindo `window.fetch` por um que rejeita (e restaurando
199
+ depois); o armazenamento bloqueado, com um `Object.defineProperty(window,
200
+ 'localStorage', { get() { throw … } })` num script de inicialização; a troca de
201
+ tema do SO, pela emulação de `prefers-color-scheme` do DevTools, que dispara o
202
+ evento `change` real da media query.
203
+
204
+ Para contraste, **meça na página** (ler os tokens com
205
+ `getComputedStyle(document.documentElement)` e calcular a razão em JS), nunca a
206
+ partir dos valores no arquivo: só assim a cascata resolvida aparece, incluindo
207
+ o token que um tema herda do outro por engano.
208
+
209
+ ## Escrita limitada a preferências futuras
210
+
211
+ O estado de execução continua somente leitura (`snapshot.readOnly === true`). A
212
+ única mutação é `POST /api/config/agent`, que salva preferência global para
213
+ execuções **futuras**, aparece via capability e só funciona em loopback. Nunca
214
+ inferir permissão pela versão: o client renderiza o formulário apenas quando
215
+ `/api/health.capabilities` contém `config:agent:write`.
77
216
 
78
217
  Quando essa etapa chegar, o contrato esperado é: as rotas de escrita passam a
79
218
  ser anunciadas em `capabilities` (o client decide o que renderizar a partir