claude-code-session-manager 0.123.1 → 0.124.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentLibrary-T14aM8ie.js → AgentLibrary-qmAgadiS.js} +1 -1
- package/dist/assets/{Button-Dz1kV8Z_.js → Button-CkuaU6cA.js} +1 -1
- package/dist/assets/{DataModel-DdYRn2Ec.js → DataModel-DEVHxW5I.js} +1 -1
- package/dist/assets/{History-zQPvqwE7.js → History-C-qw4Gx4.js} +1 -1
- package/dist/assets/{Hooks-BTrbT742.js → Hooks-DzAnCyjJ.js} +1 -1
- package/dist/assets/{Library-BwQ6GRHR.js → Library-DExZ29wM.js} +1 -1
- package/dist/assets/{ListDetail-CunxAhRC.js → ListDetail-DiQk-Hf9.js} +1 -1
- package/dist/assets/{McpServers-DpoyxXaO.js → McpServers-C0nqX36_.js} +1 -1
- package/dist/assets/{Memory-KDtGYMc3.js → Memory-D4KAWPAC.js} +1 -1
- package/dist/assets/{Panel-xXmUBo8l.js → Panel-xv9ToSjl.js} +1 -1
- package/dist/assets/{Permissions-lIzTUrW2.js → Permissions-0yEyqwZU.js} +1 -1
- package/dist/assets/{Plugins-BA1Zqk69.js → Plugins-CB28SGGJ.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CmeJG2Kw.js → ProvenanceBadge-9i4gXDvI.js} +1 -1
- package/dist/assets/{SaveBar-BTJg_9Uc.js → SaveBar-mSVRXpml.js} +1 -1
- package/dist/assets/{Scheduler-CISTDNMU.js → Scheduler-BoUetIC0.js} +1 -1
- package/dist/assets/{ScopeSwitcher-C6NWvzoj.js → ScopeSwitcher-B8tdQIKs.js} +1 -1
- package/dist/assets/{Settings-BBoGJ34v.js → Settings-D3jfKwaK.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-IJv60QAJ.js → SkillReferenceGraph-DJ4JJRt3.js} +1 -1
- package/dist/assets/{Skills-L6nGcNF1.js → Skills-P9HHxDCR.js} +1 -1
- package/dist/assets/{SystemPrompt-Cm7I2gJz.js → SystemPrompt-B_tnbBZ2.js} +1 -1
- package/dist/assets/{TagLibrary-BxPwoPE6.js → TagLibrary-CI8NHRKi.js} +1 -1
- package/dist/assets/{TiptapBody-CR-Zr3Pz.js → TiptapBody-9LktXPiD.js} +1 -1
- package/dist/assets/{Toggle-BFhlpZ-n.js → Toggle-BwsUsAA7.js} +1 -1
- package/dist/assets/{ViewTabs-CuGlZ9Ed.js → ViewTabs-CY5gMZ_N.js} +1 -1
- package/dist/assets/{historyTotals-Dxv9Fzrs.js → historyTotals-_dIaxmPX.js} +1 -1
- package/dist/assets/{index-Dx8owDeQ.js → index-d4i3X1x3.js} +419 -419
- package/dist/assets/{settingsSchema-DaqFSAQH.js → settingsSchema-Dg0Xa9RR.js} +1 -1
- package/dist/assets/{skillFrontmatter-BIJddxVB.js → skillFrontmatter-CpoFkSyj.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-1hjZ4knk.js → useAgentsDirWatch-CFPprfWG.js} +1 -1
- package/dist/index.html +1 -1
- package/dist-web/assets/app.css +32 -0
- package/dist-web/assets/app.js +1479 -0
- package/dist-web/index.html +16 -0
- package/package.json +6 -4
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +28 -10
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -6
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +68 -9
- package/src/main/config.cjs +23 -29
- package/src/main/docEdit.cjs +2 -5
- package/src/main/files.cjs +16 -4
- package/src/main/index.cjs +26 -4
- package/src/main/ipcSchemas.cjs +6 -2
- package/src/main/lib/epic/activeIndexMerge.cjs +3 -5
- package/src/main/lib/epic/epicReopen.cjs +161 -0
- package/src/main/lib/epic/epicSpawnPlan.cjs +9 -1
- package/src/main/lib/epic/epicTranscriptRestore.cjs +109 -0
- package/src/main/lib/epic/epicValidationHook.cjs +1 -1
- package/src/main/lib/epic/epicWorktreeMint.cjs +50 -3
- package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +2 -1
- package/src/main/lib/feedback-remote/remoteAssets.cjs +98 -0
- package/src/main/lib/feedback-remote/remoteIpcDispatch.cjs +216 -0
- package/src/main/lib/insideHome.cjs +102 -23
- package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +27 -0
- package/src/main/lib/prd/prdWorktreeReach.cjs +9 -1
- package/src/main/lib/scheduler/rcaReport.cjs +1 -13
- package/src/main/lib/scheduler/reaperHelpers.cjs +82 -77
- package/src/main/lib/scheduler/reviewNotice.cjs +0 -2
- package/src/main/lib/scheduler/scheduleJobTransitions.cjs +1 -1
- package/src/main/lib/scheduler/schedulerBatch.cjs +1 -1
- package/src/main/lib/sendToRenderer.cjs +22 -2
- package/src/main/lib/smfileHeaders.cjs +17 -1
- package/src/main/pluginInstall.cjs +1 -1
- package/src/main/promptSessionEvents.cjs +2 -5
- package/src/main/pty.cjs +5 -4
- package/src/main/runVerify.cjs +6 -41
- package/src/main/scheduler/budget.cjs +4 -4
- package/src/main/scheduler/prdParser.cjs +2 -3
- package/src/main/scheduler/reviewLadder.cjs +0 -9
- package/src/main/scheduler.cjs +72 -25
- package/src/main/seedAgentPersonas.cjs +1 -1
- package/src/main/templates/PRD_AUTHORING.md +2 -2
- package/src/main/voiceHotkey.cjs +3 -2
- package/src/main/webRemote.cjs +161 -1
- package/src/preload/api.d.ts +8 -1
- package/src/preload/apiSurface.cjs +19 -0
- package/src/preload/apiSurface.json +1689 -0
- package/src/preload/index.cjs +2 -0
- package/src/seed/agents/architect.md +2 -2
- package/src/seed/agents/blender-artist.md +76 -0
package/src/main/scheduler.cjs
CHANGED
|
@@ -74,7 +74,7 @@ const { readTail } = require('./lib/util/fileTail.cjs');
|
|
|
74
74
|
const {
|
|
75
75
|
claudePidAlive, classifyRunOutcome, mapOutcomeToGateOutcome, ORPHAN_REQUEUE_CAP, selectReapableJobs,
|
|
76
76
|
findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome, resolveCommitGuardOutcome,
|
|
77
|
-
readSpawnedPidFromLog, readLogMtimeMs,
|
|
77
|
+
readSpawnedPidFromLog, readLogMtimeMs, isClaudeArgv,
|
|
78
78
|
} = require('./lib/scheduler/reaperHelpers.cjs');
|
|
79
79
|
const { resolveProjectRoot } = require('./lib/opsOwnership.cjs');
|
|
80
80
|
const { sweepStrandedJobBranches } = require('./lib/git/branchSweep.cjs');
|
|
@@ -359,18 +359,16 @@ ${verdictStepNum}. VERDICT LINE — end your final report with exactly one of th
|
|
|
359
359
|
SCHEDULER_VERDICT: PASS
|
|
360
360
|
SCHEDULER_VERDICT: FAIL <one-line reason>
|
|
361
361
|
SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP
|
|
362
|
-
SCHEDULER_VERDICT: NEEDS_HUMAN <one-line question>
|
|
363
362
|
- Print PASS only when the gate is green AND the commit from step ${commitStepNum} landed.
|
|
364
|
-
- Print FAIL when the gate is red or the commit could not land.
|
|
363
|
+
- Print FAIL when the gate is red or the commit could not land. Also print
|
|
364
|
+
FAIL when a step needs something this run cannot reach (live runtime
|
|
365
|
+
state, credentials, an account or dashboard action, a host install): do
|
|
366
|
+
everything else, commit it, and name exactly what is missing in the
|
|
367
|
+
reason. Never ask a question.
|
|
365
368
|
- Never print PASS on a red gate. Why: a false PASS ships broken work that
|
|
366
369
|
no one notices. A true PASS plus a landed commit lets the scheduler ignore
|
|
367
370
|
harmless noise in your transcript, such as grep hits for "Error", an early
|
|
368
371
|
red test run, or debug tracebacks.
|
|
369
|
-
- Print NEEDS_HUMAN only when finishing needs a decision or action only the
|
|
370
|
-
human can take (credentials, an account/dashboard action, a product
|
|
371
|
-
choice, an install on the host) — never for a red gate or a bug you could
|
|
372
|
-
fix. Commit any finished work first. Phrase the question so a yes/no or
|
|
373
|
-
one-line answer unblocks it.
|
|
374
372
|
- Print BLOCKED_BY_FOREIGN_WIP ONLY when your gate failed because of a
|
|
375
373
|
sibling job's unfinished, uncommitted file — never because of your own
|
|
376
374
|
change — AND every failing path is one this prompt already listed as
|
|
@@ -975,8 +973,10 @@ function heartbeatTick(deps = {}) {
|
|
|
975
973
|
lastDispatchAttemptAt: s.lastDispatchAttemptAt ?? null,
|
|
976
974
|
lastRunAt: s.lastRunAt ?? null,
|
|
977
975
|
lastTickReason: lastTick?.reason ?? null,
|
|
976
|
+
lastTickDetail: lastTick?.detail ? stripTickDetailPrefix(lastTick.detail, { keepWord: true }).slice(0, 200) : null,
|
|
978
977
|
pendingDispatchable: Math.max(0, counts.pending - blockedPending),
|
|
979
978
|
runningCount: counts.running,
|
|
979
|
+
quietLeaseHolder: quietMachineLease.isHeld() ? (quietMachineLease.holder() ?? null) : null,
|
|
980
980
|
paused: Boolean(s.paused),
|
|
981
981
|
drain: s.drain?.active ? { since: s.drain.since ?? null, requestedAt: s.drain.requestedAt ?? null } : null,
|
|
982
982
|
};
|
|
@@ -1370,16 +1370,17 @@ function killOrphanClaudePid(pid, recordedIdentity = null) {
|
|
|
1370
1370
|
}
|
|
1371
1371
|
// win32 has no /proc or `ps`: the identity heuristic can't run, so never reap.
|
|
1372
1372
|
if (process.platform === 'win32') return 'unknown';
|
|
1373
|
-
let
|
|
1373
|
+
let argv = [];
|
|
1374
1374
|
try {
|
|
1375
|
-
|
|
1375
|
+
argv = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0');
|
|
1376
|
+
if (argv.length && argv[argv.length - 1] === '') argv.pop();
|
|
1376
1377
|
} catch {
|
|
1377
1378
|
try {
|
|
1378
1379
|
const out = require('node:child_process').execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] });
|
|
1379
|
-
|
|
1380
|
+
argv = out.trim().split(/\s+/).filter(Boolean);
|
|
1380
1381
|
} catch { return 'unknown'; }
|
|
1381
1382
|
}
|
|
1382
|
-
if (
|
|
1383
|
+
if (!isClaudeArgv(argv)) return 'mismatch';
|
|
1383
1384
|
killTree(pid, 'SIGTERM');
|
|
1384
1385
|
setTimeout(() => {
|
|
1385
1386
|
try { process.kill(pid, 0); } catch { return; /* already gone */ }
|
|
@@ -2303,6 +2304,13 @@ const loadGate = createLoadGate();
|
|
|
2303
2304
|
// actually pinned at 1 with nothing on screen explaining why. This is that
|
|
2304
2305
|
// explanation, recorded once per tick and broadcast with the rest of the state.
|
|
2305
2306
|
let lastTick = null;
|
|
2307
|
+
// Strips the leading '[scheduler] <word> [..]: ' prefix from a tick detail.
|
|
2308
|
+
// keepWord=true strips only the '[scheduler] ' tag and keeps '<word>: ' (the
|
|
2309
|
+
// heartbeat wants the hold kind, e.g. 'quiet-machine: lease held ...').
|
|
2310
|
+
function stripTickDetailPrefix(detail, { keepWord = false } = {}) {
|
|
2311
|
+
const re = keepWord ? /^\[scheduler\]\s*/ : /^\[scheduler\]\s*[\w-]+\s*(?:\[[^\]]*\])?:\s*/;
|
|
2312
|
+
return String(detail ?? '').replace(re, '');
|
|
2313
|
+
}
|
|
2306
2314
|
function recordTick(outcome, extra = {}) {
|
|
2307
2315
|
lastTick = { ...outcome, ...extra, at: new Date().toISOString() };
|
|
2308
2316
|
return outcome;
|
|
@@ -2820,10 +2828,15 @@ function partitionBootOrphans(jobs, {
|
|
|
2820
2828
|
} = {}) {
|
|
2821
2829
|
const immediate = [];
|
|
2822
2830
|
const adopted = [];
|
|
2831
|
+
const claimedPids = new Set();
|
|
2832
|
+
for (const r of jobs) {
|
|
2833
|
+
if (r.status === 'running' && r.runtime?.pid) claimedPids.add(r.runtime.pid);
|
|
2834
|
+
}
|
|
2823
2835
|
for (const j of jobs) {
|
|
2824
2836
|
if (j.status !== 'running') continue;
|
|
2825
2837
|
if (isBootRowAlive(j, {
|
|
2826
2838
|
pidAlive, getLogPid, getLogMtimeMs, logFreshWindowMs, findLiveProcess, readRecord, runsDir, identityOf, now,
|
|
2839
|
+
claimedPids,
|
|
2827
2840
|
})) adopted.push(j.slug);
|
|
2828
2841
|
else immediate.push(j.slug);
|
|
2829
2842
|
}
|
|
@@ -2832,6 +2845,7 @@ function partitionBootOrphans(jobs, {
|
|
|
2832
2845
|
|
|
2833
2846
|
function isBootRowAlive(j, {
|
|
2834
2847
|
pidAlive, getLogPid, getLogMtimeMs, logFreshWindowMs, findLiveProcess, readRecord, runsDir, identityOf, now,
|
|
2848
|
+
claimedPids = new Set(),
|
|
2835
2849
|
}) {
|
|
2836
2850
|
const logMtimeMs = typeof getLogMtimeMs === 'function' ? getLogMtimeMs(j) : null;
|
|
2837
2851
|
const runDir = j.runId ? path.join(runsDir || schedulerPaths.runsDir(), j.runId) : null;
|
|
@@ -2852,7 +2866,12 @@ function isBootRowAlive(j, {
|
|
|
2852
2866
|
if (typeof logFreshWindowMs === 'number' && Number.isFinite(logMtimeMs) && now - logMtimeMs <= logFreshWindowMs) return true;
|
|
2853
2867
|
const logPid = typeof getLogPid === 'function' ? getLogPid(j) : null;
|
|
2854
2868
|
if (logPid && pidAlive(logPid)) return true;
|
|
2855
|
-
|
|
2869
|
+
// No run log = dispatch never reached the spawn; a /proc scan could only misattribute a stray process.
|
|
2870
|
+
if (typeof findLiveProcess !== 'function' || !Number.isFinite(logMtimeMs)) return false;
|
|
2871
|
+
const livePid = findLiveProcess(j);
|
|
2872
|
+
if (!livePid || claimedPids.has(livePid)) return false;
|
|
2873
|
+
claimedPids.add(livePid);
|
|
2874
|
+
return true;
|
|
2856
2875
|
}
|
|
2857
2876
|
|
|
2858
2877
|
/**
|
|
@@ -3075,7 +3094,7 @@ function defaultScheduleSettle({ cwd, epicId }) {
|
|
|
3075
3094
|
return null;
|
|
3076
3095
|
}
|
|
3077
3096
|
},
|
|
3078
|
-
sendPrompt: enqueueExternalPrompt,
|
|
3097
|
+
sendPrompt: (tabId, prompt) => enqueueExternalPrompt(tabId, prompt, { origin: 'scheduler' }),
|
|
3079
3098
|
appendResponseEvent: appendResponseEventIfKnown,
|
|
3080
3099
|
};
|
|
3081
3100
|
epicValidationHook.scheduleEpicSettle({ cwd, epicId }, (args) => epicValidationHook.runEpicSettle(args, deps));
|
|
@@ -3280,7 +3299,7 @@ async function notifyOriginatingTab(job, {
|
|
|
3280
3299
|
return;
|
|
3281
3300
|
}
|
|
3282
3301
|
|
|
3283
|
-
sendPrompt(targetTabId, message);
|
|
3302
|
+
sendPrompt(targetTabId, message, { origin: 'scheduler' });
|
|
3284
3303
|
} catch (e) {
|
|
3285
3304
|
console.error('[scheduler] notifyOriginatingTab error', job?.slug, e);
|
|
3286
3305
|
}
|
|
@@ -4615,15 +4634,15 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
4615
4634
|
// clock time regardless of whether the job keeps writing output — the
|
|
4616
4635
|
// gap a chatty-but-runaway executor slips through (see
|
|
4617
4636
|
// computeJobBudgetMs's header for the measured p50/p90/max this budget
|
|
4618
|
-
// is calibrated against).
|
|
4619
|
-
// explicit `budgetExempt: true`
|
|
4637
|
+
// is calibrated against). Only a PRD with an
|
|
4638
|
+
// explicit `budgetExempt: true` opts out entirely — logged once here so
|
|
4620
4639
|
// an unbounded job is never silently unbounded.
|
|
4621
4640
|
const jobBudgetMs = budget.computeJobBudgetMs(
|
|
4622
4641
|
job.estimateMinutes ?? resolvePersonaDefaultEstimate(job.cwd, job.agentType || DEFAULT_PRD_AGENT_TYPE));
|
|
4623
4642
|
const budgetExempt = budget.isJobBudgetExempt(job);
|
|
4624
4643
|
if (budgetExempt) {
|
|
4625
4644
|
safeLog(`[scheduler] wall-clock budget watchdog EXEMPT for ${job.slug} ` +
|
|
4626
|
-
`(
|
|
4645
|
+
`(budgetExempt) — no wall-clock kill ceiling this run\n`);
|
|
4627
4646
|
}
|
|
4628
4647
|
let budgetWarningStamped = false;
|
|
4629
4648
|
const budgetWatchdog = {
|
|
@@ -7546,7 +7565,7 @@ function forceTickOutcome(result) {
|
|
|
7546
7565
|
case 'slots-exhausted':
|
|
7547
7566
|
return { ok: true, kind: 'warn', message: `Deferred ${result.deferredCount} job(s) — all session slots in use (${(result.holders ?? []).map((h) => h.owner).join(', ')})` };
|
|
7548
7567
|
case 'held': {
|
|
7549
|
-
const detail =
|
|
7568
|
+
const detail = stripTickDetailPrefix(result.detail);
|
|
7550
7569
|
return { ok: true, kind: 'warn', message: detail || 'Batch held' };
|
|
7551
7570
|
}
|
|
7552
7571
|
default:
|
|
@@ -7937,7 +7956,6 @@ async function reapDeadRunningJobs() {
|
|
|
7937
7956
|
grace: PIDLESS_SPAWN_GRACE_MS,
|
|
7938
7957
|
findLiveProcess: (j) => findLiveProcessForJob(j, {
|
|
7939
7958
|
worktreeDir: jobWorktree.worktreeDirFor(j.cwd || state.config?.defaultCwd || DEFAULT_PROJECT_CWD, j.slug),
|
|
7940
|
-
runCwd: j.runtime?.cwd || j.cwd,
|
|
7941
7959
|
}),
|
|
7942
7960
|
getLogPid: (j) => readSpawnedPidFromLog(logPathForJob(j)),
|
|
7943
7961
|
getLogMtimeMs: (j) => readLogMtimeMs(logPathForJob(j)),
|
|
@@ -8651,6 +8669,33 @@ function applyNeedsReviewAutoResolve(j, jobsInProject = []) {
|
|
|
8651
8669
|
return 'skipped';
|
|
8652
8670
|
}
|
|
8653
8671
|
|
|
8672
|
+
/**
|
|
8673
|
+
* Terminal outcome for a failed row that used every FAILED_AUTORESET_CAP
|
|
8674
|
+
* auto-reset and sat past STUCK_FAILED_ESCALATE_MS: skip it (same marker and
|
|
8675
|
+
* grouped review notice as the rung-6 cap-exhausted skip) so dependents drain
|
|
8676
|
+
* and the Epic's automated turn queues the fix. Mutates in place; returns
|
|
8677
|
+
* 'skipped', or null when the race guard rejects it.
|
|
8678
|
+
*/
|
|
8679
|
+
function applyStuckFailedAutoSkip(j, ageMs = 0) {
|
|
8680
|
+
if (!j || j.status !== 'failed' || reviewLadder.isCancelledJob(j)) return null;
|
|
8681
|
+
if ((j.failedAutoResetAttempts ?? 0) < reviewLadder.FAILED_AUTORESET_CAP) return null;
|
|
8682
|
+
const cap = reviewLadder.FAILED_AUTORESET_CAP;
|
|
8683
|
+
j.needsReviewAutoResolvedSkip = true;
|
|
8684
|
+
j.error = `failed auto-reset cap exhausted (${cap}/${cap} attempts) after ${Math.round(ageMs / 3_600_000)}h — auto-skipped to unblock downstream dependsOn rows`;
|
|
8685
|
+
j.reviewNotice = buildReviewNotice({
|
|
8686
|
+
job: j,
|
|
8687
|
+
report: null,
|
|
8688
|
+
epicId: j.epicId ?? j.sourcePromptId ?? null,
|
|
8689
|
+
now: new Date().toISOString(),
|
|
8690
|
+
prior: j.reviewNotice,
|
|
8691
|
+
});
|
|
8692
|
+
transitionJob(j, 'skipped', {
|
|
8693
|
+
reason: `failed auto-resets exhausted (${cap}/${cap}) — auto-skipped`,
|
|
8694
|
+
source: 'stuckFailedAutoSkip',
|
|
8695
|
+
});
|
|
8696
|
+
return 'skipped';
|
|
8697
|
+
}
|
|
8698
|
+
|
|
8654
8699
|
/**
|
|
8655
8700
|
* Self-healing pass over needs_review jobs. The verifier runs in-process, so a
|
|
8656
8701
|
* fix to runVerify.cjs only takes effect for jobs verified AFTER an app
|
|
@@ -10086,18 +10131,19 @@ function rescheduleIntervalTick() {
|
|
|
10086
10131
|
}
|
|
10087
10132
|
for (const stuck of stuckFailed) {
|
|
10088
10133
|
const j = ms.jobs.find((x) => x.slug === stuck.slug);
|
|
10089
|
-
if (!j || j.status !== 'failed'
|
|
10134
|
+
if (!j || j.status !== 'failed') continue; // race guard
|
|
10090
10135
|
// Still has auto-reset attempts left — it will be (or already was,
|
|
10091
10136
|
// earlier this same pass) picked up by the loop above instead.
|
|
10092
10137
|
// Never log "reset it by hand" for a row that isn't actually stuck.
|
|
10093
10138
|
if ((j.failedAutoResetAttempts ?? 0) < reviewLadder.FAILED_AUTORESET_CAP) continue;
|
|
10094
|
-
|
|
10139
|
+
const outcome = applyStuckFailedAutoSkip(j, stuck.ageMs);
|
|
10140
|
+
if (!outcome) continue;
|
|
10095
10141
|
console.warn(
|
|
10096
10142
|
`[scheduler] FAILED PRD STUCK: project=${stuck.cwd ?? '(unknown)'} slug=${stuck.slug} `
|
|
10097
10143
|
+ `failed=${Math.round(stuck.ageMs / 3_600_000)}h (>= ${Math.round(reviewLadder.STUCK_FAILED_ESCALATE_MS / 3_600_000)}h threshold) — `
|
|
10098
|
-
+ `auto-reset cap exhausted (${reviewLadder.FAILED_AUTORESET_CAP}/${reviewLadder.FAILED_AUTORESET_CAP} attempts);
|
|
10144
|
+
+ `auto-reset cap exhausted (${reviewLadder.FAILED_AUTORESET_CAP}/${reviewLadder.FAILED_AUTORESET_CAP} attempts); auto-skipped to unblock dependents`,
|
|
10099
10145
|
);
|
|
10100
|
-
appendAuditEvent('job_stuck_failed', { slug: stuck.slug, cwd: stuck.cwd, ageMs: stuck.ageMs });
|
|
10146
|
+
appendAuditEvent('job_stuck_failed', { slug: stuck.slug, cwd: stuck.cwd, ageMs: stuck.ageMs, outcome });
|
|
10101
10147
|
}
|
|
10102
10148
|
for (const target of exhaustedNeedsReviewTargets) {
|
|
10103
10149
|
const j = ms.jobs.find((x) => x.slug === target.slug);
|
|
@@ -10230,7 +10276,6 @@ async function init() {
|
|
|
10230
10276
|
logFreshWindowMs: IDLE_OUTPUT_KILL_MS,
|
|
10231
10277
|
findLiveProcess: (j) => findLiveProcessForJob(j, {
|
|
10232
10278
|
worktreeDir: jobWorktree.worktreeDirFor(j.cwd || DEFAULT_PROJECT_CWD, j.slug),
|
|
10233
|
-
runCwd: j.runtime?.cwd || j.cwd,
|
|
10234
10279
|
}),
|
|
10235
10280
|
readRecord: supervisorRecord.readSupervisorRecord,
|
|
10236
10281
|
});
|
|
@@ -11130,6 +11175,7 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
11130
11175
|
|
|
11131
11176
|
module.exports = {
|
|
11132
11177
|
normalizeConfig,
|
|
11178
|
+
recordTick,
|
|
11133
11179
|
reportSchedulerError,
|
|
11134
11180
|
finalizeJobWorktree,
|
|
11135
11181
|
worktreeIntegrationVerdict,
|
|
@@ -11173,6 +11219,7 @@ module.exports = {
|
|
|
11173
11219
|
runGateShadow,
|
|
11174
11220
|
awaitGateShadowIdle: async () => { while (gateShadowPending) await gateShadowPending; },
|
|
11175
11221
|
applyNeedsReviewAutoResolve,
|
|
11222
|
+
applyStuckFailedAutoSkip,
|
|
11176
11223
|
computeLooksDone,
|
|
11177
11224
|
attributeLandedCommits,
|
|
11178
11225
|
isPromotableOriginal,
|
|
@@ -80,7 +80,7 @@ const { writeJsonSync, writeTextAtomic, validatePath, validateWrite } = require(
|
|
|
80
80
|
const { splitFrontmatter } = require('./lib/prd/prdFrontmatter.cjs');
|
|
81
81
|
const { SHIPPED_PERSONA_SEEDS } = require('./lib/persona-macro/shippedPersonaSeeds.cjs');
|
|
82
82
|
|
|
83
|
-
const PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator', 'demo-video-builder'];
|
|
83
|
+
const PERSONAS = ['architect', 'dev-lead', 'project-home-builder', 'validator', 'demo-video-builder', 'blender-artist'];
|
|
84
84
|
// What `{ done: true }` (the pre-seeded-set marker format) meant: only these two personas existed
|
|
85
85
|
// in PERSONAS at the time. Fixed, not derived from the current PERSONAS array above — otherwise
|
|
86
86
|
// every future persona added to PERSONAS would retroactively count as already delivered to a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- PRD_AUTHORING.md
|
|
1
|
+
<!-- PRD_AUTHORING.md v14 -->
|
|
2
2
|
# PRD Authoring Guide — Scheduler Safety Rules
|
|
3
3
|
|
|
4
4
|
Rule: every PRD you queue follows the rules below.
|
|
@@ -226,5 +226,5 @@ Before queueing a new PRD, verify each of these:
|
|
|
226
226
|
- [ ] **§10 Negative assertions:** every "should find nothing" check is inverted to exit 0 on the clean case.
|
|
227
227
|
- [ ] **§11 End green:** the gate runs last; any intentional failure runs early and is captured.
|
|
228
228
|
- [ ] **Gate baseline:** every gate command ran green once at HEAD in the main checkout; a red suite is scoped to the PRD's own test files and the PRD notes say why. Why: a gate already red on base parks as `sentinel_fail`.
|
|
229
|
-
- [ ] **No human-only gates:** no gate or criterion needs a human or the passage of time (cron install, live post wait, live payment, dashboard action);
|
|
229
|
+
- [ ] **No human-only gates:** no gate or criterion needs a human or the passage of time (cron install, live post wait, live payment, dashboard action); keep them out of the PRD at authoring time; an executor that meets one ends `SCHEDULER_VERDICT: FAIL` naming what is missing. Why: no executor can pass them.
|
|
230
230
|
- [ ] **Gate purity:** a gate command must not create or modify tracked files; outputs go to the per-gate TMPDIR or a gitignored path. Why: a dirtied tree fails the clean-tree check.
|
package/src/main/voiceHotkey.cjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
const { app, globalShortcut, ipcMain } = require('electron');
|
|
19
19
|
const voiceSettings = require('./voiceSettings.cjs');
|
|
20
20
|
const { voiceHotkeyLog } = require('./lib/voice-hotkey-log.cjs');
|
|
21
|
+
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
21
22
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
22
23
|
|
|
23
24
|
let mainWindow = null;
|
|
@@ -170,7 +171,7 @@ function attachBeforeInput() {
|
|
|
170
171
|
|
|
171
172
|
function emitHotkey(phase, source) {
|
|
172
173
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
173
|
-
mainWindow
|
|
174
|
+
sendIfAlive(mainWindow, 'voice:hotkey', { phase, source });
|
|
174
175
|
voiceHotkeyLog('info', phase === 'down' ? 'down' : 'up', { source });
|
|
175
176
|
}
|
|
176
177
|
|
|
@@ -308,7 +309,7 @@ function registerHotkeyHandlers() {
|
|
|
308
309
|
currentConfig = await voiceSettings.load();
|
|
309
310
|
applyConfig(currentConfig);
|
|
310
311
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
311
|
-
mainWindow
|
|
312
|
+
sendIfAlive(mainWindow, 'voice:hotkey-changed', currentConfig);
|
|
312
313
|
}
|
|
313
314
|
return { ok: true, config: currentConfig };
|
|
314
315
|
}));
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -26,7 +26,7 @@ const fsp = require('node:fs/promises');
|
|
|
26
26
|
const fs = require('node:fs');
|
|
27
27
|
const { writeTextAtomic, validatePath } = require('./config.cjs');
|
|
28
28
|
const logs = require('./logs.cjs');
|
|
29
|
-
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
29
|
+
const { sendIfAlive, setRemoteFanout } = require('./lib/sendToRenderer.cjs');
|
|
30
30
|
const { claudeHome } = require('./lib/scheduler/schedulerPaths.cjs');
|
|
31
31
|
const { schemas } = require('./ipcSchemas.cjs');
|
|
32
32
|
const { makeState, confirmSas: confirmSasLogic } = require('./lib/e2eStateMachine.cjs');
|
|
@@ -902,8 +902,164 @@ async function handleMessage(raw, device) {
|
|
|
902
902
|
await dispatchEnvelope(envelope, device, client);
|
|
903
903
|
}
|
|
904
904
|
|
|
905
|
+
const IPC_INVOKE_MAX_CALLS = 50;
|
|
906
|
+
let _ipcDispatcher = null;
|
|
907
|
+
|
|
908
|
+
/** Install the shared renderer-handler dispatcher (built in index.cjs). */
|
|
909
|
+
function setIpcDispatcher(dispatcher) {
|
|
910
|
+
_ipcDispatcher = dispatcher;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** Encrypt + unicast one ipc:result frame to the calling client (never plaintext). */
|
|
914
|
+
function sendIpcResult(callId, frame, client) {
|
|
915
|
+
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
916
|
+
if (!client || !client.e2e.sessionKey) return;
|
|
917
|
+
try {
|
|
918
|
+
const inner = { type: 'ipc:result', id: callId, seq: frame.seq, total: frame.total, data: frame.data, ts: Date.now() };
|
|
919
|
+
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
|
|
920
|
+
const out = { type: 'e2e:box', id: callId, payload: { nonce, ciphertext }, ts: Date.now() };
|
|
921
|
+
if (client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
|
|
922
|
+
_ws.send(JSON.stringify(out));
|
|
923
|
+
} catch (e) {
|
|
924
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ipc:result send failed', meta: { error: e?.message } });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Batched ipc:invoke — each call runs through the shared dispatcher (same handlers as the renderer). */
|
|
929
|
+
async function dispatchIpcInvoke(envelope, device, client) {
|
|
930
|
+
const { id, payload } = envelope;
|
|
931
|
+
const ts = new Date().toISOString();
|
|
932
|
+
const calls = payload && Array.isArray(payload.calls) ? payload.calls : null;
|
|
933
|
+
const valid = calls && calls.length >= 1 && calls.length <= IPC_INVOKE_MAX_CALLS &&
|
|
934
|
+
calls.every((c) => c && typeof c === 'object' && typeof c.channel === 'string' && c.channel.length > 0 &&
|
|
935
|
+
c.channel.length <= 200 && (c.args === undefined || Array.isArray(c.args)));
|
|
936
|
+
if (!valid || typeof id !== 'string' || !id || !_ipcDispatcher) {
|
|
937
|
+
await auditLog(ts, 'ipc:invoke', device.deviceId, id, 'error:rejected', client);
|
|
938
|
+
respond(id, { error: 'rejected' }, undefined, client);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const { chunkFrames } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
|
|
942
|
+
for (let i = 0; i < calls.length; i++) {
|
|
943
|
+
const { channel, args } = calls[i];
|
|
944
|
+
let out;
|
|
945
|
+
let audit;
|
|
946
|
+
try {
|
|
947
|
+
const r = await _ipcDispatcher.invoke(client, channel, args ?? []);
|
|
948
|
+
if (r.ok) { out = { ok: true, result: r.result }; audit = 'ok'; }
|
|
949
|
+
else { out = { ok: false, error: 'rejected' }; audit = `refused:${r.reason}`; }
|
|
950
|
+
} catch (e) {
|
|
951
|
+
out = { ok: false, error: 'rejected' };
|
|
952
|
+
audit = 'refused:dispatch_error';
|
|
953
|
+
}
|
|
954
|
+
let chunked = chunkFrames(out);
|
|
955
|
+
if (!chunked.ok) {
|
|
956
|
+
chunked = chunkFrames({ ok: false, error: 'rejected' });
|
|
957
|
+
if (out.ok) audit = 'refused:too_large';
|
|
958
|
+
}
|
|
959
|
+
await auditLog(ts, `ipc:${channel}`, device.deviceId, id, audit, client);
|
|
960
|
+
for (const frame of chunked.frames) sendIpcResult(`${id}.${i}`, frame, client);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** asset:manifest / asset:get — E2E-authenticated only; reply is chunked ipc:result-style frames, unicast. */
|
|
965
|
+
async function dispatchAsset(envelope, device, client) {
|
|
966
|
+
const { type, id, payload } = envelope;
|
|
967
|
+
const ts = new Date().toISOString();
|
|
968
|
+
if (typeof id !== 'string' || !id || client.e2e.state !== 'authenticated' || !client.e2e.sessionKey) {
|
|
969
|
+
await auditLog(ts, type, device.deviceId, id, 'refused:e2e_not_authenticated', client);
|
|
970
|
+
respond(id, { error: 'rejected' }, undefined, client);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
const { chunkFrames } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
|
|
974
|
+
const assets = require('./lib/feedback-remote/remoteAssets.cjs');
|
|
975
|
+
let out;
|
|
976
|
+
let audit = 'ok';
|
|
977
|
+
try {
|
|
978
|
+
if (type === 'asset:manifest') out = { ok: true, result: assets.assetManifest() };
|
|
979
|
+
else {
|
|
980
|
+
const r = assets.readAsset(payload && payload.name);
|
|
981
|
+
if (r.ok) out = { ok: true, result: r };
|
|
982
|
+
else { out = { ok: false, error: 'rejected' }; audit = `refused:${r.error}`; }
|
|
983
|
+
}
|
|
984
|
+
} catch {
|
|
985
|
+
out = { ok: false, error: 'rejected' };
|
|
986
|
+
audit = 'refused:asset_error';
|
|
987
|
+
}
|
|
988
|
+
let chunked = chunkFrames(out);
|
|
989
|
+
if (!chunked.ok) { chunked = chunkFrames({ ok: false, error: 'rejected' }); audit = 'refused:too_large'; }
|
|
990
|
+
await auditLog(ts, type, device.deviceId, id, audit, client);
|
|
991
|
+
for (const frame of chunked.frames) sendIpcResult(id, frame, client);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ─── ipc:subscribe push fanout ─────────────────────────────────────
|
|
995
|
+
// Mirrors sendIfAlive pushes to E2E-authenticated browser clients that subscribed.
|
|
996
|
+
|
|
997
|
+
const IPC_SUBSCRIBE_MAX_CHANNELS = 50;
|
|
998
|
+
|
|
999
|
+
/** True iff the apiSurface lists `channel` as a push (on / on-dynamic) with remote not deny. */
|
|
1000
|
+
function isPushableChannel(channel) {
|
|
1001
|
+
if (typeof channel !== 'string' || channel.length === 0 || channel.length > 200) return false;
|
|
1002
|
+
const surface = require('../preload/apiSurface.cjs');
|
|
1003
|
+
return surface.some((r) => {
|
|
1004
|
+
if (r.remote === 'deny') return false;
|
|
1005
|
+
if (r.kind === 'on') return r.channel === channel;
|
|
1006
|
+
if (r.kind === 'on-dynamic') {
|
|
1007
|
+
const prefix = r.channel.endsWith('*') ? r.channel.slice(0, -1) : r.channel;
|
|
1008
|
+
return channel.length > prefix.length && channel.startsWith(prefix);
|
|
1009
|
+
}
|
|
1010
|
+
return false;
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function handleIpcSubscribe(envelope, client, subscribe) {
|
|
1015
|
+
const channels = envelope.payload && Array.isArray(envelope.payload.channels) ? envelope.payload.channels : [];
|
|
1016
|
+
if (!client.pushChannels) client.pushChannels = new Set();
|
|
1017
|
+
for (const ch of channels.slice(0, IPC_SUBSCRIBE_MAX_CHANNELS)) {
|
|
1018
|
+
if (subscribe) { if (isPushableChannel(ch)) client.pushChannels.add(ch); }
|
|
1019
|
+
else client.pushChannels.delete(ch);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** Encrypt + unicast one ipc:event frame to one subscribed client. */
|
|
1024
|
+
function sendIpcEvent(client, channel, frame) {
|
|
1025
|
+
if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
|
|
1026
|
+
if (!client.e2e.sessionKey) return;
|
|
1027
|
+
const inner = { type: 'ipc:event', id: crypto.randomUUID(), channel, seq: frame.seq, total: frame.total, data: frame.data, ts: Date.now() };
|
|
1028
|
+
const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
|
|
1029
|
+
const out = { type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() };
|
|
1030
|
+
if (client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
|
|
1031
|
+
_ws.send(JSON.stringify(out));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function fanoutIpcEvent(channel, payload) {
|
|
1035
|
+
const targets = _clients.authenticated().filter((c) => c.pushChannels && c.pushChannels.has(channel));
|
|
1036
|
+
if (targets.length === 0) return;
|
|
1037
|
+
const { chunkFrames } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
|
|
1038
|
+
const chunked = chunkFrames(payload);
|
|
1039
|
+
if (!chunked.ok) return;
|
|
1040
|
+
for (const client of targets) {
|
|
1041
|
+
try {
|
|
1042
|
+
for (const frame of chunked.frames) sendIpcEvent(client, channel, frame);
|
|
1043
|
+
} catch (e) {
|
|
1044
|
+
logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ipc:event send failed', meta: { error: e?.message } });
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
905
1049
|
async function dispatchEnvelope(envelope, device, client) {
|
|
906
1050
|
const { type, id, payload } = envelope;
|
|
1051
|
+
if (type === 'ipc:subscribe' || type === 'ipc:unsubscribe') {
|
|
1052
|
+
if (client.e2e.state === 'authenticated') handleIpcSubscribe(envelope, client, type === 'ipc:subscribe');
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (type === 'ipc:invoke') {
|
|
1056
|
+
await dispatchIpcInvoke(envelope, device, client);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (type === 'asset:manifest' || type === 'asset:get') {
|
|
1060
|
+
await dispatchAsset(envelope, device, client);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
907
1063
|
if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
|
|
908
1064
|
|
|
909
1065
|
const ts = new Date().toISOString();
|
|
@@ -1293,6 +1449,7 @@ async function revokeAllDevices() {
|
|
|
1293
1449
|
// ─── IPC handlers ─────────────────────────────────────────────────────────────
|
|
1294
1450
|
|
|
1295
1451
|
function registerRemoteHandlers() {
|
|
1452
|
+
setRemoteFanout(fanoutIpcEvent);
|
|
1296
1453
|
removeOrphanedClientFiles(path.join(claudeHome(), 'session-manager'));
|
|
1297
1454
|
const { validated } = require('./ipcSchemas.cjs');
|
|
1298
1455
|
|
|
@@ -1430,6 +1587,8 @@ function destroy() {
|
|
|
1430
1587
|
}
|
|
1431
1588
|
|
|
1432
1589
|
module.exports = {
|
|
1590
|
+
loadConfig,
|
|
1591
|
+
setIpcDispatcher,
|
|
1433
1592
|
attachWindow,
|
|
1434
1593
|
registerRemoteHandlers,
|
|
1435
1594
|
init,
|
|
@@ -1455,6 +1614,7 @@ module.exports = {
|
|
|
1455
1614
|
hasReconnectTimer: () => _reconnectTimer !== null,
|
|
1456
1615
|
cancelReconnect,
|
|
1457
1616
|
invalidateConfigCache,
|
|
1617
|
+
fanoutIpcEvent,
|
|
1458
1618
|
setE2eAuthenticated: (key, clientKey = LEGACY_CLIENT_KEY) => {
|
|
1459
1619
|
_clients.getOrCreate(clientKey).e2e = makeState('authenticated', key, null);
|
|
1460
1620
|
},
|
package/src/preload/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PromptSession } from '../renderer/state/promptSessions';
|
|
1
|
+
import type { PromptSession, PromptSessionEvent } from '../renderer/state/promptSessions';
|
|
2
2
|
|
|
3
3
|
/** Mirrors the item shape returned by main/lib/prereqs.cjs checkPrereqs(). */
|
|
4
4
|
export type OpsRetentionSettings = {
|
|
@@ -2320,9 +2320,16 @@ export interface SessionManagerAPI {
|
|
|
2320
2320
|
setWorktreeDisabled: (payload: PromptSessionsSetWorktreeDisabledPayload) => Promise<PromptSessionsSetWorktreeDisabledResult>;
|
|
2321
2321
|
/** Archives the raw Claude JSONL to a gzip sidecar main-side; pointer, or null when absent/empty. */
|
|
2322
2322
|
archiveTranscript: (payload: { cwd: string; epicId: string; claudeSessionId: string }) => Promise<{ path: string; bytes: number; gzBytes: number; sha256: string } | null>;
|
|
2323
|
+
/** Reopens a completed Epic in place (same id + claudeSessionId); never mints an Epic. */
|
|
2324
|
+
reopen: (payload: PromptSessionsReopenPayload) => Promise<PromptSessionsReopenResult>;
|
|
2323
2325
|
};
|
|
2324
2326
|
}
|
|
2325
2327
|
|
|
2328
|
+
export interface PromptSessionsReopenPayload { cwd: string; epicId: string }
|
|
2329
|
+
export type PromptSessionsReopenResult =
|
|
2330
|
+
| { ok: true; session: PromptSession; events: PromptSessionEvent[]; alreadyOpen: boolean }
|
|
2331
|
+
| { ok: false; code: 'not_found' | 'transcript_missing' | 'invalid_session_id' | 'restore_failed' | 'write_failed'; message: string };
|
|
2332
|
+
|
|
2326
2333
|
// ─────────────────────────────────── PromptSessions active-index merge
|
|
2327
2334
|
export interface PromptSessionsMergeActiveIndexPayload {
|
|
2328
2335
|
cwd: string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apiSurface.cjs — single source of truth for the renderer↔main `window.api`
|
|
3
|
+
* surface: one row per method exposed by preload/index.cjs.
|
|
4
|
+
*
|
|
5
|
+
* kind: invoke | send | on | on-dynamic (per-tab channel; `*` = the tabId suffix)
|
|
6
|
+
* remote: policy when the surface is tunnelled to a remote browser
|
|
7
|
+
* read — never writes state, spawns processes, or touches OS UI
|
|
8
|
+
* mutate — everything else that is allowed remotely
|
|
9
|
+
* deny — desktop-only (pty, clipboard, shell, updater, config fs access, …)
|
|
10
|
+
* Classification is conservative: when unsure, mutate; screen/clipboard/fs/shell => deny.
|
|
11
|
+
*
|
|
12
|
+
* Drift is caught by __tests__/apiSurface.test.cjs, which replays the real preload.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const ROWS = require('./apiSurface.json');
|
|
18
|
+
|
|
19
|
+
module.exports = Object.freeze(ROWS.map((r) => Object.freeze({ ...r })));
|