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.
@@ -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 { readFile, readdir, stat, access, realpath, lstat, open } from 'fs/promises';
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, maximum: 3 }), Type.Null()]),
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: "Most common decision recorded for the hook_id within the window."
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
- // src/schemas/_formats.ts
43926
- var ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
43927
- if (!format_exports.Has("date-time")) {
43928
- format_exports.Set("date-time", (v) => typeof v === "string" && ISO_DATE_TIME.test(v));
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
- // src/lib/validators.ts
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] parseTasksMdViaBash failed for ${featureKey}: ${message}
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=${e.message}
47674
+ `[mumei dashboard] state.json JSON.parse failed: file=${file} err=${msg}
47674
47675
  `
47675
47676
  );
47676
- throw new Error(`state.json JSON.parse failed at ${file}`);
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 errors = [...validateState.Errors(parsed)].map((e) => `${e.path}: ${e.message}`).join("; ");
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=${errors}
47691
+ `[mumei dashboard] state.json validation failed: file=${file} errors=${joined}
47682
47692
  `
47683
47693
  );
47684
- throw new Error(`state.json validation failed at ${file}: ${errors}`);
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 { projectRoot, featureDir, featureKey, vehicle, archived, now } = args;
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([Type.Literal("requirements"), Type.Literal("design"), Type.Literal("tasks")])
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: FeatureSummaryListSchema } } }, async () => {
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
- try {
50313
- const body = await readFile(p, "utf8");
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 });