claude-code-session-manager 0.95.0 → 0.97.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.
Files changed (114) hide show
  1. package/dist/assets/{DataModel-DZK9QZpV.js → DataModel-D93FoaOm.js} +1 -1
  2. package/dist/assets/History-CXxxqNP1.js +2 -0
  3. package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CzeYFQjq.js} +3 -3
  4. package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-nESrTRIg.js} +1 -1
  5. package/dist/assets/{Library-DTxpOjx8.js → Library-D2zPkwOC.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-DbinP78A.js} +1 -1
  7. package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-Bu_sVGLr.js} +2 -2
  8. package/dist/assets/Memory-BgiI6LZ_.js +8 -0
  9. package/dist/assets/{Permissions-DhMMp27n.js → Permissions-C1MMzZRm.js} +3 -3
  10. package/dist/assets/Plugins-Dhu6wAZi.js +2 -0
  11. package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-C3AcWKMG.js} +1 -1
  12. package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-plwYm0iO.js} +1 -1
  13. package/dist/assets/Scheduler-BVYTeh39.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-DFy0vv_o.js} +1 -1
  15. package/dist/assets/Settings-DIhGgdfs.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-DsI7hRPB.js} +1 -1
  17. package/dist/assets/Skills-3E748UfB.js +3 -0
  18. package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt-BqmfUJOd.js} +1 -1
  19. package/dist/assets/TagLibrary-CPz6fO8Q.js +1 -0
  20. package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-D9EoBQ1P.js} +1 -1
  21. package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BHAmZNDn.js} +1 -1
  22. package/dist/assets/index-tn5JIVPj.js +3079 -0
  23. package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-Crqy_aNz.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -2
  26. package/plugins/CLAUDE.md +3 -3
  27. package/plugins/session-manager-dev/skills/develop/SKILL.md +90 -67
  28. package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
  29. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
  30. package/scripts/README.md +1 -0
  31. package/scripts/hooks/guard-destructive-git.cjs +9 -374
  32. package/scripts/hooks/guard-inline-implementation.cjs +9 -128
  33. package/scripts/hooks/guard-prd-writes.cjs +9 -74
  34. package/scripts/hooks/guard-self-schedule.cjs +9 -61
  35. package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
  36. package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
  37. package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
  38. package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
  39. package/scripts/scheduler-mcp-server.cjs +8 -1
  40. package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
  41. package/src/main/__tests__/exchanges.test.cjs +41 -96
  42. package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
  43. package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
  44. package/src/main/__tests__/health-credentials.test.cjs +81 -0
  45. package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +39 -0
  46. package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
  47. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
  48. package/src/main/__tests__/planValidator.test.cjs +125 -0
  49. package/src/main/__tests__/prdCreate.test.cjs +3 -1
  50. package/src/main/__tests__/prdSizing.test.cjs +106 -0
  51. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
  52. package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
  53. package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
  54. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
  55. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
  56. package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
  57. package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
  58. package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
  59. package/src/main/__tests__/telemetryClient.test.cjs +2 -2
  60. package/src/main/__tests__/telemetryContract.test.cjs +2 -2
  61. package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
  62. package/src/main/__tests__/validationSentinels.test.cjs +84 -0
  63. package/src/main/build-info.json +4 -4
  64. package/src/main/config.cjs +26 -14
  65. package/src/main/exchanges.cjs +26 -21
  66. package/src/main/health.cjs +66 -4
  67. package/src/main/index.cjs +1 -1
  68. package/src/main/ipcSchemas.cjs +15 -7
  69. package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
  70. package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
  71. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
  72. package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
  73. package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
  74. package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
  75. package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
  76. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
  77. package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
  78. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
  79. package/src/main/lib/agentModelResolve.cjs +1 -0
  80. package/src/main/lib/credentials.cjs +62 -0
  81. package/src/main/lib/cwdClassify.cjs +15 -0
  82. package/src/main/lib/definitionOfDone.cjs +9 -13
  83. package/src/main/lib/effectiveModelInfo.cjs +2 -9
  84. package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
  85. package/src/main/lib/loadGate.cjs +29 -5
  86. package/src/main/lib/opsOwnership.cjs +23 -0
  87. package/src/main/lib/planValidator.cjs +80 -0
  88. package/src/main/lib/prdCreate.cjs +8 -4
  89. package/src/main/lib/prdSizing.cjs +83 -0
  90. package/src/main/lib/runLogRetention.cjs +2 -8
  91. package/src/main/lib/telemetryBacklog.cjs +9 -3
  92. package/src/main/lib/telemetryClient.cjs +12 -3
  93. package/src/main/lib/telemetrySettings.cjs +8 -2
  94. package/src/main/lib/validationSentinels.cjs +29 -0
  95. package/src/main/memoryAggregate.cjs +36 -11
  96. package/src/main/otelSettings.cjs +8 -2
  97. package/src/main/scheduler.cjs +215 -31
  98. package/src/main/seedAgentPersonas.cjs +1 -1
  99. package/src/main/sessionsStore.cjs +8 -2
  100. package/src/main/supervisor.cjs +3 -3
  101. package/src/main/templates/PRD_AUTHORING.md +9 -9
  102. package/src/main/voiceSettings.cjs +8 -2
  103. package/src/preload/api.d.ts +3 -3
  104. package/src/preload/index.cjs +5 -5
  105. package/src/seed/agents/dev-lead.md +24 -15
  106. package/src/seed/agents/validator.md +33 -0
  107. package/dist/assets/History-BDyJUGtR.js +0 -2
  108. package/dist/assets/Memory-B-9GlHqe.js +0 -8
  109. package/dist/assets/Plugins-DKfAlmMY.js +0 -2
  110. package/dist/assets/Scheduler--rn1QBYc.js +0 -16
  111. package/dist/assets/Settings-Dsx6XZP5.js +0 -3
  112. package/dist/assets/Skills-D522Zfol.js +0 -3
  113. package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
  114. package/dist/assets/index-Yf8e7VCB.js +0 -3079
@@ -23,7 +23,7 @@ const { computeStallSummary, computeDepHistorySatisfaction, FAILURE_STREAK_ESCAL
23
23
  const { findStarvedProjects, findUnresolvableDepRoots, DEFAULT_PROJECT_CWD, DEP_HISTORY_FAIL_OPEN } = require('./lib/schedulerBatch.cjs');
24
24
  const { auditLogPath, readTail } = require('./lib/auditLog.cjs');
25
25
  const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
26
- const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
26
+ const { computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
27
27
  const { allProjectCwds } = require('./lib/activeSessions.cjs');
28
28
  const { scanEpicTranscripts } = require('./lib/epicTranscriptDiagnostic.cjs');
29
29
 
@@ -627,6 +627,50 @@ function evaluateBuildFreshness({ running = null, installedOnDisk = null, repoHe
627
627
  return { ok: true, running, installedOnDisk, repoHead, restartNeeded, publishNeeded, ...(notes.length ? { note: notes.join('; ') } : {}) };
628
628
  }
629
629
 
630
+ /**
631
+ * evaluateCredentialHealth({ credState, now }) → { component, issues }
632
+ *
633
+ * Pure over its inputs — `credState` is the shape lib/credentials.cjs's
634
+ * refreshIfNeeded()/readCredentials() return ({ kind, message?, expiredAt? }),
635
+ * injected so this is testable without touching the Keychain. Surfaces the ONE
636
+ * failure that silently voids the whole scheduler on a user's machine: the
637
+ * OAuth token is expired (or missing) so every headless `claude -p` job fails
638
+ * to authenticate. The scheduler already pauses on a live 'auth' poll
639
+ * (scheduler.cjs setPaused('auth')), but nothing told the OPERATOR why the
640
+ * queue went quiet — `npm run health` read GREEN while no job could run. This
641
+ * names the cause and the fix (`claude login`).
642
+ *
643
+ * NON-CRITICAL by design: reported in components.credentials + issues so
644
+ * /local-project-health shows it, but it does not flip status.ok — health must
645
+ * not hard-fail a repo checkout just because THIS machine's token lapsed, and
646
+ * an expired token is an operator action (re-login), not a code defect. Maps:
647
+ * kind 'auth' → not ok, actionable `claude login` message (the real block)
648
+ * kind 'config' → not ok, credentials unreadable/absent
649
+ * kind 'ok'/'unsupported' → ok (token usable, or too-fresh-to-refresh)
650
+ */
651
+ function evaluateCredentialHealth({ credState, now = Date.now() } = {}) {
652
+ const issues = [];
653
+ if (!credState || typeof credState !== 'object') {
654
+ return { component: { ok: true, state: 'unknown' }, issues };
655
+ }
656
+ const { kind } = credState;
657
+ if (kind === 'auth') {
658
+ const expiredAt = typeof credState.expiredAt === 'number' ? credState.expiredAt : null;
659
+ const agoMin = expiredAt ? Math.max(0, Math.round((now - expiredAt) / 60_000)) : null;
660
+ const when = expiredAt ? new Date(expiredAt).toISOString() : 'unknown';
661
+ const msg = `Claude credentials expired${agoMin !== null ? ` (${agoMin}m ago, at ${when})` : ''} — headless \`claude -p\` scheduler jobs cannot authenticate. Run \`claude login\` in a terminal to refresh.`;
662
+ issues.push(msg);
663
+ return { component: { ok: false, state: 'expired', expiredAt, message: msg }, issues };
664
+ }
665
+ if (kind === 'config') {
666
+ const msg = `Claude credentials unreadable: ${credState.message ?? 'unknown'} — scheduler jobs cannot authenticate. Run \`claude login\`.`;
667
+ issues.push(msg);
668
+ return { component: { ok: false, state: 'unreadable', message: msg }, issues };
669
+ }
670
+ // 'ok' (fresh) or 'unsupported' (auto-refresh unavailable but token still valid)
671
+ return { component: { ok: true, state: kind === 'unsupported' ? 'valid-no-refresh' : 'valid' }, issues };
672
+ }
673
+
630
674
  /**
631
675
  * evaluateStarveEscalationHealth(jobs, now, thresholdMs, escalationReasons)
632
676
  * → { ok, projects?, message? }
@@ -1138,15 +1182,16 @@ async function check(opts = {}) {
1138
1182
  // `config`, same home as every other scheduler machine setting.
1139
1183
  try {
1140
1184
  const retentionCfg = queueState?.config?.schedulerRunLogRetention;
1141
- const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir: DEFAULT_RUNS_DIR });
1185
+ const runsDir = schedulerPaths.runsDir();
1186
+ const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir });
1142
1187
  const report = computeReport(
1143
- DEFAULT_RUNS_DIR,
1188
+ runsDir,
1144
1189
  retentionCfg?.policy || {},
1145
1190
  { liveKeys }
1146
1191
  );
1147
1192
  status.components.run_log_retention = {
1148
1193
  ok: true,
1149
- path: DEFAULT_RUNS_DIR,
1194
+ path: runsDir,
1150
1195
  totalBytes: report.usage.totalBytes,
1151
1196
  dirCount: report.usage.dirCount,
1152
1197
  runCount: report.usage.runCount,
@@ -1205,6 +1250,22 @@ async function check(opts = {}) {
1205
1250
  status.components.build = { ok: true, error: e.message };
1206
1251
  }
1207
1252
 
1253
+ // 6.8. Credential health (informational-but-loud): is the Claude OAuth token
1254
+ // usable? An expired/absent token silently voids every headless `claude -p`
1255
+ // job — the queue "runs" but nothing authenticates. refreshIfNeeded() reads
1256
+ // the Keychain/file and (post futile-refresh fix) never spawns a doomed CLI
1257
+ // fallback for an expired-no-refresh-token creds, so this is a cheap read.
1258
+ // Non-critical: names the cause + `claude login` fix without flipping ok.
1259
+ try {
1260
+ const { refreshIfNeeded } = require('./lib/credentials.cjs');
1261
+ const credState = await refreshIfNeeded();
1262
+ const { component, issues } = evaluateCredentialHealth({ credState, now: Date.now() });
1263
+ status.components.credentials = component;
1264
+ for (const i of issues) status.issues.push(i);
1265
+ } catch (e) {
1266
+ status.components.credentials = { ok: true, state: 'unknown', error: e.message };
1267
+ }
1268
+
1208
1269
  // 7. Summary scoring: ok if all critical components pass.
1209
1270
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
1210
1271
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
@@ -1231,6 +1292,7 @@ module.exports = {
1231
1292
  readFreshHeartbeat,
1232
1293
  evaluatePrdMigrationHealth,
1233
1294
  evaluateDelegationChainHealth,
1295
+ evaluateCredentialHealth,
1234
1296
  computeEpicIndexDrift,
1235
1297
  evaluateEpicIndexHealth,
1236
1298
  computeProjectProblemCounts,
@@ -1027,7 +1027,7 @@ ipcMain.handle('exchanges:list', validated(schemas.exchangesList, async (payload
1027
1027
  }
1028
1028
  }));
1029
1029
 
1030
- // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
1030
+ // OTEL telemetry export (opt-in via ~/.claude/session-manager/otel.json).
1031
1031
  ipcMain.handle('otel:get-config', async () => otelSettings.load());
1032
1032
  ipcMain.handle('otel:set-config', async (_e, cfg) => {
1033
1033
  if (!otelSettings.isValid(cfg)) {
@@ -322,10 +322,14 @@ const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be
322
322
  // Shared shape for a `dependsOn` slug entry — schedulerCreatePrd and
323
323
  // adminUpdatePrd (scheduler_update_prd, PRD 1124) must accept the exact same
324
324
  // slug shape or an update could write a dependsOn value create would have
325
- // rejected. Array-level constraints (`.max(20)`, `.optional()`) stay at each
326
- // call site since update also allows an explicit empty array to CLEAR the
327
- // dependency, which create has no reason to accept.
325
+ // rejected. Array-level constraints (`.max(MAX_DEPENDS_ON)`, `.optional()`)
326
+ // stay at each call site since update also allows an explicit empty array to
327
+ // CLEAR the dependency, which create has no reason to accept.
328
328
  const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
329
+ // Array-level cap shared by every dependsOn field below — a validate PRD in a
330
+ // large /develop plan depends on every other slug in the plan, so this must
331
+ // stay well above the largest plan size the PRD authoring flow allows.
332
+ const MAX_DEPENDS_ON = 100;
329
333
  // Wave-authoring decision (scheduler wave-disposition PRD), shared between
330
334
  // schedulerCreatePrd (authoring time) and adminPrdFrontmatterPatch (the
331
335
  // after-the-fact change path) so both can never disagree on what a valid
@@ -350,7 +354,7 @@ const schedulerCreatePrd = z.object({
350
354
  parallelGroup: z.number().int().min(1).max(999999).optional(),
351
355
  // Explicit ordering (PRD 832): slugs that must complete before this PRD
352
356
  // becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
353
- dependsOn: z.array(DepSlugSchema).max(20).optional(),
357
+ dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
354
358
  // Explicit wave-authoring decision (scheduler wave-disposition PRD) — only
355
359
  // meaningful (and only stamped) when this PRD joins an Epic that already
356
360
  // has incomplete PRDs and lands as a wave root (no dependsOn of its own);
@@ -417,7 +421,7 @@ const scheduleSetPrdDisposition = z.object({
417
421
  slug: z.string().regex(SCHEDULE_SLUG_RE),
418
422
  cwd: z.string().min(1).max(4096).optional(),
419
423
  disposition: PrdDispositionSchema,
420
- dependsOn: z.array(DepSlugSchema).max(20).optional(),
424
+ dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
421
425
  });
422
426
 
423
427
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
@@ -501,7 +505,7 @@ const adminPrdFrontmatterPatch = z.object({
501
505
  // non-empty array is validated write-time against the SAME resolver
502
506
  // (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
503
507
  // can never disagree about what a dependsOn entry resolves to.
504
- dependsOn: z.array(DepSlugSchema).max(20).optional(),
508
+ dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
505
509
  // Patchable after creation too — the Scheduler UI's "change disposition"
506
510
  // action (promote a wave to its own head, or attach it behind another
507
511
  // chain) rewrites this alongside dependsOn via the same
@@ -632,10 +636,14 @@ const memoryStale = z.object({
632
636
  // the encoded cwd slug (memoryAggregate.cjs reads directly from
633
637
  // ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
634
638
  // handlers. `refresh: true` is the cost gate that fires the single claude -p
635
- // clustering pass; falsy returns the cached result.
639
+ // clustering pass; falsy returns the cached result. `cwd` (PRD 1389,
640
+ // optional — same shape as memory:stale's) addresses the per-project cache
641
+ // at <cwd>/session-manager-operations/memory-clusters/clusters.json;
642
+ // absent/invalid cwd just disables caching for that call.
636
643
  const memoryAggregate = z.object({
637
644
  workspace: z.string().regex(MEMORY_WORKSPACE_RE),
638
645
  refresh: z.boolean().optional(),
646
+ cwd: z.string().max(4096).optional(),
639
647
  }).strict();
640
648
 
641
649
  // ──────────────────────────────────────────── Project Brief (PRD 837)
@@ -35,7 +35,7 @@ async function mkHome() {
35
35
  process.env.HOME = dir;
36
36
  // Explicit opt-in: overrides the test-environment no-op guard so this
37
37
  // suite's real telemetryClient calls actually persist into an isolated dir.
38
- process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
38
+ process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.claude', 'session-manager');
39
39
  return dir;
40
40
  }
41
41
 
@@ -0,0 +1,115 @@
1
+ /**
2
+ * credentials-futile-refresh.test.cjs — the 2026-09-23 credential-refresh
3
+ * storm. On this machine the OAuth creds were expired with NO refresh token
4
+ * (expiredAtMs 1775277875159). Every 15 s billing poll called refreshIfNeeded,
5
+ * which tried the OAuth grant (instant 'unsupported' — no refresh token),
6
+ * then spawned `claude --version` as a CLI fallback (which shares the same
7
+ * missing token, so it can't help either), then logged auth_failed_expired —
8
+ * the identical triple, forever, spawning a doomed child process each time.
9
+ *
10
+ * The fix: a futile-refresh short-circuit. When the token is expired AND has
11
+ * no refresh token, refreshIfNeeded re-reads once (to catch an out-of-band
12
+ * `claude login`) and otherwise returns the auth verdict WITHOUT the OAuth
13
+ * call or the CLI-fallback spawn, logging at most once per cooldown window.
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/credentials-futile-refresh.test.cjs
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import { test, expect, beforeEach, afterAll } from 'vitest';
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ // credentials.cjs freezes CREDS_PATH and REFRESH_LOG_PATH from os.homedir() at
26
+ // require time. os.homedir() honors $HOME on POSIX, so point HOME at a temp
27
+ // dir BEFORE the require (not in beforeEach — that runs too late) so both the
28
+ // creds file we write and the log the code writes resolve under our sandbox.
29
+ const originalHome = process.env.HOME;
30
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-cred-futile-'));
31
+ process.env.HOME = tmpHome;
32
+
33
+ const credentials = require('../credentials.cjs');
34
+ const { isRefreshFutile, refreshIfNeeded, __resetFutileRefreshThrottle } = credentials;
35
+
36
+ const CREDS_FILE = path.join(tmpHome, '.claude', '.credentials.json');
37
+ const LOG_FILE = path.join(tmpHome, '.claude', 'session-manager', 'credential-refresh.log');
38
+
39
+ function writeCreds(oauth) {
40
+ fs.mkdirSync(path.dirname(CREDS_FILE), { recursive: true });
41
+ fs.writeFileSync(CREDS_FILE, JSON.stringify({ claudeAiOauth: oauth }), 'utf8');
42
+ }
43
+
44
+ function readLogLines() {
45
+ try {
46
+ return fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ beforeEach(() => {
53
+ // Clean slate per test: no stale creds file, no carried-over log or throttle.
54
+ try { fs.unlinkSync(CREDS_FILE); } catch { /* absent */ }
55
+ try { fs.unlinkSync(LOG_FILE); } catch { /* absent */ }
56
+ __resetFutileRefreshThrottle();
57
+ });
58
+
59
+ afterAll(() => {
60
+ process.env.HOME = originalHome;
61
+ fs.rmSync(tmpHome, { recursive: true, force: true });
62
+ });
63
+
64
+ test('isRefreshFutile: expired + no refresh token is futile; anything else is not', () => {
65
+ const past = Date.now() - 60_000;
66
+ const future = Date.now() + 60 * 60_000;
67
+ expect(isRefreshFutile({ accessToken: 'a', expiresAt: past })).toBe(true);
68
+ // Has a refresh token → OAuth grant is worth attempting, not futile.
69
+ expect(isRefreshFutile({ accessToken: 'a', expiresAt: past, refreshToken: 'r' })).toBe(false);
70
+ // Not expired → not futile even without a refresh token.
71
+ expect(isRefreshFutile({ accessToken: 'a', expiresAt: future })).toBe(false);
72
+ });
73
+
74
+ test('a futile refresh returns an auth verdict without spawning the CLI fallback, and logs the no-refresh-token event once', async () => {
75
+ writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 }); // expired, no refreshToken
76
+
77
+ const res = await refreshIfNeeded();
78
+
79
+ expect(res.kind).toBe('auth');
80
+ expect(res.message).toMatch(/no refresh token/i);
81
+ expect(typeof res.expiredAt).toBe('number');
82
+
83
+ const events = readLogLines().map((e) => e.event);
84
+ // The distinctive no-refresh-token event fires...
85
+ expect(events).toContain('auth_failed_expired_no_refresh_token');
86
+ // ...and the storm's spawn/oauth triple never runs.
87
+ expect(events).not.toContain('cli_fallback_ok');
88
+ expect(events).not.toContain('cli_fallback_failed');
89
+ expect(events).not.toContain('oauth_refresh_unsupported');
90
+ });
91
+
92
+ test('repeated polls in the cooldown window do NOT re-log — the every-15s storm is suppressed', async () => {
93
+ writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 });
94
+
95
+ // Simulate several poll cycles back-to-back (as pollLoop would, every 15 s).
96
+ for (let i = 0; i < 5; i++) {
97
+ const res = await refreshIfNeeded();
98
+ expect(res.kind).toBe('auth');
99
+ }
100
+
101
+ const futileLogs = readLogLines().filter((e) => e.event === 'auth_failed_expired_no_refresh_token');
102
+ expect(futileLogs.length).toBe(1); // once, not five times
103
+ });
104
+
105
+ test('an out-of-band `claude login` (fresh creds on disk) is picked up on the next call', async () => {
106
+ writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 });
107
+ expect((await refreshIfNeeded()).kind).toBe('auth');
108
+
109
+ // User runs `claude` in a terminal → fresh creds land on disk.
110
+ writeCreds({ accessToken: 'fresh', expiresAt: Date.now() + 60 * 60_000 });
111
+
112
+ const res = await refreshIfNeeded();
113
+ expect(res.kind).toBe('ok');
114
+ expect(res.creds.accessToken).toBe('fresh');
115
+ });
@@ -1,9 +1,13 @@
1
1
  /**
2
- * epicWorktreeProjectConfig.test.cjs — PRD 1035: the per-project "disable
3
- * Epic worktree isolation" UI toggle. Points the module at a throwaway
4
- * tmpdir file via SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH so it never touches
5
- * the real ~/.claude/session-manager/epic-worktree-project-config.json on
6
- * the machine running the tests.
2
+ * epicWorktreeProjectConfig.test.cjs — PRD 1035/1390: the per-project "disable
3
+ * Epic worktree isolation" UI toggle, now stored at
4
+ * `<cwd>/session-manager-operations/prompt-sessions/epic-worktree-config.json`
5
+ * instead of a single machine-wide map. Uses real mkdtemp'd project cwds (the
6
+ * same convention as epicStatusMirror.test.cjs) since the file location is
7
+ * now derived from `cwd` via opsPath(), not from a single overridable path.
8
+ * SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH still points the LEGACY global map (the
9
+ * one-shot migration source) at a throwaway tmpdir file, so tests never touch
10
+ * the real ~/.claude/session-manager/epic-worktree-project-config.json.
7
11
  *
8
12
  * Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs
9
13
  */
@@ -16,60 +20,79 @@ const os = require('node:os');
16
20
  const path = require('node:path');
17
21
 
18
22
  let tmpRoot;
19
- let configFile;
23
+ let legacyConfigFile;
20
24
  let originalOverride;
25
+ const projectDirs = [];
21
26
 
22
27
  beforeEach(() => {
23
28
  tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epic-worktree-project-config-'));
24
- configFile = path.join(tmpRoot, 'config.json');
29
+ legacyConfigFile = path.join(tmpRoot, 'legacy-config.json');
25
30
  originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
26
- process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = configFile;
31
+ process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = legacyConfigFile;
27
32
  });
28
33
 
29
34
  afterEach(() => {
30
35
  if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
31
36
  else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
32
37
  fs.rmSync(tmpRoot, { recursive: true, force: true });
38
+ while (projectDirs.length) fs.rmSync(projectDirs.pop(), { recursive: true, force: true });
33
39
  });
34
40
 
35
41
  function freshModule() {
36
42
  return require('../epicWorktreeProjectConfig.cjs');
37
43
  }
38
44
 
45
+ function mkProjectCwd(name) {
46
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `sm-epic-worktree-project-${name}-`));
47
+ projectDirs.push(dir);
48
+ return dir;
49
+ }
50
+
51
+ function perProjectFile(m, cwd) {
52
+ return m.perProjectConfigPath(cwd);
53
+ }
54
+
39
55
  test('defaults to not disabled for a project never touched', () => {
40
56
  const m = freshModule();
41
- expect(m.isEpicWorktreeDisabledForProject('/some/project')).toBe(false);
57
+ const cwd = mkProjectCwd('a');
58
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
42
59
  });
43
60
 
44
- test('setEpicWorktreeDisabledForProject(true) persists across a fresh read', () => {
61
+ test('setEpicWorktreeDisabledForProject(true) persists across a fresh read, in the per-project ops file', () => {
45
62
  const m = freshModule();
46
- m.setEpicWorktreeDisabledForProject('/proj/a', true);
47
- expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(true);
48
- expect(fs.existsSync(configFile)).toBe(true);
49
- const onDisk = JSON.parse(fs.readFileSync(configFile, 'utf8'));
50
- expect(onDisk).toEqual({ '/proj/a': true });
63
+ const cwd = mkProjectCwd('a');
64
+ m.setEpicWorktreeDisabledForProject(cwd, true);
65
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(true);
66
+ const file = perProjectFile(m, cwd);
67
+ expect(fs.existsSync(file)).toBe(true);
68
+ expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: true });
51
69
  });
52
70
 
53
71
  test('is scoped per cwd — a different project is unaffected', () => {
54
72
  const m = freshModule();
55
- m.setEpicWorktreeDisabledForProject('/proj/a', true);
56
- expect(m.isEpicWorktreeDisabledForProject('/proj/b')).toBe(false);
73
+ const cwdA = mkProjectCwd('a');
74
+ const cwdB = mkProjectCwd('b');
75
+ m.setEpicWorktreeDisabledForProject(cwdA, true);
76
+ expect(m.isEpicWorktreeDisabledForProject(cwdB)).toBe(false);
57
77
  });
58
78
 
59
- test('setEpicWorktreeDisabledForProject(false) clears the entry rather than writing false', () => {
79
+ test('setEpicWorktreeDisabledForProject(false) writes disabled:false rather than deleting the file', () => {
60
80
  const m = freshModule();
61
- m.setEpicWorktreeDisabledForProject('/proj/a', true);
62
- m.setEpicWorktreeDisabledForProject('/proj/a', false);
63
- expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(false);
64
- const onDisk = JSON.parse(fs.readFileSync(configFile, 'utf8'));
65
- expect(onDisk).toEqual({});
81
+ const cwd = mkProjectCwd('a');
82
+ m.setEpicWorktreeDisabledForProject(cwd, true);
83
+ m.setEpicWorktreeDisabledForProject(cwd, false);
84
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
85
+ const file = perProjectFile(m, cwd);
86
+ expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: false });
66
87
  });
67
88
 
68
- test('a missing or corrupt config file reads as not-disabled rather than throwing', () => {
89
+ test('a missing or corrupt per-project config file reads as not-disabled rather than throwing', () => {
69
90
  const m = freshModule();
70
- fs.mkdirSync(path.dirname(configFile), { recursive: true });
71
- fs.writeFileSync(configFile, '{ not valid json');
72
- expect(m.isEpicWorktreeDisabledForProject('/proj/a')).toBe(false);
91
+ const cwd = mkProjectCwd('a');
92
+ const file = perProjectFile(m, cwd);
93
+ fs.mkdirSync(path.dirname(file), { recursive: true });
94
+ fs.writeFileSync(file, '{ not valid json');
95
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
73
96
  });
74
97
 
75
98
  test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throwing', () => {
@@ -77,3 +100,38 @@ test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throw
77
100
  expect(m.isEpicWorktreeDisabledForProject(null)).toBe(false);
78
101
  expect(m.isEpicWorktreeDisabledForProject(undefined)).toBe(false);
79
102
  });
103
+
104
+ test('one-shot migration: a legacy global entry seeds the new per-project file on first read', () => {
105
+ const m = freshModule();
106
+ const cwd = mkProjectCwd('a');
107
+ fs.writeFileSync(legacyConfigFile, JSON.stringify({ [cwd]: true }));
108
+
109
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(true);
110
+ const file = perProjectFile(m, cwd);
111
+ expect(fs.existsSync(file)).toBe(true);
112
+ expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: true });
113
+
114
+ // The legacy file itself is left in place, untouched.
115
+ expect(JSON.parse(fs.readFileSync(legacyConfigFile, 'utf8'))).toEqual({ [cwd]: true });
116
+ });
117
+
118
+ test('migration leaves the legacy map intact for OTHER, still-unmigrated projects', () => {
119
+ const m = freshModule();
120
+ const cwdA = mkProjectCwd('a');
121
+ const cwdB = mkProjectCwd('b');
122
+ fs.writeFileSync(legacyConfigFile, JSON.stringify({ [cwdA]: true, [cwdB]: true }));
123
+
124
+ // Touching cwdA migrates only cwdA's own per-project file.
125
+ expect(m.isEpicWorktreeDisabledForProject(cwdA)).toBe(true);
126
+ expect(fs.existsSync(perProjectFile(m, cwdB))).toBe(false);
127
+
128
+ // cwdB still resolves correctly straight from the untouched legacy map.
129
+ expect(m.isEpicWorktreeDisabledForProject(cwdB)).toBe(true);
130
+ });
131
+
132
+ test('a project with no legacy entry migrates to disabled:false without throwing', () => {
133
+ const m = freshModule();
134
+ const cwd = mkProjectCwd('a');
135
+ fs.writeFileSync(legacyConfigFile, JSON.stringify({ '/some/other/project': true }));
136
+ expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
137
+ });
@@ -847,34 +847,28 @@ test('[epic] SM_EPIC_WORKTREE_DISABLE=1 restores in-place behaviour independentl
847
847
 
848
848
  test('[epic] the per-project UI toggle (epicWorktreeProjectConfig.cjs) disables isolation for just that cwd', async () => {
849
849
  const projectConfig = require('../epicWorktreeProjectConfig.cjs');
850
- const originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
851
- process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = path.join(tmpRoot, 'epic-worktree-project-config.json');
852
- try {
853
- projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
854
- expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
850
+ projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
851
+ expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
855
852
 
856
- const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
857
- expect(result.ok).toBe(false);
858
- expect(result.reason).toMatch(/isolation toggle/);
859
-
860
- // A different, untouched project cwd is unaffected — the toggle is
861
- // per-project, not machine-wide like the env var.
862
- const otherCwd = path.join(tmpRoot, 'other-repo');
863
- initRepo(otherCwd);
864
- const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
865
- expect(otherResult.ok).toBe(true);
866
- await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
867
-
868
- // Turning it back off restores isolation for the original project.
869
- projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
870
- expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
871
- const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
872
- expect(restored.ok).toBe(true);
873
- await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
874
- } finally {
875
- if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
876
- else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
877
- }
853
+ const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
854
+ expect(result.ok).toBe(false);
855
+ expect(result.reason).toMatch(/isolation toggle/);
856
+
857
+ // A different, untouched project cwd is unaffected — the toggle is
858
+ // per-project (its own <cwd>/session-manager-operations/prompt-sessions/
859
+ // epic-worktree-config.json), not machine-wide like the env var.
860
+ const otherCwd = path.join(tmpRoot, 'other-repo');
861
+ initRepo(otherCwd);
862
+ const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
863
+ expect(otherResult.ok).toBe(true);
864
+ await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
865
+
866
+ // Turning it back off restores isolation for the original project.
867
+ projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
868
+ expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
869
+ const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
870
+ expect(restored.ok).toBe(true);
871
+ await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
878
872
  });
879
873
 
880
874
  test('[epic] reconcileWorktreesOnBoot removes a leaked epic worktree with no liveness predicate given', async () => {
@@ -112,14 +112,21 @@ test('writeGuardShims: rewriting the pointer to a DIFFERENT app root lets an alr
112
112
  const shimBytesBefore = fs.readFileSync(shimFile, 'utf8');
113
113
 
114
114
  // Simulate an app upgrade: a second "app root" at a brand-new path, with
115
- // its own copy of scripts/hooks/guard-prd-writes.cjs (this is the ONLY file
116
- // guardShims ever reads through the pointer at invocation time).
115
+ // its own copy of scripts/hooks/guard-prd-writes.cjs — the file guardShims
116
+ // reads through the pointer at invocation time — plus the sibling policy
117
+ // module it `require()`s at load time (guard-prd-writes-policy.cjs, PRD
118
+ // 1415); a real upgrade ships both since both are listed in package.json's
119
+ // "files".
117
120
  const secondAppRoot = await mkTmp('sm-guard-shims-upgraded-app-');
118
- await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks'), { recursive: true });
121
+ await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks', 'lib'), { recursive: true });
119
122
  await fsp.copyFile(
120
123
  path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
121
124
  path.join(secondAppRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
122
125
  );
126
+ await fsp.copyFile(
127
+ path.join(APP_ROOT, 'scripts', 'hooks', 'lib', 'guard-prd-writes-policy.cjs'),
128
+ path.join(secondAppRoot, 'scripts', 'hooks', 'lib', 'guard-prd-writes-policy.cjs'),
129
+ );
123
130
 
124
131
  const r = await writeGuardShims({ homeDir, appRoot: secondAppRoot });
125
132
  expect(r.ok).toBe(true);
@@ -9,7 +9,7 @@
9
9
  // batteries. These tests pin the pure decision, the audit rate-limit and the
10
10
  // escalation, with loadavg/cores/clock all injected.
11
11
  const assert = require('node:assert/strict');
12
- const { isLoadGated, createLoadGate, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
12
+ const { isLoadGated, createLoadGate, topCpuConsumers, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
13
13
  const { loadGateThreshold, LOAD_GATE_PER_CORE } = require('../schedulerConfig.cjs');
14
14
 
15
15
  const ORIGINAL_ENV = process.env.SM_LOAD_GATE_PER_CORE;
@@ -65,6 +65,49 @@ test('SM_LOAD_GATE_PER_CORE is honored and clamped to [0.25, 4]; 0 disables; gar
65
65
  assert.equal(loadGateThreshold(), 0.85);
66
66
  });
67
67
 
68
+ // ─── topCpuConsumers: platform-correct ps invocation ───────────────────────
69
+ //
70
+ // The audit line the scheduler warn-logs when the load gate escalates. Purely
71
+ // diagnostic (nothing branches on it) but it was blank on macOS: the code only
72
+ // ran the GNU `ps --sort` form, which BSD ps rejects with "illegal option",
73
+ // so every Mac logged `top CPU: n/a` exactly when the operator needed to know
74
+ // what was saturating the box.
75
+
76
+ test('Linux uses the GNU ps form (-eo … --sort=-pcpu) and skips the header row', () => {
77
+ const calls = [];
78
+ const fakeExec = (bin, args) => {
79
+ calls.push({ bin, args });
80
+ return 'PID %CPU COMMAND\n 1 90.0 node\n 2 10.0 tsc\n';
81
+ };
82
+ const rows = topCpuConsumers(2, { execImpl: fakeExec, platform: 'linux' });
83
+ assert.deepEqual(calls[0], { bin: 'ps', args: ['-eo', 'pid,pcpu,comm', '--sort=-pcpu'] });
84
+ assert.deepEqual(rows, ['1 90.0 node', '2 10.0 tsc']);
85
+ });
86
+
87
+ test('macOS uses the BSD ps form (-Aco … -r), NOT the GNU --sort that BSD ps rejects', () => {
88
+ const calls = [];
89
+ const fakeExec = (bin, args) => {
90
+ calls.push({ bin, args });
91
+ return ' PID %CPU COMM\n 847 94.4 suggestd\n 443 15.1 WindowServer\n';
92
+ };
93
+ const rows = topCpuConsumers(3, { execImpl: fakeExec, platform: 'darwin' });
94
+ assert.deepEqual(calls[0].args, ['-Aco', 'pid,pcpu,comm', '-r']);
95
+ assert.ok(!calls[0].args.some((a) => String(a).includes('--sort')), 'must not pass the GNU --sort to BSD ps');
96
+ assert.deepEqual(rows, ['847 94.4 suggestd', '443 15.1 WindowServer']);
97
+ });
98
+
99
+ test('an unsupported platform returns [] without ever spawning ps', () => {
100
+ let spawned = false;
101
+ const rows = topCpuConsumers(3, { execImpl: () => { spawned = true; return ''; }, platform: 'win32' });
102
+ assert.deepEqual(rows, []);
103
+ assert.equal(spawned, false);
104
+ });
105
+
106
+ test('a ps failure (throw) degrades to [] rather than propagating — the audit line is best-effort', () => {
107
+ const rows = topCpuConsumers(3, { execImpl: () => { throw new Error('boom'); }, platform: 'darwin' });
108
+ assert.deepEqual(rows, []);
109
+ });
110
+
68
111
  // ─── createLoadGate: decision, 1-minute-only, bypass ───────────────────────
69
112
 
70
113
  function gateWith({ l1 = 12.95, l5 = 0, l15 = 0, cores = 14, threshold = 0.85, clock }) {
@@ -73,6 +73,20 @@ test('assertOpsWrite throws for a refused write and is silent for an allowed one
73
73
  assert.doesNotThrow(() => assertOpsWrite(`${P}/project-brief/brief.json`, 'project-home'));
74
74
  });
75
75
 
76
+ test('assertOpsWrite fails closed (not a raw crash) on a relative path that looks like it is inside an ops root', () => {
77
+ // A caller that skips opsPath/resolveProjectRoot (which already throw on a
78
+ // relative cwd) and builds its own path string by hand must still get a
79
+ // graceful, catchable refusal here — this is the last line of defense
80
+ // (PRD 1082). Before this guard, classifyCwd's absolute-path check (added
81
+ // for the worktree-relative-cwd hazard) left innermostOpsRoot null for a
82
+ // relative path, and path.dirname(null) threw an untagged TypeError
83
+ // instead of the intended `unknownCwd`-tagged Error.
84
+ assert.throws(
85
+ () => assertOpsWrite('session-manager-operations/scheduler/state/queue.json', 'scheduler'),
86
+ (e) => e.unknownCwd === true && /absolute/.test(e.message),
87
+ );
88
+ });
89
+
76
90
  test('every declared owner is a non-empty string (table sanity)', () => {
77
91
  for (const [ns, owner] of Object.entries(OWNERS)) {
78
92
  assert.equal(typeof owner, 'string', `${ns} owner must be a string`);