claude-code-session-manager 0.89.0 → 0.90.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.
- package/dist/assets/{AgentLibrary-tuj-j1kQ.js → AgentLibrary-CrmoSn9-.js} +1 -1
- package/dist/assets/DataModel-SPLQTR8E.js +1 -0
- package/dist/assets/{History-DylcasUk.js → History-DT6S3abz.js} +2 -2
- package/dist/assets/{Hooks--2xoag9a.js → Hooks-CC2iv5Oq.js} +3 -3
- package/dist/assets/{HostBilko-oaw8paAh.js → HostBilko-DqY7dA2a.js} +1 -1
- package/dist/assets/{Library-DzKA09Iy.js → Library-CUoOC77T.js} +1 -1
- package/dist/assets/{ListDetail-CEdcO80v.js → ListDetail-DgrG72f4.js} +1 -1
- package/dist/assets/MarkdownEditor-C8dB3ZWU.js +1 -0
- package/dist/assets/{McpServers-BB7ayW7W.js → McpServers-DupdjvFt.js} +2 -2
- package/dist/assets/{Memory-BOdYvrL4.js → Memory-C_4LhC2r.js} +6 -6
- package/dist/assets/{Panel-Yf3SxsRv.js → Panel-DWmE5Fu9.js} +1 -1
- package/dist/assets/{Permissions-BYCpHNy5.js → Permissions-hxiN2IWM.js} +3 -3
- package/dist/assets/{Plugins-B0lXiYqV.js → Plugins-fttEgZqy.js} +2 -2
- package/dist/assets/{ProvenanceBadge-T7x8LWcV.js → ProvenanceBadge-DROCzlbx.js} +1 -1
- package/dist/assets/{SaveBar-BdVb4DbR.js → SaveBar-BZs0IQHq.js} +1 -1
- package/dist/assets/Scheduler-Bi4oZ8gg.js +16 -0
- package/dist/assets/{ScopeSwitcher-WVhmNsL5.js → ScopeSwitcher-C0538XU8.js} +1 -1
- package/dist/assets/{Settings-Bt4dVsl6.js → Settings-CcW6x-wu.js} +2 -2
- package/dist/assets/{SkillReferenceGraph-R-0YykV8.js → SkillReferenceGraph-BtCcPAYO.js} +1 -1
- package/dist/assets/{Skills-DXV_LdJP.js → Skills-Daf_1sQR.js} +2 -2
- package/dist/assets/SystemPrompt-rYIla_wt.js +1 -0
- package/dist/assets/{TagLibrary-B9OuqU6M.js → TagLibrary-81Mr1RLU.js} +1 -1
- package/dist/assets/{TiptapBody-HKKmB0AE.js → TiptapBody-BxuLNg1e.js} +1 -1
- package/dist/assets/{Toggle-CBggPonH.js → Toggle-CN8EVEai.js} +1 -1
- package/dist/assets/{index-BcJqLDiY.js → index-A9TWmB7_.js} +345 -345
- package/dist/assets/{index-BHTX4OTc.css → index-Dh7BLxdA.css} +1 -1
- package/dist/assets/{settingsSchema-BYE9DVw_.js → settingsSchema-C9I9a0jv.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/README.md +1 -0
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
- package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
- package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +108 -45
- package/src/main/health.cjs +20 -3
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
- package/src/main/lib/effectiveModelInfo.cjs +3 -3
- package/src/main/lib/epicDelegationStats.cjs +3 -2
- package/src/main/lib/epicSpawnCwd.cjs +36 -5
- package/src/main/lib/epicSpawnPlan.cjs +119 -0
- package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
- package/src/main/lib/epicTranscriptPath.cjs +184 -0
- package/src/main/lib/gitWorktree.cjs +1 -1
- package/src/main/lib/mcpToolCatalog.cjs +6 -1
- package/src/main/lib/prdCreate.cjs +27 -1
- package/src/main/lib/prdFrontmatter.cjs +16 -5
- package/src/main/lib/rcaReport.cjs +27 -3
- package/src/main/lib/schedulerPaths.cjs +27 -4
- package/src/main/lib/telemetryClient.cjs +11 -4
- package/src/main/lib/terminalRunOutcome.cjs +1 -0
- package/src/main/pty.cjs +1 -1
- package/src/main/runVerify.cjs +89 -1
- package/src/main/scheduler.cjs +17 -4
- package/src/main/templates/PRD_AUTHORING.md +11 -0
- package/src/main/transcripts.cjs +28 -7
- package/src/preload/api.d.ts +2 -0
- package/src/preload/index.cjs +1 -0
- package/dist/assets/DataModel-DOim5SXW.js +0 -1
- package/dist/assets/MarkdownEditor-BMyo0F7s.js +0 -1
- package/dist/assets/Scheduler-DlOCWioV.js +0 -14
- package/dist/assets/SystemPrompt-DiQRo_WF.js +0 -1
|
@@ -80,11 +80,14 @@ test('every counter event still yields appVersion + machineDigest once persisted
|
|
|
80
80
|
counters.trackEpicCreate(deps);
|
|
81
81
|
counters.trackSchedulerJobFinish({ status: 'completed' }, deps);
|
|
82
82
|
|
|
83
|
-
// track() ingress does real
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// track() ingress does real fs I/O and the counter functions fire it without
|
|
84
|
+
// awaiting the promise, so poll (bounded) for all 4 records rather than a fixed
|
|
85
|
+
// sleep — a fixed 50ms lost the race under CPU contention.
|
|
86
|
+
let recs = [];
|
|
87
|
+
for (let i = 0; i < 200 && recs.length < 4; i++) {
|
|
88
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
89
|
+
recs = await readQueueLines(client);
|
|
90
|
+
}
|
|
88
91
|
expect(recs.length).toBe(4);
|
|
89
92
|
for (const rec of recs) {
|
|
90
93
|
const meta = asPersistedMetadata(rec);
|
|
@@ -27,7 +27,7 @@ const fs = require('node:fs');
|
|
|
27
27
|
const path = require('node:path');
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
30
|
-
const {
|
|
30
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
31
31
|
|
|
32
32
|
// Mirrors rawSessionModel.ts's RAW_MODELS — duplicated rather than imported
|
|
33
33
|
// because that file is a renderer ES module and this is a main-process CJS
|
|
@@ -252,10 +252,10 @@ function findLatestTranscriptModel(cwd, agentType, deps) {
|
|
|
252
252
|
matches.sort((a, b) => (Date.parse(b.createdAt || 0) || 0) - (Date.parse(a.createdAt || 0) || 0));
|
|
253
253
|
|
|
254
254
|
const homeDir = deps.homeDir || os.homedir();
|
|
255
|
-
const encodeCwdFn = deps.encodeCwd || encodeCwd;
|
|
256
255
|
const re = /"model":"([^"]+)"/g;
|
|
257
256
|
for (const session of matches) {
|
|
258
|
-
const filePath =
|
|
257
|
+
const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId: session.claudeSessionId, deps: { homeDir } }).path;
|
|
258
|
+
if (!filePath) continue;
|
|
259
259
|
const tail = readTail(filePath, MAX_TRANSCRIPT_TAIL_BYTES, deps);
|
|
260
260
|
if (!tail) continue;
|
|
261
261
|
let match;
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
const fs = require('node:fs');
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const path = require('node:path');
|
|
30
|
-
const {
|
|
30
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
31
31
|
const { classifyLine } = require('./classifyTranscriptLine.cjs');
|
|
32
32
|
|
|
33
33
|
// Same cap transcripts.cjs's readDelta uses for a single pass — bounds
|
|
@@ -85,7 +85,8 @@ function countInlineEdits(cwd, claudeSessionId, deps = {}) {
|
|
|
85
85
|
const readFn = deps.readTranscriptTail || readTranscriptTail;
|
|
86
86
|
const homeDir = deps.homeDir || os.homedir();
|
|
87
87
|
try {
|
|
88
|
-
const filePath =
|
|
88
|
+
const filePath = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps: { homeDir } }).path;
|
|
89
|
+
if (!filePath) return 0;
|
|
89
90
|
const text = readFn(filePath, deps);
|
|
90
91
|
let count = 0;
|
|
91
92
|
for (const line of text.split('\n')) {
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { execFileSync } = require('node:child_process');
|
|
30
32
|
const { readActiveIndex } = require('./epicMint.cjs');
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -61,6 +63,26 @@ function isUsableDir(dir, statImpl) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Re-attach `branch` at `dir` (`git worktree add`). Sync + bounded; never throws. `dir` comes from
|
|
68
|
+
* a git-tracked file, so it is only honoured when it sits in a `session-manager-epic-worktrees`
|
|
69
|
+
* segment and the branch is an `sm-epic/` one — nothing else may be materialised from that record.
|
|
70
|
+
* @returns {boolean} true when `dir` now exists as a checkout.
|
|
71
|
+
*/
|
|
72
|
+
function restoreEpicWorktree({ dir, branch, baseCwd }) {
|
|
73
|
+
try {
|
|
74
|
+
if (!path.isAbsolute(dir) || !path.isAbsolute(baseCwd)) return false;
|
|
75
|
+
if (!path.resolve(dir).split(path.sep).includes('session-manager-epic-worktrees')) return false;
|
|
76
|
+
if (typeof branch !== 'string' || !/^sm-epic\/[A-Za-z0-9._-]+$/.test(branch)) return false;
|
|
77
|
+
const run = (args) => execFileSync('git', args, { cwd: baseCwd, stdio: 'ignore', timeout: 15_000 });
|
|
78
|
+
run(['worktree', 'prune']);
|
|
79
|
+
run(['worktree', 'add', dir, branch]);
|
|
80
|
+
return isUsableDir(dir, fs.statSync);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/**
|
|
65
87
|
* @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
|
|
66
88
|
* @returns {string} `worktree.dir` when the matching Epic has one AND it still
|
|
@@ -77,10 +99,19 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
|
|
|
77
99
|
const dir = session.worktree?.dir;
|
|
78
100
|
if (!dir) return cwd;
|
|
79
101
|
if (!isUsableDir(dir, statSync)) {
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
102
|
+
// NEVER fall back to the project cwd: the CLI files the transcript under
|
|
103
|
+
// encodeCwd(spawn cwd), so a resumed session started in a different dir splits
|
|
104
|
+
// its transcript across two encodings ("lost session"). Instead re-attach the
|
|
105
|
+
// Epic's own branch at the SAME recorded path (encoding unchanged); if that is
|
|
106
|
+
// impossible, return the recorded dir anyway so the spawn fails loudly (ENOENT)
|
|
107
|
+
// rather than silently forking the transcript. Read-side callers (no `restore`)
|
|
108
|
+
// keep the old cwd answer — they never spawn and must never mutate git state.
|
|
109
|
+
if (deps.restore) {
|
|
110
|
+
const restored = (deps.restoreWorktree || restoreEpicWorktree)({ dir, branch: session.worktree?.branch, baseCwd: cwd });
|
|
111
|
+
console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — ${restored ? 'restored at same path' : 'restore failed; spawn will fail rather than split the transcript'}`);
|
|
112
|
+
return dir;
|
|
113
|
+
}
|
|
114
|
+
console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — read-side falls back to ${cwd}`);
|
|
84
115
|
return cwd;
|
|
85
116
|
}
|
|
86
117
|
return dir;
|
|
@@ -92,4 +123,4 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
|
|
|
92
123
|
return cwd;
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
module.exports = { resolveEpicSpawnCwd };
|
|
126
|
+
module.exports = { resolveEpicSpawnCwd, restoreEpicWorktree };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicSpawnPlan.cjs — decides the spawn cwd AND the --resume/--session-id flag for an Epic's
|
|
5
|
+
* chat run TOGETHER (PRD 1319). They used to be computed independently: the flag from "does a
|
|
6
|
+
* transcript exist under ANY encoding" and the cwd separately, so a transcript stranded under a
|
|
7
|
+
* swept /tmp worktree encoding produced `--resume` from a cwd that cannot see it ("No
|
|
8
|
+
* conversation found"), the flag-swap retry then hit `--session-id` ("already in use"), and both
|
|
9
|
+
* doors were locked.
|
|
10
|
+
*
|
|
11
|
+
* Chosen fix: (a) first — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's own branch
|
|
12
|
+
* at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again. When that
|
|
13
|
+
* is impossible, (b): refuse BEFORE spawning with an actionable message naming the missing
|
|
14
|
+
* worktree path, never claim a flag that cannot work. Reachability = "some existing transcript
|
|
15
|
+
* lives under encodeCwd(spawn cwd)" and the spawn cwd is a real directory.
|
|
16
|
+
*
|
|
17
|
+
* Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
|
|
18
|
+
* breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
|
|
19
|
+
* worktree dir still missing?) without re-running the restore git commands.
|
|
20
|
+
*
|
|
21
|
+
* Ops-root hazard (epicSpawnCwd.cjs): `execCwd` returned here is for the spawn cwd option ONLY;
|
|
22
|
+
* ops reads/writes keep using the project `cwd` (projectRootOf / resolveProjectRoot).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
const { encodeCwd } = require('./encodeCwd.cjs');
|
|
29
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
30
|
+
const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
|
|
31
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
32
|
+
|
|
33
|
+
/** Map<sessionId, { dir, message }> — sessions found unreachable; valid while `dir` stays missing. */
|
|
34
|
+
const unreachable = new Map();
|
|
35
|
+
|
|
36
|
+
function __resetForTests() {
|
|
37
|
+
unreachable.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isDir(dir, statSync) {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(dir).isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Epic record for a session id: active-index first, else a bounded scan of archived `<id>.json`. */
|
|
49
|
+
function findEpic(cwd, claudeSessionId, deps) {
|
|
50
|
+
try {
|
|
51
|
+
for (const s of Object.values((deps.readActiveIndex || readActiveIndex)(cwd).sessions || {})) {
|
|
52
|
+
if (s && s.claudeSessionId === claudeSessionId) return s;
|
|
53
|
+
}
|
|
54
|
+
} catch { /* unreadable index → try archives */ }
|
|
55
|
+
try {
|
|
56
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
57
|
+
const dir = opsPath(cwd, 'prompt-sessions');
|
|
58
|
+
for (const name of fs.readdirSync(dir)) {
|
|
59
|
+
if (!name.endsWith('.json') || name === 'active-index.json') continue;
|
|
60
|
+
try {
|
|
61
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
|
|
62
|
+
if (s && s.claudeSessionId === claudeSessionId) return s;
|
|
63
|
+
} catch { /* malformed archive */ }
|
|
64
|
+
}
|
|
65
|
+
} catch { /* no ops dir */ }
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {{ cwd: string, claudeSessionId: string, fallbackResume?: boolean, deps?: object }} opts
|
|
71
|
+
* @returns {{ ok: true, execCwd: string, useResume: boolean, transcriptPath: string|null }
|
|
72
|
+
* | { ok: false, code: 'epic_closed'|'session_unreachable', message: string }}
|
|
73
|
+
*/
|
|
74
|
+
function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {} } = {}) {
|
|
75
|
+
const statSync = deps.statSync || fs.statSync;
|
|
76
|
+
const epic = findEpic(cwd, claudeSessionId, deps);
|
|
77
|
+
|
|
78
|
+
if (epic && epic.status === 'completed') {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
code: 'epic_closed',
|
|
82
|
+
message: `This Epic is closed (completed) — it no longer accepts requests. Start a new Epic instead of routing work into session ${claudeSessionId}.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const brokenDir = epic?.worktree?.dir;
|
|
87
|
+
const tripped = unreachable.get(claudeSessionId);
|
|
88
|
+
if (tripped && !isDir(tripped.dir, statSync)) return { ok: false, code: 'session_unreachable', message: tripped.message };
|
|
89
|
+
unreachable.delete(claudeSessionId);
|
|
90
|
+
|
|
91
|
+
let useResume = !!fallbackResume;
|
|
92
|
+
let resolved = null;
|
|
93
|
+
try {
|
|
94
|
+
resolved = resolveEpicTranscriptPath({ cwd, claudeSessionId, deps });
|
|
95
|
+
useResume = !!resolved.existsAnywhere;
|
|
96
|
+
} catch { /* keep the caller-supplied flag */ }
|
|
97
|
+
|
|
98
|
+
const execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
|
|
99
|
+
|
|
100
|
+
if (resolved && resolved.existsAnywhere) {
|
|
101
|
+
const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
|
|
102
|
+
const home = path.join(projectsDir, encodeCwd(execCwd));
|
|
103
|
+
const reachable = isDir(execCwd, statSync) && resolved.existingPaths.some((p) => path.dirname(p) === home);
|
|
104
|
+
if (!reachable) {
|
|
105
|
+
const missing = brokenDir || execCwd;
|
|
106
|
+
const message =
|
|
107
|
+
`Session ${claudeSessionId} cannot be resumed: its transcript is at ${resolved.existingPaths[0]}, ` +
|
|
108
|
+
`which the CLI only finds when run from ${missing}, and that worktree no longer exists and could not be ` +
|
|
109
|
+
`re-attached (likely swept from /tmp). Recreate it (git worktree add "${missing}" ${epic?.worktree?.branch || '<sm-epic branch>'}) ` +
|
|
110
|
+
`or start a new Epic.`;
|
|
111
|
+
unreachable.set(claudeSessionId, { dir: missing, message });
|
|
112
|
+
return { ok: false, code: 'session_unreachable', message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true, execCwd, useResume, transcriptPath: resolved?.path ?? null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { planEpicSpawn, __resetForTests };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicTranscriptDiagnostic.cjs — read-only scan for Epics whose claude
|
|
5
|
+
* transcript is not where the project-scoped lookup would put it.
|
|
6
|
+
*
|
|
7
|
+
* mislocated — exactly one transcript exists and it is not under
|
|
8
|
+
* <encodeCwd(project cwd)> (i.e. only a worktree encoding).
|
|
9
|
+
* duplicated — the same session id has transcripts under 2+ encodings; the
|
|
10
|
+
* state in which `claude --resume` fails from any third cwd.
|
|
11
|
+
*
|
|
12
|
+
* Bounded: walks Epic records of the supplied project cwds and stats each
|
|
13
|
+
* Epic's candidate paths via resolveEpicTranscriptPath. Never enumerates
|
|
14
|
+
* ~/.claude/projects. Writes nothing. Complexity: O(epics x candidates).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { encodeCwd } = require('./encodeCwd.cjs');
|
|
21
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
22
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
23
|
+
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
24
|
+
|
|
25
|
+
/** [epicId, session] pairs: active-index rows plus archived per-Epic `<id>.json` records. */
|
|
26
|
+
function epicRecords(cwd) {
|
|
27
|
+
const out = new Map();
|
|
28
|
+
try {
|
|
29
|
+
for (const [id, s] of Object.entries(readActiveIndex(cwd).sessions || {})) out.set(id, s);
|
|
30
|
+
} catch { /* unreadable index → archives only */ }
|
|
31
|
+
let dir;
|
|
32
|
+
let names = [];
|
|
33
|
+
try {
|
|
34
|
+
dir = opsPath(cwd, 'prompt-sessions');
|
|
35
|
+
names = fs.readdirSync(dir);
|
|
36
|
+
} catch { /* no ops dir → skip */ }
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
if (!name.endsWith('.json') || name === 'active-index.json') continue;
|
|
39
|
+
const id = name.slice(0, -5);
|
|
40
|
+
if (out.has(id)) continue;
|
|
41
|
+
try {
|
|
42
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))?.session;
|
|
43
|
+
if (s && typeof s === 'object') out.set(id, s);
|
|
44
|
+
} catch { /* malformed record → skip */ }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{ cwds: string[], homeDir?: string }} opts
|
|
51
|
+
* @returns {Array<{ sessionId: string, epicId: string, cwd: string, paths: string[], classification: 'mislocated'|'duplicated' }>}
|
|
52
|
+
*/
|
|
53
|
+
function scanEpicTranscripts({ cwds, homeDir = os.homedir() }) {
|
|
54
|
+
const findings = [];
|
|
55
|
+
const projectsDir = path.join(homeDir, '.claude', 'projects');
|
|
56
|
+
for (const cwd of cwds || []) {
|
|
57
|
+
const sessions = epicRecords(cwd);
|
|
58
|
+
const home = path.join(projectsDir, encodeCwd(cwd));
|
|
59
|
+
for (const [epicId, s] of sessions) {
|
|
60
|
+
const sessionId = s && s.claudeSessionId;
|
|
61
|
+
if (!sessionId || typeof sessionId !== 'string') continue;
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = resolveEpicTranscriptPath({ cwd, claudeSessionId: sessionId, deps: { homeDir } });
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const paths = res.existingPaths;
|
|
69
|
+
if (paths.length > 1) {
|
|
70
|
+
findings.push({ sessionId, epicId, cwd, paths, classification: 'duplicated' });
|
|
71
|
+
} else if (paths.length === 1 && path.dirname(paths[0]) !== home) {
|
|
72
|
+
findings.push({ sessionId, epicId, cwd, paths, classification: 'mislocated' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { scanEpicTranscripts };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* epicTranscriptPath.cjs — answers "where does this Epic session's JSONL
|
|
5
|
+
* transcript actually live?".
|
|
6
|
+
*
|
|
7
|
+
* The CLI writes `~/.claude/projects/<encodeCwd(spawn cwd)>/<id>.jsonl`, and an
|
|
8
|
+
* Epic's spawn cwd is its isolated worktree dir (chatRunner.cjs via
|
|
9
|
+
* resolveEpicSpawnCwd), NOT the project cwd. Turn 1 can still land under the
|
|
10
|
+
* project encoding (the worktree mint is fire-and-forget), and the worktree dir
|
|
11
|
+
* itself is routinely swept from /tmp while its transcript survives under
|
|
12
|
+
* ~/.claude/projects. So candidates are built from the recorded `worktree.dir`
|
|
13
|
+
* even when that directory no longer exists.
|
|
14
|
+
*
|
|
15
|
+
* Bounded: never enumerates ~/.claude/projects, only stats its candidate list.
|
|
16
|
+
* The active-index.json read and the archive scan are memoised per cwd on
|
|
17
|
+
* mtimeMs + size (same shape as transcripts.cjs's usageCache) because
|
|
18
|
+
* usageFor() can call this for up to 500 ids in one IPC.
|
|
19
|
+
*
|
|
20
|
+
* Ops-root hazard (see epicSpawnCwd.cjs): `cwd` here is the PROJECT cwd used
|
|
21
|
+
* for ops-root reads; this returns transcript paths only, never a spawn cwd.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const { encodeCwd } = require('./encodeCwd.cjs');
|
|
28
|
+
const { readActiveIndex } = require('./epicMint.cjs');
|
|
29
|
+
const { resolveEpicSpawnCwd } = require('./epicSpawnCwd.cjs');
|
|
30
|
+
|
|
31
|
+
/** Map<cwd, { key, sessions }> — active-index.json parse, keyed on mtime+size. */
|
|
32
|
+
const indexCache = new Map();
|
|
33
|
+
/** Map<cwd, { dirKey, files: Map<name,{key,sid,dir}>, byId: Map<sid,string[]> }> */
|
|
34
|
+
const archiveCache = new Map();
|
|
35
|
+
|
|
36
|
+
function __resetCacheForTests() {
|
|
37
|
+
indexCache.clear();
|
|
38
|
+
archiveCache.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emptyResult() {
|
|
42
|
+
return { path: null, candidates: [], existsAnywhere: false, existingPaths: [] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function statKey(statSync, p) {
|
|
46
|
+
try {
|
|
47
|
+
const s = statSync(p);
|
|
48
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
49
|
+
} catch {
|
|
50
|
+
return 'missing';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function opsFile(cwd, ...segments) {
|
|
55
|
+
const { opsPath } = require('./opsOwnership.cjs');
|
|
56
|
+
return opsPath(cwd, 'prompt-sessions', ...segments);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sessions map from active-index.json; O(1) stat when unchanged. */
|
|
60
|
+
function cachedSessions(cwd, deps, statSync) {
|
|
61
|
+
const key = statKey(statSync, opsFile(cwd, 'active-index.json'));
|
|
62
|
+
const hit = indexCache.get(cwd);
|
|
63
|
+
if (hit && hit.key === key) return hit.sessions;
|
|
64
|
+
let sessions = {};
|
|
65
|
+
try {
|
|
66
|
+
sessions = (deps.readActiveIndex || readActiveIndex)(cwd).sessions || {};
|
|
67
|
+
} catch {
|
|
68
|
+
sessions = {};
|
|
69
|
+
}
|
|
70
|
+
indexCache.set(cwd, { key, sessions });
|
|
71
|
+
return sessions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Worktree dirs recorded for `sid` across archived per-Epic JSON files. */
|
|
75
|
+
function archivedWorktreeDirs(cwd, sid, statSync) {
|
|
76
|
+
let dirPath;
|
|
77
|
+
try {
|
|
78
|
+
dirPath = opsFile(cwd);
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const dirKey = statKey(statSync, dirPath);
|
|
83
|
+
let entry = archiveCache.get(cwd);
|
|
84
|
+
if (entry && entry.dirKey === dirKey) return entry.byId.get(sid) || [];
|
|
85
|
+
const prevFiles = entry ? entry.files : new Map();
|
|
86
|
+
const files = new Map();
|
|
87
|
+
let names = [];
|
|
88
|
+
try {
|
|
89
|
+
names = fs.readdirSync(dirPath);
|
|
90
|
+
} catch {
|
|
91
|
+
names = [];
|
|
92
|
+
}
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
if (!name.endsWith('.json') || name === 'active-index.json') continue;
|
|
95
|
+
const file = path.join(dirPath, name);
|
|
96
|
+
const key = statKey(statSync, file);
|
|
97
|
+
const prev = prevFiles.get(name);
|
|
98
|
+
if (prev && prev.key === key) {
|
|
99
|
+
files.set(name, prev);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let rec = { key, sid: null, dir: null };
|
|
103
|
+
try {
|
|
104
|
+
const s = JSON.parse(fs.readFileSync(file, 'utf8'))?.session;
|
|
105
|
+
if (s && typeof s.claudeSessionId === 'string' && typeof s.worktree?.dir === 'string') {
|
|
106
|
+
rec = { key, sid: s.claudeSessionId, dir: s.worktree.dir };
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// malformed archive → no hint
|
|
110
|
+
}
|
|
111
|
+
files.set(name, rec);
|
|
112
|
+
}
|
|
113
|
+
const byId = new Map();
|
|
114
|
+
for (const rec of files.values()) {
|
|
115
|
+
if (!rec.sid || !rec.dir) continue;
|
|
116
|
+
const list = byId.get(rec.sid) || [];
|
|
117
|
+
list.push(rec.dir);
|
|
118
|
+
byId.set(rec.sid, list);
|
|
119
|
+
}
|
|
120
|
+
entry = { dirKey, files, byId };
|
|
121
|
+
archiveCache.set(cwd, entry);
|
|
122
|
+
return byId.get(sid) || [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
|
|
127
|
+
* @returns {{ path: string|null, candidates: string[], existsAnywhere: boolean, existingPaths: string[] }}
|
|
128
|
+
*/
|
|
129
|
+
function resolveEpicTranscriptPath({ cwd, claudeSessionId, deps = {} } = {}) {
|
|
130
|
+
if (!cwd || typeof cwd !== 'string' || !claudeSessionId || typeof claudeSessionId !== 'string') {
|
|
131
|
+
return emptyResult();
|
|
132
|
+
}
|
|
133
|
+
// The id becomes a filename — refuse anything that could escape the project dir.
|
|
134
|
+
if (/[\\/\0]/.test(claudeSessionId) || claudeSessionId === '.' || claudeSessionId === '..') {
|
|
135
|
+
return emptyResult();
|
|
136
|
+
}
|
|
137
|
+
const statSync = deps.statSync || fs.statSync;
|
|
138
|
+
const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
|
|
139
|
+
const file = (dir) => path.join(projectsDir, encodeCwd(dir), `${claudeSessionId}.jsonl`);
|
|
140
|
+
|
|
141
|
+
const worktreeDirs = [];
|
|
142
|
+
try {
|
|
143
|
+
const sessions = cachedSessions(cwd, deps, statSync);
|
|
144
|
+
let inIndex = false;
|
|
145
|
+
for (const s of Object.values(sessions)) {
|
|
146
|
+
if (s && s.claudeSessionId === claudeSessionId) {
|
|
147
|
+
inIndex = true;
|
|
148
|
+
if (typeof s.worktree?.dir === 'string' && s.worktree.dir) worktreeDirs.push(s.worktree.dir);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!inIndex) worktreeDirs.push(...archivedWorktreeDirs(cwd, claudeSessionId, statSync));
|
|
152
|
+
} catch {
|
|
153
|
+
// unreadable ops state → fall through to whatever candidates we have
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let spawnCwd = cwd;
|
|
157
|
+
try {
|
|
158
|
+
spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId, deps: { ...deps, readActiveIndex: () => ({ sessions: cachedSessions(cwd, deps, statSync) }) } }) || cwd;
|
|
159
|
+
} catch {
|
|
160
|
+
spawnCwd = cwd;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const candidates = [...new Set([file(spawnCwd), ...worktreeDirs.map(file), file(cwd)])];
|
|
164
|
+
|
|
165
|
+
const existing = [];
|
|
166
|
+
for (const p of candidates) {
|
|
167
|
+
try {
|
|
168
|
+
const st = statSync(p);
|
|
169
|
+
if (st.isFile()) existing.push({ p, mtimeMs: st.mtimeMs, size: st.size });
|
|
170
|
+
} catch {
|
|
171
|
+
// absent
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const existingPaths = existing.map((e) => e.p);
|
|
175
|
+
const newest = (list) => list.reduce((a, b) => (b.mtimeMs > a.mtimeMs ? b : a));
|
|
176
|
+
const nonEmpty = existing.filter((e) => e.size > 0);
|
|
177
|
+
let chosen = candidates[0];
|
|
178
|
+
if (nonEmpty.length) chosen = newest(nonEmpty).p;
|
|
179
|
+
else if (existing.length) chosen = existing[0].p;
|
|
180
|
+
|
|
181
|
+
return { path: chosen, candidates, existsAnywhere: existing.length > 0, existingPaths };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { resolveEpicTranscriptPath, __resetCacheForTests };
|
|
@@ -330,7 +330,7 @@ async function removeWorktreeDir(cwd, dir) {
|
|
|
330
330
|
/* best-effort */
|
|
331
331
|
}
|
|
332
332
|
const parentDir = path.dirname(dir);
|
|
333
|
-
// Guard against ever rmdir-ing the worktree base (
|
|
333
|
+
// Guard against ever rmdir-ing the worktree base (persistent state dir or SM_WORKTREE_ROOT) itself — every real caller's
|
|
334
334
|
// `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
|
|
335
335
|
// dir, never the tmpdir root, but this keeps the guarantee explicit rather
|
|
336
336
|
// than relying solely on call-site discipline.
|
|
@@ -96,7 +96,12 @@ const MCP_TOOL_CATALOG = [
|
|
|
96
96
|
+ 'and is rejected at write time if it does not name a real persona file. '
|
|
97
97
|
+ 'If this PRD has no dependsOn of its own AND the target Epic already has incomplete PRDs, '
|
|
98
98
|
+ '`disposition` ("append" or "new-head") is REQUIRED — the write is refused without it (a '
|
|
99
|
-
+ 'headless job caller instead gets a logged "append" default, since no human is present to ask).'
|
|
99
|
+
+ 'headless job caller instead gets a logged "append" default, since no human is present to ask). '
|
|
100
|
+
+ '`deliverable` ("artifact") + `artifactPaths` declare an ARTIFACT-ONLY PRD: `artifactPaths` are the '
|
|
101
|
+
+ 'files this PRD produces that are NOT meant to be committed (git-excluded), they are stat-checked on '
|
|
102
|
+
+ 'disk by the verifier, and declaring them is the ONLY way an artifact-only PRD can pass the finish '
|
|
103
|
+
+ 'protocol without a commit. Each requires the other (the write is refused if only one is given), and '
|
|
104
|
+
+ 'no path may contain "..".',
|
|
100
105
|
whenToUse: 'Use whenever new work should be queued into an already-approved Epic — this is the /develop path.',
|
|
101
106
|
whenNotToUse: 'TWO DISTINCT FAILURE MODES if this tool is not usable — do not conflate them: '
|
|
102
107
|
+ '(a) this tool call is PRESENT in your tool list but ERRORS as app-not-running / admin '
|
|
@@ -75,7 +75,7 @@ function deriveSlugFromTitle(title) {
|
|
|
75
75
|
function buildPrdBody(input) {
|
|
76
76
|
const {
|
|
77
77
|
title, cwd, estimateMinutes, goal, acceptanceCriteria,
|
|
78
|
-
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition,
|
|
78
|
+
implementationNotes, outOfScope, sourcePromptId, sourceTabId, tag, agentType, dependsOn, quietMachine, disposition, deliverable, artifactPaths,
|
|
79
79
|
} = input;
|
|
80
80
|
|
|
81
81
|
// No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
|
|
@@ -114,6 +114,9 @@ function buildPrdBody(input) {
|
|
|
114
114
|
// site above); a first-ever PRD in an Epic, or one with its own explicit
|
|
115
115
|
// dependsOn, has nothing to decide against and omits this key.
|
|
116
116
|
if (disposition) fmLines.push(`disposition: ${disposition}`);
|
|
117
|
+
// Artifact-only declaration — validated by createPrd() before this runs.
|
|
118
|
+
if (deliverable) fmLines.push(`deliverable: ${deliverable}`);
|
|
119
|
+
if (artifactPaths && artifactPaths.length) fmLines.push(`artifactPaths: [${artifactPaths.join(', ')}]`);
|
|
117
120
|
// Opt-in exclusive-lease flag (PRD 1107): serializes this job against
|
|
118
121
|
// every other job machine-wide for its run, for a PRD whose acceptance
|
|
119
122
|
// criteria are wall-clock/timing measurements that CPU contention from
|
|
@@ -317,6 +320,29 @@ async function createPrd(input, remote) {
|
|
|
317
320
|
if (input.parallelGroup != null) {
|
|
318
321
|
console.warn(`[prdCreate] parallelGroup input is deprecated and ignored (got ${input.parallelGroup}) — numbers are unique per project; use dependsOn for ordering`);
|
|
319
322
|
}
|
|
323
|
+
// Artifact-only declaration (deliverable + artifactPaths): fail-closed,
|
|
324
|
+
// checked BEFORE the NN allocation so a refusal burns no number.
|
|
325
|
+
const hasArtifactPaths = Array.isArray(input.artifactPaths) && input.artifactPaths.length > 0;
|
|
326
|
+
if (input.deliverable != null && input.deliverable !== 'artifact') {
|
|
327
|
+
return { ok: false, status: 400, error: 'deliverable must be exactly "artifact" when given' };
|
|
328
|
+
}
|
|
329
|
+
if (input.deliverable === 'artifact' && !hasArtifactPaths) {
|
|
330
|
+
return { ok: false, status: 400, error: 'deliverable: "artifact" requires a non-empty artifactPaths — name every git-excluded file this PRD produces' };
|
|
331
|
+
}
|
|
332
|
+
if (input.artifactPaths != null && input.deliverable !== 'artifact') {
|
|
333
|
+
return { ok: false, status: 400, error: 'artifactPaths requires deliverable: "artifact" — artifactPaths given without it' };
|
|
334
|
+
}
|
|
335
|
+
if (hasArtifactPaths) {
|
|
336
|
+
for (const ap of input.artifactPaths) {
|
|
337
|
+
if (typeof ap !== 'string' || !ap.trim() || /[\r\n,\[\]]/.test(ap)) {
|
|
338
|
+
return { ok: false, status: 400, error: `artifactPaths entry ${JSON.stringify(ap)} must be a non-empty single-line path without commas or brackets` };
|
|
339
|
+
}
|
|
340
|
+
if (ap.includes('..')) {
|
|
341
|
+
return { ok: false, status: 400, error: `artifactPaths entry "${ap}" must not contain ".."` };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
320
346
|
const nn = await remote.allocateParallelGroup(input.cwd);
|
|
321
347
|
const filenameSlug = `${nn}-${slug}`;
|
|
322
348
|
|
|
@@ -77,8 +77,8 @@ function splitFrontmatter(raw) {
|
|
|
77
77
|
* never emitted, which is how `scheduler_update_prd` clears a dependency.
|
|
78
78
|
*/
|
|
79
79
|
|
|
80
|
-
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition']);
|
|
81
|
-
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition'];
|
|
80
|
+
const RECOGNIZED_KEYS = new Set(['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths']);
|
|
81
|
+
const EMIT_ORDER = ['title', 'cwd', 'estimateMinutes', 'parallelGroup', 'sourcePromptId', 'sourceTabId', 'tag', 'agentType', 'createdVia', 'issuedAt', 'dependsOn', 'quietMachine', 'disposition', 'deliverable', 'artifactPaths'];
|
|
82
82
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
83
83
|
|
|
84
84
|
function indentOf(line) {
|
|
@@ -185,6 +185,17 @@ function applyKey(fm, key, after) {
|
|
|
185
185
|
// full rationale. Only these two values are recognized.
|
|
186
186
|
if (v === 'append' || v === 'new-head') fm.disposition = v;
|
|
187
187
|
return;
|
|
188
|
+
case 'deliverable':
|
|
189
|
+
// Artifact-only PRD declaration — only the literal `artifact` is
|
|
190
|
+
// recognized; anything else is dropped so a typo can never opt in.
|
|
191
|
+
if (v === 'artifact') fm.deliverable = v;
|
|
192
|
+
return;
|
|
193
|
+
case 'artifactPaths': {
|
|
194
|
+
// Inline `[a, b]` list, same shape as dependsOn.
|
|
195
|
+
const list = parseInlineList(after);
|
|
196
|
+
if (list) fm.artifactPaths = list;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
188
199
|
}
|
|
189
200
|
}
|
|
190
201
|
|
|
@@ -220,10 +231,10 @@ function parsePrdFile(text) {
|
|
|
220
231
|
|
|
221
232
|
if (RECOGNIZED_KEYS.has(key)) {
|
|
222
233
|
applyKey(fm, key, after);
|
|
223
|
-
if (key === 'dependsOn') {
|
|
224
|
-
if (fm
|
|
234
|
+
if (key === 'dependsOn' || key === 'artifactPaths') {
|
|
235
|
+
if (fm[key]) {
|
|
225
236
|
if (!fm._raw) fm._raw = {};
|
|
226
|
-
fm._raw[key] = { line, parsed: fm
|
|
237
|
+
fm._raw[key] = { line, parsed: fm[key] };
|
|
227
238
|
}
|
|
228
239
|
} else {
|
|
229
240
|
const parsed = parseScalar(after);
|