claude-code-session-manager 0.38.7 → 0.39.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/{TiptapBody-C74mBmMU.js → TiptapBody-CtuLATFR.js} +1 -1
- package/dist/assets/{index-BGTU12rl.css → index-B6JNrbpD.css} +1 -1
- package/dist/assets/{index-DPSmbyU_.js → index-CefVSjmm.js} +559 -559
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +24 -17
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/src/main/__tests__/prdLocations.test.cjs +56 -0
- package/src/main/__tests__/prdMigration.test.cjs +126 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +8 -8
- package/src/main/config.cjs +9 -0
- package/src/main/health.cjs +26 -20
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/importReferences.spec.cjs +56 -0
- package/src/main/lib/definitionOfDone.cjs +8 -9
- package/src/main/lib/importReferences.cjs +34 -0
- package/src/main/lib/prdCreate.cjs +3 -3
- package/src/main/lib/prdLocations.cjs +46 -0
- package/src/main/lib/prdMigration.cjs +83 -0
- package/src/main/lib/rcaFeedbackHook.cjs +5 -5
- package/src/main/queueOps.cjs +44 -15
- package/src/main/scheduler.cjs +191 -60
- package/src/main/templates/PRD_AUTHORING.md +2 -2
- package/src/preload/api.d.ts +16 -0
- package/src/preload/index.cjs +1 -0
package/src/main/scheduler.cjs
CHANGED
|
@@ -83,6 +83,8 @@ const queueOps = require('./queueOps.cjs');
|
|
|
83
83
|
// match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
|
|
84
84
|
// home-dir layout.
|
|
85
85
|
const { sweep: sweepFeedback } = require('../../scripts/lib/watchdogHelpers.cjs');
|
|
86
|
+
const { resolvePrdsDirs, resolvePrdWriteDir } = require('./lib/prdLocations.cjs');
|
|
87
|
+
const { migratePrds } = require('./lib/prdMigration.cjs');
|
|
86
88
|
|
|
87
89
|
const MAX_INVESTIGATION_DURATION_MS = 30 * 60_000;
|
|
88
90
|
|
|
@@ -407,19 +409,73 @@ function biasJobOomScore(pid) {
|
|
|
407
409
|
|
|
408
410
|
// ---------- fs helpers ----------
|
|
409
411
|
|
|
412
|
+
// ---------- per-project PRD dir resolution (PRD 808) ----------
|
|
413
|
+
//
|
|
414
|
+
// PRDS_DIR above is now the LEGACY global dir — kept only as a migration
|
|
415
|
+
// source and a search fallback. Live PRDs resolve per-job to
|
|
416
|
+
// `<job.cwd>/session-manager-operations/scheduler/prds/` via prdLocations.cjs.
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Every PRD-source directory currently in play: the legacy global dir (still
|
|
420
|
+
* searched so a not-yet-migrated file is never invisible) plus every active
|
|
421
|
+
* project's own PRDs dir. Used by scans that enumerate PRDs across projects
|
|
422
|
+
* (reconcile, list-prds, lint, rescan).
|
|
423
|
+
*/
|
|
424
|
+
function candidatePrdsDirs() {
|
|
425
|
+
return [PRDS_DIR, ...resolvePrdsDirs()];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The PRD-source directory for a given job cwd (falls back to DEFAULT_PROJECT_CWD). */
|
|
429
|
+
function prdDirForCwd(cwd) {
|
|
430
|
+
return resolvePrdWriteDir(cwd || DEFAULT_PROJECT_CWD);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Absolute path to `<job's project PRDs dir>/<job.slug>.md`. */
|
|
434
|
+
function prdPathForJob(job) {
|
|
435
|
+
return path.join(prdDirForCwd(job && job.cwd), `${job && job.slug}.md`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Search every candidate PRD dir for `<slug>.md` (legacy dir first, then
|
|
440
|
+
* each active project's dir). Returns the containing dir, or null if the
|
|
441
|
+
* slug isn't found anywhere. Used by slug-only callers (IPC handlers) that
|
|
442
|
+
* don't have a job's cwd on hand.
|
|
443
|
+
*/
|
|
444
|
+
async function findPrdDir(slug) {
|
|
445
|
+
for (const dir of candidatePrdsDirs()) {
|
|
446
|
+
try {
|
|
447
|
+
await fsp.access(path.join(dir, `${slug}.md`));
|
|
448
|
+
return dir;
|
|
449
|
+
} catch { /* not here — try the next candidate dir */ }
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
410
454
|
/**
|
|
411
|
-
* Resolve
|
|
455
|
+
* Resolve `<dir>/<slug>.md` for a directory already known to contain (or be
|
|
456
|
+
* about to receive) the slug, and enforce path containment. Returns the
|
|
412
457
|
* absolute path on success, null on slug-escape attempts. The zod schema
|
|
413
458
|
* for slugs already blocks `..` because the SLUG_RE excludes `/`, but
|
|
414
459
|
* defense-in-depth: a second containment check after path.resolve costs
|
|
415
460
|
* nothing and catches future regex laxity.
|
|
416
461
|
*/
|
|
417
|
-
function
|
|
418
|
-
const resolved = path.resolve(path.join(
|
|
419
|
-
if (!resolved.startsWith(
|
|
462
|
+
function safeSlugPathIn(dir, slug) {
|
|
463
|
+
const resolved = path.resolve(path.join(dir, `${slug}.md`));
|
|
464
|
+
if (!resolved.startsWith(dir + path.sep)) return null;
|
|
420
465
|
return resolved;
|
|
421
466
|
}
|
|
422
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Locate an EXISTING `<slug>.md` across every candidate PRD dir and return
|
|
470
|
+
* its safe, containment-checked absolute path — or null if the slug isn't
|
|
471
|
+
* found anywhere (or would escape its containing dir).
|
|
472
|
+
*/
|
|
473
|
+
async function safeSlugPath(slug) {
|
|
474
|
+
const dir = await findPrdDir(slug);
|
|
475
|
+
if (!dir) return null;
|
|
476
|
+
return safeSlugPathIn(dir, slug);
|
|
477
|
+
}
|
|
478
|
+
|
|
423
479
|
// Bundled authoring guide seeded into the scheduler dir so the session-manager-dev
|
|
424
480
|
// plugin's /develop and /prd skills — which reference this stable `~`-absolute
|
|
425
481
|
// path — work on any user's machine, not just the author's.
|
|
@@ -437,6 +493,37 @@ function ensureDirs() {
|
|
|
437
493
|
} catch { /* non-fatal: the guide is a convenience, not load-bearing for a run */ }
|
|
438
494
|
}
|
|
439
495
|
|
|
496
|
+
/**
|
|
497
|
+
* One-time, idempotent migration (PRD 808): move every PRD source .md file
|
|
498
|
+
* still sitting in the legacy global PRDS_DIR into its own project's
|
|
499
|
+
* `<cwd>/session-manager-operations/scheduler/prds/`, based on that PRD's
|
|
500
|
+
* own frontmatter `cwd`. Runs at every boot — a no-op fs.readdir once
|
|
501
|
+
* nothing is left to move. Files migratePrds couldn't resolve (missing/
|
|
502
|
+
* unparseable cwd, cwd not on disk) are left in place and logged as a
|
|
503
|
+
* warning — never silently dropped — so a human can fix the frontmatter.
|
|
504
|
+
*/
|
|
505
|
+
async function runPrdMigration() {
|
|
506
|
+
let result;
|
|
507
|
+
try {
|
|
508
|
+
result = await migratePrds(PRDS_DIR);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
logs.writeLine({ level: 'error', scope: 'scheduler', message: 'PRD migration failed', meta: { error: e?.message } });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
console.log(`[scheduler] PRD migration: moved ${result.moved}, skipped ${result.skipped}`);
|
|
514
|
+
if (result.unresolved.length > 0) {
|
|
515
|
+
logs.writeLine({
|
|
516
|
+
level: 'warn',
|
|
517
|
+
scope: 'scheduler',
|
|
518
|
+
message: `PRD migration: ${result.unresolved.length} file(s) left in legacy dir`,
|
|
519
|
+
meta: { legacyDir: PRDS_DIR, unresolved: result.unresolved },
|
|
520
|
+
});
|
|
521
|
+
for (const u of result.unresolved) {
|
|
522
|
+
console.warn(`[scheduler] PRD migration: left ${u.file} in legacy dir (${u.reason})`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
440
527
|
// Matches only numbered timestamp backups (queue.json.bak-<epoch>), not the
|
|
441
528
|
// bare `queue.json.bak` single-shot copy some older code paths left behind.
|
|
442
529
|
const QUEUE_BAK_RE = /^queue\.json\.bak-\d+$/;
|
|
@@ -611,20 +698,31 @@ function mutate(fn) {
|
|
|
611
698
|
// preserve the existing call shape (callers don't need to thread PRDS_DIR).
|
|
612
699
|
const parsePrdRaw = prdParser.parsePrdRaw;
|
|
613
700
|
const parsePrd = prdParser.parsePrd;
|
|
701
|
+
// Aggregates every candidate PRD dir's files into one flat, sorted list.
|
|
702
|
+
// prdParser's dir-mtime cache is keyed per-dir internally by its own single
|
|
703
|
+
// mtime var, so scanning N dirs here costs N stat+readdir passes rather than
|
|
704
|
+
// one — acceptable: PRD counts per project are bounded (hundreds, not
|
|
705
|
+
// millions), and correctness across multiple project dirs matters more than
|
|
706
|
+
// preserving the single-dir cache's steady-state zero-read fast path.
|
|
614
707
|
async function listPrdFiles() {
|
|
615
708
|
ensureDirs();
|
|
616
|
-
|
|
709
|
+
const dirs = candidatePrdsDirs();
|
|
710
|
+
const perDir = await Promise.all(dirs.map((dir) => prdParser.listPrdFiles(dir)));
|
|
711
|
+
return perDir.flat().sort();
|
|
617
712
|
}
|
|
618
713
|
|
|
619
714
|
/**
|
|
620
715
|
* Atomically mint a brand-new parallel-group NN for a PRD about to be
|
|
621
|
-
* authored under
|
|
622
|
-
*
|
|
623
|
-
*
|
|
716
|
+
* authored under the given project's own PRDs dir (falls back to
|
|
717
|
+
* DEFAULT_PROJECT_CWD's dir when no cwd is supplied). See
|
|
718
|
+
* prdParser.allocateParallelGroup for the collision-proof mechanics. Callers
|
|
719
|
+
* wanting to join an EXISTING group (deliberate parallel siblings) skip this
|
|
720
|
+
* and just reuse the NN prefix.
|
|
624
721
|
*/
|
|
625
|
-
async function allocateParallelGroup() {
|
|
626
|
-
|
|
627
|
-
|
|
722
|
+
async function allocateParallelGroup(cwd) {
|
|
723
|
+
const dir = prdDirForCwd(cwd);
|
|
724
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
725
|
+
return prdParser.allocateParallelGroup(dir);
|
|
628
726
|
}
|
|
629
727
|
|
|
630
728
|
/**
|
|
@@ -739,6 +837,7 @@ async function reconcile(state) {
|
|
|
739
837
|
parallelGroup: p.parallelGroup,
|
|
740
838
|
estimateMinutes: p.estimateMinutes,
|
|
741
839
|
sourcePromptId: p.sourcePromptId,
|
|
840
|
+
sourceTabId: p.sourceTabId,
|
|
742
841
|
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
743
842
|
});
|
|
744
843
|
}
|
|
@@ -784,6 +883,7 @@ async function reconcile(state) {
|
|
|
784
883
|
parallelGroup: p.parallelGroup,
|
|
785
884
|
estimateMinutes: p.estimateMinutes,
|
|
786
885
|
sourcePromptId: p.sourcePromptId,
|
|
886
|
+
sourceTabId: p.sourceTabId,
|
|
787
887
|
bodyPreview: p.body.split('\n').slice(0, 6).join('\n'),
|
|
788
888
|
status: 'pending',
|
|
789
889
|
runId: null,
|
|
@@ -1241,7 +1341,7 @@ async function notifyOriginatingTab(job, {
|
|
|
1241
1341
|
sendPrompt = enqueueExternalPrompt,
|
|
1242
1342
|
} = {}) {
|
|
1243
1343
|
try {
|
|
1244
|
-
const prdPath =
|
|
1344
|
+
const prdPath = prdPathForJob(job);
|
|
1245
1345
|
const prd = await parsePrdRaw(prdPath).catch(() => null);
|
|
1246
1346
|
|
|
1247
1347
|
let targetTabId = prd?.sourceTabId || null;
|
|
@@ -1390,7 +1490,7 @@ async function executeJob(job, runDir, defaultCwd, onPid) {
|
|
|
1390
1490
|
|
|
1391
1491
|
// Read full PRD body fresh from disk (queue stored only the preview).
|
|
1392
1492
|
let prompt;
|
|
1393
|
-
const prdPath =
|
|
1493
|
+
const prdPath = prdPathForJob(job);
|
|
1394
1494
|
try {
|
|
1395
1495
|
const parsed = await parsePrd(prdPath);
|
|
1396
1496
|
// Centrally enforce the review → security-review → verify → commit finish
|
|
@@ -1757,7 +1857,7 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
1757
1857
|
|
|
1758
1858
|
let originalBody = '';
|
|
1759
1859
|
try {
|
|
1760
|
-
originalBody = (await parsePrd(
|
|
1860
|
+
originalBody = (await parsePrd(prdPathForJob(failedJob))).body;
|
|
1761
1861
|
} catch {
|
|
1762
1862
|
originalBody = failedJob.bodyPreview || '(original PRD missing from disk)';
|
|
1763
1863
|
}
|
|
@@ -1767,7 +1867,7 @@ async function spawnInvestigation(failedJob, runDir) {
|
|
|
1767
1867
|
const baseSlug = failedJob.slug.replace(/^\d+-/, '');
|
|
1768
1868
|
const group = failedJob.parallelGroup ?? 99;
|
|
1769
1869
|
const fixSlug = `${String(group).padStart(2, '0')}-fix-${baseSlug}`;
|
|
1770
|
-
const fixPath = path.join(
|
|
1870
|
+
const fixPath = path.join(prdDirForCwd(failedJob.cwd), `${fixSlug}.md`);
|
|
1771
1871
|
|
|
1772
1872
|
if (fs.existsSync(fixPath)) {
|
|
1773
1873
|
console.log(`[scheduler] skip investigation: fix plan already exists at ${fixPath}`);
|
|
@@ -1943,7 +2043,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
1943
2043
|
new Date().toISOString(),
|
|
1944
2044
|
);
|
|
1945
2045
|
|
|
1946
|
-
const prdPath =
|
|
2046
|
+
const prdPath = prdPathForJob(job);
|
|
1947
2047
|
const stateForDeps = await readQueue();
|
|
1948
2048
|
verifyResult = await verifyRun({
|
|
1949
2049
|
runDir,
|
|
@@ -2119,7 +2219,7 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
|
|
|
2119
2219
|
// investigation fires (mirrors reverifyNeedsReview's auto-fix section)
|
|
2120
2220
|
// so the periodic pass 10 min later sees it already attempted and
|
|
2121
2221
|
// does not spawn a duplicate.
|
|
2122
|
-
const fixSlugExists = (slug) => fs.existsSync(path.join(
|
|
2222
|
+
const fixSlugExists = (slug) => candidatePrdsDirs().some((dir) => fs.existsSync(path.join(dir, `${slug}.md`)));
|
|
2123
2223
|
if (
|
|
2124
2224
|
process.env.SM_AUTOFIX_DISABLE !== '1' &&
|
|
2125
2225
|
isEligibleForImmediateAutoFix(s.jobs[i2], s.jobs, fixSlugExists)
|
|
@@ -2777,7 +2877,7 @@ async function reverifyNeedsReview() {
|
|
|
2777
2877
|
const leftForReview = [];
|
|
2778
2878
|
for (const job of candidates) {
|
|
2779
2879
|
const runDir = path.join(RUNS_DIR, job.runId || resolveRunId(job));
|
|
2780
|
-
const prdPath =
|
|
2880
|
+
const prdPath = prdPathForJob(job);
|
|
2781
2881
|
// Derive committedDuringRun from the recorded run window. The live
|
|
2782
2882
|
// commit-guard uses gitHead() (before/after HEAD diff); here the run is
|
|
2783
2883
|
// already over so we query git log filtered to [startedAt, finishedAt+60s].
|
|
@@ -2897,7 +2997,7 @@ async function reverifyNeedsReview() {
|
|
|
2897
2997
|
if (process.env.SM_AUTOFIX_DISABLE !== '1') {
|
|
2898
2998
|
const afterHeal = await readQueue();
|
|
2899
2999
|
const targets = selectAutoFixTargets(afterHeal.jobs, {
|
|
2900
|
-
fixSlugExists: (s) => fs.existsSync(path.join(
|
|
3000
|
+
fixSlugExists: (s) => candidatePrdsDirs().some((dir) => fs.existsSync(path.join(dir, `${s}.md`))),
|
|
2901
3001
|
});
|
|
2902
3002
|
for (const job of targets) {
|
|
2903
3003
|
const runId = job.runId || resolveRunId(job);
|
|
@@ -2993,7 +3093,7 @@ function registerScheduleHandlers() {
|
|
|
2993
3093
|
}));
|
|
2994
3094
|
|
|
2995
3095
|
ipcMain.handle('schedule:reset-job', validated(schemas.scheduleSlug, async ({ slug }) => {
|
|
2996
|
-
if (!safeSlugPath(slug)) return { ok: false, error: 'invalid slug' };
|
|
3096
|
+
if (!(await safeSlugPath(slug))) return { ok: false, error: 'invalid slug' };
|
|
2997
3097
|
const found = await mutate((state) => {
|
|
2998
3098
|
const idx = state.jobs.findIndex((j) => j.slug === slug);
|
|
2999
3099
|
if (idx < 0) return false;
|
|
@@ -3054,8 +3154,9 @@ function registerScheduleHandlers() {
|
|
|
3054
3154
|
await fsp.mkdir(archiveDir, { recursive: true });
|
|
3055
3155
|
let archived = 0;
|
|
3056
3156
|
for (const job of victims) {
|
|
3057
|
-
const
|
|
3058
|
-
|
|
3157
|
+
const srcDir = await findPrdDir(job.slug) ?? prdDirForCwd(job.cwd);
|
|
3158
|
+
const src = path.resolve(path.join(srcDir, `${job.slug}.md`));
|
|
3159
|
+
if (!src.startsWith(srcDir + path.sep)) continue;
|
|
3059
3160
|
const dst = path.join(archiveDir, `${job.slug}.md`);
|
|
3060
3161
|
try {
|
|
3061
3162
|
await fsp.rename(src, dst);
|
|
@@ -3085,7 +3186,7 @@ function registerScheduleHandlers() {
|
|
|
3085
3186
|
});
|
|
3086
3187
|
|
|
3087
3188
|
ipcMain.handle('schedule:read-prd', validated(schemas.scheduleSlug, async ({ slug }) => {
|
|
3088
|
-
const filePath = safeSlugPath(slug);
|
|
3189
|
+
const filePath = await safeSlugPath(slug);
|
|
3089
3190
|
if (!filePath) return { ok: false, error: 'invalid slug' };
|
|
3090
3191
|
try {
|
|
3091
3192
|
const text = await fsp.readFile(filePath, 'utf8');
|
|
@@ -3111,7 +3212,14 @@ function registerScheduleHandlers() {
|
|
|
3111
3212
|
}));
|
|
3112
3213
|
|
|
3113
3214
|
ipcMain.handle('schedule:write-prd', validated(schemas.scheduleWritePrd, async (data) => {
|
|
3114
|
-
|
|
3215
|
+
// Editing an existing PRD writes back to whichever dir it already lives
|
|
3216
|
+
// in; a brand-new slug (no cwd known yet — e.g. the renderer's "New PRD"
|
|
3217
|
+
// template, authored before the user fills in `cwd`) falls back to the
|
|
3218
|
+
// legacy global dir until it's re-saved with a real cwd and migrated by
|
|
3219
|
+
// the next reconcile-driven scan.
|
|
3220
|
+
const dir = (await findPrdDir(data.slug)) ?? PRDS_DIR;
|
|
3221
|
+
if (dir === PRDS_DIR) ensureDirs();
|
|
3222
|
+
const resolved = safeSlugPathIn(dir, data.slug);
|
|
3115
3223
|
if (!resolved) return { ok: false, error: 'invalid slug' };
|
|
3116
3224
|
try {
|
|
3117
3225
|
await config.writeTextAtomic(resolved, data.body);
|
|
@@ -3128,31 +3236,35 @@ function registerScheduleHandlers() {
|
|
|
3128
3236
|
|
|
3129
3237
|
ipcMain.handle('schedule:list-prds', async () => {
|
|
3130
3238
|
ensureDirs();
|
|
3131
|
-
let entries;
|
|
3132
|
-
try {
|
|
3133
|
-
entries = await fsp.readdir(PRDS_DIR);
|
|
3134
|
-
} catch (e) {
|
|
3135
|
-
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: readdir failed', meta: { error: e?.message } });
|
|
3136
|
-
return [];
|
|
3137
|
-
}
|
|
3138
3239
|
const out = [];
|
|
3139
|
-
for (const
|
|
3140
|
-
|
|
3141
|
-
const filePath = path.join(PRDS_DIR, name);
|
|
3240
|
+
for (const dir of candidatePrdsDirs()) {
|
|
3241
|
+
let entries;
|
|
3142
3242
|
try {
|
|
3143
|
-
|
|
3144
|
-
const stat = await fsp.stat(filePath);
|
|
3145
|
-
out.push({
|
|
3146
|
-
slug: parsed.slug,
|
|
3147
|
-
parallelGroup: parsed.parallelGroup,
|
|
3148
|
-
title: parsed.title,
|
|
3149
|
-
cwd: parsed.cwd || '',
|
|
3150
|
-
estimateMinutes: parsed.estimateMinutes,
|
|
3151
|
-
sourcePromptId: parsed.sourcePromptId,
|
|
3152
|
-
mtimeMs: stat.mtimeMs,
|
|
3153
|
-
});
|
|
3243
|
+
entries = await fsp.readdir(dir);
|
|
3154
3244
|
} catch (e) {
|
|
3155
|
-
|
|
3245
|
+
if (e?.code !== 'ENOENT') {
|
|
3246
|
+
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: readdir failed', meta: { dir, error: e?.message } });
|
|
3247
|
+
}
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
for (const name of entries) {
|
|
3251
|
+
if (!name.endsWith('.md') || name.startsWith('.')) continue;
|
|
3252
|
+
const filePath = path.join(dir, name);
|
|
3253
|
+
try {
|
|
3254
|
+
const parsed = await parsePrd(filePath);
|
|
3255
|
+
const stat = await fsp.stat(filePath);
|
|
3256
|
+
out.push({
|
|
3257
|
+
slug: parsed.slug,
|
|
3258
|
+
parallelGroup: parsed.parallelGroup,
|
|
3259
|
+
title: parsed.title,
|
|
3260
|
+
cwd: parsed.cwd || '',
|
|
3261
|
+
estimateMinutes: parsed.estimateMinutes,
|
|
3262
|
+
sourcePromptId: parsed.sourcePromptId,
|
|
3263
|
+
mtimeMs: stat.mtimeMs,
|
|
3264
|
+
});
|
|
3265
|
+
} catch (e) {
|
|
3266
|
+
logs.writeLine({ level: 'warn', scope: 'scheduler', message: 'list-prds: skipping unparseable file', meta: { name, error: e?.message } });
|
|
3267
|
+
}
|
|
3156
3268
|
}
|
|
3157
3269
|
}
|
|
3158
3270
|
out.sort((a, b) => a.slug.localeCompare(b.slug, undefined, { numeric: true }));
|
|
@@ -3175,6 +3287,7 @@ function registerScheduleHandlers() {
|
|
|
3175
3287
|
|
|
3176
3288
|
async function init() {
|
|
3177
3289
|
ensureDirs();
|
|
3290
|
+
await runPrdMigration();
|
|
3178
3291
|
sweepQueueBackups().catch((e) => console.warn('[scheduler] backup sweep failed', e?.message));
|
|
3179
3292
|
|
|
3180
3293
|
// Hydrate cached state from the sidecar before any scheduling decisions.
|
|
@@ -3359,14 +3472,20 @@ const remote = {
|
|
|
3359
3472
|
return buildScheduleStatePayload(state, { withPaths: true });
|
|
3360
3473
|
},
|
|
3361
3474
|
|
|
3362
|
-
|
|
3363
|
-
|
|
3475
|
+
// `cwd` is optional: prdCreate.cjs's create flow passes the target
|
|
3476
|
+
// project's cwd explicitly (the file may not exist yet, so there's
|
|
3477
|
+
// nothing for findPrdDir to search for); the renderer's slug-only IPC
|
|
3478
|
+
// path (editing an already-queued PRD) omits it and relies on findPrdDir.
|
|
3479
|
+
async readPrd(slug, cwd) {
|
|
3480
|
+
const dir = cwd ? prdDirForCwd(cwd) : await findPrdDir(slug);
|
|
3481
|
+
if (!dir) return { ok: false, error: 'invalid slug' };
|
|
3482
|
+
const filePath = safeSlugPathIn(dir, slug);
|
|
3364
3483
|
if (!filePath) return { ok: false, error: 'invalid slug' };
|
|
3365
3484
|
try {
|
|
3366
3485
|
// realpath resolves symlinks; re-check boundary to block a rogue agent job
|
|
3367
|
-
// that places a symlink inside
|
|
3486
|
+
// that places a symlink inside the PRDs dir pointing outside the safe root.
|
|
3368
3487
|
const real = await fsp.realpath(filePath);
|
|
3369
|
-
if (!real.startsWith(
|
|
3488
|
+
if (!real.startsWith(dir + path.sep)) {
|
|
3370
3489
|
return { ok: false, error: 'invalid slug' };
|
|
3371
3490
|
}
|
|
3372
3491
|
const text = await fsp.readFile(real, 'utf8');
|
|
@@ -3395,17 +3514,29 @@ const remote = {
|
|
|
3395
3514
|
}
|
|
3396
3515
|
},
|
|
3397
3516
|
|
|
3398
|
-
|
|
3399
|
-
|
|
3517
|
+
// `cwd` optional — see readPrd's comment above; prdCreate.cjs's create
|
|
3518
|
+
// flow supplies it (the destination project dir for a brand-new file that
|
|
3519
|
+
// doesn't exist yet, so findPrdDir would return nothing to write into).
|
|
3520
|
+
async writePrd(slug, body, cwd) {
|
|
3521
|
+
let dir;
|
|
3522
|
+
if (cwd) {
|
|
3523
|
+
dir = prdDirForCwd(cwd);
|
|
3524
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
3525
|
+
} else {
|
|
3526
|
+
dir = (await findPrdDir(slug)) ?? PRDS_DIR;
|
|
3527
|
+
if (dir === PRDS_DIR) ensureDirs();
|
|
3528
|
+
}
|
|
3529
|
+
const resolved = safeSlugPathIn(dir, slug);
|
|
3400
3530
|
if (!resolved) return { ok: false, error: 'invalid slug' };
|
|
3401
3531
|
try {
|
|
3402
|
-
// Symlink defense, matching readPrd/readLog:
|
|
3403
|
-
// does NOT resolve symlinks, so a rogue job could plant
|
|
3404
|
-
// arbitrary $HOME path and have writeTextAtomic clobber it.
|
|
3405
|
-
// real parent dir (the file itself may not exist yet) and
|
|
3406
|
-
// containment; also reject the target if it is already a
|
|
3532
|
+
// Symlink defense, matching readPrd/readLog: safeSlugPathIn is lexical
|
|
3533
|
+
// and does NOT resolve symlinks, so a rogue job could plant a PRDs-dir
|
|
3534
|
+
// entry → an arbitrary $HOME path and have writeTextAtomic clobber it.
|
|
3535
|
+
// Resolve the real parent dir (the file itself may not exist yet) and
|
|
3536
|
+
// re-assert containment; also reject the target if it is already a
|
|
3537
|
+
// symlink.
|
|
3407
3538
|
const realParent = await fsp.realpath(path.dirname(resolved));
|
|
3408
|
-
if (realParent !==
|
|
3539
|
+
if (realParent !== dir && !realParent.startsWith(dir + path.sep)) {
|
|
3409
3540
|
return { ok: false, error: 'invalid slug' };
|
|
3410
3541
|
}
|
|
3411
3542
|
const existing = await fsp.lstat(resolved).catch(() => null);
|
|
@@ -3421,7 +3552,7 @@ const remote = {
|
|
|
3421
3552
|
},
|
|
3422
3553
|
|
|
3423
3554
|
async resetJob(slug) {
|
|
3424
|
-
if (!safeSlugPath(slug)) return { ok: false, error: 'invalid slug' };
|
|
3555
|
+
if (!(await safeSlugPath(slug))) return { ok: false, error: 'invalid slug' };
|
|
3425
3556
|
const found = await mutate((state) => {
|
|
3426
3557
|
const idx = state.jobs.findIndex((j) => j.slug === slug);
|
|
3427
3558
|
if (idx < 0) return false;
|
|
@@ -3499,4 +3630,4 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
|
|
|
3499
3630
|
});
|
|
3500
3631
|
}
|
|
3501
3632
|
|
|
3502
|
-
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus };
|
|
3633
|
+
module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, allocateParallelGroup, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, isEligibleForImmediateAutoFix, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt, committedInWindow, computeCommittedDuringRun, classifySigtermWithCommit, isFixPlanSlug, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH, forceTickOutcome, applyPauseCleared, detectNetworkErrorInLog, detectRateLimitInLog, classifyFailureOutcome, TRANSIENT_RETRY_CAP, buildScheduleStatePayload, partitionBootOrphans, applyOrphanOutcome, BOOT_ORPHAN_KILL_GRACE_MS, feedbackSweepDue, FEEDBACK_SWEEP_TICK_INTERVAL, sweepFeedback, registerAdminRoutes, notifyOriginatingTab, isNotifiableTerminalStatus, candidatePrdsDirs, prdDirForCwd, prdPathForJob, findPrdDir, runPrdMigration };
|
|
@@ -392,8 +392,8 @@ the user may or may not have open.
|
|
|
392
392
|
|
|
393
393
|
### Fallback: writing the PRD file directly
|
|
394
394
|
|
|
395
|
-
When the app is not running, write `<NN>-<slug>.md` by hand into
|
|
396
|
-
|
|
395
|
+
When the app is not running, write `<NN>-<slug>.md` by hand into the target repo's own
|
|
396
|
+
`<cwd>/session-manager-operations/scheduler/prds/` following the frontmatter rules in §6 and the
|
|
397
397
|
body conventions the rest of this guide describes (`# Goal`, `# Acceptance criteria`,
|
|
398
398
|
`# Implementation notes`, `## Engineering standards` inlined verbatim — see `/develop`'s output
|
|
399
399
|
for the exact shape). **Trade-off:** this path has no atomic `NN` allocation. The tool's
|
package/src/preload/api.d.ts
CHANGED
|
@@ -113,6 +113,15 @@ export interface ReadTextResult {
|
|
|
113
113
|
error: string | null;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** One resolved node in a CLAUDE.md-like file's `@path` import chain. */
|
|
117
|
+
export interface ImportRef {
|
|
118
|
+
path: string;
|
|
119
|
+
exists: boolean;
|
|
120
|
+
sizeBytes: number;
|
|
121
|
+
tokenEstimate: number;
|
|
122
|
+
ok: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
export interface WriteResult {
|
|
117
126
|
ok: boolean;
|
|
118
127
|
mtimeMs: number;
|
|
@@ -390,6 +399,12 @@ export interface ScheduleJob {
|
|
|
390
399
|
verifierVerdict?: string;
|
|
391
400
|
/** Per-job values carried in queue.json for dependency checking. */
|
|
392
401
|
dependsOn?: string[];
|
|
402
|
+
/** Originating tab id (PRD frontmatter `sourceTabId`), refreshed from the
|
|
403
|
+
* PRD file on every reconcile. When the PRD was authored from inside a
|
|
404
|
+
* PromptSessionConversation, this equals that PromptSession's own id
|
|
405
|
+
* (its chat key) — lets the Scheduler UI trace a job back to the
|
|
406
|
+
* PromptSession that spawned it. */
|
|
407
|
+
sourceTabId?: string | null;
|
|
393
408
|
}
|
|
394
409
|
|
|
395
410
|
export interface SchedulePaths {
|
|
@@ -1172,6 +1187,7 @@ export interface SessionManagerAPI {
|
|
|
1172
1187
|
watch: (paths: string[]) => void;
|
|
1173
1188
|
unwatch: (paths: string[]) => void;
|
|
1174
1189
|
onChanged: (handler: (info: ConfigChangedEvent) => void) => () => void;
|
|
1190
|
+
parseImports: (path: string) => Promise<{ ok: true; imports: ImportRef[] } | { ok: false; error: string }>;
|
|
1175
1191
|
};
|
|
1176
1192
|
voice: {
|
|
1177
1193
|
onHotkey: (handler: (event: VoiceHotkeyEvent) => void) => () => void;
|
package/src/preload/index.cjs
CHANGED
|
@@ -156,6 +156,7 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
156
156
|
exists: (path) => ipcRenderer.invoke('config:exists', { path }),
|
|
157
157
|
watch: (paths) => ipcRenderer.send('config:watch', { paths }),
|
|
158
158
|
unwatch: (paths) => ipcRenderer.send('config:unwatch', { paths }),
|
|
159
|
+
parseImports: (path) => ipcRenderer.invoke('config:parse-imports', { path }),
|
|
159
160
|
onChanged: (handler) => {
|
|
160
161
|
const listener = (_e, info) => handler(info);
|
|
161
162
|
ipcRenderer.on('config:changed', listener);
|