mumei-dashboard 0.4.2 → 0.4.4
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-B_X-hYC0.css +2 -0
- package/dist/assets/index-CNgVvsNr.js +119 -0
- package/dist/index.html +2 -2
- package/dist/server/index.js +484 -370
- 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';
|
|
@@ -43544,7 +43544,8 @@ var HookEventSchema = Type.Object(
|
|
|
43544
43544
|
Type.Literal("warn"),
|
|
43545
43545
|
Type.Literal("block"),
|
|
43546
43546
|
Type.Literal("noop"),
|
|
43547
|
-
Type.Literal("pass")
|
|
43547
|
+
Type.Literal("pass"),
|
|
43548
|
+
Type.Literal("error")
|
|
43548
43549
|
])
|
|
43549
43550
|
},
|
|
43550
43551
|
{ additionalProperties: false }
|
|
@@ -43717,6 +43718,21 @@ var FeatureDetailSchema = Type.Object(
|
|
|
43717
43718
|
),
|
|
43718
43719
|
timeline: Type.Array(TimelineEntrySchema),
|
|
43719
43720
|
acs: Type.Array(AcSchema, { description: "Empty array when planVehicle=true." }),
|
|
43721
|
+
phase: Type.Union(
|
|
43722
|
+
[
|
|
43723
|
+
Type.Literal("plan"),
|
|
43724
|
+
Type.Literal("implement"),
|
|
43725
|
+
Type.Literal("review"),
|
|
43726
|
+
Type.Literal("done"),
|
|
43727
|
+
Type.Null()
|
|
43728
|
+
],
|
|
43729
|
+
{
|
|
43730
|
+
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)."
|
|
43731
|
+
}
|
|
43732
|
+
),
|
|
43733
|
+
currentWave: Type.Union([Type.Integer({ minimum: 0 }), Type.Null()], {
|
|
43734
|
+
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."
|
|
43735
|
+
}),
|
|
43720
43736
|
waveplan: Type.Array(WaveplanEntrySchema),
|
|
43721
43737
|
reviews: Type.Array(ReviewSummarySchema),
|
|
43722
43738
|
costPerIter: Type.Array(CostPerIterSchema)
|
|
@@ -43770,7 +43786,9 @@ var FeatureSummarySchema = Type.Object(
|
|
|
43770
43786
|
lastVerdict: Type.Union([VerdictSchema, Type.Null()], {
|
|
43771
43787
|
description: "Verdict from the most recent review JSON (Phase 5 / /mumei:review). Null when no review has run yet."
|
|
43772
43788
|
}),
|
|
43773
|
-
lastIter: Type.Union([Type.Integer({ minimum: 1
|
|
43789
|
+
lastIter: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
|
43790
|
+
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."
|
|
43791
|
+
}),
|
|
43774
43792
|
tokens: Type.Integer({
|
|
43775
43793
|
minimum: 0,
|
|
43776
43794
|
description: "Sum of input_tokens + output_tokens from cost-log.jsonl entries (phase=after) for this feature."
|
|
@@ -43810,6 +43828,37 @@ var FeatureSummarySchema = Type.Object(
|
|
|
43810
43828
|
}
|
|
43811
43829
|
);
|
|
43812
43830
|
var FeatureSummaryListSchema = Type.Array(FeatureSummarySchema);
|
|
43831
|
+
var FeatureWarningsSchema = Type.Object(
|
|
43832
|
+
{
|
|
43833
|
+
skippedArchiveStates: Type.Integer({
|
|
43834
|
+
minimum: 0,
|
|
43835
|
+
description: "Count of archive state.json files dropped by skip+warn (shape drift or JSON parse failure). Excludes active spec/plan state.json which fail-fast via setErrorHandler."
|
|
43836
|
+
}),
|
|
43837
|
+
skippedReviews: Type.Integer({
|
|
43838
|
+
minimum: 0,
|
|
43839
|
+
description: "Count of review.json files dropped by skip+warn during latestReview() shape validation."
|
|
43840
|
+
}),
|
|
43841
|
+
skippedCostLogLines: Type.Integer({
|
|
43842
|
+
minimum: 0,
|
|
43843
|
+
description: "Count of cost-log.jsonl lines dropped by readJsonl validate (shape drift). Torn-write lines that fail JSON.parse are NOT counted (those are silent by design for append-only logs)."
|
|
43844
|
+
})
|
|
43845
|
+
},
|
|
43846
|
+
{
|
|
43847
|
+
additionalProperties: false,
|
|
43848
|
+
description: "Non-fatal skip+warn counts surfaced from /api/features. Zero counts mean the entire .mumei/ tree validated cleanly. Non-zero counts indicate one or more older / corrupt files that were silently skipped during aggregation \u2014 the SPA can surface a banner so the user can investigate. The actual file paths are written to stderr."
|
|
43849
|
+
}
|
|
43850
|
+
);
|
|
43851
|
+
var FeaturesResponseSchema = Type.Object(
|
|
43852
|
+
{
|
|
43853
|
+
features: FeatureSummaryListSchema,
|
|
43854
|
+
warnings: FeatureWarningsSchema
|
|
43855
|
+
},
|
|
43856
|
+
{
|
|
43857
|
+
title: "mumei features response",
|
|
43858
|
+
description: 'Response shape for GET /api/features. Wraps the feature summary list with per-aggregation skip+warn counts so the SPA can render a "N items skipped" banner without having to parse stderr.',
|
|
43859
|
+
additionalProperties: false
|
|
43860
|
+
}
|
|
43861
|
+
);
|
|
43813
43862
|
|
|
43814
43863
|
// src/schemas/meta.ts
|
|
43815
43864
|
var MetaSchema = Type.Object(
|
|
@@ -43906,10 +43955,11 @@ var HooksTrendSchema = Type.Array(
|
|
|
43906
43955
|
Type.Literal("warn"),
|
|
43907
43956
|
Type.Literal("block"),
|
|
43908
43957
|
Type.Literal("noop"),
|
|
43909
|
-
Type.Literal("pass")
|
|
43958
|
+
Type.Literal("pass"),
|
|
43959
|
+
Type.Literal("error")
|
|
43910
43960
|
],
|
|
43911
43961
|
{
|
|
43912
|
-
description:
|
|
43962
|
+
description: 'Most common decision recorded for the hook_id within the window. "error" surfaces internal hook failures (e.g. cwd anchor unreachable) that previously exited silently.'
|
|
43913
43963
|
}
|
|
43914
43964
|
)
|
|
43915
43965
|
},
|
|
@@ -43921,11 +43971,219 @@ var HooksTrendSchema = Type.Array(
|
|
|
43921
43971
|
description: "GET /api/trends/hooks?topN=10&windowH=24 result. Top-N hook_id rows by firing count within the window."
|
|
43922
43972
|
}
|
|
43923
43973
|
);
|
|
43924
|
-
|
|
43925
|
-
|
|
43926
|
-
|
|
43927
|
-
|
|
43928
|
-
|
|
43974
|
+
async function* readJsonl(filePath, opts) {
|
|
43975
|
+
try {
|
|
43976
|
+
await access(filePath);
|
|
43977
|
+
} catch {
|
|
43978
|
+
return;
|
|
43979
|
+
}
|
|
43980
|
+
const stream = createReadStream(filePath, { encoding: "utf8" });
|
|
43981
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
43982
|
+
let lineNumber = 0;
|
|
43983
|
+
try {
|
|
43984
|
+
for await (const line of rl) {
|
|
43985
|
+
lineNumber += 1;
|
|
43986
|
+
if (!line.trim()) continue;
|
|
43987
|
+
let parsed;
|
|
43988
|
+
try {
|
|
43989
|
+
parsed = JSON.parse(line);
|
|
43990
|
+
} catch {
|
|
43991
|
+
continue;
|
|
43992
|
+
}
|
|
43993
|
+
if (opts?.validate && !opts.validate(parsed)) {
|
|
43994
|
+
process.stderr.write(
|
|
43995
|
+
`[mumei dashboard] JSONL shape violation, skipping: file=${filePath} line=${lineNumber}
|
|
43996
|
+
`
|
|
43997
|
+
);
|
|
43998
|
+
opts.onSkip?.({ file: filePath, line: lineNumber, reason: "shape" });
|
|
43999
|
+
continue;
|
|
44000
|
+
}
|
|
44001
|
+
yield parsed;
|
|
44002
|
+
}
|
|
44003
|
+
} finally {
|
|
44004
|
+
rl.close();
|
|
44005
|
+
stream.destroy();
|
|
44006
|
+
}
|
|
44007
|
+
}
|
|
44008
|
+
function utcDay(iso) {
|
|
44009
|
+
const isoDay = iso.slice(0, 10);
|
|
44010
|
+
if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(isoDay)) return isoDay;
|
|
44011
|
+
return "";
|
|
44012
|
+
}
|
|
44013
|
+
async function aggregateTokensByDay(files, days, now = /* @__PURE__ */ new Date()) {
|
|
44014
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
44015
|
+
const dayKeys = utcDayWindow(now, days);
|
|
44016
|
+
for (const k of dayKeys) buckets.set(k, 0);
|
|
44017
|
+
const earliest = dayKeys[0];
|
|
44018
|
+
if (!earliest) return [];
|
|
44019
|
+
for (const file of files) {
|
|
44020
|
+
for await (const e of readJsonl(file)) {
|
|
44021
|
+
if (e.phase !== "after") continue;
|
|
44022
|
+
const d = utcDay(e.ts);
|
|
44023
|
+
if (!d || d < earliest) continue;
|
|
44024
|
+
if (!buckets.has(d)) continue;
|
|
44025
|
+
const tokens = (e.input_tokens ?? 0) + (e.output_tokens ?? 0);
|
|
44026
|
+
buckets.set(d, (buckets.get(d) ?? 0) + tokens);
|
|
44027
|
+
}
|
|
44028
|
+
}
|
|
44029
|
+
return dayKeys.map((d) => ({ d, v: buckets.get(d) ?? 0 }));
|
|
44030
|
+
}
|
|
44031
|
+
async function aggregateMonthTokens(files, now = /* @__PURE__ */ new Date()) {
|
|
44032
|
+
const monthPrefix = now.toISOString().slice(0, 7);
|
|
44033
|
+
let monthTokens = 0;
|
|
44034
|
+
let cacheRead = 0;
|
|
44035
|
+
let nonCacheInput = 0;
|
|
44036
|
+
for (const file of files) {
|
|
44037
|
+
for await (const e of readJsonl(file)) {
|
|
44038
|
+
if (e.phase !== "after") continue;
|
|
44039
|
+
if (!e.ts.startsWith(monthPrefix)) continue;
|
|
44040
|
+
const inputT = e.input_tokens ?? 0;
|
|
44041
|
+
const outputT = e.output_tokens ?? 0;
|
|
44042
|
+
const readT = e.cache_read_input_tokens ?? 0;
|
|
44043
|
+
monthTokens += inputT + outputT;
|
|
44044
|
+
cacheRead += readT;
|
|
44045
|
+
nonCacheInput += inputT;
|
|
44046
|
+
}
|
|
44047
|
+
}
|
|
44048
|
+
const denom = nonCacheInput + cacheRead;
|
|
44049
|
+
const cacheHitRate = denom > 0 ? cacheRead / denom : 0;
|
|
44050
|
+
return { monthTokens, cacheHitRate };
|
|
44051
|
+
}
|
|
44052
|
+
async function aggregateReviewsByDay(reviewDirs, days, now = /* @__PURE__ */ new Date()) {
|
|
44053
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
44054
|
+
const dayKeys = utcDayWindow(now, days);
|
|
44055
|
+
for (const k of dayKeys) buckets.set(k, { d: k, PASS: 0, NI: 0, MI: 0 });
|
|
44056
|
+
const earliest = dayKeys[0];
|
|
44057
|
+
if (!earliest) return [];
|
|
44058
|
+
for (const dir of reviewDirs) {
|
|
44059
|
+
const entries = await safeReaddir(dir);
|
|
44060
|
+
for (const ent of entries) {
|
|
44061
|
+
if (!ent.isFile()) continue;
|
|
44062
|
+
if (!ent.name.endsWith(".json")) continue;
|
|
44063
|
+
if (ent.name.endsWith("-detectors.json")) continue;
|
|
44064
|
+
const fp = sp2__default.join(dir, ent.name);
|
|
44065
|
+
const body = await safeReadFile(fp);
|
|
44066
|
+
if (!body) continue;
|
|
44067
|
+
let parsed;
|
|
44068
|
+
try {
|
|
44069
|
+
parsed = JSON.parse(body);
|
|
44070
|
+
} catch {
|
|
44071
|
+
continue;
|
|
44072
|
+
}
|
|
44073
|
+
const mt = await safeMtime(fp);
|
|
44074
|
+
const iso = mt ?? parsed.ts ?? "";
|
|
44075
|
+
const d = utcDay(iso);
|
|
44076
|
+
if (!d || d < earliest) continue;
|
|
44077
|
+
const bucket = buckets.get(d);
|
|
44078
|
+
if (!bucket) continue;
|
|
44079
|
+
switch (parsed.verdict) {
|
|
44080
|
+
case "PASS":
|
|
44081
|
+
bucket.PASS += 1;
|
|
44082
|
+
break;
|
|
44083
|
+
case "NEEDS_IMPROVEMENT":
|
|
44084
|
+
bucket.NI += 1;
|
|
44085
|
+
break;
|
|
44086
|
+
case "MAJOR_ISSUES":
|
|
44087
|
+
bucket.MI += 1;
|
|
44088
|
+
break;
|
|
44089
|
+
}
|
|
44090
|
+
}
|
|
44091
|
+
}
|
|
44092
|
+
return dayKeys.map((d) => buckets.get(d) ?? { d, PASS: 0, NI: 0, MI: 0 });
|
|
44093
|
+
}
|
|
44094
|
+
async function aggregateHooksTopN(filePath, topN, windowH, now = /* @__PURE__ */ new Date()) {
|
|
44095
|
+
const cutoff = new Date(now.getTime() - windowH * 36e5).toISOString();
|
|
44096
|
+
const counts = /* @__PURE__ */ new Map();
|
|
44097
|
+
for await (const e of readJsonl(filePath)) {
|
|
44098
|
+
if (!e.ts || e.ts < cutoff) continue;
|
|
44099
|
+
if (!e.hook_id) continue;
|
|
44100
|
+
const slot = counts.get(e.hook_id) ?? { count: 0, decisions: /* @__PURE__ */ new Map() };
|
|
44101
|
+
slot.count += 1;
|
|
44102
|
+
const dec = e.decision || "noop";
|
|
44103
|
+
slot.decisions.set(dec, (slot.decisions.get(dec) ?? 0) + 1);
|
|
44104
|
+
counts.set(e.hook_id, slot);
|
|
44105
|
+
}
|
|
44106
|
+
const rows = [];
|
|
44107
|
+
for (const [hook_id, slot] of counts) {
|
|
44108
|
+
let topDecision = "noop";
|
|
44109
|
+
let topCount = -1;
|
|
44110
|
+
for (const [d, c] of slot.decisions) {
|
|
44111
|
+
if (c > topCount) {
|
|
44112
|
+
topCount = c;
|
|
44113
|
+
topDecision = d;
|
|
44114
|
+
}
|
|
44115
|
+
}
|
|
44116
|
+
rows.push({ hook_id, count: slot.count, decision: topDecision });
|
|
44117
|
+
}
|
|
44118
|
+
rows.sort((a, b) => b.count - a.count);
|
|
44119
|
+
return rows.slice(0, topN);
|
|
44120
|
+
}
|
|
44121
|
+
async function eventCount24h(args) {
|
|
44122
|
+
const now = args.now ?? /* @__PURE__ */ new Date();
|
|
44123
|
+
const cutoff = new Date(now.getTime() - 24 * 36e5).toISOString();
|
|
44124
|
+
let count = 0;
|
|
44125
|
+
for (const file of args.costLogFiles) {
|
|
44126
|
+
for await (const e of readJsonl(file)) {
|
|
44127
|
+
if (e.phase === "after" && e.ts >= cutoff) count += 1;
|
|
44128
|
+
}
|
|
44129
|
+
}
|
|
44130
|
+
for await (const e of readJsonl(args.hookStatsFile)) {
|
|
44131
|
+
if (e.ts && e.ts >= cutoff) count += 1;
|
|
44132
|
+
}
|
|
44133
|
+
for (const dir of args.reviewDirs) {
|
|
44134
|
+
const entries = await safeReaddir(dir);
|
|
44135
|
+
for (const ent of entries) {
|
|
44136
|
+
if (!ent.isFile()) continue;
|
|
44137
|
+
if (!ent.name.endsWith(".json")) continue;
|
|
44138
|
+
const fp = sp2__default.join(dir, ent.name);
|
|
44139
|
+
const mt = await safeMtime(fp);
|
|
44140
|
+
if (mt && mt >= cutoff) count += 1;
|
|
44141
|
+
}
|
|
44142
|
+
}
|
|
44143
|
+
for (const ts of args.gitTimestamps) {
|
|
44144
|
+
if (ts >= cutoff) count += 1;
|
|
44145
|
+
}
|
|
44146
|
+
return count;
|
|
44147
|
+
}
|
|
44148
|
+
async function hooksPerSec(filePath, now = /* @__PURE__ */ new Date()) {
|
|
44149
|
+
const cutoff = new Date(now.getTime() - 24 * 36e5).toISOString();
|
|
44150
|
+
let count = 0;
|
|
44151
|
+
for await (const e of readJsonl(filePath)) {
|
|
44152
|
+
if (e.ts && e.ts >= cutoff) count += 1;
|
|
44153
|
+
}
|
|
44154
|
+
return count / (24 * 3600);
|
|
44155
|
+
}
|
|
44156
|
+
function utcDayWindow(now, days) {
|
|
44157
|
+
if (days <= 0) return [];
|
|
44158
|
+
const out = [];
|
|
44159
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
44160
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
44161
|
+
const d = new Date(today.getTime() - i * 864e5);
|
|
44162
|
+
out.push(d.toISOString().slice(0, 10));
|
|
44163
|
+
}
|
|
44164
|
+
return out;
|
|
44165
|
+
}
|
|
44166
|
+
async function safeReaddir(dir) {
|
|
44167
|
+
try {
|
|
44168
|
+
return await readdir(dir, { withFileTypes: true });
|
|
44169
|
+
} catch {
|
|
44170
|
+
return [];
|
|
44171
|
+
}
|
|
44172
|
+
}
|
|
44173
|
+
async function safeReadFile(p) {
|
|
44174
|
+
try {
|
|
44175
|
+
return await readFile(p, "utf8");
|
|
44176
|
+
} catch {
|
|
44177
|
+
return null;
|
|
44178
|
+
}
|
|
44179
|
+
}
|
|
44180
|
+
async function safeMtime(p) {
|
|
44181
|
+
try {
|
|
44182
|
+
const s = await stat(p);
|
|
44183
|
+
return s.mtime.toISOString();
|
|
44184
|
+
} catch {
|
|
44185
|
+
return null;
|
|
44186
|
+
}
|
|
43929
44187
|
}
|
|
43930
44188
|
|
|
43931
44189
|
// node_modules/@sinclair/typebox/build/esm/errors/function.mjs
|
|
@@ -46226,6 +46484,12 @@ var TypeCompiler;
|
|
|
46226
46484
|
TypeCompiler2.Compile = Compile;
|
|
46227
46485
|
})(TypeCompiler || (TypeCompiler = {}));
|
|
46228
46486
|
|
|
46487
|
+
// src/schemas/_formats.ts
|
|
46488
|
+
var ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
46489
|
+
if (!format_exports.Has("date-time")) {
|
|
46490
|
+
format_exports.Set("date-time", (v) => typeof v === "string" && ISO_DATE_TIME.test(v));
|
|
46491
|
+
}
|
|
46492
|
+
|
|
46229
46493
|
// src/schemas/cost-log.ts
|
|
46230
46494
|
var CostLogEntrySchema = Type.Object(
|
|
46231
46495
|
{
|
|
@@ -46374,130 +46638,6 @@ var ReviewSchema = Type.Object(
|
|
|
46374
46638
|
}
|
|
46375
46639
|
);
|
|
46376
46640
|
|
|
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
46641
|
// src/schemas/state.ts
|
|
46502
46642
|
var StateSchema = Type.Object(
|
|
46503
46643
|
{
|
|
@@ -46598,225 +46738,10 @@ var StateSchema = Type.Object(
|
|
|
46598
46738
|
}
|
|
46599
46739
|
);
|
|
46600
46740
|
|
|
46601
|
-
//
|
|
46741
|
+
// server/lib/validators.ts
|
|
46602
46742
|
var validateState = TypeCompiler.Compile(StateSchema);
|
|
46603
46743
|
var validateCostLogEntry = TypeCompiler.Compile(CostLogEntrySchema);
|
|
46604
46744
|
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
46745
|
|
|
46821
46746
|
// server/activity.ts
|
|
46822
46747
|
var exec = promisify(execFile);
|
|
@@ -47188,12 +47113,13 @@ async function buildWaveplan(args) {
|
|
|
47188
47113
|
memo.set(memoKey, { ts: Date.now(), payload: [] });
|
|
47189
47114
|
return [];
|
|
47190
47115
|
}
|
|
47116
|
+
const isArchive = tf.includes(`${sp2__default.sep}archive${sp2__default.sep}`);
|
|
47191
47117
|
let waveplan;
|
|
47192
47118
|
try {
|
|
47193
|
-
waveplan = await parseTasksMdViaBash({ pluginRoot, tasksFile: tf, featureKey, projectRoot });
|
|
47119
|
+
waveplan = isArchive ? await parseTasksMdInline(tf) : await parseTasksMdViaBash({ pluginRoot, tasksFile: tf, featureKey, projectRoot });
|
|
47194
47120
|
} catch (err) {
|
|
47195
47121
|
const message = err instanceof Error ? err.message : String(err);
|
|
47196
|
-
logAtLevel("warn", `[tasks-bridge]
|
|
47122
|
+
logAtLevel("warn", `[tasks-bridge] tasks parse failed for ${featureKey}: ${message}
|
|
47197
47123
|
`);
|
|
47198
47124
|
waveplan = [];
|
|
47199
47125
|
}
|
|
@@ -47267,6 +47193,63 @@ function extractWaveHeaders(body) {
|
|
|
47267
47193
|
if (current) waves.push(current);
|
|
47268
47194
|
return waves;
|
|
47269
47195
|
}
|
|
47196
|
+
async function parseTasksMdInline(tasksFile) {
|
|
47197
|
+
const fs = await import('fs/promises');
|
|
47198
|
+
const body = await fs.readFile(tasksFile, "utf8");
|
|
47199
|
+
const waves = extractWaveHeaders(body);
|
|
47200
|
+
const tasks = extractTasksWithMeta(body);
|
|
47201
|
+
return waves.map(({ wave, goal, verify }) => ({
|
|
47202
|
+
wave,
|
|
47203
|
+
goal,
|
|
47204
|
+
verify,
|
|
47205
|
+
tasks: tasks.filter((t) => t.id.startsWith(`${wave}.`))
|
|
47206
|
+
}));
|
|
47207
|
+
}
|
|
47208
|
+
function extractTasksWithMeta(body) {
|
|
47209
|
+
const out = [];
|
|
47210
|
+
const taskRe = /^- \[([x ])\] (\d+(?:\.\d+)+)\s+(.*)$/;
|
|
47211
|
+
const filesRe = /^\s*_Files:_\s*(.+?)\s*$/;
|
|
47212
|
+
const dependsRe = /^\s*_Depends:_\s*(.+?)\s*$/;
|
|
47213
|
+
const reqsRe = /^\s*_Requirements:_\s*(.+?)\s*$/;
|
|
47214
|
+
let cur = null;
|
|
47215
|
+
for (const raw of body.split("\n")) {
|
|
47216
|
+
const taskMatch = taskRe.exec(raw);
|
|
47217
|
+
if (taskMatch) {
|
|
47218
|
+
if (cur) out.push(cur);
|
|
47219
|
+
cur = {
|
|
47220
|
+
id: taskMatch[2] ?? "",
|
|
47221
|
+
done: taskMatch[1] === "x",
|
|
47222
|
+
description: taskMatch[3] ?? "",
|
|
47223
|
+
files: [],
|
|
47224
|
+
depends: [],
|
|
47225
|
+
reqs: []
|
|
47226
|
+
};
|
|
47227
|
+
continue;
|
|
47228
|
+
}
|
|
47229
|
+
if (!cur) continue;
|
|
47230
|
+
if (/^##/.test(raw)) {
|
|
47231
|
+
out.push(cur);
|
|
47232
|
+
cur = null;
|
|
47233
|
+
continue;
|
|
47234
|
+
}
|
|
47235
|
+
const filesMatch = filesRe.exec(raw);
|
|
47236
|
+
if (filesMatch) {
|
|
47237
|
+
cur.files = splitCsv(filesMatch[1] ?? "");
|
|
47238
|
+
continue;
|
|
47239
|
+
}
|
|
47240
|
+
const dependsMatch = dependsRe.exec(raw);
|
|
47241
|
+
if (dependsMatch) {
|
|
47242
|
+
cur.depends = splitCsv(dependsMatch[1] ?? "").filter((d) => d !== "-");
|
|
47243
|
+
continue;
|
|
47244
|
+
}
|
|
47245
|
+
const reqsMatch = reqsRe.exec(raw);
|
|
47246
|
+
if (reqsMatch) {
|
|
47247
|
+
cur.reqs = splitCsv(reqsMatch[1] ?? "");
|
|
47248
|
+
}
|
|
47249
|
+
}
|
|
47250
|
+
if (cur) out.push(cur);
|
|
47251
|
+
return out;
|
|
47252
|
+
}
|
|
47270
47253
|
function extractTaskDescriptions(body) {
|
|
47271
47254
|
const map3 = /* @__PURE__ */ new Map();
|
|
47272
47255
|
const lineRe = /^- \[[x ]\] (\d+(?:\.\d+)*)\s+(.*)$/;
|
|
@@ -47296,6 +47279,9 @@ async function buildFeatureDetail(args) {
|
|
|
47296
47279
|
const planVehicle = dir.subroot === "plans";
|
|
47297
47280
|
const slug = featureKey;
|
|
47298
47281
|
const acs = planVehicle ? [] : await parseAcs(sp2__default.join(dir.absDir, "requirements.md"));
|
|
47282
|
+
const stateForWave = planVehicle ? null : await readStateJson(dir.absDir);
|
|
47283
|
+
const currentWave = stateForWave?.current_wave ?? null;
|
|
47284
|
+
const phase = stateForWave?.phase ?? null;
|
|
47299
47285
|
const waveplan = await buildWaveplan({ projectRoot, featureKey, pluginRoot });
|
|
47300
47286
|
const reviews = await loadReviews(sp2__default.join(dir.absDir, "reviews"));
|
|
47301
47287
|
const costPerIter = await loadCostPerIter({
|
|
@@ -47316,6 +47302,8 @@ async function buildFeatureDetail(args) {
|
|
|
47316
47302
|
archived,
|
|
47317
47303
|
timeline,
|
|
47318
47304
|
acs,
|
|
47305
|
+
phase,
|
|
47306
|
+
currentWave,
|
|
47319
47307
|
waveplan: waveplan.map((w) => ({
|
|
47320
47308
|
wave: w.wave,
|
|
47321
47309
|
goal: w.goal,
|
|
@@ -47664,24 +47652,51 @@ async function buildTimeline(args) {
|
|
|
47664
47652
|
return dedupTimeline(events);
|
|
47665
47653
|
}
|
|
47666
47654
|
var exec4 = promisify(execFile);
|
|
47655
|
+
var StateValidationError = class extends Error {
|
|
47656
|
+
file;
|
|
47657
|
+
fieldErrors;
|
|
47658
|
+
stage;
|
|
47659
|
+
constructor(args) {
|
|
47660
|
+
super(args.message);
|
|
47661
|
+
this.name = "StateValidationError";
|
|
47662
|
+
this.file = args.file;
|
|
47663
|
+
this.fieldErrors = args.fieldErrors;
|
|
47664
|
+
this.stage = args.stage;
|
|
47665
|
+
}
|
|
47666
|
+
};
|
|
47667
47667
|
function parseStateOrThrow(body, file) {
|
|
47668
47668
|
let parsed;
|
|
47669
47669
|
try {
|
|
47670
47670
|
parsed = JSON.parse(body);
|
|
47671
47671
|
} catch (e) {
|
|
47672
|
+
const msg = e.message;
|
|
47672
47673
|
process.stderr.write(
|
|
47673
|
-
`[mumei dashboard] state.json JSON.parse failed: file=${file} err=${
|
|
47674
|
+
`[mumei dashboard] state.json JSON.parse failed: file=${file} err=${msg}
|
|
47674
47675
|
`
|
|
47675
47676
|
);
|
|
47676
|
-
throw new
|
|
47677
|
+
throw new StateValidationError({
|
|
47678
|
+
file,
|
|
47679
|
+
stage: "json",
|
|
47680
|
+
fieldErrors: [{ path: "/", message: msg }],
|
|
47681
|
+
message: `state.json JSON.parse failed at ${file}`
|
|
47682
|
+
});
|
|
47677
47683
|
}
|
|
47678
47684
|
if (!validateState.Check(parsed)) {
|
|
47679
|
-
const
|
|
47685
|
+
const fieldErrors = [...validateState.Errors(parsed)].map((e) => ({
|
|
47686
|
+
path: e.path,
|
|
47687
|
+
message: e.message
|
|
47688
|
+
}));
|
|
47689
|
+
const joined = fieldErrors.map((e) => `${e.path}: ${e.message}`).join("; ");
|
|
47680
47690
|
process.stderr.write(
|
|
47681
|
-
`[mumei dashboard] state.json validation failed: file=${file} errors=${
|
|
47691
|
+
`[mumei dashboard] state.json validation failed: file=${file} errors=${joined}
|
|
47682
47692
|
`
|
|
47683
47693
|
);
|
|
47684
|
-
throw new
|
|
47694
|
+
throw new StateValidationError({
|
|
47695
|
+
file,
|
|
47696
|
+
stage: "shape",
|
|
47697
|
+
fieldErrors,
|
|
47698
|
+
message: `state.json validation failed at ${file}: ${joined}`
|
|
47699
|
+
});
|
|
47685
47700
|
}
|
|
47686
47701
|
return parsed;
|
|
47687
47702
|
}
|
|
@@ -47694,6 +47709,12 @@ var PHASE_NEXT = {
|
|
|
47694
47709
|
async function listFeatures(args) {
|
|
47695
47710
|
const { projectRoot, now = /* @__PURE__ */ new Date() } = args;
|
|
47696
47711
|
const summaries = [];
|
|
47712
|
+
const warnings = {
|
|
47713
|
+
skippedArchiveStates: 0,
|
|
47714
|
+
skippedReviews: 0,
|
|
47715
|
+
skippedCostLogLines: 0
|
|
47716
|
+
};
|
|
47717
|
+
const seenCostLogSkips = /* @__PURE__ */ new Set();
|
|
47697
47718
|
for (const vehicle of ["spec", "plan"]) {
|
|
47698
47719
|
const dir = sp2__default.join(projectRoot, ".mumei", vehicle === "spec" ? "specs" : "plans");
|
|
47699
47720
|
const entries = await safeReaddir3(dir);
|
|
@@ -47705,7 +47726,9 @@ async function listFeatures(args) {
|
|
|
47705
47726
|
featureKey: entry.name,
|
|
47706
47727
|
vehicle,
|
|
47707
47728
|
archived: false,
|
|
47708
|
-
now
|
|
47729
|
+
now,
|
|
47730
|
+
warnings,
|
|
47731
|
+
seenCostLogSkips
|
|
47709
47732
|
});
|
|
47710
47733
|
if (summary) summaries.push(summary);
|
|
47711
47734
|
}
|
|
@@ -47733,16 +47756,28 @@ async function listFeatures(args) {
|
|
|
47733
47756
|
featureKey: slug.name,
|
|
47734
47757
|
vehicle,
|
|
47735
47758
|
archived: true,
|
|
47736
|
-
now
|
|
47759
|
+
now,
|
|
47760
|
+
warnings,
|
|
47761
|
+
seenCostLogSkips
|
|
47737
47762
|
});
|
|
47738
47763
|
if (summary) summaries.push(summary);
|
|
47739
47764
|
}
|
|
47740
47765
|
}
|
|
47766
|
+
warnings.skippedCostLogLines = seenCostLogSkips.size;
|
|
47741
47767
|
summaries.sort((a, b) => a.lastActivityMin - b.lastActivityMin);
|
|
47742
|
-
return summaries;
|
|
47768
|
+
return { features: summaries, warnings };
|
|
47743
47769
|
}
|
|
47744
47770
|
async function summariseFeature(args) {
|
|
47745
|
-
const {
|
|
47771
|
+
const {
|
|
47772
|
+
projectRoot,
|
|
47773
|
+
featureDir,
|
|
47774
|
+
featureKey,
|
|
47775
|
+
vehicle,
|
|
47776
|
+
archived,
|
|
47777
|
+
now,
|
|
47778
|
+
warnings,
|
|
47779
|
+
seenCostLogSkips
|
|
47780
|
+
} = args;
|
|
47746
47781
|
const stateRaw = await safeReadFile2(sp2__default.join(featureDir, "state.json"));
|
|
47747
47782
|
if (!stateRaw) return null;
|
|
47748
47783
|
const stateFilePath = sp2__default.join(featureDir, "state.json");
|
|
@@ -47755,6 +47790,7 @@ async function summariseFeature(args) {
|
|
|
47755
47790
|
`[mumei dashboard] archive state.json shape drift, skipping: file=${stateFilePath}
|
|
47756
47791
|
`
|
|
47757
47792
|
);
|
|
47793
|
+
warnings.skippedArchiveStates += 1;
|
|
47758
47794
|
return null;
|
|
47759
47795
|
}
|
|
47760
47796
|
state = parsed;
|
|
@@ -47763,6 +47799,7 @@ async function summariseFeature(args) {
|
|
|
47763
47799
|
`[mumei dashboard] archive state.json JSON.parse failed, skipping: file=${stateFilePath}
|
|
47764
47800
|
`
|
|
47765
47801
|
);
|
|
47802
|
+
warnings.skippedArchiveStates += 1;
|
|
47766
47803
|
return null;
|
|
47767
47804
|
}
|
|
47768
47805
|
} else {
|
|
@@ -47771,11 +47808,12 @@ async function summariseFeature(args) {
|
|
|
47771
47808
|
const phase = state.phase;
|
|
47772
47809
|
const tasksBody = await safeReadFile2(sp2__default.join(featureDir, "tasks.md"));
|
|
47773
47810
|
const tasksWaveCount = tasksBody ? (tasksBody.match(/^## Wave \d+:/gm) ?? []).length : 0;
|
|
47774
|
-
const review = await latestReview(sp2__default.join(featureDir, "reviews"));
|
|
47811
|
+
const review = await latestReview(sp2__default.join(featureDir, "reviews"), warnings);
|
|
47775
47812
|
const cost = await loadCost({
|
|
47776
47813
|
perFeatureFile: sp2__default.join(featureDir, "cost-log.jsonl"),
|
|
47777
47814
|
projectWideFile: sp2__default.join(projectRoot, ".mumei", "cost-log.jsonl"),
|
|
47778
|
-
featureKey
|
|
47815
|
+
featureKey,
|
|
47816
|
+
seenCostLogSkips
|
|
47779
47817
|
});
|
|
47780
47818
|
const stateMtime = await safeMtime2(sp2__default.join(featureDir, "state.json"));
|
|
47781
47819
|
const gitMtime = await latestCommitTimestamp(projectRoot, sp2__default.relative(projectRoot, featureDir));
|
|
@@ -47842,7 +47880,7 @@ function countCompletedWaves(tasksBody) {
|
|
|
47842
47880
|
if (inWave && currentTotal > 0 && currentDone === currentTotal) completed += 1;
|
|
47843
47881
|
return completed;
|
|
47844
47882
|
}
|
|
47845
|
-
async function latestReview(reviewsDir) {
|
|
47883
|
+
async function latestReview(reviewsDir, warnings) {
|
|
47846
47884
|
const entries = await safeReaddir3(reviewsDir);
|
|
47847
47885
|
const candidates = entries.filter((e) => e.isFile() && e.name.endsWith(".json") && !e.name.endsWith("-detectors.json")).map((e) => e.name).sort();
|
|
47848
47886
|
const latestName = candidates[candidates.length - 1];
|
|
@@ -47861,6 +47899,7 @@ async function latestReview(reviewsDir) {
|
|
|
47861
47899
|
`[mumei dashboard] review.json shape violation, skipping: file=${reviewPath}
|
|
47862
47900
|
`
|
|
47863
47901
|
);
|
|
47902
|
+
warnings.skippedReviews += 1;
|
|
47864
47903
|
return null;
|
|
47865
47904
|
}
|
|
47866
47905
|
try {
|
|
@@ -47885,7 +47924,10 @@ async function loadCost(args) {
|
|
|
47885
47924
|
const merged = /* @__PURE__ */ new Map();
|
|
47886
47925
|
for (const file of [args.perFeatureFile, args.projectWideFile]) {
|
|
47887
47926
|
for await (const e of readJsonl(file, {
|
|
47888
|
-
validate: (v) => validateCostLogEntry.Check(v)
|
|
47927
|
+
validate: (v) => validateCostLogEntry.Check(v),
|
|
47928
|
+
onSkip: (info) => {
|
|
47929
|
+
args.seenCostLogSkips.add(`${info.file}:${info.line}`);
|
|
47930
|
+
}
|
|
47889
47931
|
})) {
|
|
47890
47932
|
if (e.phase !== "after") continue;
|
|
47891
47933
|
if (file === args.projectWideFile && e.feature !== args.featureKey) continue;
|
|
@@ -50203,12 +50245,17 @@ var SlugParam = Type.Object({
|
|
|
50203
50245
|
});
|
|
50204
50246
|
var DocParam = Type.Object({
|
|
50205
50247
|
slug: Type.String({ pattern: "^[A-Za-z0-9_-]+$", minLength: 1, maxLength: 100 }),
|
|
50206
|
-
doc: Type.Union([
|
|
50248
|
+
doc: Type.Union([
|
|
50249
|
+
Type.Literal("requirements"),
|
|
50250
|
+
Type.Literal("design"),
|
|
50251
|
+
Type.Literal("tasks"),
|
|
50252
|
+
Type.Literal("scratch")
|
|
50253
|
+
])
|
|
50207
50254
|
});
|
|
50208
50255
|
var FeatureQuery = Type.Object({
|
|
50209
50256
|
feature: Type.String({ pattern: "^[A-Za-z0-9_-]+$", minLength: 1, maxLength: 100 })
|
|
50210
50257
|
});
|
|
50211
|
-
app.get("/api/features", { schema: { response: { 200:
|
|
50258
|
+
app.get("/api/features", { schema: { response: { 200: FeaturesResponseSchema } } }, async () => {
|
|
50212
50259
|
return listFeatures({ projectRoot: PROJECT_ROOT });
|
|
50213
50260
|
});
|
|
50214
50261
|
app.get("/api/meta", { schema: { response: { 200: MetaSchema } } }, async () => {
|
|
@@ -50302,18 +50349,76 @@ app.get("/api/hook-stats", async () => {
|
|
|
50302
50349
|
]);
|
|
50303
50350
|
return JSON.parse(stdout);
|
|
50304
50351
|
});
|
|
50352
|
+
var DOC_SLUG_RE = /^[A-Za-z0-9_-]{1,100}$/;
|
|
50353
|
+
async function readMarkdownIfInside(file, root) {
|
|
50354
|
+
const resolved = sp2__default.resolve(file);
|
|
50355
|
+
if (!resolved.startsWith(root + sp2__default.sep)) return null;
|
|
50356
|
+
try {
|
|
50357
|
+
return await readFile(resolved, "utf8");
|
|
50358
|
+
} catch {
|
|
50359
|
+
return null;
|
|
50360
|
+
}
|
|
50361
|
+
}
|
|
50305
50362
|
app.get("/api/feature/:slug/:doc", { schema: { params: DocParam } }, async (req, reply) => {
|
|
50306
50363
|
const { slug, doc } = req.params;
|
|
50364
|
+
if (!DOC_SLUG_RE.test(slug)) {
|
|
50365
|
+
reply.code(400);
|
|
50366
|
+
return { error: "invalid slug" };
|
|
50367
|
+
}
|
|
50368
|
+
if (doc === "scratch") {
|
|
50369
|
+
const bare = slug.replace(/^REQ-\d+-/, "");
|
|
50370
|
+
const candidates2 = [
|
|
50371
|
+
sp2__default.join(MUMEI_DIR, "scratch", `${bare}.md`),
|
|
50372
|
+
sp2__default.join(MUMEI_DIR, "scratch", `${slug}.md`)
|
|
50373
|
+
];
|
|
50374
|
+
for (const p of candidates2) {
|
|
50375
|
+
const body = await readMarkdownIfInside(p, MUMEI_DIR);
|
|
50376
|
+
if (body !== null) {
|
|
50377
|
+
reply.type("text/markdown");
|
|
50378
|
+
return body;
|
|
50379
|
+
}
|
|
50380
|
+
}
|
|
50381
|
+
reply.code(404);
|
|
50382
|
+
return { error: "not found" };
|
|
50383
|
+
}
|
|
50307
50384
|
const candidates = [
|
|
50308
50385
|
sp2__default.join(MUMEI_DIR, "specs", slug, `${doc}.md`),
|
|
50309
50386
|
sp2__default.join(MUMEI_DIR, "plans", slug, `${doc}.md`)
|
|
50310
50387
|
];
|
|
50388
|
+
try {
|
|
50389
|
+
const specsRoot = sp2__default.join(MUMEI_DIR, "specs");
|
|
50390
|
+
for (const ent of await readdir(specsRoot, { withFileTypes: true })) {
|
|
50391
|
+
if (ent.isDirectory() && ent.name.endsWith(`-${slug}`) && DOC_SLUG_RE.test(ent.name)) {
|
|
50392
|
+
candidates.push(sp2__default.join(specsRoot, ent.name, `${doc}.md`));
|
|
50393
|
+
}
|
|
50394
|
+
}
|
|
50395
|
+
} catch {
|
|
50396
|
+
}
|
|
50397
|
+
try {
|
|
50398
|
+
const archiveRoot = sp2__default.join(MUMEI_DIR, "archive");
|
|
50399
|
+
const months = (await readdir(archiveRoot, { withFileTypes: true })).sort(
|
|
50400
|
+
(a, b) => b.name.localeCompare(a.name)
|
|
50401
|
+
);
|
|
50402
|
+
for (const m of months) {
|
|
50403
|
+
if (!m.isDirectory()) continue;
|
|
50404
|
+
const monthDir = sp2__default.join(archiveRoot, m.name);
|
|
50405
|
+
candidates.push(sp2__default.join(monthDir, slug, `${doc}.md`));
|
|
50406
|
+
try {
|
|
50407
|
+
for (const sub of await readdir(monthDir, { withFileTypes: true })) {
|
|
50408
|
+
if (sub.isDirectory() && sub.name.endsWith(`-${slug}`) && DOC_SLUG_RE.test(sub.name)) {
|
|
50409
|
+
candidates.push(sp2__default.join(monthDir, sub.name, `${doc}.md`));
|
|
50410
|
+
}
|
|
50411
|
+
}
|
|
50412
|
+
} catch {
|
|
50413
|
+
}
|
|
50414
|
+
}
|
|
50415
|
+
} catch {
|
|
50416
|
+
}
|
|
50311
50417
|
for (const p of candidates) {
|
|
50312
|
-
|
|
50313
|
-
|
|
50418
|
+
const body = await readMarkdownIfInside(p, MUMEI_DIR);
|
|
50419
|
+
if (body !== null) {
|
|
50314
50420
|
reply.type("text/markdown");
|
|
50315
50421
|
return body;
|
|
50316
|
-
} catch {
|
|
50317
50422
|
}
|
|
50318
50423
|
}
|
|
50319
50424
|
reply.code(404);
|
|
@@ -50321,6 +50426,15 @@ app.get("/api/feature/:slug/:doc", { schema: { params: DocParam } }, async (req,
|
|
|
50321
50426
|
});
|
|
50322
50427
|
app.setErrorHandler((err, req, reply) => {
|
|
50323
50428
|
req.log.error({ err }, "request handler threw");
|
|
50429
|
+
if (err instanceof StateValidationError) {
|
|
50430
|
+
reply.code(500).send({
|
|
50431
|
+
error: "state.json shape violation",
|
|
50432
|
+
stage: err.stage,
|
|
50433
|
+
file: err.file,
|
|
50434
|
+
fieldErrors: err.fieldErrors
|
|
50435
|
+
});
|
|
50436
|
+
return;
|
|
50437
|
+
}
|
|
50324
50438
|
const fastifyErr = err;
|
|
50325
50439
|
if (fastifyErr.validation) {
|
|
50326
50440
|
reply.code(400).send({ error: "validation_failed", details: fastifyErr.validation });
|