mumei-dashboard 0.4.2 → 0.4.3

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