issue-flow 0.7.6 → 0.8.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.
- package/dist/{analyze-TNFTUMOO.js → analyze-RBNKXWL5.js} +10 -8
- package/dist/{analyze-TNFTUMOO.js.map → analyze-RBNKXWL5.js.map} +1 -1
- package/dist/{chunk-JECNPBBO.js → chunk-36ECROTC.js} +11 -9
- package/dist/{chunk-JECNPBBO.js.map → chunk-36ECROTC.js.map} +1 -1
- package/dist/{chunk-6S2XT3JE.js → chunk-A4M6AKIK.js} +10 -8
- package/dist/{chunk-6S2XT3JE.js.map → chunk-A4M6AKIK.js.map} +1 -1
- package/dist/{chunk-5NLLEWJD.js → chunk-BASRRXAM.js} +12 -2
- package/dist/{chunk-5NLLEWJD.js.map → chunk-BASRRXAM.js.map} +1 -1
- package/dist/{chunk-5Q2TMW7K.js → chunk-BH7DYADV.js} +11 -1
- package/dist/chunk-BH7DYADV.js.map +1 -0
- package/dist/chunk-F52S6ZGF.js +447 -0
- package/dist/chunk-F52S6ZGF.js.map +1 -0
- package/dist/{chunk-CD7BMWXW.js → chunk-HSGTZ3CT.js} +3 -3
- package/dist/{chunk-QLVAEDVD.js → chunk-J3PJBZXJ.js} +3 -3
- package/dist/{chunk-DWF46N65.js → chunk-J4SICTAN.js} +11 -9
- package/dist/{chunk-DWF46N65.js.map → chunk-J4SICTAN.js.map} +1 -1
- package/dist/{chunk-DF7SUHT5.js → chunk-JWZKV3CL.js} +2 -2
- package/dist/{chunk-5IRBHUYO.js → chunk-MK43N4X3.js} +11 -9
- package/dist/{chunk-5IRBHUYO.js.map → chunk-MK43N4X3.js.map} +1 -1
- package/dist/{chunk-I7FTA5FI.js → chunk-QO2MWMAZ.js} +2 -47
- package/dist/chunk-QO2MWMAZ.js.map +1 -0
- package/dist/{chunk-D56VWHSM.js → chunk-RQW5W4XC.js} +2 -2
- package/dist/chunk-SLHZPB3C.js +50 -0
- package/dist/chunk-SLHZPB3C.js.map +1 -0
- package/dist/{chunk-BMRBQ23Q.js → chunk-TGP6WK65.js} +9 -7
- package/dist/{chunk-BMRBQ23Q.js.map → chunk-TGP6WK65.js.map} +1 -1
- package/dist/{chunk-OE7RYTUT.js → chunk-VPDWTZ3F.js} +2 -2
- package/dist/{chunk-A42R36BX.js → chunk-VSF2CR3G.js} +10 -8
- package/dist/{chunk-A42R36BX.js.map → chunk-VSF2CR3G.js.map} +1 -1
- package/dist/cli.js +28 -13
- package/dist/cli.js.map +1 -1
- package/dist/{config-U3FC4WH6.js → config-R7O5YC65.js} +3 -3
- package/dist/execute-LU3HWA54.js +14 -0
- package/dist/{generate-RHPOWGDX.js → generate-TGJQLGMV.js} +10 -8
- package/dist/{generate-RHPOWGDX.js.map → generate-TGJQLGMV.js.map} +1 -1
- package/dist/init-WP7Z2LFQ.js +9 -0
- package/dist/plan-2MWMCKRY.js +16 -0
- package/dist/pr-XVQDEQ7B.js +16 -0
- package/dist/pr-review-VO6ZXAFM.js +16 -0
- package/dist/prd-FPZAHQEO.js +16 -0
- package/dist/review-AHALLMXO.js +14 -0
- package/dist/{run-Z6ZDXZB6.js → run-2CXVR3VZ.js} +23 -196
- package/dist/run-2CXVR3VZ.js.map +1 -0
- package/dist/web-JVPK2PIF.js +188 -0
- package/dist/web-JVPK2PIF.js.map +1 -0
- package/package.json +1 -1
- package/web/CLAUDE.md +48 -0
- package/web/public/app.css +266 -0
- package/web/public/app.js +237 -0
- package/web/public/index.html +26 -0
- package/dist/chunk-5Q2TMW7K.js.map +0 -1
- package/dist/chunk-I7FTA5FI.js.map +0 -1
- package/dist/execute-BNBA77WJ.js +0 -13
- package/dist/init-IF72GR7O.js +0 -9
- package/dist/plan-FITZZXZQ.js +0 -15
- package/dist/pr-GO244D7M.js +0 -15
- package/dist/pr-review-XY6VL62M.js +0 -15
- package/dist/prd-2Y6RMVTF.js +0 -15
- package/dist/review-ITKXJMX3.js +0 -13
- package/dist/run-Z6ZDXZB6.js.map +0 -1
- /package/dist/{chunk-CD7BMWXW.js.map → chunk-HSGTZ3CT.js.map} +0 -0
- /package/dist/{chunk-QLVAEDVD.js.map → chunk-J3PJBZXJ.js.map} +0 -0
- /package/dist/{chunk-DF7SUHT5.js.map → chunk-JWZKV3CL.js.map} +0 -0
- /package/dist/{chunk-D56VWHSM.js.map → chunk-RQW5W4XC.js.map} +0 -0
- /package/dist/{chunk-OE7RYTUT.js.map → chunk-VPDWTZ3F.js.map} +0 -0
- /package/dist/{config-U3FC4WH6.js.map → config-R7O5YC65.js.map} +0 -0
- /package/dist/{execute-BNBA77WJ.js.map → execute-LU3HWA54.js.map} +0 -0
- /package/dist/{init-IF72GR7O.js.map → init-WP7Z2LFQ.js.map} +0 -0
- /package/dist/{plan-FITZZXZQ.js.map → plan-2MWMCKRY.js.map} +0 -0
- /package/dist/{pr-GO244D7M.js.map → pr-XVQDEQ7B.js.map} +0 -0
- /package/dist/{pr-review-XY6VL62M.js.map → pr-review-VO6ZXAFM.js.map} +0 -0
- /package/dist/{prd-2Y6RMVTF.js.map → prd-FPZAHQEO.js.map} +0 -0
- /package/dist/{review-ITKXJMX3.js.map → review-AHALLMXO.js.map} +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ensureSingleWebServer,
|
|
4
|
+
getWebLockFile,
|
|
5
|
+
readWebLock,
|
|
6
|
+
removeWebLock
|
|
7
|
+
} from "./chunk-F52S6ZGF.js";
|
|
8
|
+
import "./chunk-SLHZPB3C.js";
|
|
9
|
+
import {
|
|
10
|
+
ISSUES_DIR_NAME,
|
|
11
|
+
PROJECTS_DIR_NAME,
|
|
12
|
+
getGlobalRoot,
|
|
13
|
+
loadWebConfig
|
|
14
|
+
} from "./chunk-BASRRXAM.js";
|
|
15
|
+
import {
|
|
16
|
+
printError,
|
|
17
|
+
printInfo,
|
|
18
|
+
printWarning,
|
|
19
|
+
sessionSnapshotSchema
|
|
20
|
+
} from "./chunk-BH7DYADV.js";
|
|
21
|
+
|
|
22
|
+
// src/web/session-directory.ts
|
|
23
|
+
import { readdir, readFile, stat as statFile } from "fs/promises";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
var DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
26
|
+
var DEFAULT_STALE_AFTER_MS = 3e4;
|
|
27
|
+
async function listSubdirectories(dir) {
|
|
28
|
+
try {
|
|
29
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
30
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function discoverSessionFiles(root) {
|
|
36
|
+
const projectsDir = join(root, PROJECTS_DIR_NAME);
|
|
37
|
+
const projectIds = await listSubdirectories(projectsDir);
|
|
38
|
+
const perProject = await Promise.all(
|
|
39
|
+
projectIds.map(async (projectId) => {
|
|
40
|
+
const issuesDir = join(projectsDir, projectId, ISSUES_DIR_NAME);
|
|
41
|
+
const issueIds = await listSubdirectories(issuesDir);
|
|
42
|
+
return issueIds.map((issueId) => join(issuesDir, issueId, "session.json"));
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
return perProject.flat();
|
|
46
|
+
}
|
|
47
|
+
async function readSessionFile(filePath) {
|
|
48
|
+
let raw;
|
|
49
|
+
let mtimeMs;
|
|
50
|
+
try {
|
|
51
|
+
const [content, stat] = await Promise.all([readFile(filePath, "utf-8"), statFile(filePath)]);
|
|
52
|
+
raw = content;
|
|
53
|
+
mtimeMs = stat.mtimeMs;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const parsed = sessionSnapshotSchema.safeParse(JSON.parse(raw));
|
|
59
|
+
if (!parsed.success || parsed.data.sessionId === null) return null;
|
|
60
|
+
return { snapshot: parsed.data, updatedAtMs: mtimeMs };
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function watchSessionDirectory(options = {}) {
|
|
66
|
+
const root = getGlobalRoot(options);
|
|
67
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
68
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
69
|
+
const warn = options.onWarn;
|
|
70
|
+
let sessions = /* @__PURE__ */ new Map();
|
|
71
|
+
let warned = false;
|
|
72
|
+
async function scan() {
|
|
73
|
+
let files;
|
|
74
|
+
try {
|
|
75
|
+
files = await discoverSessionFiles(root);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (!warned) {
|
|
78
|
+
warned = true;
|
|
79
|
+
warn?.(
|
|
80
|
+
`issue-flow: web monitor could not scan the session directory (will keep retrying silently): ${err instanceof Error ? err.message : String(err)}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const next = /* @__PURE__ */ new Map();
|
|
87
|
+
for (const filePath of files) {
|
|
88
|
+
const result = await readSessionFile(filePath);
|
|
89
|
+
if (result === null) continue;
|
|
90
|
+
if (now - result.updatedAtMs > staleAfterMs) continue;
|
|
91
|
+
const sessionId = result.snapshot.sessionId;
|
|
92
|
+
next.set(sessionId, {
|
|
93
|
+
issueDir: join(filePath, ".."),
|
|
94
|
+
filePath,
|
|
95
|
+
snapshot: result.snapshot,
|
|
96
|
+
updatedAtMs: result.updatedAtMs
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
sessions = next;
|
|
100
|
+
}
|
|
101
|
+
void scan();
|
|
102
|
+
const timer = setInterval(() => void scan(), pollIntervalMs);
|
|
103
|
+
timer.unref();
|
|
104
|
+
return {
|
|
105
|
+
sessions: () => [...sessions.values()],
|
|
106
|
+
getSession: (sessionId) => sessions.get(sessionId),
|
|
107
|
+
refresh: scan,
|
|
108
|
+
close: () => clearInterval(timer)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// src/commands/web.ts
|
|
113
|
+
var STOP_POLL_INTERVAL_MS = 100;
|
|
114
|
+
var STOP_TIMEOUT_MS = 5e3;
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
async function runWebServe(options) {
|
|
119
|
+
const cli = {};
|
|
120
|
+
if (options.port !== void 0) cli.port = options.port;
|
|
121
|
+
if (options.host !== void 0) cli.host = options.host;
|
|
122
|
+
if (options.refresh !== void 0) cli.refreshSeconds = options.refresh;
|
|
123
|
+
const webConfig = await loadWebConfig({ cli });
|
|
124
|
+
const sessions = watchSessionDirectory();
|
|
125
|
+
const noop = () => {
|
|
126
|
+
};
|
|
127
|
+
const handle = await ensureSingleWebServer({
|
|
128
|
+
sessions,
|
|
129
|
+
port: webConfig.port,
|
|
130
|
+
host: webConfig.host,
|
|
131
|
+
refreshSeconds: webConfig.refreshSeconds,
|
|
132
|
+
unref: false,
|
|
133
|
+
info: noop,
|
|
134
|
+
warn: noop
|
|
135
|
+
});
|
|
136
|
+
if (handle === null) {
|
|
137
|
+
sessions.close();
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
if (handle.server === void 0) {
|
|
141
|
+
sessions.close();
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
const originalClose = handle.close;
|
|
145
|
+
handle.close = async () => {
|
|
146
|
+
await originalClose();
|
|
147
|
+
sessions.close();
|
|
148
|
+
};
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
async function runWebStop() {
|
|
152
|
+
const lockFile = getWebLockFile();
|
|
153
|
+
const lock = await readWebLock(lockFile);
|
|
154
|
+
if (lock === null) {
|
|
155
|
+
printInfo("No web monitor is currently running.");
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
process.kill(lock.pid, "SIGTERM");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err.code === "ESRCH") {
|
|
162
|
+
printWarning(
|
|
163
|
+
"The web monitor lock referenced a process that is no longer running; removing it."
|
|
164
|
+
);
|
|
165
|
+
await removeWebLock(lockFile);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
printError(
|
|
169
|
+
`Failed to stop the web monitor: ${err instanceof Error ? err.message : String(err)}`
|
|
170
|
+
);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
if (await readWebLock(lockFile) === null) {
|
|
176
|
+
printInfo(`Web monitor (pid ${lock.pid}) stopped.`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
await sleep(STOP_POLL_INTERVAL_MS);
|
|
180
|
+
}
|
|
181
|
+
printWarning("Sent the stop signal, but the web monitor did not confirm shutdown in time.");
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
export {
|
|
185
|
+
runWebServe,
|
|
186
|
+
runWebStop
|
|
187
|
+
};
|
|
188
|
+
//# sourceMappingURL=web-JVPK2PIF.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/web/session-directory.ts","../src/commands/web.ts"],"sourcesContent":["import { readdir, readFile, stat as statFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { ValidatedSessionSnapshot } from '../schemas.js';\nimport { sessionSnapshotSchema } from '../schemas.js';\nimport {\n type GetGlobalRootOptions,\n getGlobalRoot,\n ISSUES_DIR_NAME,\n PROJECTS_DIR_NAME,\n} from '../storage/paths.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` throttles writes to ~1s and force-flushes on every\n * phase/session boundary, so a live run's file is never quiet for long; this\n * threshold only needs to be comfortably above one throttle window plus one\n * poll interval to avoid flapping a session in and out on timing alone.\n */\nexport const DEFAULT_STALE_AFTER_MS = 30_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 /** 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.json'));\n }),\n );\n\n return perProject.flat();\n}\n\ninterface ReadResult {\n snapshot: ValidatedSessionSnapshot;\n updatedAtMs: number;\n}\n\n/**\n * Read and validate one `session.json`. Anything short of a fully valid file\n * (missing, mid-write, corrupted, written by an incompatible future version)\n * reads as \"not currently a session\" — the directory scan must never fail\n * because one file is momentarily inconsistent (`FilePublisher` writes via a\n * temp file + rename, but a reader can still race a concurrent write).\n */\nasync function readSessionFile(filePath: string): Promise<ReadResult | null> {\n let raw: string;\n let mtimeMs: number;\n try {\n const [content, stat] = await Promise.all([readFile(filePath, 'utf-8'), statFile(filePath)]);\n raw = content;\n mtimeMs = stat.mtimeMs;\n } catch {\n return null;\n }\n\n try {\n const parsed = sessionSnapshotSchema.safeParse(JSON.parse(raw));\n if (!parsed.success || parsed.data.sessionId === null) return null;\n return { snapshot: parsed.data, updatedAtMs: mtimeMs };\n } catch {\n return null;\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 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
package/web/CLAUDE.md
CHANGED
|
@@ -20,6 +20,54 @@ zero é um valor legítimo.
|
|
|
20
20
|
Texto dinâmico sempre via `textContent`/`el()`; nunca `innerHTML` com dados
|
|
21
21
|
do snapshot.
|
|
22
22
|
|
|
23
|
+
## Abas, Kanban e drawer
|
|
24
|
+
|
|
25
|
+
O painel tem duas abas ("Execução" e "Kanban") e um drawer de detalhes por
|
|
26
|
+
story. Três regras seguram esse conjunto:
|
|
27
|
+
|
|
28
|
+
- **Acesso a story sempre por `getStoryById()` / `getStories()`.** Elas são a
|
|
29
|
+
camada de leitura: normalizam num lugar só o que pode faltar num
|
|
30
|
+
`session.json` antigo (`status` → `'backlog'`, `dependencies`/
|
|
31
|
+
`acceptanceCriteria` → `[]`, `description` → `''`) e são o ponto onde uma
|
|
32
|
+
futura camada de escrita entra. Nenhum consumidor varre `snapshot.stories`
|
|
33
|
+
por conta própria, ou a normalização se espalha.
|
|
34
|
+
- **Estado de UI vive em `state`** (`activeTab`, `selectedStoryId`), junto de
|
|
35
|
+
`logFilter`, nunca em variável solta ou em referência a nó do DOM. O drawer
|
|
36
|
+
guarda o **id** da story, não o card: `render()` recria o Kanban a cada poll,
|
|
37
|
+
então uma referência guardada na abertura apontaria para um nó fora do
|
|
38
|
+
documento. Pelo mesmo motivo o foco volta ao card via
|
|
39
|
+
`[data-story-id="…"]` no momento de fechar.
|
|
40
|
+
- **`renderKanban()` e `renderDrawer()` são chamadas incondicionalmente** dentro
|
|
41
|
+
de `render()`, não ao trocar de aba. Uma aba inativa não pode ficar defasada,
|
|
42
|
+
e é o `renderDrawer()` de cada poll que mantém o drawer aberto em dia (e o
|
|
43
|
+
fecha quando a story some do plano).
|
|
44
|
+
|
|
45
|
+
Cada card do Kanban é um `<button>` — Enter/Espaço e foco saem de graça. Como
|
|
46
|
+
`<button>` só aceita *phrasing content*, todo o conteúdo interno é `<span>` com
|
|
47
|
+
`display: block`/`flex` no CSS; `<p>` ou `<div>` ali dentro é HTML inválido.
|
|
48
|
+
|
|
49
|
+
Detalhes cosméticos que não são cosméticos: `.tab-panel[hidden]` e
|
|
50
|
+
`.drawer[hidden]` precisam de `display: none` explícito, senão o `display:
|
|
51
|
+
grid`/`flex` da regra base vence o atributo `hidden`. E o overlay/drawer ficam
|
|
52
|
+
em `z-index` 20/21 para cobrir o `.banner` de desconexão, que é `sticky` com
|
|
53
|
+
`z-index: 10`.
|
|
54
|
+
|
|
55
|
+
## Escrita: o que ainda não existe
|
|
56
|
+
|
|
57
|
+
A interface é somente leitura por contrato (`snapshot.readOnly === true`,
|
|
58
|
+
`capabilities: []`), e o servidor não registra nenhuma rota de escrita — o
|
|
59
|
+
comentário em `src/web/server.ts` reserva `POST /api/control/*` para isso. O
|
|
60
|
+
Kanban e o drawer foram desenhados para que a escrita caiba depois sem
|
|
61
|
+
redesenhar a interação: o drawer já é o lugar onde uma story é inspecionada por
|
|
62
|
+
id, e `getStoryById()` já é o único ponto por onde ela é lida.
|
|
63
|
+
|
|
64
|
+
Quando essa etapa chegar, o contrato esperado é: as rotas de escrita passam a
|
|
65
|
+
ser anunciadas em `capabilities` (o client decide o que renderizar a partir
|
|
66
|
+
disso, nunca da versão do servidor), `readOnly` passa a `false`, e cada
|
|
67
|
+
mutação responde com o snapshot atualizado para que a UI não precise adivinhar
|
|
68
|
+
o efeito nem esperar o próximo poll. Enquanto `capabilities` estiver vazio,
|
|
69
|
+
nenhum controle de escrita deve aparecer na tela.
|
|
70
|
+
|
|
23
71
|
## Métricas (tokens e custo)
|
|
24
72
|
|
|
25
73
|
`formatUsage()` espelha `formatTokens()` de `src/core/metrics.ts` — mesma
|
package/web/public/app.css
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
--ok-bg: #dcfce7;
|
|
19
19
|
--idle-bg: #e5e7eb;
|
|
20
20
|
--shadow: 0 1px 2px rgb(0 0 0 / 0.06);
|
|
21
|
+
--overlay: rgb(0 0 0 / 0.45);
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
@media (prefers-color-scheme: dark) {
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
--ok-bg: #14301f;
|
|
39
40
|
--idle-bg: #262d38;
|
|
40
41
|
--shadow: none;
|
|
42
|
+
--overlay: rgb(0 0 0 / 0.65);
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -110,6 +112,53 @@ a:hover {
|
|
|
110
112
|
text-decoration: underline;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/* ---- Abas ---- */
|
|
116
|
+
|
|
117
|
+
.tabs {
|
|
118
|
+
display: flex;
|
|
119
|
+
gap: 4px;
|
|
120
|
+
border-bottom: 1px solid var(--border);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.tab {
|
|
124
|
+
appearance: none;
|
|
125
|
+
background: none;
|
|
126
|
+
border: none;
|
|
127
|
+
border-bottom: 2px solid transparent;
|
|
128
|
+
margin-bottom: -1px;
|
|
129
|
+
padding: 8px 14px;
|
|
130
|
+
font: inherit;
|
|
131
|
+
font-weight: 600;
|
|
132
|
+
color: var(--muted);
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
border-radius: 6px 6px 0 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.tab:hover {
|
|
138
|
+
color: var(--fg);
|
|
139
|
+
background: var(--card);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.tab.is-active {
|
|
143
|
+
color: var(--accent);
|
|
144
|
+
border-bottom-color: var(--accent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* O painel repete o grid de main: as seções dentro dele são os cards que antes
|
|
148
|
+
eram filhos diretos de main e dependem desse espaçamento. */
|
|
149
|
+
.tab-panel {
|
|
150
|
+
display: grid;
|
|
151
|
+
gap: 16px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.tab-panel[hidden] {
|
|
155
|
+
display: none;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.tab-panel > * {
|
|
159
|
+
min-width: 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
113
162
|
/* ---- Cabeçalho ---- */
|
|
114
163
|
|
|
115
164
|
.header {
|
|
@@ -534,6 +583,223 @@ progress {
|
|
|
534
583
|
word-break: break-word;
|
|
535
584
|
}
|
|
536
585
|
|
|
586
|
+
/* ---- Kanban ---- */
|
|
587
|
+
|
|
588
|
+
.kanban {
|
|
589
|
+
display: grid;
|
|
590
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
591
|
+
gap: 12px;
|
|
592
|
+
align-items: start;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.kanban-column {
|
|
596
|
+
background: var(--bg);
|
|
597
|
+
border: 1px solid var(--border);
|
|
598
|
+
border-radius: 8px;
|
|
599
|
+
padding: 10px;
|
|
600
|
+
display: grid;
|
|
601
|
+
gap: 8px;
|
|
602
|
+
min-width: 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.kanban-column-head {
|
|
606
|
+
display: flex;
|
|
607
|
+
align-items: center;
|
|
608
|
+
justify-content: space-between;
|
|
609
|
+
gap: 8px;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.kanban-column-title {
|
|
613
|
+
margin: 0;
|
|
614
|
+
font-size: 0.72rem;
|
|
615
|
+
font-weight: 600;
|
|
616
|
+
text-transform: uppercase;
|
|
617
|
+
letter-spacing: 0.06em;
|
|
618
|
+
color: var(--muted);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.kanban-column-count {
|
|
622
|
+
flex: none;
|
|
623
|
+
background: var(--idle-bg);
|
|
624
|
+
color: var(--muted);
|
|
625
|
+
border-radius: 999px;
|
|
626
|
+
padding: 0 7px;
|
|
627
|
+
font-size: 0.74rem;
|
|
628
|
+
font-variant-numeric: tabular-nums;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.kanban-card {
|
|
632
|
+
appearance: none;
|
|
633
|
+
display: grid;
|
|
634
|
+
gap: 6px;
|
|
635
|
+
justify-items: start;
|
|
636
|
+
width: 100%;
|
|
637
|
+
text-align: left;
|
|
638
|
+
background: var(--card);
|
|
639
|
+
border: 1px solid var(--border);
|
|
640
|
+
border-radius: 8px;
|
|
641
|
+
padding: 10px;
|
|
642
|
+
font: inherit;
|
|
643
|
+
color: inherit;
|
|
644
|
+
cursor: pointer;
|
|
645
|
+
box-shadow: var(--shadow);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
.kanban-card:hover {
|
|
649
|
+
border-color: var(--accent);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.kanban-card-head {
|
|
653
|
+
display: flex;
|
|
654
|
+
align-items: center;
|
|
655
|
+
gap: 2px;
|
|
656
|
+
color: var(--muted);
|
|
657
|
+
font-size: 0.8rem;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.kanban-card-title {
|
|
661
|
+
display: block;
|
|
662
|
+
font-weight: 600;
|
|
663
|
+
line-height: 1.35;
|
|
664
|
+
overflow-wrap: anywhere;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/* Trecho curto: limita a 3 linhas sem truncar a string, então o texto completo
|
|
668
|
+
continua no DOM para leitores de tela e para o drawer. */
|
|
669
|
+
.kanban-card-desc {
|
|
670
|
+
margin: 0;
|
|
671
|
+
color: var(--muted);
|
|
672
|
+
font-size: 0.84rem;
|
|
673
|
+
line-height: 1.4;
|
|
674
|
+
overflow: hidden;
|
|
675
|
+
display: -webkit-box;
|
|
676
|
+
-webkit-box-orient: vertical;
|
|
677
|
+
-webkit-line-clamp: 3;
|
|
678
|
+
line-clamp: 3;
|
|
679
|
+
overflow-wrap: anywhere;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.kanban-empty {
|
|
683
|
+
font-size: 0.84rem;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
@media (max-width: 719px) {
|
|
687
|
+
.kanban {
|
|
688
|
+
grid-template-columns: minmax(0, 1fr);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/* ---- Drawer de detalhes ---- */
|
|
693
|
+
|
|
694
|
+
/* Acima do .banner sticky (z-index 10), que senão atravessa o overlay. */
|
|
695
|
+
.drawer-overlay {
|
|
696
|
+
position: fixed;
|
|
697
|
+
inset: 0;
|
|
698
|
+
z-index: 20;
|
|
699
|
+
background: var(--overlay);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.drawer {
|
|
703
|
+
position: fixed;
|
|
704
|
+
top: 0;
|
|
705
|
+
right: 0;
|
|
706
|
+
bottom: 0;
|
|
707
|
+
width: min(420px, 100vw);
|
|
708
|
+
z-index: 21;
|
|
709
|
+
display: flex;
|
|
710
|
+
flex-direction: column;
|
|
711
|
+
background: var(--card);
|
|
712
|
+
border-left: 1px solid var(--border);
|
|
713
|
+
padding: 16px;
|
|
714
|
+
gap: 12px;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.drawer[hidden],
|
|
718
|
+
.drawer-overlay[hidden] {
|
|
719
|
+
display: none;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.drawer-head {
|
|
723
|
+
display: flex;
|
|
724
|
+
align-items: flex-start;
|
|
725
|
+
justify-content: space-between;
|
|
726
|
+
gap: 12px;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.drawer-head h2 {
|
|
730
|
+
margin: 0;
|
|
731
|
+
/* O título carrega o id + o título da story: fora do padrão caixa-alta das
|
|
732
|
+
demais seções, que são rótulos fixos e curtos. */
|
|
733
|
+
font-size: 0.95rem;
|
|
734
|
+
text-transform: none;
|
|
735
|
+
letter-spacing: normal;
|
|
736
|
+
color: var(--fg);
|
|
737
|
+
overflow-wrap: anywhere;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.drawer-close {
|
|
741
|
+
appearance: none;
|
|
742
|
+
flex: none;
|
|
743
|
+
background: none;
|
|
744
|
+
border: none;
|
|
745
|
+
padding: 2px 6px;
|
|
746
|
+
font: inherit;
|
|
747
|
+
color: var(--muted);
|
|
748
|
+
cursor: pointer;
|
|
749
|
+
border-radius: 6px;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.drawer-close:hover {
|
|
753
|
+
color: var(--fg);
|
|
754
|
+
background: var(--idle-bg);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* A rolagem vive aqui, não na página: o drawer é fixo e ocupa a altura toda. */
|
|
758
|
+
.drawer-body {
|
|
759
|
+
overflow-y: auto;
|
|
760
|
+
display: grid;
|
|
761
|
+
gap: 14px;
|
|
762
|
+
align-content: start;
|
|
763
|
+
justify-items: start;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.drawer-timing {
|
|
767
|
+
margin: 0;
|
|
768
|
+
font-size: 0.84rem;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.drawer-section {
|
|
772
|
+
width: 100%;
|
|
773
|
+
display: grid;
|
|
774
|
+
gap: 6px;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
.drawer-section-title {
|
|
778
|
+
margin: 0;
|
|
779
|
+
font-size: 0.72rem;
|
|
780
|
+
font-weight: 600;
|
|
781
|
+
text-transform: uppercase;
|
|
782
|
+
letter-spacing: 0.06em;
|
|
783
|
+
color: var(--muted);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
.drawer-text {
|
|
787
|
+
margin: 0;
|
|
788
|
+
white-space: pre-wrap;
|
|
789
|
+
overflow-wrap: anywhere;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
.drawer-list {
|
|
793
|
+
margin: 0;
|
|
794
|
+
padding-left: 18px;
|
|
795
|
+
display: grid;
|
|
796
|
+
gap: 4px;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
.drawer-list li {
|
|
800
|
+
overflow-wrap: anywhere;
|
|
801
|
+
}
|
|
802
|
+
|
|
537
803
|
/* ---- Diversos ---- */
|
|
538
804
|
|
|
539
805
|
.empty {
|