mumei-dashboard 0.4.2 → 0.4.3
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/assets/index-BGD0sG4o.css +2 -0
- package/dist/assets/index-DU32GYsV.js +119 -0
- package/dist/index.html +2 -2
- package/dist/server/index.js +411 -358
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -2
- package/dist/assets/index-BHULKI8F.js +0 -49
- package/dist/assets/index-DHEyKEQT.css +0 -2
package/dist/server/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import { execFile } from 'child_process';
|
|
3
3
|
import { existsSync, createReadStream, stat as stat$1, unwatchFile, watchFile, watch as watch$1 } from 'fs';
|
|
4
|
-
import {
|
|
4
|
+
import { readdir, readFile, stat, access, realpath, lstat, open } from 'fs/promises';
|
|
5
5
|
import * as sp2 from 'path';
|
|
6
6
|
import sp2__default, { resolve, join, relative, sep } from 'path';
|
|
7
7
|
import { promisify } from 'util';
|
|
@@ -43717,6 +43717,21 @@ var FeatureDetailSchema = Type.Object(
|
|
|
43717
43717
|
),
|
|
43718
43718
|
timeline: Type.Array(TimelineEntrySchema),
|
|
43719
43719
|
acs: Type.Array(AcSchema, { description: "Empty array when planVehicle=true." }),
|
|
43720
|
+
phase: Type.Union(
|
|
43721
|
+
[
|
|
43722
|
+
Type.Literal("plan"),
|
|
43723
|
+
Type.Literal("implement"),
|
|
43724
|
+
Type.Literal("review"),
|
|
43725
|
+
Type.Literal("done"),
|
|
43726
|
+
Type.Null()
|
|
43727
|
+
],
|
|
43728
|
+
{
|
|
43729
|
+
description: "Phase from state.json. Null when state.json is missing or unreadable. Frontend's Tasks tab only renders the active-Wave shimmer while phase === 'implement' (and not archived)."
|
|
43730
|
+
}
|
|
43731
|
+
),
|
|
43732
|
+
currentWave: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()], {
|
|
43733
|
+
description: "Current Wave index from state.json (spec vehicle only). Null when planVehicle=true, when state.json is absent, or when current_wave is unset. Frontend's Tasks tab highlights this Wave only while phase === 'implement' and !archived; otherwise all Waves render as historical state."
|
|
43734
|
+
}),
|
|
43720
43735
|
waveplan: Type.Array(WaveplanEntrySchema),
|
|
43721
43736
|
reviews: Type.Array(ReviewSummarySchema),
|
|
43722
43737
|
costPerIter: Type.Array(CostPerIterSchema)
|
|
@@ -43770,7 +43785,9 @@ var FeatureSummarySchema = Type.Object(
|
|
|
43770
43785
|
lastVerdict: Type.Union([VerdictSchema, Type.Null()], {
|
|
43771
43786
|
description: "Verdict from the most recent review JSON (Phase 5 / /mumei:review). Null when no review has run yet."
|
|
43772
43787
|
}),
|
|
43773
|
-
lastIter: Type.Union([Type.Integer({ minimum: 1
|
|
43788
|
+
lastIter: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
|
43789
|
+
description: "Review iter index from the most recent review JSON. Current orchestrator caps at 3 (REQ-7.6) but historical archived reviews may carry higher values; no upper bound is enforced here."
|
|
43790
|
+
}),
|
|
43774
43791
|
tokens: Type.Integer({
|
|
43775
43792
|
minimum: 0,
|
|
43776
43793
|
description: "Sum of input_tokens + output_tokens from cost-log.jsonl entries (phase=after) for this feature."
|
|
@@ -43921,11 +43938,218 @@ var HooksTrendSchema = Type.Array(
|
|
|
43921
43938
|
description: "GET /api/trends/hooks?topN=10&windowH=24 result. Top-N hook_id rows by firing count within the window."
|
|
43922
43939
|
}
|
|
43923
43940
|
);
|
|
43924
|
-
|
|
43925
|
-
|
|
43926
|
-
|
|
43927
|
-
|
|
43928
|
-
|
|
43941
|
+
async function* readJsonl(filePath, opts) {
|
|
43942
|
+
try {
|
|
43943
|
+
await access(filePath);
|
|
43944
|
+
} catch {
|
|
43945
|
+
return;
|
|
43946
|
+
}
|
|
43947
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
43948
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
43949
|
+
let lineNumber = 0;
|
|
43950
|
+
try {
|
|
43951
|
+
for await (const line of rl) {
|
|
43952
|
+
lineNumber += 1;
|
|
43953
|
+
if (!line.trim()) continue;
|
|
43954
|
+
let parsed;
|
|
43955
|
+
try {
|
|
43956
|
+
parsed = JSON.parse(line);
|
|
43957
|
+
} catch {
|
|
43958
|
+
continue;
|
|
43959
|
+
}
|
|
43960
|
+
if (opts?.validate && !opts.validate(parsed)) {
|
|
43961
|
+
process.stderr.write(
|
|
43962
|
+
`[mumei dashboard] JSONL shape violation, skipping: file=${filePath} line=${lineNumber}
|
|
43963
|
+
`
|
|
43964
|
+
);
|
|
43965
|
+
continue;
|
|
43966
|
+
}
|
|
43967
|
+
yield parsed;
|
|
43968
|
+
}
|
|
43969
|
+
} finally {
|
|
43970
|
+
rl.close();
|
|
43971
|
+
stream.destroy();
|
|
43972
|
+
}
|
|
43973
|
+
}
|
|
43974
|
+
function utcDay(iso) {
|
|
43975
|
+
const isoDay = iso.slice(0, 10);
|
|
43976
|
+
if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(isoDay)) return isoDay;
|
|
43977
|
+
return "";
|
|
43978
|
+
}
|
|
43979
|
+
async function aggregateTokensByDay(files, days, now = /* @__PURE__ */ new Date()) {
|
|
43980
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
43981
|
+
const dayKeys = utcDayWindow(now, days);
|
|
43982
|
+
for (const k of dayKeys) buckets.set(k, 0);
|
|
43983
|
+
const earliest = dayKeys[0];
|
|
43984
|
+
if (!earliest) return [];
|
|
43985
|
+
for (const file of files) {
|
|
43986
|
+
for await (const e of readJsonl(file)) {
|
|
43987
|
+
if (e.phase !== "after") continue;
|
|
43988
|
+
const d = utcDay(e.ts);
|
|
43989
|
+
if (!d || d < earliest) continue;
|
|
43990
|
+
if (!buckets.has(d)) continue;
|
|
43991
|
+
const tokens = (e.input_tokens ?? 0) + (e.output_tokens ?? 0);
|
|
43992
|
+
buckets.set(d, (buckets.get(d) ?? 0) + tokens);
|
|
43993
|
+
}
|
|
43994
|
+
}
|
|
43995
|
+
return dayKeys.map((d) => ({ d, v: buckets.get(d) ?? 0 }));
|
|
43996
|
+
}
|
|
43997
|
+
async function aggregateMonthTokens(files, now = /* @__PURE__ */ new Date()) {
|
|
43998
|
+
const monthPrefix = now.toISOString().slice(0, 7);
|
|
43999
|
+
let monthTokens = 0;
|
|
44000
|
+
let cacheRead = 0;
|
|
44001
|
+
let nonCacheInput = 0;
|
|
44002
|
+
for (const file of files) {
|
|
44003
|
+
for await (const e of readJsonl(file)) {
|
|
44004
|
+
if (e.phase !== "after") continue;
|
|
44005
|
+
if (!e.ts.startsWith(monthPrefix)) continue;
|
|
44006
|
+
const inputT = e.input_tokens ?? 0;
|
|
44007
|
+
const outputT = e.output_tokens ?? 0;
|
|
44008
|
+
const readT = e.cache_read_input_tokens ?? 0;
|
|
44009
|
+
monthTokens += inputT + outputT;
|
|
44010
|
+
cacheRead += readT;
|
|
44011
|
+
nonCacheInput += inputT;
|
|
44012
|
+
}
|
|
44013
|
+
}
|
|
44014
|
+
const denom = nonCacheInput + cacheRead;
|
|
44015
|
+
const cacheHitRate = denom > 0 ? cacheRead / denom : 0;
|
|
44016
|
+
return { monthTokens, cacheHitRate };
|
|
44017
|
+
}
|
|
44018
|
+
async function aggregateReviewsByDay(reviewDirs, days, now = /* @__PURE__ */ new Date()) {
|
|
44019
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
44020
|
+
const dayKeys = utcDayWindow(now, days);
|
|
44021
|
+
for (const k of dayKeys) buckets.set(k, { d: k, PASS: 0, NI: 0, MI: 0 });
|
|
44022
|
+
const earliest = dayKeys[0];
|
|
44023
|
+
if (!earliest) return [];
|
|
44024
|
+
for (const dir of reviewDirs) {
|
|
44025
|
+
const entries = await safeReaddir(dir);
|
|
44026
|
+
for (const ent of entries) {
|
|
44027
|
+
if (!ent.isFile()) continue;
|
|
44028
|
+
if (!ent.name.endsWith(".json")) continue;
|
|
44029
|
+
if (ent.name.endsWith("-detectors.json")) continue;
|
|
44030
|
+
const fp = sp2__default.join(dir, ent.name);
|
|
44031
|
+
const body = await safeReadFile(fp);
|
|
44032
|
+
if (!body) continue;
|
|
44033
|
+
let parsed;
|
|
44034
|
+
try {
|
|
44035
|
+
parsed = JSON.parse(body);
|
|
44036
|
+
} catch {
|
|
44037
|
+
continue;
|
|
44038
|
+
}
|
|
44039
|
+
const mt = await safeMtime(fp);
|
|
44040
|
+
const iso = mt ?? parsed.ts ?? "";
|
|
44041
|
+
const d = utcDay(iso);
|
|
44042
|
+
if (!d || d < earliest) continue;
|
|
44043
|
+
const bucket = buckets.get(d);
|
|
44044
|
+
if (!bucket) continue;
|
|
44045
|
+
switch (parsed.verdict) {
|
|
44046
|
+
case "PASS":
|
|
44047
|
+
bucket.PASS += 1;
|
|
44048
|
+
break;
|
|
44049
|
+
case "NEEDS_IMPROVEMENT":
|
|
44050
|
+
bucket.NI += 1;
|
|
44051
|
+
break;
|
|
44052
|
+
case "MAJOR_ISSUES":
|
|
44053
|
+
bucket.MI += 1;
|
|
44054
|
+
break;
|
|
44055
|
+
}
|
|
44056
|
+
}
|
|
44057
|
+
}
|
|
44058
|
+
return dayKeys.map((d) => buckets.get(d) ?? { d, PASS: 0, NI: 0, MI: 0 });
|
|
44059
|
+
}
|
|
44060
|
+
async function aggregateHooksTopN(filePath, topN, windowH, now = /* @__PURE__ */ new Date()) {
|
|
44061
|
+
const cutoff = new Date(now.getTime() - windowH * 36e5).toISOString();
|
|
44062
|
+
const counts = /* @__PURE__ */ new Map();
|
|
44063
|
+
for await (const e of readJsonl(filePath)) {
|
|
44064
|
+
if (!e.ts || e.ts < cutoff) continue;
|
|
44065
|
+
if (!e.hook_id) continue;
|
|
44066
|
+
const slot = counts.get(e.hook_id) ?? { count: 0, decisions: /* @__PURE__ */ new Map() };
|
|
44067
|
+
slot.count += 1;
|
|
44068
|
+
const dec = e.decision || "noop";
|
|
44069
|
+
slot.decisions.set(dec, (slot.decisions.get(dec) ?? 0) + 1);
|
|
44070
|
+
counts.set(e.hook_id, slot);
|
|
44071
|
+
}
|
|
44072
|
+
const rows = [];
|
|
44073
|
+
for (const [hook_id, slot] of counts) {
|
|
44074
|
+
let topDecision = "noop";
|
|
44075
|
+
let topCount = -1;
|
|
44076
|
+
for (const [d, c] of slot.decisions) {
|
|
44077
|
+
if (c > topCount) {
|
|
44078
|
+
topCount = c;
|
|
44079
|
+
topDecision = d;
|
|
44080
|
+
}
|
|
44081
|
+
}
|
|
44082
|
+
rows.push({ hook_id, count: slot.count, decision: topDecision });
|
|
44083
|
+
}
|
|
44084
|
+
rows.sort((a, b) => b.count - a.count);
|
|
44085
|
+
return rows.slice(0, topN);
|
|
44086
|
+
}
|
|
44087
|
+
async function eventCount24h(args) {
|
|
44088
|
+
const now = args.now ?? /* @__PURE__ */ new Date();
|
|
44089
|
+
const cutoff = new Date(now.getTime() - 24 * 36e5).toISOString();
|
|
44090
|
+
let count = 0;
|
|
44091
|
+
for (const file of args.costLogFiles) {
|
|
44092
|
+
for await (const e of readJsonl(file)) {
|
|
44093
|
+
if (e.phase === "after" && e.ts >= cutoff) count += 1;
|
|
44094
|
+
}
|
|
44095
|
+
}
|
|
44096
|
+
for await (const e of readJsonl(args.hookStatsFile)) {
|
|
44097
|
+
if (e.ts && e.ts >= cutoff) count += 1;
|
|
44098
|
+
}
|
|
44099
|
+
for (const dir of args.reviewDirs) {
|
|
44100
|
+
const entries = await safeReaddir(dir);
|
|
44101
|
+
for (const ent of entries) {
|
|
44102
|
+
if (!ent.isFile()) continue;
|
|
44103
|
+
if (!ent.name.endsWith(".json")) continue;
|
|
44104
|
+
const fp = sp2__default.join(dir, ent.name);
|
|
44105
|
+
const mt = await safeMtime(fp);
|
|
44106
|
+
if (mt && mt >= cutoff) count += 1;
|
|
44107
|
+
}
|
|
44108
|
+
}
|
|
44109
|
+
for (const ts of args.gitTimestamps) {
|
|
44110
|
+
if (ts >= cutoff) count += 1;
|
|
44111
|
+
}
|
|
44112
|
+
return count;
|
|
44113
|
+
}
|
|
44114
|
+
async function hooksPerSec(filePath, now = /* @__PURE__ */ new Date()) {
|
|
44115
|
+
const cutoff = new Date(now.getTime() - 24 * 36e5).toISOString();
|
|
44116
|
+
let count = 0;
|
|
44117
|
+
for await (const e of readJsonl(filePath)) {
|
|
44118
|
+
if (e.ts && e.ts >= cutoff) count += 1;
|
|
44119
|
+
}
|
|
44120
|
+
return count / (24 * 3600);
|
|
44121
|
+
}
|
|
44122
|
+
function utcDayWindow(now, days) {
|
|
44123
|
+
if (days <= 0) return [];
|
|
44124
|
+
const out = [];
|
|
44125
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
44126
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
44127
|
+
const d = new Date(today.getTime() - i * 864e5);
|
|
44128
|
+
out.push(d.toISOString().slice(0, 10));
|
|
44129
|
+
}
|
|
44130
|
+
return out;
|
|
44131
|
+
}
|
|
44132
|
+
async function safeReaddir(dir) {
|
|
44133
|
+
try {
|
|
44134
|
+
return await readdir(dir, { withFileTypes: true });
|
|
44135
|
+
} catch {
|
|
44136
|
+
return [];
|
|
44137
|
+
}
|
|
44138
|
+
}
|
|
44139
|
+
async function safeReadFile(p) {
|
|
44140
|
+
try {
|
|
44141
|
+
return await readFile(p, "utf8");
|
|
44142
|
+
} catch {
|
|
44143
|
+
return null;
|
|
44144
|
+
}
|
|
44145
|
+
}
|
|
44146
|
+
async function safeMtime(p) {
|
|
44147
|
+
try {
|
|
44148
|
+
const s = await stat(p);
|
|
44149
|
+
return s.mtime.toISOString();
|
|
44150
|
+
} catch {
|
|
44151
|
+
return null;
|
|
44152
|
+
}
|
|
43929
44153
|
}
|
|
43930
44154
|
|
|
43931
44155
|
// node_modules/@sinclair/typebox/build/esm/errors/function.mjs
|
|
@@ -46226,6 +46450,12 @@ var TypeCompiler;
|
|
|
46226
46450
|
TypeCompiler2.Compile = Compile;
|
|
46227
46451
|
})(TypeCompiler || (TypeCompiler = {}));
|
|
46228
46452
|
|
|
46453
|
+
// src/schemas/_formats.ts
|
|
46454
|
+
var ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
46455
|
+
if (!format_exports.Has("date-time")) {
|
|
46456
|
+
format_exports.Set("date-time", (v) => typeof v === "string" && ISO_DATE_TIME.test(v));
|
|
46457
|
+
}
|
|
46458
|
+
|
|
46229
46459
|
// src/schemas/cost-log.ts
|
|
46230
46460
|
var CostLogEntrySchema = Type.Object(
|
|
46231
46461
|
{
|
|
@@ -46374,130 +46604,6 @@ var ReviewSchema = Type.Object(
|
|
|
46374
46604
|
}
|
|
46375
46605
|
);
|
|
46376
46606
|
|
|
46377
|
-
// src/schemas/sse-event.ts
|
|
46378
|
-
var InlineActivityEventSchema = Type.Union([
|
|
46379
|
-
Type.Object(
|
|
46380
|
-
{
|
|
46381
|
-
ts: Type.String({ format: "date-time" }),
|
|
46382
|
-
kind: Type.Literal("commit"),
|
|
46383
|
-
slug: Type.Optional(Type.Union([Type.String(), Type.Null()])),
|
|
46384
|
-
ref: Type.String({ pattern: "^[0-9a-f]{7,40}$" }),
|
|
46385
|
-
message: Type.String()
|
|
46386
|
-
},
|
|
46387
|
-
{ additionalProperties: false }
|
|
46388
|
-
),
|
|
46389
|
-
Type.Object(
|
|
46390
|
-
{
|
|
46391
|
-
ts: Type.String({ format: "date-time" }),
|
|
46392
|
-
kind: Type.Literal("review"),
|
|
46393
|
-
slug: Type.String(),
|
|
46394
|
-
verdict: Type.Union([
|
|
46395
|
-
Type.Literal("PASS"),
|
|
46396
|
-
Type.Literal("NEEDS_IMPROVEMENT"),
|
|
46397
|
-
Type.Literal("MAJOR_ISSUES")
|
|
46398
|
-
]),
|
|
46399
|
-
iter: Type.Integer({ minimum: 1, maximum: 3 })
|
|
46400
|
-
},
|
|
46401
|
-
{ additionalProperties: false }
|
|
46402
|
-
),
|
|
46403
|
-
Type.Object(
|
|
46404
|
-
{
|
|
46405
|
-
ts: Type.String({ format: "date-time" }),
|
|
46406
|
-
kind: Type.Literal("phase"),
|
|
46407
|
-
slug: Type.String(),
|
|
46408
|
-
from: Type.Union([
|
|
46409
|
-
Type.Literal("plan"),
|
|
46410
|
-
Type.Literal("implement"),
|
|
46411
|
-
Type.Literal("review"),
|
|
46412
|
-
Type.Literal("done")
|
|
46413
|
-
]),
|
|
46414
|
-
to: Type.Union([
|
|
46415
|
-
Type.Literal("plan"),
|
|
46416
|
-
Type.Literal("implement"),
|
|
46417
|
-
Type.Literal("review"),
|
|
46418
|
-
Type.Literal("done")
|
|
46419
|
-
])
|
|
46420
|
-
},
|
|
46421
|
-
{ additionalProperties: false }
|
|
46422
|
-
),
|
|
46423
|
-
Type.Object(
|
|
46424
|
-
{
|
|
46425
|
-
ts: Type.String({ format: "date-time" }),
|
|
46426
|
-
kind: Type.Literal("hook"),
|
|
46427
|
-
hook_id: Type.String({
|
|
46428
|
-
description: "Hook rule short id emitted by hooks/_lib/hook-stats.sh:mumei_hook_stats_record."
|
|
46429
|
-
}),
|
|
46430
|
-
decision: Type.Union([
|
|
46431
|
-
Type.Literal("allow"),
|
|
46432
|
-
Type.Literal("deny"),
|
|
46433
|
-
Type.Literal("warn"),
|
|
46434
|
-
Type.Literal("block"),
|
|
46435
|
-
Type.Literal("noop"),
|
|
46436
|
-
Type.Literal("pass")
|
|
46437
|
-
])
|
|
46438
|
-
},
|
|
46439
|
-
{ additionalProperties: false }
|
|
46440
|
-
)
|
|
46441
|
-
]);
|
|
46442
|
-
var FeatureUpdateEventSchema = Type.Object(
|
|
46443
|
-
{
|
|
46444
|
-
type: Type.Literal("feature.update"),
|
|
46445
|
-
slug: Type.Optional(
|
|
46446
|
-
Type.String({
|
|
46447
|
-
pattern: "^(REQ-[0-9]+(-[a-z0-9-]+)?|[a-z0-9][a-z0-9-]*)$",
|
|
46448
|
-
description: "Feature key whose state.json or review changed (REQ-N-slug for spec, bare slug for plan); useEventStream invalidates ['features'], ['feature', slug, 'detail'], AND ['meta','stats']. Omit when the change is project-wide (e.g. .hook-stats.jsonl) and only `affects` is meaningful."
|
|
46449
|
-
})
|
|
46450
|
-
),
|
|
46451
|
-
affects: Type.Optional(
|
|
46452
|
-
Type.Array(
|
|
46453
|
-
Type.Union([Type.Literal("hooks"), Type.Literal("reviews"), Type.Literal("tokens")]),
|
|
46454
|
-
{
|
|
46455
|
-
uniqueItems: true,
|
|
46456
|
-
description: "Trend kinds whose underlying data changed. useEventStream invalidates `['trend', kind, ...]` for each entry, in addition to the slug-scoped invalidations above. Omit when no trend is affected."
|
|
46457
|
-
}
|
|
46458
|
-
)
|
|
46459
|
-
)
|
|
46460
|
-
},
|
|
46461
|
-
{ additionalProperties: false }
|
|
46462
|
-
);
|
|
46463
|
-
var CostUpdatedEventSchema = Type.Object(
|
|
46464
|
-
{
|
|
46465
|
-
type: Type.Literal("cost.updated"),
|
|
46466
|
-
slug: Type.Optional(
|
|
46467
|
-
Type.Union([Type.String(), Type.Null()], {
|
|
46468
|
-
description: "Feature slug owning the cost-log, or null when the change was project-wide."
|
|
46469
|
-
})
|
|
46470
|
-
)
|
|
46471
|
-
},
|
|
46472
|
-
{ additionalProperties: false }
|
|
46473
|
-
);
|
|
46474
|
-
var ActivityChangedEventSchema = Type.Object(
|
|
46475
|
-
{
|
|
46476
|
-
type: Type.Literal("activity.changed")
|
|
46477
|
-
},
|
|
46478
|
-
{ additionalProperties: false }
|
|
46479
|
-
);
|
|
46480
|
-
var ActivityAddedEventSchema = Type.Object(
|
|
46481
|
-
{
|
|
46482
|
-
type: Type.Literal("activity.added"),
|
|
46483
|
-
event: InlineActivityEventSchema
|
|
46484
|
-
},
|
|
46485
|
-
{ additionalProperties: false }
|
|
46486
|
-
);
|
|
46487
|
-
var SseEventSchema = Type.Union(
|
|
46488
|
-
[
|
|
46489
|
-
FeatureUpdateEventSchema,
|
|
46490
|
-
CostUpdatedEventSchema,
|
|
46491
|
-
ActivityChangedEventSchema,
|
|
46492
|
-
ActivityAddedEventSchema
|
|
46493
|
-
],
|
|
46494
|
-
{
|
|
46495
|
-
$id: "https://mumei.dev/schemas/sse-event.schema.json",
|
|
46496
|
-
title: "mumei dashboard SSE event",
|
|
46497
|
-
description: "Server-Sent Events emitted by dashboard/server/sse.ts on /api/events. All events are debounced 200ms per (event, slug). state.json updates emit BOTH feature.update AND activity.changed; review/hook activity emits only activity.changed. The client treats activity.changed and activity.added as cache-invalidation triggers and refetches /api/activity. Producer: backend chokidar -> EventEmitter pipeline. Consumer: dashboard/src/hooks/useEventStream.ts."
|
|
46498
|
-
}
|
|
46499
|
-
);
|
|
46500
|
-
|
|
46501
46607
|
// src/schemas/state.ts
|
|
46502
46608
|
var StateSchema = Type.Object(
|
|
46503
46609
|
{
|
|
@@ -46598,225 +46704,10 @@ var StateSchema = Type.Object(
|
|
|
46598
46704
|
}
|
|
46599
46705
|
);
|
|
46600
46706
|
|
|
46601
|
-
//
|
|
46707
|
+
// server/lib/validators.ts
|
|
46602
46708
|
var validateState = TypeCompiler.Compile(StateSchema);
|
|
46603
46709
|
var validateCostLogEntry = TypeCompiler.Compile(CostLogEntrySchema);
|
|
46604
46710
|
var validateReview = TypeCompiler.Compile(ReviewSchema);
|
|
46605
|
-
TypeCompiler.Compile(SseEventSchema);
|
|
46606
|
-
TypeCompiler.Compile(ActivityEventSchema);
|
|
46607
|
-
async function* readJsonl(filePath, opts) {
|
|
46608
|
-
try {
|
|
46609
|
-
await access(filePath);
|
|
46610
|
-
} catch {
|
|
46611
|
-
return;
|
|
46612
|
-
}
|
|
46613
|
-
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
46614
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
46615
|
-
let lineNumber = 0;
|
|
46616
|
-
try {
|
|
46617
|
-
for await (const line of rl) {
|
|
46618
|
-
lineNumber += 1;
|
|
46619
|
-
if (!line.trim()) continue;
|
|
46620
|
-
let parsed;
|
|
46621
|
-
try {
|
|
46622
|
-
parsed = JSON.parse(line);
|
|
46623
|
-
} catch {
|
|
46624
|
-
continue;
|
|
46625
|
-
}
|
|
46626
|
-
if (opts?.validate && !opts.validate(parsed)) {
|
|
46627
|
-
process.stderr.write(
|
|
46628
|
-
`[mumei dashboard] JSONL shape violation, skipping: file=${filePath} line=${lineNumber}
|
|
46629
|
-
`
|
|
46630
|
-
);
|
|
46631
|
-
continue;
|
|
46632
|
-
}
|
|
46633
|
-
yield parsed;
|
|
46634
|
-
}
|
|
46635
|
-
} finally {
|
|
46636
|
-
rl.close();
|
|
46637
|
-
stream.destroy();
|
|
46638
|
-
}
|
|
46639
|
-
}
|
|
46640
|
-
function utcDay(iso) {
|
|
46641
|
-
const isoDay = iso.slice(0, 10);
|
|
46642
|
-
if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(isoDay)) return isoDay;
|
|
46643
|
-
return "";
|
|
46644
|
-
}
|
|
46645
|
-
async function aggregateTokensByDay(files, days, now = /* @__PURE__ */ new Date()) {
|
|
46646
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
46647
|
-
const dayKeys = utcDayWindow(now, days);
|
|
46648
|
-
for (const k of dayKeys) buckets.set(k, 0);
|
|
46649
|
-
const earliest = dayKeys[0];
|
|
46650
|
-
if (!earliest) return [];
|
|
46651
|
-
for (const file of files) {
|
|
46652
|
-
for await (const e of readJsonl(file)) {
|
|
46653
|
-
if (e.phase !== "after") continue;
|
|
46654
|
-
const d = utcDay(e.ts);
|
|
46655
|
-
if (!d || d < earliest) continue;
|
|
46656
|
-
if (!buckets.has(d)) continue;
|
|
46657
|
-
const tokens = (e.input_tokens ?? 0) + (e.output_tokens ?? 0);
|
|
46658
|
-
buckets.set(d, (buckets.get(d) ?? 0) + tokens);
|
|
46659
|
-
}
|
|
46660
|
-
}
|
|
46661
|
-
return dayKeys.map((d) => ({ d, v: buckets.get(d) ?? 0 }));
|
|
46662
|
-
}
|
|
46663
|
-
async function aggregateMonthTokens(files, now = /* @__PURE__ */ new Date()) {
|
|
46664
|
-
const monthPrefix = now.toISOString().slice(0, 7);
|
|
46665
|
-
let monthTokens = 0;
|
|
46666
|
-
let cacheRead = 0;
|
|
46667
|
-
let nonCacheInput = 0;
|
|
46668
|
-
for (const file of files) {
|
|
46669
|
-
for await (const e of readJsonl(file)) {
|
|
46670
|
-
if (e.phase !== "after") continue;
|
|
46671
|
-
if (!e.ts.startsWith(monthPrefix)) continue;
|
|
46672
|
-
const inputT = e.input_tokens ?? 0;
|
|
46673
|
-
const outputT = e.output_tokens ?? 0;
|
|
46674
|
-
const readT = e.cache_read_input_tokens ?? 0;
|
|
46675
|
-
monthTokens += inputT + outputT;
|
|
46676
|
-
cacheRead += readT;
|
|
46677
|
-
nonCacheInput += inputT;
|
|
46678
|
-
}
|
|
46679
|
-
}
|
|
46680
|
-
const denom = nonCacheInput + cacheRead;
|
|
46681
|
-
const cacheHitRate = denom > 0 ? cacheRead / denom : 0;
|
|
46682
|
-
return { monthTokens, cacheHitRate };
|
|
46683
|
-
}
|
|
46684
|
-
async function aggregateReviewsByDay(reviewDirs, days, now = /* @__PURE__ */ new Date()) {
|
|
46685
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
46686
|
-
const dayKeys = utcDayWindow(now, days);
|
|
46687
|
-
for (const k of dayKeys) buckets.set(k, { d: k, PASS: 0, NI: 0, MI: 0 });
|
|
46688
|
-
const earliest = dayKeys[0];
|
|
46689
|
-
if (!earliest) return [];
|
|
46690
|
-
for (const dir of reviewDirs) {
|
|
46691
|
-
const entries = await safeReaddir(dir);
|
|
46692
|
-
for (const ent of entries) {
|
|
46693
|
-
if (!ent.isFile()) continue;
|
|
46694
|
-
if (!ent.name.endsWith(".json")) continue;
|
|
46695
|
-
if (ent.name.endsWith("-detectors.json")) continue;
|
|
46696
|
-
const fp = sp2__default.join(dir, ent.name);
|
|
46697
|
-
const body = await safeReadFile(fp);
|
|
46698
|
-
if (!body) continue;
|
|
46699
|
-
let parsed;
|
|
46700
|
-
try {
|
|
46701
|
-
parsed = JSON.parse(body);
|
|
46702
|
-
} catch {
|
|
46703
|
-
continue;
|
|
46704
|
-
}
|
|
46705
|
-
const mt = await safeMtime(fp);
|
|
46706
|
-
const iso = mt ?? parsed.ts ?? "";
|
|
46707
|
-
const d = utcDay(iso);
|
|
46708
|
-
if (!d || d < earliest) continue;
|
|
46709
|
-
const bucket = buckets.get(d);
|
|
46710
|
-
if (!bucket) continue;
|
|
46711
|
-
switch (parsed.verdict) {
|
|
46712
|
-
case "PASS":
|
|
46713
|
-
bucket.PASS += 1;
|
|
46714
|
-
break;
|
|
46715
|
-
case "NEEDS_IMPROVEMENT":
|
|
46716
|
-
bucket.NI += 1;
|
|
46717
|
-
break;
|
|
46718
|
-
case "MAJOR_ISSUES":
|
|
46719
|
-
bucket.MI += 1;
|
|
46720
|
-
break;
|
|
46721
|
-
}
|
|
46722
|
-
}
|
|
46723
|
-
}
|
|
46724
|
-
return dayKeys.map((d) => buckets.get(d) ?? { d, PASS: 0, NI: 0, MI: 0 });
|
|
46725
|
-
}
|
|
46726
|
-
async function aggregateHooksTopN(filePath, topN, windowH, now = /* @__PURE__ */ new Date()) {
|
|
46727
|
-
const cutoff = new Date(now.getTime() - windowH * 36e5).toISOString();
|
|
46728
|
-
const counts = /* @__PURE__ */ new Map();
|
|
46729
|
-
for await (const e of readJsonl(filePath)) {
|
|
46730
|
-
if (!e.ts || e.ts < cutoff) continue;
|
|
46731
|
-
if (!e.hook_id) continue;
|
|
46732
|
-
const slot = counts.get(e.hook_id) ?? { count: 0, decisions: /* @__PURE__ */ new Map() };
|
|
46733
|
-
slot.count += 1;
|
|
46734
|
-
const dec = e.decision || "noop";
|
|
46735
|
-
slot.decisions.set(dec, (slot.decisions.get(dec) ?? 0) + 1);
|
|
46736
|
-
counts.set(e.hook_id, slot);
|
|
46737
|
-
}
|
|
46738
|
-
const rows = [];
|
|
46739
|
-
for (const [hook_id, slot] of counts) {
|
|
46740
|
-
let topDecision = "noop";
|
|
46741
|
-
let topCount = -1;
|
|
46742
|
-
for (const [d, c] of slot.decisions) {
|
|
46743
|
-
if (c > topCount) {
|
|
46744
|
-
topCount = c;
|
|
46745
|
-
topDecision = d;
|
|
46746
|
-
}
|
|
46747
|
-
}
|
|
46748
|
-
rows.push({ hook_id, count: slot.count, decision: topDecision });
|
|
46749
|
-
}
|
|
46750
|
-
rows.sort((a, b) => b.count - a.count);
|
|
46751
|
-
return rows.slice(0, topN);
|
|
46752
|
-
}
|
|
46753
|
-
async function eventCount24h(args) {
|
|
46754
|
-
const now = args.now ?? /* @__PURE__ */ new Date();
|
|
46755
|
-
const cutoff = new Date(now.getTime() - 24 * 36e5).toISOString();
|
|
46756
|
-
let count = 0;
|
|
46757
|
-
for (const file of args.costLogFiles) {
|
|
46758
|
-
for await (const e of readJsonl(file)) {
|
|
46759
|
-
if (e.phase === "after" && e.ts >= cutoff) count += 1;
|
|
46760
|
-
}
|
|
46761
|
-
}
|
|
46762
|
-
for await (const e of readJsonl(args.hookStatsFile)) {
|
|
46763
|
-
if (e.ts && e.ts >= cutoff) count += 1;
|
|
46764
|
-
}
|
|
46765
|
-
for (const dir of args.reviewDirs) {
|
|
46766
|
-
const entries = await safeReaddir(dir);
|
|
46767
|
-
for (const ent of entries) {
|
|
46768
|
-
if (!ent.isFile()) continue;
|
|
46769
|
-
if (!ent.name.endsWith(".json")) continue;
|
|
46770
|
-
const fp = sp2__default.join(dir, ent.name);
|
|
46771
|
-
const mt = await safeMtime(fp);
|
|
46772
|
-
if (mt && mt >= cutoff) count += 1;
|
|
46773
|
-
}
|
|
46774
|
-
}
|
|
46775
|
-
for (const ts of args.gitTimestamps) {
|
|
46776
|
-
if (ts >= cutoff) count += 1;
|
|
46777
|
-
}
|
|
46778
|
-
return count;
|
|
46779
|
-
}
|
|
46780
|
-
async function hooksPerSec(filePath, now = /* @__PURE__ */ new Date()) {
|
|
46781
|
-
const cutoff = new Date(now.getTime() - 24 * 36e5).toISOString();
|
|
46782
|
-
let count = 0;
|
|
46783
|
-
for await (const e of readJsonl(filePath)) {
|
|
46784
|
-
if (e.ts && e.ts >= cutoff) count += 1;
|
|
46785
|
-
}
|
|
46786
|
-
return count / (24 * 3600);
|
|
46787
|
-
}
|
|
46788
|
-
function utcDayWindow(now, days) {
|
|
46789
|
-
if (days <= 0) return [];
|
|
46790
|
-
const out = [];
|
|
46791
|
-
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
46792
|
-
for (let i = days - 1; i >= 0; i--) {
|
|
46793
|
-
const d = new Date(today.getTime() - i * 864e5);
|
|
46794
|
-
out.push(d.toISOString().slice(0, 10));
|
|
46795
|
-
}
|
|
46796
|
-
return out;
|
|
46797
|
-
}
|
|
46798
|
-
async function safeReaddir(dir) {
|
|
46799
|
-
try {
|
|
46800
|
-
return await readdir(dir, { withFileTypes: true });
|
|
46801
|
-
} catch {
|
|
46802
|
-
return [];
|
|
46803
|
-
}
|
|
46804
|
-
}
|
|
46805
|
-
async function safeReadFile(p) {
|
|
46806
|
-
try {
|
|
46807
|
-
return await readFile(p, "utf8");
|
|
46808
|
-
} catch {
|
|
46809
|
-
return null;
|
|
46810
|
-
}
|
|
46811
|
-
}
|
|
46812
|
-
async function safeMtime(p) {
|
|
46813
|
-
try {
|
|
46814
|
-
const s = await stat(p);
|
|
46815
|
-
return s.mtime.toISOString();
|
|
46816
|
-
} catch {
|
|
46817
|
-
return null;
|
|
46818
|
-
}
|
|
46819
|
-
}
|
|
46820
46711
|
|
|
46821
46712
|
// server/activity.ts
|
|
46822
46713
|
var exec = promisify(execFile);
|
|
@@ -47188,12 +47079,13 @@ async function buildWaveplan(args) {
|
|
|
47188
47079
|
memo.set(memoKey, { ts: Date.now(), payload: [] });
|
|
47189
47080
|
return [];
|
|
47190
47081
|
}
|
|
47082
|
+
const isArchive = tf.includes(`${sp2__default.sep}archive${sp2__default.sep}`);
|
|
47191
47083
|
let waveplan;
|
|
47192
47084
|
try {
|
|
47193
|
-
waveplan = await parseTasksMdViaBash({ pluginRoot, tasksFile: tf, featureKey, projectRoot });
|
|
47085
|
+
waveplan = isArchive ? await parseTasksMdInline(tf) : await parseTasksMdViaBash({ pluginRoot, tasksFile: tf, featureKey, projectRoot });
|
|
47194
47086
|
} catch (err) {
|
|
47195
47087
|
const message = err instanceof Error ? err.message : String(err);
|
|
47196
|
-
logAtLevel("warn", `[tasks-bridge]
|
|
47088
|
+
logAtLevel("warn", `[tasks-bridge] tasks parse failed for ${featureKey}: ${message}
|
|
47197
47089
|
`);
|
|
47198
47090
|
waveplan = [];
|
|
47199
47091
|
}
|
|
@@ -47267,6 +47159,63 @@ function extractWaveHeaders(body) {
|
|
|
47267
47159
|
if (current) waves.push(current);
|
|
47268
47160
|
return waves;
|
|
47269
47161
|
}
|
|
47162
|
+
async function parseTasksMdInline(tasksFile) {
|
|
47163
|
+
const fs = await import('fs/promises');
|
|
47164
|
+
const body = await fs.readFile(tasksFile, "utf8");
|
|
47165
|
+
const waves = extractWaveHeaders(body);
|
|
47166
|
+
const tasks = extractTasksWithMeta(body);
|
|
47167
|
+
return waves.map(({ wave, goal, verify }) => ({
|
|
47168
|
+
wave,
|
|
47169
|
+
goal,
|
|
47170
|
+
verify,
|
|
47171
|
+
tasks: tasks.filter((t) => t.id.startsWith(`${wave}.`))
|
|
47172
|
+
}));
|
|
47173
|
+
}
|
|
47174
|
+
function extractTasksWithMeta(body) {
|
|
47175
|
+
const out = [];
|
|
47176
|
+
const taskRe = /^- \[([x ])\] (\d+(?:\.\d+)+)\s+(.*)$/;
|
|
47177
|
+
const filesRe = /^\s*_Files:_\s*(.+?)\s*$/;
|
|
47178
|
+
const dependsRe = /^\s*_Depends:_\s*(.+?)\s*$/;
|
|
47179
|
+
const reqsRe = /^\s*_Requirements:_\s*(.+?)\s*$/;
|
|
47180
|
+
let cur = null;
|
|
47181
|
+
for (const raw of body.split("\n")) {
|
|
47182
|
+
const taskMatch = taskRe.exec(raw);
|
|
47183
|
+
if (taskMatch) {
|
|
47184
|
+
if (cur) out.push(cur);
|
|
47185
|
+
cur = {
|
|
47186
|
+
id: taskMatch[2] ?? "",
|
|
47187
|
+
done: taskMatch[1] === "x",
|
|
47188
|
+
description: taskMatch[3] ?? "",
|
|
47189
|
+
files: [],
|
|
47190
|
+
depends: [],
|
|
47191
|
+
reqs: []
|
|
47192
|
+
};
|
|
47193
|
+
continue;
|
|
47194
|
+
}
|
|
47195
|
+
if (!cur) continue;
|
|
47196
|
+
if (/^##/.test(raw)) {
|
|
47197
|
+
out.push(cur);
|
|
47198
|
+
cur = null;
|
|
47199
|
+
continue;
|
|
47200
|
+
}
|
|
47201
|
+
const filesMatch = filesRe.exec(raw);
|
|
47202
|
+
if (filesMatch) {
|
|
47203
|
+
cur.files = splitCsv(filesMatch[1] ?? "");
|
|
47204
|
+
continue;
|
|
47205
|
+
}
|
|
47206
|
+
const dependsMatch = dependsRe.exec(raw);
|
|
47207
|
+
if (dependsMatch) {
|
|
47208
|
+
cur.depends = splitCsv(dependsMatch[1] ?? "").filter((d) => d !== "-");
|
|
47209
|
+
continue;
|
|
47210
|
+
}
|
|
47211
|
+
const reqsMatch = reqsRe.exec(raw);
|
|
47212
|
+
if (reqsMatch) {
|
|
47213
|
+
cur.reqs = splitCsv(reqsMatch[1] ?? "");
|
|
47214
|
+
}
|
|
47215
|
+
}
|
|
47216
|
+
if (cur) out.push(cur);
|
|
47217
|
+
return out;
|
|
47218
|
+
}
|
|
47270
47219
|
function extractTaskDescriptions(body) {
|
|
47271
47220
|
const map3 = /* @__PURE__ */ new Map();
|
|
47272
47221
|
const lineRe = /^- \[[x ]\] (\d+(?:\.\d+)*)\s+(.*)$/;
|
|
@@ -47296,6 +47245,9 @@ async function buildFeatureDetail(args) {
|
|
|
47296
47245
|
const planVehicle = dir.subroot === "plans";
|
|
47297
47246
|
const slug = featureKey;
|
|
47298
47247
|
const acs = planVehicle ? [] : await parseAcs(sp2__default.join(dir.absDir, "requirements.md"));
|
|
47248
|
+
const stateForWave = planVehicle ? null : await readStateJson(dir.absDir);
|
|
47249
|
+
const currentWave = stateForWave?.current_wave ?? null;
|
|
47250
|
+
const phase = stateForWave?.phase ?? null;
|
|
47299
47251
|
const waveplan = await buildWaveplan({ projectRoot, featureKey, pluginRoot });
|
|
47300
47252
|
const reviews = await loadReviews(sp2__default.join(dir.absDir, "reviews"));
|
|
47301
47253
|
const costPerIter = await loadCostPerIter({
|
|
@@ -47316,6 +47268,8 @@ async function buildFeatureDetail(args) {
|
|
|
47316
47268
|
archived,
|
|
47317
47269
|
timeline,
|
|
47318
47270
|
acs,
|
|
47271
|
+
phase,
|
|
47272
|
+
currentWave,
|
|
47319
47273
|
waveplan: waveplan.map((w) => ({
|
|
47320
47274
|
wave: w.wave,
|
|
47321
47275
|
goal: w.goal,
|
|
@@ -47664,24 +47618,51 @@ async function buildTimeline(args) {
|
|
|
47664
47618
|
return dedupTimeline(events);
|
|
47665
47619
|
}
|
|
47666
47620
|
var exec4 = promisify(execFile);
|
|
47621
|
+
var StateValidationError = class extends Error {
|
|
47622
|
+
file;
|
|
47623
|
+
fieldErrors;
|
|
47624
|
+
stage;
|
|
47625
|
+
constructor(args) {
|
|
47626
|
+
super(args.message);
|
|
47627
|
+
this.name = "StateValidationError";
|
|
47628
|
+
this.file = args.file;
|
|
47629
|
+
this.fieldErrors = args.fieldErrors;
|
|
47630
|
+
this.stage = args.stage;
|
|
47631
|
+
}
|
|
47632
|
+
};
|
|
47667
47633
|
function parseStateOrThrow(body, file) {
|
|
47668
47634
|
let parsed;
|
|
47669
47635
|
try {
|
|
47670
47636
|
parsed = JSON.parse(body);
|
|
47671
47637
|
} catch (e) {
|
|
47638
|
+
const msg = e.message;
|
|
47672
47639
|
process.stderr.write(
|
|
47673
|
-
`[mumei dashboard] state.json JSON.parse failed: file=${file} err=${
|
|
47640
|
+
`[mumei dashboard] state.json JSON.parse failed: file=${file} err=${msg}
|
|
47674
47641
|
`
|
|
47675
47642
|
);
|
|
47676
|
-
throw new
|
|
47643
|
+
throw new StateValidationError({
|
|
47644
|
+
file,
|
|
47645
|
+
stage: "json",
|
|
47646
|
+
fieldErrors: [{ path: "/", message: msg }],
|
|
47647
|
+
message: `state.json JSON.parse failed at ${file}`
|
|
47648
|
+
});
|
|
47677
47649
|
}
|
|
47678
47650
|
if (!validateState.Check(parsed)) {
|
|
47679
|
-
const
|
|
47651
|
+
const fieldErrors = [...validateState.Errors(parsed)].map((e) => ({
|
|
47652
|
+
path: e.path,
|
|
47653
|
+
message: e.message
|
|
47654
|
+
}));
|
|
47655
|
+
const joined = fieldErrors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
47680
47656
|
process.stderr.write(
|
|
47681
|
-
`[mumei dashboard] state.json validation failed: file=${file} errors=${
|
|
47657
|
+
`[mumei dashboard] state.json validation failed: file=${file} errors=${joined}
|
|
47682
47658
|
`
|
|
47683
47659
|
);
|
|
47684
|
-
throw new
|
|
47660
|
+
throw new StateValidationError({
|
|
47661
|
+
file,
|
|
47662
|
+
stage: "shape",
|
|
47663
|
+
fieldErrors,
|
|
47664
|
+
message: `state.json validation failed at ${file}: ${joined}`
|
|
47665
|
+
});
|
|
47685
47666
|
}
|
|
47686
47667
|
return parsed;
|
|
47687
47668
|
}
|
|
@@ -50203,7 +50184,12 @@ var SlugParam = Type.Object({
|
|
|
50203
50184
|
});
|
|
50204
50185
|
var DocParam = Type.Object({
|
|
50205
50186
|
slug: Type.String({ pattern: "^[A-Za-z0-9_-]+$", minLength: 1, maxLength: 100 }),
|
|
50206
|
-
doc: Type.Union([
|
|
50187
|
+
doc: Type.Union([
|
|
50188
|
+
Type.Literal("requirements"),
|
|
50189
|
+
Type.Literal("design"),
|
|
50190
|
+
Type.Literal("tasks"),
|
|
50191
|
+
Type.Literal("scratch")
|
|
50192
|
+
])
|
|
50207
50193
|
});
|
|
50208
50194
|
var FeatureQuery = Type.Object({
|
|
50209
50195
|
feature: Type.String({ pattern: "^[A-Za-z0-9_-]+$", minLength: 1, maxLength: 100 })
|
|
@@ -50302,18 +50288,76 @@ app.get("/api/hook-stats", async () => {
|
|
|
50302
50288
|
]);
|
|
50303
50289
|
return JSON.parse(stdout);
|
|
50304
50290
|
});
|
|
50291
|
+
var DOC_SLUG_RE = /^[A-Za-z0-9_-]{1,100}$/;
|
|
50292
|
+
async function readMarkdownIfInside(file, root) {
|
|
50293
|
+
const resolved = sp2__default.resolve(file);
|
|
50294
|
+
if (!resolved.startsWith(root + sp2__default.sep)) return null;
|
|
50295
|
+
try {
|
|
50296
|
+
return await readFile(resolved, "utf8");
|
|
50297
|
+
} catch {
|
|
50298
|
+
return null;
|
|
50299
|
+
}
|
|
50300
|
+
}
|
|
50305
50301
|
app.get("/api/feature/:slug/:doc", { schema: { params: DocParam } }, async (req, reply) => {
|
|
50306
50302
|
const { slug, doc } = req.params;
|
|
50303
|
+
if (!DOC_SLUG_RE.test(slug)) {
|
|
50304
|
+
reply.code(400);
|
|
50305
|
+
return { error: "invalid slug" };
|
|
50306
|
+
}
|
|
50307
|
+
if (doc === "scratch") {
|
|
50308
|
+
const bare = slug.replace(/^REQ-\d+-/, "");
|
|
50309
|
+
const candidates2 = [
|
|
50310
|
+
sp2__default.join(MUMEI_DIR, "scratch", `${bare}.md`),
|
|
50311
|
+
sp2__default.join(MUMEI_DIR, "scratch", `${slug}.md`)
|
|
50312
|
+
];
|
|
50313
|
+
for (const p of candidates2) {
|
|
50314
|
+
const body = await readMarkdownIfInside(p, MUMEI_DIR);
|
|
50315
|
+
if (body !== null) {
|
|
50316
|
+
reply.type("text/markdown");
|
|
50317
|
+
return body;
|
|
50318
|
+
}
|
|
50319
|
+
}
|
|
50320
|
+
reply.code(404);
|
|
50321
|
+
return { error: "not found" };
|
|
50322
|
+
}
|
|
50307
50323
|
const candidates = [
|
|
50308
50324
|
sp2__default.join(MUMEI_DIR, "specs", slug, `${doc}.md`),
|
|
50309
50325
|
sp2__default.join(MUMEI_DIR, "plans", slug, `${doc}.md`)
|
|
50310
50326
|
];
|
|
50327
|
+
try {
|
|
50328
|
+
const specsRoot = sp2__default.join(MUMEI_DIR, "specs");
|
|
50329
|
+
for (const ent of await readdir(specsRoot, { withFileTypes: true })) {
|
|
50330
|
+
if (ent.isDirectory() && ent.name.endsWith(`-${slug}`) && DOC_SLUG_RE.test(ent.name)) {
|
|
50331
|
+
candidates.push(sp2__default.join(specsRoot, ent.name, `${doc}.md`));
|
|
50332
|
+
}
|
|
50333
|
+
}
|
|
50334
|
+
} catch {
|
|
50335
|
+
}
|
|
50336
|
+
try {
|
|
50337
|
+
const archiveRoot = sp2__default.join(MUMEI_DIR, "archive");
|
|
50338
|
+
const months = (await readdir(archiveRoot, { withFileTypes: true })).sort(
|
|
50339
|
+
(a, b) => b.name.localeCompare(a.name)
|
|
50340
|
+
);
|
|
50341
|
+
for (const m of months) {
|
|
50342
|
+
if (!m.isDirectory()) continue;
|
|
50343
|
+
const monthDir = sp2__default.join(archiveRoot, m.name);
|
|
50344
|
+
candidates.push(sp2__default.join(monthDir, slug, `${doc}.md`));
|
|
50345
|
+
try {
|
|
50346
|
+
for (const sub of await readdir(monthDir, { withFileTypes: true })) {
|
|
50347
|
+
if (sub.isDirectory() && sub.name.endsWith(`-${slug}`) && DOC_SLUG_RE.test(sub.name)) {
|
|
50348
|
+
candidates.push(sp2__default.join(monthDir, sub.name, `${doc}.md`));
|
|
50349
|
+
}
|
|
50350
|
+
}
|
|
50351
|
+
} catch {
|
|
50352
|
+
}
|
|
50353
|
+
}
|
|
50354
|
+
} catch {
|
|
50355
|
+
}
|
|
50311
50356
|
for (const p of candidates) {
|
|
50312
|
-
|
|
50313
|
-
|
|
50357
|
+
const body = await readMarkdownIfInside(p, MUMEI_DIR);
|
|
50358
|
+
if (body !== null) {
|
|
50314
50359
|
reply.type("text/markdown");
|
|
50315
50360
|
return body;
|
|
50316
|
-
} catch {
|
|
50317
50361
|
}
|
|
50318
50362
|
}
|
|
50319
50363
|
reply.code(404);
|
|
@@ -50321,6 +50365,15 @@ app.get("/api/feature/:slug/:doc", { schema: { params: DocParam } }, async (req,
|
|
|
50321
50365
|
});
|
|
50322
50366
|
app.setErrorHandler((err, req, reply) => {
|
|
50323
50367
|
req.log.error({ err }, "request handler threw");
|
|
50368
|
+
if (err instanceof StateValidationError) {
|
|
50369
|
+
reply.code(500).send({
|
|
50370
|
+
error: "state.json shape violation",
|
|
50371
|
+
stage: err.stage,
|
|
50372
|
+
file: err.file,
|
|
50373
|
+
fieldErrors: err.fieldErrors
|
|
50374
|
+
});
|
|
50375
|
+
return;
|
|
50376
|
+
}
|
|
50324
50377
|
const fastifyErr = err;
|
|
50325
50378
|
if (fastifyErr.validation) {
|
|
50326
50379
|
reply.code(400).send({ error: "validation_failed", details: fastifyErr.validation });
|