syntaur 0.66.1 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -937,6 +937,38 @@ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
937
937
  });
938
938
 
939
939
  // src/lifecycle/event-emit.ts
940
+ var event_emit_exports = {};
941
+ __export(event_emit_exports, {
942
+ emitEvent: () => emitEvent,
943
+ isSuppressingEvents: () => isSuppressingEvents,
944
+ recordStatusEvent: () => recordStatusEvent,
945
+ resolveActor: () => resolveActor,
946
+ setSuppressEvents: () => setSuppressEvents,
947
+ withSuppressedEvents: () => withSuppressedEvents
948
+ });
949
+ function setSuppressEvents(value) {
950
+ suppressEvents = value;
951
+ }
952
+ function isSuppressingEvents() {
953
+ return suppressEvents;
954
+ }
955
+ function withSuppressedEvents(fn) {
956
+ const prior = suppressEvents;
957
+ suppressEvents = true;
958
+ try {
959
+ const result = fn();
960
+ if (result instanceof Promise) {
961
+ return result.finally(() => {
962
+ suppressEvents = prior;
963
+ });
964
+ }
965
+ suppressEvents = prior;
966
+ return result;
967
+ } catch (e) {
968
+ suppressEvents = prior;
969
+ throw e;
970
+ }
971
+ }
940
972
  function resolveActor(by) {
941
973
  return by ?? "system";
942
974
  }
@@ -952,6 +984,10 @@ function recordStatusEvent(input) {
952
984
  details: { from: input.from, to: input.to, command: input.command }
953
985
  });
954
986
  }
987
+ function emitEvent(input) {
988
+ if (suppressEvents) return;
989
+ recordEvent(input);
990
+ }
955
991
  var suppressEvents;
956
992
  var init_event_emit = __esm({
957
993
  "src/lifecycle/event-emit.ts"() {
@@ -3764,7 +3800,9 @@ __export(config_exports, {
3764
3800
  getTerminal: () => getTerminal,
3765
3801
  normalizeFactDeclarations: () => normalizeFactDeclarations,
3766
3802
  parseAgentCommand: () => parseAgentCommand,
3803
+ parseDurationMs: () => parseDurationMs,
3767
3804
  parseSearchConfig: () => parseSearchConfig,
3805
+ parseStalenessConfig: () => parseStalenessConfig,
3768
3806
  parseStatusConfig: () => parseStatusConfig,
3769
3807
  parseTerminalConfig: () => parseTerminalConfig,
3770
3808
  readConfig: () => readConfig,
@@ -3780,6 +3818,7 @@ __export(config_exports, {
3780
3818
  validateDeriveConfig: () => validateDeriveConfig,
3781
3819
  validateDeriveShape: () => validateDeriveShape,
3782
3820
  validateFactDeclarations: () => validateFactDeclarations,
3821
+ validateStalenessConfig: () => validateStalenessConfig,
3783
3822
  writeAgentsConfig: () => writeAgentsConfig,
3784
3823
  writeHotkeyBindingsConfig: () => writeHotkeyBindingsConfig,
3785
3824
  writeSearchConfig: () => writeSearchConfig,
@@ -3791,6 +3830,13 @@ __export(config_exports, {
3791
3830
  import { readFile as readFile5 } from "fs/promises";
3792
3831
  import { spawnSync } from "child_process";
3793
3832
  import { resolve as resolve7, isAbsolute } from "path";
3833
+ function parseDurationMs(raw2) {
3834
+ const m = raw2.trim().match(DURATION_RE);
3835
+ if (!m) return null;
3836
+ const n = Number(m[1]);
3837
+ if (!Number.isFinite(n) || n <= 0) return null;
3838
+ return n * DURATION_UNIT_MS[m[2] ?? "ms"];
3839
+ }
3794
3840
  function parseAgentCommand(value, agentId) {
3795
3841
  if (typeof value !== "string" || value.trim() === "") {
3796
3842
  throw new AgentConfigError(
@@ -5027,6 +5073,65 @@ ${cleanedFm}
5027
5073
  ---${afterFrontmatter}`;
5028
5074
  await writeFileForce(configPath, newContent);
5029
5075
  }
5076
+ function parseStalenessConfig(content) {
5077
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
5078
+ if (!match) return null;
5079
+ const fmBlock = match[1];
5080
+ const blockStart = fmBlock.match(/^staleness:\s*$/m);
5081
+ if (!blockStart) return null;
5082
+ const startIdx = (blockStart.index ?? 0) + blockStart[0].length;
5083
+ const lines = fmBlock.slice(startIdx).split("\n");
5084
+ const out = {};
5085
+ for (const line of lines) {
5086
+ if (line.trim() === "") continue;
5087
+ const trimmed = line.trimStart();
5088
+ const indent = line.length - trimmed.length;
5089
+ if (indent === 0) break;
5090
+ const ci = trimmed.indexOf(":");
5091
+ if (ci <= 0) continue;
5092
+ const key = trimmed.slice(0, ci).trim();
5093
+ const field = STALENESS_KEY_TO_FIELD[key];
5094
+ if (!field) continue;
5095
+ let value = trimmed.slice(ci + 1).trim();
5096
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
5097
+ value = value.slice(1, -1);
5098
+ }
5099
+ const ms = parseDurationMs(value);
5100
+ if (ms !== null) out[field] = ms;
5101
+ }
5102
+ return Object.keys(out).length > 0 ? out : null;
5103
+ }
5104
+ function validateStalenessConfig(content) {
5105
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
5106
+ if (!match) return [];
5107
+ const fmBlock = match[1];
5108
+ const blockStart = fmBlock.match(/^staleness:\s*$/m);
5109
+ if (!blockStart) return [];
5110
+ const startIdx = (blockStart.index ?? 0) + blockStart[0].length;
5111
+ const lines = fmBlock.slice(startIdx).split("\n");
5112
+ const problems = [];
5113
+ for (const line of lines) {
5114
+ if (line.trim() === "") continue;
5115
+ const trimmed = line.trimStart();
5116
+ const indent = line.length - trimmed.length;
5117
+ if (indent === 0) break;
5118
+ const ci = trimmed.indexOf(":");
5119
+ if (ci <= 0) continue;
5120
+ const key = trimmed.slice(0, ci).trim();
5121
+ let value = trimmed.slice(ci + 1).trim();
5122
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
5123
+ value = value.slice(1, -1);
5124
+ }
5125
+ if (!(key in STALENESS_KEY_TO_FIELD)) {
5126
+ problems.push(`staleness.${key}: unknown key (expected one of ${Object.keys(STALENESS_KEY_TO_FIELD).join(", ")})`);
5127
+ continue;
5128
+ }
5129
+ if (parseDurationMs(value) === null) {
5130
+ problems.push(`staleness.${key}: "${value}" is not a positive duration (e.g. 7d, 12h, 30m, 90s, 500ms)`);
5131
+ }
5132
+ }
5133
+ return problems;
5134
+ }
5030
5135
  function parseSearchConfig(content) {
5031
5136
  const match = content.match(/^---\n([\s\S]*?)\n---/);
5032
5137
  if (!match) return null;
@@ -5309,7 +5414,9 @@ async function readConfig() {
5309
5414
  }
5310
5415
  })(),
5311
5416
  searchConfig: parseSearchConfig(content),
5312
- workspaceVisibility: parseWorkspaceVisibilityConfig(fmBlock)
5417
+ workspaceVisibility: parseWorkspaceVisibilityConfig(fmBlock),
5418
+ staleness: parseStalenessConfig(content),
5419
+ stalenessWatchdog: String(fm["stalenessWatchdog"]).toLowerCase() === "true"
5313
5420
  };
5314
5421
  }
5315
5422
  function getAssignmentTypes(config) {
@@ -5372,7 +5479,7 @@ async function updateAgentsConfig(mutation, options = {}) {
5372
5479
  await writeAgentsConfig(next);
5373
5480
  return { previous, next, written: true };
5374
5481
  }
5375
- var DEFAULT_ASSIGNMENT_TYPES, DEFAULT_CONFIG, AUTO_CREATE_WORKTREE_VALUES, SESSION_AUTO_TRACK_VALUES, AgentConfigError, DEFAULT_STATUS_COLORS, KNOWN_AGENT_SCALAR_FIELDS, migratedConfigPaths, TerminalConfigError;
5482
+ var STALENESS_KEY_TO_FIELD, DURATION_RE, DURATION_UNIT_MS, DEFAULT_ASSIGNMENT_TYPES, DEFAULT_CONFIG, AUTO_CREATE_WORKTREE_VALUES, SESSION_AUTO_TRACK_VALUES, AgentConfigError, DEFAULT_STATUS_COLORS, KNOWN_AGENT_SCALAR_FIELDS, migratedConfigPaths, TerminalConfigError;
5376
5483
  var init_config2 = __esm({
5377
5484
  "src/utils/config.ts"() {
5378
5485
  "use strict";
@@ -5389,6 +5496,21 @@ var init_config2 = __esm({
5389
5496
  init_terminal_schema();
5390
5497
  init_search_schema();
5391
5498
  init_workspace_visibility_schema();
5499
+ STALENESS_KEY_TO_FIELD = {
5500
+ inProgressNoActivity: "inProgressNoActivityMs",
5501
+ readyUnclaimed: "readyUnclaimedMs",
5502
+ reviewAging: "reviewAgingMs",
5503
+ blockedAging: "blockedAgingMs",
5504
+ planApprovalAging: "planApprovalAgingMs"
5505
+ };
5506
+ DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/;
5507
+ DURATION_UNIT_MS = {
5508
+ ms: 1,
5509
+ s: 1e3,
5510
+ m: 6e4,
5511
+ h: 36e5,
5512
+ d: 864e5
5513
+ };
5392
5514
  DEFAULT_ASSIGNMENT_TYPES = {
5393
5515
  definitions: [
5394
5516
  { id: "feature", label: "Feature" },
@@ -5431,7 +5553,9 @@ var init_config2 = __esm({
5431
5553
  searchConfig: null,
5432
5554
  workspaceVisibility: {
5433
5555
  hidden: []
5434
- }
5556
+ },
5557
+ staleness: null,
5558
+ stalenessWatchdog: false
5435
5559
  };
5436
5560
  AUTO_CREATE_WORKTREE_VALUES = ["skip", "ask", "always"];
5437
5561
  SESSION_AUTO_TRACK_VALUES = ["all", "workspaces-only", "off"];
@@ -7793,6 +7917,74 @@ var init_overviewCopy = __esm({
7793
7917
  }
7794
7918
  });
7795
7919
 
7920
+ // src/staleness/classify.ts
7921
+ function resolveStaleThresholds(overrides) {
7922
+ const merged = { ...DEFAULT_STALE_THRESHOLDS };
7923
+ if (overrides) {
7924
+ for (const key of Object.keys(merged)) {
7925
+ const v = overrides[key];
7926
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) merged[key] = v;
7927
+ }
7928
+ }
7929
+ return merged;
7930
+ }
7931
+ function classifyNeedsAttention(input, thresholds = DEFAULT_STALE_THRESHOLDS) {
7932
+ if (input.isTerminal) return [];
7933
+ const reasons = [];
7934
+ const age = input.statusAgeMs;
7935
+ const aged = (gate) => age !== null && age >= gate;
7936
+ const blocked = input.disposition === "blocked" || input.blockedReason !== null;
7937
+ if (input.phase === IN_PROGRESS_PHASE && !blocked && aged(thresholds.inProgressNoActivityMs) && input.lastActivityMs !== null && input.lastActivityMs >= thresholds.inProgressNoActivityMs) {
7938
+ reasons.push({
7939
+ kind: "in_progress_no_activity",
7940
+ label: "In progress, but no recent activity",
7941
+ severity: "medium"
7942
+ });
7943
+ }
7944
+ if (input.phase === READY_PHASE && input.assignee === null && aged(thresholds.readyUnclaimedMs)) {
7945
+ reasons.push({
7946
+ kind: "ready_unclaimed",
7947
+ label: "Ready to implement, unclaimed",
7948
+ severity: "medium"
7949
+ });
7950
+ }
7951
+ if (input.phase === REVIEW_PHASE && aged(thresholds.reviewAgingMs)) {
7952
+ reasons.push({ kind: "review_aging", label: "Awaiting review", severity: "high" });
7953
+ }
7954
+ if (blocked && aged(thresholds.blockedAgingMs)) {
7955
+ reasons.push({ kind: "blocked_aging", label: "Blocked and aging", severity: "high" });
7956
+ }
7957
+ if (input.phase === PLANNING_PHASE && input.planExists && !input.planApproved && aged(thresholds.planApprovalAgingMs)) {
7958
+ reasons.push({
7959
+ kind: "plan_awaiting_approval",
7960
+ label: "Plan awaiting approval",
7961
+ severity: "medium"
7962
+ });
7963
+ }
7964
+ if (input.depsSatisfied === false && (input.phase === READY_PHASE || input.phase === IN_PROGRESS_PHASE)) {
7965
+ reasons.push({ kind: "deps_unsatisfied", label: "Unmet dependencies", severity: "high" });
7966
+ }
7967
+ return reasons;
7968
+ }
7969
+ var DAY, DEFAULT_STALE_THRESHOLDS, PLANNING_PHASE, READY_PHASE, IN_PROGRESS_PHASE, REVIEW_PHASE;
7970
+ var init_classify = __esm({
7971
+ "src/staleness/classify.ts"() {
7972
+ "use strict";
7973
+ DAY = 24 * 60 * 60 * 1e3;
7974
+ DEFAULT_STALE_THRESHOLDS = {
7975
+ inProgressNoActivityMs: 7 * DAY,
7976
+ readyUnclaimedMs: 3 * DAY,
7977
+ reviewAgingMs: 3 * DAY,
7978
+ blockedAgingMs: 3 * DAY,
7979
+ planApprovalAgingMs: 3 * DAY
7980
+ };
7981
+ PLANNING_PHASE = "ready_for_planning";
7982
+ READY_PHASE = "ready_to_implement";
7983
+ IN_PROGRESS_PHASE = "in_progress";
7984
+ REVIEW_PHASE = "review";
7985
+ }
7986
+ });
7987
+
7796
7988
  // src/dashboard/servers.ts
7797
7989
  import { readdir as readdir9, readFile as readFile12, unlink as unlink2 } from "fs/promises";
7798
7990
  import { resolve as resolve15 } from "path";
@@ -8427,7 +8619,38 @@ var init_scanner = __esm({
8427
8619
  });
8428
8620
 
8429
8621
  // src/dashboard/api.ts
8430
- import { readdir as readdir10, readFile as readFile13, writeFile as writeFile3 } from "fs/promises";
8622
+ var api_exports = {};
8623
+ __export(api_exports, {
8624
+ WorkspaceBlockedError: () => WorkspaceBlockedError,
8625
+ clearStatusConfigCache: () => clearStatusConfigCache,
8626
+ collectStaleCandidates: () => collectStaleCandidates,
8627
+ createWorkspace: () => createWorkspace,
8628
+ deleteWorkspace: () => deleteWorkspace,
8629
+ getAssignmentDetail: () => getAssignmentDetail,
8630
+ getAssignmentDetailById: () => getAssignmentDetailById,
8631
+ getEditableDocument: () => getEditableDocument,
8632
+ getEditableDocumentById: () => getEditableDocumentById,
8633
+ getHelp: () => getHelp,
8634
+ getMemoryDetail: () => getMemoryDetail,
8635
+ getOverview: () => getOverview,
8636
+ getPlaybookDetail: () => getPlaybookDetail,
8637
+ getProjectDetail: () => getProjectDetail,
8638
+ getResourceDetail: () => getResourceDetail,
8639
+ getStatusConfig: () => getStatusConfig,
8640
+ installRecordsInvalidation: () => installRecordsInvalidation,
8641
+ invalidateRecordsCache: () => invalidateRecordsCache,
8642
+ listAllMemories: () => listAllMemories,
8643
+ listAllResources: () => listAllResources,
8644
+ listArchived: () => listArchived,
8645
+ listAssignmentsBoard: () => listAssignmentsBoard,
8646
+ listPlaybooks: () => listPlaybooks,
8647
+ listProjects: () => listProjects,
8648
+ listWorkspaceRecords: () => listWorkspaceRecords,
8649
+ listWorkspaces: () => listWorkspaces,
8650
+ resolveProjectPath: () => resolveProjectPath,
8651
+ resolveWorkspaceMembers: () => resolveWorkspaceMembers
8652
+ });
8653
+ import { readdir as readdir10, readFile as readFile13, writeFile as writeFile3, stat as stat2 } from "fs/promises";
8431
8654
  import { resolve as resolve17, dirname as dirname2, basename } from "path";
8432
8655
  function clearFrontmatterField(content, key) {
8433
8656
  const fieldRegex = new RegExp(`^(${escapeRegExp2(key)}:)\\s*.*$`, "m");
@@ -8818,10 +9041,10 @@ async function getOverview(projectsDir, serversDir2, assignmentsDir2, options =
8818
9041
  (total, record) => total + (record.summary.progress["failed"] ?? 0),
8819
9042
  0
8820
9043
  ),
8821
- staleAssignments: activeProjectRecords.reduce(
8822
- (total, record) => total + activeAssignments(record.assignments).filter((assignment) => isStale(assignment.updated)).length,
8823
- 0
8824
- ) + activeStandaloneRecords.filter((sr) => isStale(sr.record.updated)).length
9044
+ // Derived from the SAME classifier verdict as the stale segment (via the
9045
+ // pre-cap segment total) so the badge count can never diverge from the
9046
+ // listed rows.
9047
+ staleAssignments: segments.stale.total
8825
9048
  },
8826
9049
  hero,
8827
9050
  segments,
@@ -9973,9 +10196,77 @@ function segmentSeverity(segment) {
9973
10196
  return "medium";
9974
10197
  }
9975
10198
  }
10199
+ function topStaleReason(reasons) {
10200
+ if (reasons.length === 0) return null;
10201
+ return reasons.slice().sort((a, b) => STALE_SEVERITY_RANK[b.severity] - STALE_SEVERITY_RANK[a.severity])[0];
10202
+ }
10203
+ async function readProgressActivityMs(progressPath, now) {
10204
+ try {
10205
+ const s = await stat2(progressPath);
10206
+ return Math.max(0, now - s.mtimeMs);
10207
+ } catch {
10208
+ return null;
10209
+ }
10210
+ }
10211
+ function classifyAssignmentRecord(assignment, terminalStatuses, depsSatisfied, lastActivityMs, thresholds) {
10212
+ const virtuals = deriveStatusVirtuals(assignment, terminalStatuses);
10213
+ return classifyNeedsAttention(
10214
+ {
10215
+ phase: virtuals.phase,
10216
+ disposition: virtuals.disposition,
10217
+ isTerminal: terminalStatuses.has(assignment.status),
10218
+ assignee: assignment.assignee ?? null,
10219
+ blockedReason: assignment.blockedReason,
10220
+ depsSatisfied,
10221
+ // plan_awaiting_approval is deferred to the decision inbox's plan-approval
10222
+ // category for now; pass values that keep that reason dormant.
10223
+ planExists: false,
10224
+ planApproved: true,
10225
+ statusAgeMs: virtuals.statusAge,
10226
+ lastActivityMs
10227
+ },
10228
+ thresholds
10229
+ );
10230
+ }
10231
+ async function collectStaleCandidates(projectsDir, assignmentsDir2) {
10232
+ const [projectRecords, standaloneRecords] = await Promise.all([
10233
+ listProjectRecords(projectsDir),
10234
+ listStandaloneRecords(assignmentsDir2)
10235
+ ]);
10236
+ const { terminalStatuses } = await getStatusConfig();
10237
+ const thresholds = resolveStaleThresholds((await readConfig()).staleness);
10238
+ const now = Date.now();
10239
+ const out = [];
10240
+ for (const record of projectRecords) {
10241
+ if (isProjectArchived(record.summary)) continue;
10242
+ const projectPath = resolve17(projectsDir, record.summary.slug);
10243
+ const depMap = /* @__PURE__ */ new Map();
10244
+ for (const a of record.assignments) depMap.set(a.slug, a.status);
10245
+ for (const assignment of activeAssignments(record.assignments)) {
10246
+ const depsSatisfied = assignment.dependsOn.length === 0 ? true : (await getUnmetDependencies(projectPath, assignment.dependsOn, terminalStatuses, depMap)).length === 0;
10247
+ const lastActivityMs = await readProgressActivityMs(
10248
+ resolve17(projectPath, "assignments", assignment.slug, "progress.md"),
10249
+ now
10250
+ );
10251
+ const reasons = classifyAssignmentRecord(assignment, terminalStatuses, depsSatisfied, lastActivityMs, thresholds);
10252
+ if (reasons.length > 0) {
10253
+ out.push({ assignmentId: assignment.id, projectSlug: record.summary.slug, reasons });
10254
+ }
10255
+ }
10256
+ }
10257
+ for (const sr of standaloneRecords) {
10258
+ if (sr.record.archived === true) continue;
10259
+ const lastActivityMs = await readProgressActivityMs(resolve17(sr.assignmentDir, "progress.md"), now);
10260
+ const reasons = classifyAssignmentRecord(sr.record, terminalStatuses, true, lastActivityMs, thresholds);
10261
+ if (reasons.length > 0) out.push({ assignmentId: sr.record.id, projectSlug: null, reasons });
10262
+ }
10263
+ return out;
10264
+ }
9976
10265
  async function buildOverviewSegmentBuckets(projectsDir, projectRecords, standaloneRecords, traces) {
9977
10266
  const now = Date.now();
9978
10267
  const buckets = emptyBuckets();
10268
+ const { terminalStatuses } = await getStatusConfig();
10269
+ const staleThresholds = resolveStaleThresholds((await readConfig()).staleness);
9979
10270
  const newestPool = [];
9980
10271
  for (const record of projectRecords) {
9981
10272
  if (isProjectArchived(record.summary)) continue;
@@ -9984,6 +10275,7 @@ async function buildOverviewSegmentBuckets(projectsDir, projectRecords, standalo
9984
10275
  depMap.set(a.slug, a.status);
9985
10276
  }
9986
10277
  const visibleAssignments = activeAssignments(record.assignments);
10278
+ const projectPath = resolve17(projectsDir, record.summary.slug);
9987
10279
  const resolvedTransitions = await Promise.all(
9988
10280
  visibleAssignments.map(async (assignment) => {
9989
10281
  const t0 = traces ? performance.now() : 0;
@@ -9995,13 +10287,25 @@ async function buildOverviewSegmentBuckets(projectsDir, projectRecords, standalo
9995
10287
  { traces, dependencyStatusMap: depMap }
9996
10288
  );
9997
10289
  if (traces) accumulatePhase(traces, "get-available-transitions", performance.now() - t0);
9998
- return { assignment, availableTransitions };
10290
+ const depsSatisfied = assignment.dependsOn.length === 0 ? true : (await getUnmetDependencies(projectPath, assignment.dependsOn, terminalStatuses, depMap)).length === 0;
10291
+ const lastActivityMs = await readProgressActivityMs(
10292
+ resolve17(projectPath, "assignments", assignment.slug, "progress.md"),
10293
+ now
10294
+ );
10295
+ return { assignment, availableTransitions, depsSatisfied, lastActivityMs };
9999
10296
  })
10000
10297
  );
10001
- for (const { assignment, availableTransitions } of resolvedTransitions) {
10298
+ for (const { assignment, availableTransitions, depsSatisfied, lastActivityMs } of resolvedTransitions) {
10002
10299
  const segmentId = STATUS_TO_SEGMENT[assignment.status];
10003
- const stale = isStale(assignment.updated);
10004
- const isTerminal = TERMINAL_STATUSES2.has(assignment.status);
10300
+ const isTerminal = terminalStatuses.has(assignment.status);
10301
+ const staleReasons = classifyAssignmentRecord(
10302
+ assignment,
10303
+ terminalStatuses,
10304
+ depsSatisfied,
10305
+ lastActivityMs,
10306
+ staleThresholds
10307
+ );
10308
+ const stale = staleReasons.length > 0;
10005
10309
  const agingMs = Math.max(0, now - parseTimestamp(assignment.updated));
10006
10310
  const baseId = `${record.summary.slug}:${assignment.slug}`;
10007
10311
  const shared = {
@@ -10030,11 +10334,12 @@ async function buildOverviewSegmentBuckets(projectsDir, projectRecords, standalo
10030
10334
  buckets[segmentId].push(primary);
10031
10335
  }
10032
10336
  if (stale && !isTerminal) {
10337
+ const top = topStaleReason(staleReasons);
10033
10338
  const staleItem = {
10034
10339
  ...shared,
10035
10340
  id: `${baseId}:stale`,
10036
10341
  severity: "low",
10037
- reason: SEGMENT_REASON.stale,
10342
+ reason: top?.label ?? SEGMENT_REASON.stale,
10038
10343
  segment: "stale"
10039
10344
  };
10040
10345
  buckets.stale.push(staleItem);
@@ -10058,14 +10363,22 @@ async function buildOverviewSegmentBuckets(projectsDir, projectRecords, standalo
10058
10363
  const t0 = traces ? performance.now() : 0;
10059
10364
  const availableTransitions = await getStandaloneAvailableTransitions(sr.record);
10060
10365
  if (traces) accumulatePhase(traces, "get-available-transitions", performance.now() - t0);
10061
- return { sr, availableTransitions };
10366
+ const lastActivityMs = await readProgressActivityMs(resolve17(sr.assignmentDir, "progress.md"), now);
10367
+ return { sr, availableTransitions, lastActivityMs };
10062
10368
  })
10063
10369
  );
10064
- for (const { sr, availableTransitions } of resolvedStandaloneTransitions) {
10370
+ for (const { sr, availableTransitions, lastActivityMs } of resolvedStandaloneTransitions) {
10065
10371
  const assignment = sr.record;
10066
10372
  const segmentId = STATUS_TO_SEGMENT[assignment.status];
10067
- const stale = isStale(assignment.updated);
10068
- const isTerminal = TERMINAL_STATUSES2.has(assignment.status);
10373
+ const isTerminal = terminalStatuses.has(assignment.status);
10374
+ const staleReasons = classifyAssignmentRecord(
10375
+ assignment,
10376
+ terminalStatuses,
10377
+ true,
10378
+ lastActivityMs,
10379
+ staleThresholds
10380
+ );
10381
+ const stale = staleReasons.length > 0;
10069
10382
  const agingMs = Math.max(0, now - parseTimestamp(assignment.updated));
10070
10383
  const baseId = `standalone:${sr.id}`;
10071
10384
  const shared = {
@@ -10093,11 +10406,12 @@ async function buildOverviewSegmentBuckets(projectsDir, projectRecords, standalo
10093
10406
  });
10094
10407
  }
10095
10408
  if (stale && !isTerminal) {
10409
+ const top = topStaleReason(staleReasons);
10096
10410
  buckets.stale.push({
10097
10411
  ...shared,
10098
10412
  id: `${baseId}:stale`,
10099
10413
  severity: "low",
10100
- reason: SEGMENT_REASON.stale,
10414
+ reason: top?.label ?? SEGMENT_REASON.stale,
10101
10415
  segment: "stale"
10102
10416
  });
10103
10417
  }
@@ -10218,13 +10532,6 @@ function parseTimestamp(timestamp) {
10218
10532
  const parsed = Date.parse(timestamp);
10219
10533
  return Number.isFinite(parsed) ? parsed : 0;
10220
10534
  }
10221
- function isStale(updated) {
10222
- const timestamp = parseTimestamp(updated);
10223
- if (timestamp === 0) {
10224
- return false;
10225
- }
10226
- return Date.now() - timestamp > STALE_ASSIGNMENT_MS;
10227
- }
10228
10535
  async function countOpenQuestions(projectPath, assignmentSlug) {
10229
10536
  const commentsPath = resolve17(
10230
10537
  projectPath,
@@ -10343,7 +10650,7 @@ async function getPlaybookDetail(playbooksDir2, slug) {
10343
10650
  enabled
10344
10651
  };
10345
10652
  }
10346
- var WorkspaceBlockedError, STALE_ASSIGNMENT_MS, RECENT_PROJECTS_LIMIT, RECENT_ACTIVITY_LIMIT, RECENT_SESSIONS_LIMIT, NEWEST_CREATED_LIMIT, SEGMENT_DISPLAY_CAP, STALE_LIMIT_DEFAULT, STALE_LIMIT_MAX, TERMINAL_STATUSES2, STATUS_TO_SEGMENT, HERO_PRIORITY, projectRecordsCache, standaloneRecordsCache, DEFAULT_TRANSITION_DEFINITIONS, _cachedConfig, REFERENCED_BY_LIMIT, migratedProjectsDirs, DEFAULT_GRAPH_COLORS;
10653
+ var WorkspaceBlockedError, RECENT_PROJECTS_LIMIT, RECENT_ACTIVITY_LIMIT, RECENT_SESSIONS_LIMIT, NEWEST_CREATED_LIMIT, SEGMENT_DISPLAY_CAP, STALE_LIMIT_DEFAULT, STALE_LIMIT_MAX, STATUS_TO_SEGMENT, HERO_PRIORITY, projectRecordsCache, standaloneRecordsCache, DEFAULT_TRANSITION_DEFINITIONS, _cachedConfig, REFERENCED_BY_LIMIT, migratedProjectsDirs, DEFAULT_GRAPH_COLORS, STALE_SEVERITY_RANK;
10347
10654
  var init_api = __esm({
10348
10655
  "src/dashboard/api.ts"() {
10349
10656
  "use strict";
@@ -10361,6 +10668,7 @@ var init_api = __esm({
10361
10668
  init_help();
10362
10669
  init_agent_sessions();
10363
10670
  init_overviewCopy();
10671
+ init_classify();
10364
10672
  WorkspaceBlockedError = class extends Error {
10365
10673
  blockedBy;
10366
10674
  constructor(blockedBy) {
@@ -10371,7 +10679,6 @@ var init_api = __esm({
10371
10679
  this.blockedBy = blockedBy;
10372
10680
  }
10373
10681
  };
10374
- STALE_ASSIGNMENT_MS = 7 * 24 * 60 * 60 * 1e3;
10375
10682
  RECENT_PROJECTS_LIMIT = 6;
10376
10683
  RECENT_ACTIVITY_LIMIT = 12;
10377
10684
  RECENT_SESSIONS_LIMIT = 10;
@@ -10379,7 +10686,6 @@ var init_api = __esm({
10379
10686
  SEGMENT_DISPLAY_CAP = 5;
10380
10687
  STALE_LIMIT_DEFAULT = 50;
10381
10688
  STALE_LIMIT_MAX = 200;
10382
- TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "archived"]);
10383
10689
  STATUS_TO_SEGMENT = {
10384
10690
  review: "readyForReview",
10385
10691
  ready_to_implement: "readyToImplement",
@@ -10472,6 +10778,7 @@ var init_api = __esm({
10472
10778
  failed: "fill:#9f2d2d,stroke:#651616,color:#ffffff",
10473
10779
  review: "fill:#c6911e,stroke:#7a5a10,color:#ffffff"
10474
10780
  };
10781
+ STALE_SEVERITY_RANK = { high: 3, medium: 2, low: 1 };
10475
10782
  }
10476
10783
  });
10477
10784
 
@@ -10907,12 +11214,13 @@ __export(recompute_exports, {
10907
11214
  markDeriveMigrated: () => markDeriveMigrated,
10908
11215
  recomputeAll: () => recomputeAll,
10909
11216
  recomputeAndWrite: () => recomputeAndWrite,
11217
+ recomputeAssignmentDir: () => recomputeAssignmentDir,
10910
11218
  recomputeDependents: () => recomputeDependents,
10911
11219
  resolveDeriveContext: () => resolveDeriveContext
10912
11220
  });
10913
11221
  import { createHash as createHash2 } from "crypto";
10914
- import { open, readdir as readdir12, readFile as readFile17, unlink as unlink5, stat as stat2 } from "fs/promises";
10915
- import { dirname as dirname4, resolve as resolve22 } from "path";
11222
+ import { open, readdir as readdir12, readFile as readFile17, unlink as unlink5, stat as stat3 } from "fs/promises";
11223
+ import { basename as basename3, dirname as dirname4, resolve as resolve22 } from "path";
10916
11224
  async function isDeriveMigrated() {
10917
11225
  return fileExists(resolve22(syntaurRoot(), MIGRATION_MARKER));
10918
11226
  }
@@ -10952,7 +11260,7 @@ async function acquireLock(assignmentDir) {
10952
11260
  const code = err.code;
10953
11261
  if (code !== "EEXIST") throw err;
10954
11262
  try {
10955
- const info = await stat2(lockPath);
11263
+ const info = await stat3(lockPath);
10956
11264
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
10957
11265
  await unlink5(lockPath).catch(() => {
10958
11266
  });
@@ -11144,6 +11452,18 @@ async function recomputeAll(projectsDir, standaloneDir, opts) {
11144
11452
  }
11145
11453
  return summary;
11146
11454
  }
11455
+ async function recomputeAssignmentDir(assignmentDir, cause, by) {
11456
+ try {
11457
+ const assignmentPath = resolve22(assignmentDir, "assignment.md");
11458
+ if (!await fileExists(assignmentPath)) return null;
11459
+ const parent = dirname4(assignmentDir);
11460
+ const projectDir = basename3(parent) === "assignments" ? dirname4(parent) : null;
11461
+ const context = await resolveDeriveContext();
11462
+ return await recomputeAndWrite(assignmentPath, { cause, by, projectDir, context });
11463
+ } catch {
11464
+ return null;
11465
+ }
11466
+ }
11147
11467
  var LOCK_FILE, LOCK_STALE_MS, LOCK_WAIT_MS, LOCK_MAX_WAITS, CAS_RETRIES, MIGRATION_MARKER;
11148
11468
  var init_recompute = __esm({
11149
11469
  "src/lifecycle/recompute.ts"() {
@@ -11251,14 +11571,14 @@ var init_process_info = __esm({
11251
11571
  });
11252
11572
 
11253
11573
  // src/usage/cwd-extractor.ts
11254
- import { open as open3, readdir as readdir13, stat as stat3 } from "fs/promises";
11574
+ import { open as open3, readdir as readdir13, stat as stat4 } from "fs/promises";
11255
11575
  import { join as join4 } from "path";
11256
11576
  import { homedir as homedir3 } from "os";
11257
11577
  async function extractClaudeSessionMeta(jsonlPath) {
11258
11578
  const cwd = await derivePathFromTranscript(jsonlPath);
11259
11579
  if (!cwd) return null;
11260
- const basename6 = jsonlPath.split("/").pop() ?? "";
11261
- const sessionId = basename6.replace(/\.jsonl$/, "");
11580
+ const basename7 = jsonlPath.split("/").pop() ?? "";
11581
+ const sessionId = basename7.replace(/\.jsonl$/, "");
11262
11582
  if (!sessionId) return null;
11263
11583
  const startTs = await readFirstTimestamp(jsonlPath);
11264
11584
  const endTs = await readLastTimestamp(jsonlPath);
@@ -11353,8 +11673,8 @@ async function* walkClaudeProjects(opts = {}) {
11353
11673
  async function* walkCodexSessions(opts = {}) {
11354
11674
  const root = resolveCodexSessionsRoot(opts.root);
11355
11675
  for await (const filePath of walkJsonlRecursive(root)) {
11356
- const basename6 = filePath.split("/").pop() ?? "";
11357
- if (!basename6.endsWith(".jsonl")) continue;
11676
+ const basename7 = filePath.split("/").pop() ?? "";
11677
+ if (!basename7.endsWith(".jsonl")) continue;
11358
11678
  if (opts.sinceMtimeMs !== void 0) {
11359
11679
  const mtime = await mtimeMs(filePath);
11360
11680
  if (mtime !== null && mtime < opts.sinceMtimeMs) continue;
@@ -11372,10 +11692,10 @@ function resolveCodexSessionsRoot(override) {
11372
11692
  return join4(homedir3(), ".codex", "sessions");
11373
11693
  }
11374
11694
  async function extractPiSessionMeta(jsonlPath) {
11375
- const basename6 = jsonlPath.split("/").pop() ?? "";
11376
- const underscoreIdx = basename6.lastIndexOf("_");
11695
+ const basename7 = jsonlPath.split("/").pop() ?? "";
11696
+ const underscoreIdx = basename7.lastIndexOf("_");
11377
11697
  if (underscoreIdx === -1) return null;
11378
- const sessionId = basename6.slice(underscoreIdx + 1).replace(/\.jsonl$/, "");
11698
+ const sessionId = basename7.slice(underscoreIdx + 1).replace(/\.jsonl$/, "");
11379
11699
  if (!sessionId) return null;
11380
11700
  let handle;
11381
11701
  try {
@@ -11490,7 +11810,7 @@ async function* walkJsonlRecursive(root) {
11490
11810
  }
11491
11811
  async function mtimeMs(path) {
11492
11812
  try {
11493
- const s = await stat3(path);
11813
+ const s = await stat4(path);
11494
11814
  return s.mtimeMs;
11495
11815
  } catch {
11496
11816
  return null;
@@ -11731,7 +12051,7 @@ function toDiscovered(meta) {
11731
12051
  transcriptPath: meta.path
11732
12052
  };
11733
12053
  }
11734
- var detectDir, claudeSessions, codexSessions, AGENT_TARGETS, AGENT_TARGETS_BY_ID;
12054
+ var detectDir, claudeSessions, codexSessions, piSessions, AGENT_TARGETS, AGENT_TARGETS_BY_ID;
11735
12055
  var init_registry = __esm({
11736
12056
  "src/targets/registry.ts"() {
11737
12057
  "use strict";
@@ -11759,6 +12079,16 @@ var init_registry = __esm({
11759
12079
  }
11760
12080
  }
11761
12081
  };
12082
+ piSessions = {
12083
+ globs: (root) => [join10(root ?? resolvePiSessionsRoot(), "*", "*.jsonl")],
12084
+ parse: async (file) => toDiscovered(await extractPiSessionMeta(file)),
12085
+ walk: async function* (opts = {}) {
12086
+ for await (const meta of walkPiSessions({ root: opts.root, sinceMtimeMs: opts.sinceMtimeMs })) {
12087
+ const d = toDiscovered(meta);
12088
+ if (d) yield d;
12089
+ }
12090
+ }
12091
+ };
11762
12092
  AGENT_TARGETS = [
11763
12093
  {
11764
12094
  id: "cursor",
@@ -11815,6 +12145,7 @@ var init_registry = __esm({
11815
12145
  detect: detectDir(home(".pi")),
11816
12146
  skillsDir: { global: home(".pi", "agent", "skills") },
11817
12147
  instructions: { files: [{ path: "AGENTS.md", renderer: "codexAgents" }] },
12148
+ sessions: piSessions,
11818
12149
  tier3: {
11819
12150
  kind: "pi-extension",
11820
12151
  source: "platforms/pi/extensions/syntaur",
@@ -12085,6 +12416,40 @@ var init_scanner2 = __esm({
12085
12416
  }
12086
12417
  });
12087
12418
 
12419
+ // src/staleness/watchdog.ts
12420
+ var watchdog_exports = {};
12421
+ __export(watchdog_exports, {
12422
+ runStalenessWatchdogTick: () => runStalenessWatchdogTick
12423
+ });
12424
+ function runStalenessWatchdogTick(candidates, seen, emit) {
12425
+ const staleNow = /* @__PURE__ */ new Map();
12426
+ for (const c of candidates) {
12427
+ if (c.reasons.length > 0) staleNow.set(c.assignmentId, c);
12428
+ }
12429
+ let newlyStale = 0;
12430
+ for (const [id, c] of staleNow) {
12431
+ if (!seen.has(id)) {
12432
+ seen.add(id);
12433
+ newlyStale++;
12434
+ emit({ assignmentId: id, projectSlug: c.projectSlug, type: "staleness-detected", reasons: c.reasons });
12435
+ }
12436
+ }
12437
+ let cleared = 0;
12438
+ for (const id of [...seen]) {
12439
+ if (!staleNow.has(id)) {
12440
+ seen.delete(id);
12441
+ cleared++;
12442
+ emit({ assignmentId: id, projectSlug: null, type: "staleness-cleared", reasons: [] });
12443
+ }
12444
+ }
12445
+ return { scanned: candidates.length, stale: staleNow.size, newlyStale, cleared };
12446
+ }
12447
+ var init_watchdog = __esm({
12448
+ "src/staleness/watchdog.ts"() {
12449
+ "use strict";
12450
+ }
12451
+ });
12452
+
12088
12453
  // src/dashboard/server.ts
12089
12454
  init_paths();
12090
12455
  init_api();
@@ -13253,7 +13618,7 @@ init_timestamp();
13253
13618
  init_fs();
13254
13619
  init_git_worktree();
13255
13620
  import { Router as Router2 } from "express";
13256
- import { resolve as resolve23, basename as basename3, isAbsolute as isAbsolute4 } from "path";
13621
+ import { resolve as resolve23, basename as basename4, isAbsolute as isAbsolute4 } from "path";
13257
13622
  import { rm, readFile as readFile18, open as fsOpen, stat as fsStat, realpath as fsRealpath } from "fs/promises";
13258
13623
  import { spawnSync as spawnSync3 } from "child_process";
13259
13624
 
@@ -14474,7 +14839,7 @@ function createWriteRouter(projectsDir, assignmentsDir2, todosDir2) {
14474
14839
  res.status(404).json({ error: `Project "${slug}" not found` });
14475
14840
  return;
14476
14841
  }
14477
- const document = await getEditableDocument(projectsDir, "memory", basename3(projectDir), itemSlug);
14842
+ const document = await getEditableDocument(projectsDir, "memory", basename4(projectDir), itemSlug);
14478
14843
  if (!document) {
14479
14844
  res.status(404).json({ error: "Memory not found" });
14480
14845
  return;
@@ -14493,7 +14858,7 @@ function createWriteRouter(projectsDir, assignmentsDir2, todosDir2) {
14493
14858
  res.status(404).json({ error: `Project "${slug}" not found` });
14494
14859
  return;
14495
14860
  }
14496
- const document = await getEditableDocument(projectsDir, "resource", basename3(projectDir), itemSlug);
14861
+ const document = await getEditableDocument(projectsDir, "resource", basename4(projectDir), itemSlug);
14497
14862
  if (!document) {
14498
14863
  res.status(404).json({ error: "Resource not found" });
14499
14864
  return;
@@ -14578,7 +14943,7 @@ ${body.startsWith("\n") ? body.slice(1) : body}${body.endsWith("\n") ? "" : "\n"
14578
14943
  let content = renderItemStub(kind, {
14579
14944
  slug: requestedSlug,
14580
14945
  name,
14581
- projectSlug: basename3(projectDir),
14946
+ projectSlug: basename4(projectDir),
14582
14947
  timestamp
14583
14948
  });
14584
14949
  const customBody = typeof body.body === "string" ? body.body : "";
@@ -14595,13 +14960,13 @@ ${body.startsWith("\n") ? body.slice(1) : body}${body.endsWith("\n") ? "" : "\n"
14595
14960
  } catch (err) {
14596
14961
  if (err.code === "EEXIST") {
14597
14962
  res.status(409).json({
14598
- error: `${kind === "memory" ? "Memory" : "Resource"} with slug "${requestedSlug}" already exists in project "${basename3(projectDir)}".`
14963
+ error: `${kind === "memory" ? "Memory" : "Resource"} with slug "${requestedSlug}" already exists in project "${basename4(projectDir)}".`
14599
14964
  });
14600
14965
  return;
14601
14966
  }
14602
14967
  throw err;
14603
14968
  }
14604
- res.status(201).json({ slug: requestedSlug, projectSlug: basename3(projectDir), content });
14969
+ res.status(201).json({ slug: requestedSlug, projectSlug: basename4(projectDir), content });
14605
14970
  } catch (error) {
14606
14971
  console.error(`Error creating ${kind}:`, error);
14607
14972
  res.status(500).json({ error: `Failed to create ${kind}: ${error.message}` });
@@ -14636,7 +15001,7 @@ ${body.startsWith("\n") ? body.slice(1) : body}${body.endsWith("\n") ? "" : "\n"
14636
15001
  ${nextBody}${nextBody.endsWith("\n") ? "" : "\n"}`;
14637
15002
  merged = setTopLevelField(merged, "updated", nowTimestamp());
14638
15003
  await writeFileForce(filePath, merged);
14639
- const detail = await getItemDetail(kind, basename3(projectDir), itemSlug);
15004
+ const detail = await getItemDetail(kind, basename4(projectDir), itemSlug);
14640
15005
  res.json({ [kind]: detail, content: merged });
14641
15006
  } catch (error) {
14642
15007
  console.error(`Error updating ${kind}:`, error);
@@ -17854,7 +18219,7 @@ async function resolveSessionPlan(input, terminal) {
17854
18219
  // src/launch/execute.ts
17855
18220
  import { spawn as spawn3 } from "child_process";
17856
18221
  import { homedir as homedir5 } from "os";
17857
- import { basename as basename4, join as join6, resolve as resolve26 } from "path";
18222
+ import { basename as basename5, join as join6, resolve as resolve26 } from "path";
17858
18223
  init_fs();
17859
18224
  init_config2();
17860
18225
  init_session_id();
@@ -17874,7 +18239,7 @@ var TerminalNotFoundError = class extends Error {
17874
18239
  var realSpawn = (command, args, options) => spawn3(command, args, options);
17875
18240
  var WRAPPER_COMMANDS = /* @__PURE__ */ new Set(["osascript", "open", "sh"]);
17876
18241
  function isWrapperCommand(command) {
17877
- return WRAPPER_COMMANDS.has(basename4(command));
18242
+ return WRAPPER_COMMANDS.has(basename5(command));
17878
18243
  }
17879
18244
  var WRAPPER_EXIT_TIMEOUT_MS = 1500;
17880
18245
  async function executeLaunchPlan(plan, spawnFn = realSpawn) {
@@ -19654,7 +20019,7 @@ async function readEvents(jobId) {
19654
20019
 
19655
20020
  // src/schedules/attempt.ts
19656
20021
  import { createHash as createHash3 } from "crypto";
19657
- import { open as open4, readFile as readFile22, stat as stat4, unlink as unlink6 } from "fs/promises";
20022
+ import { open as open4, readFile as readFile22, stat as stat5, unlink as unlink6 } from "fs/promises";
19658
20023
  import { resolve as resolve31 } from "path";
19659
20024
 
19660
20025
  // src/schedules/triggers.ts
@@ -19851,7 +20216,7 @@ async function acquireJobLock(id) {
19851
20216
  const code = err.code;
19852
20217
  if (code !== "EEXIST") throw err;
19853
20218
  try {
19854
- const info = await stat4(lockPath);
20219
+ const info = await stat5(lockPath);
19855
20220
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS2) {
19856
20221
  await unlink6(lockPath).catch(() => {
19857
20222
  });
@@ -21853,8 +22218,8 @@ init_api();
21853
22218
  import { raw } from "express";
21854
22219
 
21855
22220
  // src/todos/attachments.ts
21856
- import { mkdir as mkdir4, readdir as readdir15, stat as stat5, rename as rename5, rm as rm4, unlink as unlink7, writeFile as writeFile6, cp } from "fs/promises";
21857
- import { resolve as resolve39, basename as basename5, dirname as dirname9, extname } from "path";
22221
+ import { mkdir as mkdir4, readdir as readdir15, stat as stat6, rename as rename5, rm as rm4, unlink as unlink7, writeFile as writeFile6, cp } from "fs/promises";
22222
+ import { resolve as resolve39, basename as basename6, dirname as dirname9, extname } from "path";
21858
22223
 
21859
22224
  // src/utils/proof-artifact-id.ts
21860
22225
  import { randomBytes as randomBytes2 } from "crypto";
@@ -21927,7 +22292,7 @@ function isSafeInlineMime(mime) {
21927
22292
  return SAFE_INLINE_MIME.has(mime);
21928
22293
  }
21929
22294
  function sanitizeAttachmentName(name) {
21930
- let n = basename5(name || "").replace(/["'\\/]/g, "_");
22295
+ let n = basename6(name || "").replace(/["'\\/]/g, "_");
21931
22296
  n = Array.from(n, (ch) => {
21932
22297
  const code = ch.charCodeAt(0);
21933
22298
  return code < 32 || code === 127 ? "_" : ch;
@@ -21950,7 +22315,7 @@ function attachmentDirFor(todosDir2, scopeId, todoId) {
21950
22315
  }
21951
22316
  async function dirExists(p) {
21952
22317
  try {
21953
- return (await stat5(p)).isDirectory();
22318
+ return (await stat6(p)).isDirectory();
21954
22319
  } catch {
21955
22320
  return false;
21956
22321
  }
@@ -21985,7 +22350,7 @@ async function listAttachments(todosDir2, scopeId, todoId) {
21985
22350
  if (!ATTACHMENT_ID_RE.test(id)) continue;
21986
22351
  const filename = stored.slice(sep2 + 2);
21987
22352
  try {
21988
- const st = await stat5(resolve39(dir, stored));
22353
+ const st = await stat6(resolve39(dir, stored));
21989
22354
  if (!st.isFile()) continue;
21990
22355
  out.push({ id, filename, mime: mimeForName(filename), size: st.size, createdAt: st.mtime.toISOString() });
21991
22356
  } catch {
@@ -24151,7 +24516,7 @@ init_fs();
24151
24516
  init_config2();
24152
24517
  import { execFile as execFile2 } from "child_process";
24153
24518
  import { promisify as promisify2 } from "util";
24154
- import { cp as cp2, mkdtemp, rm as rm5, readFile as readFile29, writeFile as writeFile7, unlink as unlink8, stat as stat6, open as open5, rename as rename8 } from "fs/promises";
24519
+ import { cp as cp2, mkdtemp, rm as rm5, readFile as readFile29, writeFile as writeFile7, unlink as unlink8, stat as stat7, open as open5, rename as rename8 } from "fs/promises";
24155
24520
  import { resolve as resolve42, join as join9 } from "path";
24156
24521
  import { tmpdir } from "os";
24157
24522
  var exec2 = promisify2(execFile2);
@@ -24245,7 +24610,7 @@ async function cloneOrInit(repoUrl, destDir) {
24245
24610
  }
24246
24611
  async function copyRecursive(src, dest) {
24247
24612
  if (!await fileExists(src)) return;
24248
- const s = await stat6(src);
24613
+ const s = await stat7(src);
24249
24614
  if (s.isDirectory()) {
24250
24615
  await ensureDir(dest);
24251
24616
  await cp2(src, dest, { recursive: true, force: true });
@@ -25101,6 +25466,41 @@ function runRollup() {
25101
25466
  return { daysComputed, rowsWritten };
25102
25467
  }
25103
25468
 
25469
+ // src/usage/pricing.ts
25470
+ var PER_MILLION = 1e6;
25471
+ var MODEL_PRICING = {
25472
+ // Moonshot Kimi K2.6 — the model pi emits today. Official Moonshot list price.
25473
+ // source: https://platform.moonshot.ai/ (official) — cross-checked
25474
+ // https://openrouter.ai/moonshotai/kimi-k2.6 (retrieved 2026-06-17)
25475
+ "moonshotai/kimi-k2.6": { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0.95 },
25476
+ // Moonshot Kimi K2.5 — prior Kimi model. Official Moonshot list price.
25477
+ // source: https://platform.moonshot.ai/ — cross-checked
25478
+ // https://openrouter.ai/moonshotai/kimi-k2.5 (retrieved 2026-06-17)
25479
+ "moonshotai/kimi-k2.5": { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0.6 },
25480
+ // Z.ai (Zhipu) GLM-5.2 — official z.ai platform list price.
25481
+ // source: https://docs.z.ai/guides/overview/pricing — cross-checked
25482
+ // https://openrouter.ai/z-ai/glm-5 (retrieved 2026-06-18)
25483
+ "zai-org/glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 1.4 },
25484
+ // MiniMax M2.5 — official MiniMax platform list price. No separately-pinned
25485
+ // cached-read rate is published, so cacheRead is set conservatively to the
25486
+ // input rate (upper bound); revise if MiniMax publishes a cache rate.
25487
+ // source: https://platform.minimax.io/docs/guides/pricing-token-plan
25488
+ // — cross-checked https://openrouter.ai/minimax/minimax-m2.5 (retrieved 2026-06-18)
25489
+ "minimaxai/minimax-m2.5": { input: 0.15, output: 0.9, cacheRead: 0.15, cacheWrite: 0.15 }
25490
+ // NOTE: opaque Synthetic tier aliases like `syn:large:text` have no public
25491
+ // per-token rate (they route to whatever Synthetic assigns), so they remain
25492
+ // unpriced (→ $0). Reseller discounts (e.g. DeepInfra K2.6 0.75/3.50/0.15) are
25493
+ // rejected by the canonical-source rule and are NOT used here.
25494
+ };
25495
+ function normalizeModelKey(model) {
25496
+ return model.replace(/^\s*\[[^\]]*\]\s*/, "").replace(/^hf:/i, "").trim().toLowerCase();
25497
+ }
25498
+ function priceForModel(model, tokens) {
25499
+ const rate = MODEL_PRICING[normalizeModelKey(model)];
25500
+ if (!rate) return null;
25501
+ return (tokens.inputTokens * rate.input + tokens.outputTokens * rate.output + tokens.cacheReadTokens * rate.cacheRead + tokens.cacheCreationTokens * rate.cacheWrite) / PER_MILLION;
25502
+ }
25503
+
25104
25504
  // src/usage/collect.ts
25105
25505
  var THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
25106
25506
  function formatDayFromCcusageDate(yyyymmdd) {
@@ -25134,6 +25534,12 @@ async function collectAndPersist() {
25134
25534
  cwd,
25135
25535
  eventTs: row.eventTs
25136
25536
  });
25537
+ const totalCost = row.totalCost > 0 ? row.totalCost : priceForModel(row.model, {
25538
+ inputTokens: row.inputTokens,
25539
+ outputTokens: row.outputTokens,
25540
+ cacheCreationTokens: row.cacheCreationTokens,
25541
+ cacheReadTokens: row.cacheReadTokens
25542
+ }) ?? 0;
25137
25543
  return {
25138
25544
  sessionId: row.sessionId,
25139
25545
  model: row.model,
@@ -25144,7 +25550,7 @@ async function collectAndPersist() {
25144
25550
  cacheCreationTokens: row.cacheCreationTokens,
25145
25551
  cacheReadTokens: row.cacheReadTokens,
25146
25552
  totalTokens: row.totalTokens,
25147
- totalCost: row.totalCost,
25553
+ totalCost,
25148
25554
  cwd,
25149
25555
  projectSlug: attr.projectSlug ?? "",
25150
25556
  assignmentSlug: attr.assignmentSlug ?? "",
@@ -25161,8 +25567,72 @@ async function collectAndPersist() {
25161
25567
  tx.immediate();
25162
25568
  return { isFirstRun, rowsIngested: enriched.length };
25163
25569
  }
25570
+ function backfillZeroCostEvents() {
25571
+ const db5 = getUsageDb();
25572
+ const select = db5.prepare(
25573
+ `SELECT session_id, model, input_tokens, output_tokens,
25574
+ cache_creation_tokens, cache_read_tokens
25575
+ FROM usage_events
25576
+ WHERE total_cost = 0`
25577
+ );
25578
+ const update = db5.prepare(
25579
+ `UPDATE usage_events SET total_cost = @cost, updated_at = @updatedAt
25580
+ WHERE session_id = @sessionId AND model = @model AND total_cost = 0`
25581
+ );
25582
+ let updated = 0;
25583
+ const tx = db5.transaction(() => {
25584
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25585
+ for (const r of select.all()) {
25586
+ const cost = priceForModel(r.model, {
25587
+ inputTokens: r.input_tokens,
25588
+ outputTokens: r.output_tokens,
25589
+ cacheCreationTokens: r.cache_creation_tokens,
25590
+ cacheReadTokens: r.cache_read_tokens
25591
+ });
25592
+ if (cost !== null && cost > 0) {
25593
+ updated += update.run({ cost, updatedAt, sessionId: r.session_id, model: r.model }).changes;
25594
+ }
25595
+ }
25596
+ });
25597
+ tx.immediate();
25598
+ return updated;
25599
+ }
25600
+ function reattributeOrphanEvents() {
25601
+ const db5 = getUsageDb();
25602
+ const select = db5.prepare(
25603
+ `SELECT session_id, model, cwd, event_ts
25604
+ FROM usage_events
25605
+ WHERE project_slug = '' AND assignment_slug = ''`
25606
+ );
25607
+ const update = db5.prepare(
25608
+ `UPDATE usage_events
25609
+ SET project_slug = @projectSlug, assignment_slug = @assignmentSlug, updated_at = @updatedAt
25610
+ WHERE session_id = @sessionId AND model = @model
25611
+ AND project_slug = '' AND assignment_slug = ''`
25612
+ );
25613
+ let updated = 0;
25614
+ const tx = db5.transaction(() => {
25615
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
25616
+ for (const r of select.all()) {
25617
+ const attr = resolveAttribution({
25618
+ sessionId: r.session_id,
25619
+ cwd: r.cwd,
25620
+ eventTs: r.event_ts
25621
+ });
25622
+ const projectSlug = attr.projectSlug ?? "";
25623
+ const assignmentSlug = attr.assignmentSlug ?? "";
25624
+ if (projectSlug !== "" || assignmentSlug !== "") {
25625
+ updated += update.run({ projectSlug, assignmentSlug, updatedAt, sessionId: r.session_id, model: r.model }).changes;
25626
+ }
25627
+ }
25628
+ });
25629
+ tx.immediate();
25630
+ return updated;
25631
+ }
25164
25632
  async function collectUsage() {
25165
25633
  const info = await collectAndPersist();
25634
+ backfillZeroCostEvents();
25635
+ reattributeOrphanEvents();
25166
25636
  runRollup();
25167
25637
  advanceMetaIso("usage_collector_heartbeat", (/* @__PURE__ */ new Date()).toISOString());
25168
25638
  return info;
@@ -25795,6 +26265,8 @@ function createDashboardServer(options) {
25795
26265
  });
25796
26266
  }
25797
26267
  let watcherHandle = null;
26268
+ const STALENESS_WATCHDOG_INTERVAL_MS = 5 * 60 * 1e3;
26269
+ let stalenessWatchdogTimer = null;
25798
26270
  return {
25799
26271
  async start() {
25800
26272
  const { recomputeAndWrite: recomputeAndWrite2, recomputeAll: recomputeAll2, resolveDeriveContext: resolveDeriveContext2, isDeriveMigrated: isDeriveMigrated2 } = await Promise.resolve().then(() => (init_recompute(), recompute_exports));
@@ -25891,6 +26363,38 @@ function createDashboardServer(options) {
25891
26363
  onAgentSessionsChanged: () => broadcast({ type: "agent-sessions-updated", timestamp: (/* @__PURE__ */ new Date()).toISOString() })
25892
26364
  });
25893
26365
  startUsageCollector();
26366
+ const startupConfig = await readConfig();
26367
+ if (startupConfig.stalenessWatchdog) {
26368
+ const { collectStaleCandidates: collectStaleCandidates2 } = await Promise.resolve().then(() => (init_api(), api_exports));
26369
+ const { runStalenessWatchdogTick: runStalenessWatchdogTick2 } = await Promise.resolve().then(() => (init_watchdog(), watchdog_exports));
26370
+ const { emitEvent: emitEvent2 } = await Promise.resolve().then(() => (init_event_emit(), event_emit_exports));
26371
+ const stalenessSeen = /* @__PURE__ */ new Set();
26372
+ const watchdogTick = async () => {
26373
+ if (!await migrationGate()) return;
26374
+ try {
26375
+ const candidates = await collectStaleCandidates2(projectsDir, assignmentsDir2);
26376
+ const summary = runStalenessWatchdogTick2(candidates, stalenessSeen, (e) => {
26377
+ emitEvent2({
26378
+ assignmentId: e.assignmentId,
26379
+ projectSlug: e.projectSlug,
26380
+ type: e.type,
26381
+ actor: "system",
26382
+ details: { reasons: e.reasons.map((r) => r.kind) }
26383
+ });
26384
+ });
26385
+ if (summary.newlyStale > 0 || summary.cleared > 0) {
26386
+ console.log(
26387
+ `staleness watchdog: ${summary.newlyStale} newly stale, ${summary.cleared} cleared (${summary.stale}/${summary.scanned} stale).`
26388
+ );
26389
+ }
26390
+ } catch (err) {
26391
+ console.error("staleness watchdog tick failed:", err);
26392
+ }
26393
+ };
26394
+ void watchdogTick();
26395
+ stalenessWatchdogTimer = setInterval(() => void watchdogTick(), STALENESS_WATCHDOG_INTERVAL_MS);
26396
+ stalenessWatchdogTimer.unref?.();
26397
+ }
25894
26398
  return new Promise((resolvePromise, reject) => {
25895
26399
  server.on("error", (err) => {
25896
26400
  if (err.code === "EADDRINUSE") {
@@ -25912,6 +26416,10 @@ function createDashboardServer(options) {
25912
26416
  });
25913
26417
  },
25914
26418
  async stop() {
26419
+ if (stalenessWatchdogTimer) {
26420
+ clearInterval(stalenessWatchdogTimer);
26421
+ stalenessWatchdogTimer = null;
26422
+ }
25915
26423
  await stopAutodiscovery();
25916
26424
  await stopUsageCollector();
25917
26425
  if (watcherHandle) {