gsd-lite 0.3.1 → 0.3.5
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/commands/prd.md +3 -142
- package/commands/resume.md +1 -1
- package/commands/start.md +3 -155
- package/hooks/gsd-context-monitor.cjs +23 -17
- package/hooks/gsd-session-init.cjs +1 -1
- package/hooks/gsd-statusline.cjs +17 -15
- package/package.json +1 -1
- package/references/evidence-spec.md +167 -0
- package/references/execution-loop.md +162 -0
- package/references/review-classification.md +84 -0
- package/references/state-diagram.md +218 -0
- package/src/schema.js +146 -26
- package/src/server.js +7 -0
- package/src/tools/orchestrator.js +76 -47
- package/src/tools/state.js +104 -60
- package/src/tools/verify.js +6 -3
- package/src/utils.js +39 -18
package/src/tools/state.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// State CRUD tools
|
|
2
2
|
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
|
-
import { stat } from 'node:fs/promises';
|
|
5
|
-
import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject } from '../utils.js';
|
|
4
|
+
import { stat, writeFile, rename, unlink } from 'node:fs/promises';
|
|
5
|
+
import { ensureDir, readJson, writeJson, writeAtomic, getStatePath, getGitHead, isPlainObject, clearGsdDirCache } from '../utils.js';
|
|
6
6
|
import {
|
|
7
7
|
CANONICAL_FIELDS,
|
|
8
8
|
TASK_LIFECYCLE,
|
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
validateResearchDecisionIndex,
|
|
11
11
|
validateResearcherResult,
|
|
12
12
|
validateState,
|
|
13
|
+
validateStateUpdate,
|
|
13
14
|
validateTransition,
|
|
14
15
|
createInitialState,
|
|
15
16
|
} from '../schema.js';
|
|
16
17
|
import { runAll } from './verify.js';
|
|
17
18
|
|
|
18
19
|
const RESEARCH_FILES = ['STACK.md', 'ARCHITECTURE.md', 'PITFALLS.md', 'SUMMARY.md'];
|
|
20
|
+
const MAX_EVIDENCE_ENTRIES = 200;
|
|
19
21
|
|
|
20
22
|
// C-1: Serialize all state mutations to prevent TOCTOU races
|
|
21
23
|
let _mutationQueue = Promise.resolve();
|
|
@@ -53,55 +55,58 @@ export async function init({ project, phases, research, force = false, basePath
|
|
|
53
55
|
const gsdDir = join(basePath, '.gsd');
|
|
54
56
|
const statePath = join(gsdDir, 'state.json');
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const phasesDir = join(gsdDir, 'phases');
|
|
58
|
+
return withStateLock(async () => {
|
|
59
|
+
// Guard: reject re-initialization unless force is set
|
|
60
|
+
if (!force) {
|
|
61
|
+
try {
|
|
62
|
+
await stat(statePath);
|
|
63
|
+
return { error: true, message: 'state.json already exists; pass force: true to reinitialize' };
|
|
64
|
+
} catch {} // File doesn't exist, proceed
|
|
65
|
+
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
if (research) {
|
|
68
|
-
await ensureDir(join(gsdDir, 'research'));
|
|
69
|
-
}
|
|
67
|
+
const phasesDir = join(gsdDir, 'phases');
|
|
70
68
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
clearGsdDirCache(); // Invalidate cache since we're creating .gsd/
|
|
70
|
+
await ensureDir(phasesDir);
|
|
71
|
+
if (research) {
|
|
72
|
+
await ensureDir(join(gsdDir, 'research'));
|
|
73
|
+
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
`# ${project}\n\nPlan placeholder — populate during planning phase.\n`,
|
|
79
|
-
);
|
|
75
|
+
const state = createInitialState({ project, phases });
|
|
76
|
+
if (state.error) return state;
|
|
77
|
+
state.git_head = await getGitHead(basePath);
|
|
80
78
|
|
|
81
|
-
|
|
82
|
-
for (const phase of state.phases) {
|
|
79
|
+
// Create plan.md placeholder (atomic write)
|
|
83
80
|
await writeAtomic(
|
|
84
|
-
join(
|
|
85
|
-
`#
|
|
81
|
+
join(gsdDir, 'plan.md'),
|
|
82
|
+
`# ${project}\n\nPlan placeholder — populate during planning phase.\n`,
|
|
86
83
|
);
|
|
87
|
-
}
|
|
88
84
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
85
|
+
// Create phase placeholder .md files (atomic writes)
|
|
86
|
+
for (const phase of state.phases) {
|
|
87
|
+
await writeAtomic(
|
|
88
|
+
join(phasesDir, `phase-${phase.id}.md`),
|
|
89
|
+
`# Phase ${phase.id}: ${phase.name}\n\nTasks and details go here.\n`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
96
92
|
|
|
97
|
-
|
|
93
|
+
const trackedFiles = [
|
|
94
|
+
join(gsdDir, 'plan.md'),
|
|
95
|
+
...state.phases.map((phase) => join(phasesDir, `phase-${phase.id}.md`)),
|
|
96
|
+
];
|
|
97
|
+
const mtimes = await Promise.all(trackedFiles.map(async (filePath) => (await stat(filePath)).mtimeMs));
|
|
98
|
+
state.context.last_session = new Date(Math.ceil(Math.max(...mtimes))).toISOString();
|
|
99
|
+
await writeJson(statePath, state);
|
|
100
|
+
|
|
101
|
+
return { success: true };
|
|
102
|
+
});
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
/**
|
|
101
106
|
* Read state.json, optionally filtering to specific fields.
|
|
102
107
|
*/
|
|
103
108
|
export async function read({ fields, basePath = process.cwd() } = {}) {
|
|
104
|
-
const statePath = getStatePath(basePath);
|
|
109
|
+
const statePath = await getStatePath(basePath);
|
|
105
110
|
if (!statePath) {
|
|
106
111
|
return { error: true, message: 'No .gsd directory found' };
|
|
107
112
|
}
|
|
@@ -143,7 +148,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
const statePath = getStatePath(basePath);
|
|
151
|
+
const statePath = await getStatePath(basePath);
|
|
147
152
|
if (!statePath) {
|
|
148
153
|
return { error: true, message: 'No .gsd directory found' };
|
|
149
154
|
}
|
|
@@ -217,8 +222,10 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
217
222
|
}
|
|
218
223
|
}
|
|
219
224
|
|
|
220
|
-
//
|
|
221
|
-
const validation =
|
|
225
|
+
// Use incremental validation for simple updates (no phases changes)
|
|
226
|
+
const validation = !updates.phases
|
|
227
|
+
? validateStateUpdate(state, updates)
|
|
228
|
+
: validateState(merged);
|
|
222
229
|
if (!validation.valid) {
|
|
223
230
|
return {
|
|
224
231
|
error: true,
|
|
@@ -236,7 +243,6 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
236
243
|
*/
|
|
237
244
|
function verificationPassed(verification) {
|
|
238
245
|
if (!verification || typeof verification !== 'object') return false;
|
|
239
|
-
if ('passed' in verification) return verification.passed === true;
|
|
240
246
|
return ['lint', 'typecheck', 'test'].every((key) => (
|
|
241
247
|
verification[key]
|
|
242
248
|
&& typeof verification[key].exit_code === 'number'
|
|
@@ -271,7 +277,7 @@ export async function phaseComplete({
|
|
|
271
277
|
if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
|
|
272
278
|
return { error: true, message: 'direction_ok must be a boolean when provided' };
|
|
273
279
|
}
|
|
274
|
-
const statePath = getStatePath(basePath);
|
|
280
|
+
const statePath = await getStatePath(basePath);
|
|
275
281
|
if (!statePath) {
|
|
276
282
|
return { error: true, message: 'No .gsd directory found' };
|
|
277
283
|
}
|
|
@@ -358,10 +364,11 @@ export async function phaseComplete({
|
|
|
358
364
|
}
|
|
359
365
|
await writeJson(statePath, state);
|
|
360
366
|
return {
|
|
361
|
-
|
|
362
|
-
|
|
367
|
+
success: true,
|
|
368
|
+
action: 'direction_drift',
|
|
363
369
|
workflow_mode: 'awaiting_user',
|
|
364
370
|
phase_id: phase.id,
|
|
371
|
+
message: 'Direction drift detected; awaiting user decision before phase can complete',
|
|
365
372
|
};
|
|
366
373
|
}
|
|
367
374
|
|
|
@@ -410,7 +417,7 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
|
|
|
410
417
|
return { error: true, message: 'data.scope must be a string' };
|
|
411
418
|
}
|
|
412
419
|
|
|
413
|
-
const statePath = getStatePath(basePath);
|
|
420
|
+
const statePath = await getStatePath(basePath);
|
|
414
421
|
if (!statePath) {
|
|
415
422
|
return { error: true, message: 'No .gsd directory found' };
|
|
416
423
|
}
|
|
@@ -427,6 +434,14 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
|
|
|
427
434
|
}
|
|
428
435
|
|
|
429
436
|
state.evidence[id] = data;
|
|
437
|
+
|
|
438
|
+
// Auto-prune when evidence exceeds limit
|
|
439
|
+
const entries = Object.keys(state.evidence);
|
|
440
|
+
if (entries.length > MAX_EVIDENCE_ENTRIES) {
|
|
441
|
+
const gsdDir = dirname(statePath);
|
|
442
|
+
await _pruneEvidenceFromState(state, state.current_phase, gsdDir);
|
|
443
|
+
}
|
|
444
|
+
|
|
430
445
|
await writeJson(statePath, state);
|
|
431
446
|
return { success: true };
|
|
432
447
|
});
|
|
@@ -472,7 +487,7 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
|
|
|
472
487
|
* Scope format is "task:X.Y" where X is the phase number.
|
|
473
488
|
*/
|
|
474
489
|
export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
|
|
475
|
-
const statePath = getStatePath(basePath);
|
|
490
|
+
const statePath = await getStatePath(basePath);
|
|
476
491
|
if (!statePath) {
|
|
477
492
|
return { error: true, message: 'No .gsd directory found' };
|
|
478
493
|
}
|
|
@@ -521,6 +536,11 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
|
|
|
521
536
|
if (!phase || !Array.isArray(phase.todo)) {
|
|
522
537
|
return { error: true, message: 'Phase todo must be an array' };
|
|
523
538
|
}
|
|
539
|
+
// D-4: Zero-task phase — immediately trigger review so phase can advance
|
|
540
|
+
if (phase.todo.length === 0) {
|
|
541
|
+
return { mode: 'trigger_review' };
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
const runnableTasks = [];
|
|
525
545
|
|
|
526
546
|
for (const task of phase.todo) {
|
|
@@ -723,7 +743,8 @@ export function reclassifyReviewLevel(task, executorResult) {
|
|
|
723
743
|
|
|
724
744
|
// Check for explicit [LEVEL-UP] in decisions
|
|
725
745
|
const hasLevelUp = (executorResult.decisions || []).some(d =>
|
|
726
|
-
typeof d === 'string' && d.includes('[LEVEL-UP]')
|
|
746
|
+
(typeof d === 'string' && d.includes('[LEVEL-UP]'))
|
|
747
|
+
|| (d && typeof d === 'object' && typeof d.summary === 'string' && d.summary.includes('[LEVEL-UP]'))
|
|
727
748
|
);
|
|
728
749
|
if (hasLevelUp) return 'L2';
|
|
729
750
|
|
|
@@ -792,6 +813,9 @@ export function applyResearchRefresh(state, newResearch) {
|
|
|
792
813
|
const oldIndex = state.research?.decision_index || {};
|
|
793
814
|
const newIndex = newResearch?.decision_index || {};
|
|
794
815
|
|
|
816
|
+
// Copy-on-write: build merged index without mutating oldIndex
|
|
817
|
+
const mergedIndex = { ...oldIndex };
|
|
818
|
+
|
|
795
819
|
// Collect IDs of decisions that changed or were removed
|
|
796
820
|
const invalidatedIds = new Set();
|
|
797
821
|
|
|
@@ -800,11 +824,11 @@ export function applyResearchRefresh(state, newResearch) {
|
|
|
800
824
|
if (id in newIndex) {
|
|
801
825
|
const newDecision = newIndex[id];
|
|
802
826
|
if (oldDecision.summary === newDecision.summary) {
|
|
803
|
-
// Rule 1: same conclusion — update metadata
|
|
804
|
-
|
|
827
|
+
// Rule 1: same conclusion — update metadata
|
|
828
|
+
mergedIndex[id] = { ...oldDecision, ...newDecision };
|
|
805
829
|
} else {
|
|
806
830
|
// Rule 2: changed conclusion — replace and invalidate
|
|
807
|
-
|
|
831
|
+
mergedIndex[id] = newDecision;
|
|
808
832
|
invalidatedIds.add(id);
|
|
809
833
|
}
|
|
810
834
|
} else {
|
|
@@ -817,13 +841,13 @@ export function applyResearchRefresh(state, newResearch) {
|
|
|
817
841
|
// Rule 4: brand new IDs — just add them
|
|
818
842
|
for (const [id, newDecision] of Object.entries(newIndex)) {
|
|
819
843
|
if (!(id in oldIndex)) {
|
|
820
|
-
|
|
844
|
+
mergedIndex[id] = newDecision;
|
|
821
845
|
}
|
|
822
846
|
}
|
|
823
847
|
|
|
824
|
-
//
|
|
848
|
+
// Assign merged index to state (atomic replacement)
|
|
825
849
|
if (!state.research) state.research = {};
|
|
826
|
-
state.research.decision_index =
|
|
850
|
+
state.research.decision_index = mergedIndex;
|
|
827
851
|
|
|
828
852
|
// C-3: Only invalidate tasks whose lifecycle allows needs_revalidation
|
|
829
853
|
if (invalidatedIds.size > 0) {
|
|
@@ -867,7 +891,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
867
891
|
return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
|
|
868
892
|
}
|
|
869
893
|
|
|
870
|
-
const statePath = getStatePath(basePath);
|
|
894
|
+
const statePath = await getStatePath(basePath);
|
|
871
895
|
if (!statePath) {
|
|
872
896
|
return { error: true, message: 'No .gsd directory found' };
|
|
873
897
|
}
|
|
@@ -883,12 +907,30 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
883
907
|
const researchDir = join(gsdDir, 'research');
|
|
884
908
|
await ensureDir(researchDir);
|
|
885
909
|
|
|
910
|
+
// Atomic multi-file write: write all artifacts first, then rename in batch
|
|
886
911
|
const normalizedArtifacts = normalizeResearchArtifacts(artifacts);
|
|
887
|
-
|
|
888
|
-
|
|
912
|
+
const tmpSuffix = `.${process.pid}-${Date.now()}.tmp`;
|
|
913
|
+
const tmpPaths = [];
|
|
914
|
+
try {
|
|
915
|
+
for (const fileName of RESEARCH_FILES) {
|
|
916
|
+
const finalPath = join(researchDir, fileName);
|
|
917
|
+
const tmpFile = finalPath + tmpSuffix;
|
|
918
|
+
tmpPaths.push({ tmp: tmpFile, final: finalPath });
|
|
919
|
+
await writeFile(tmpFile, normalizedArtifacts[fileName], 'utf-8');
|
|
920
|
+
}
|
|
921
|
+
// All writes succeeded — rename in batch
|
|
922
|
+
for (const { tmp, final: finalPath } of tmpPaths) {
|
|
923
|
+
await rename(tmp, finalPath);
|
|
924
|
+
}
|
|
925
|
+
} catch (err) {
|
|
926
|
+
// Cleanup any temp files on failure
|
|
927
|
+
for (const { tmp } of tmpPaths) {
|
|
928
|
+
try { await unlink(tmp); } catch {}
|
|
929
|
+
}
|
|
930
|
+
throw err;
|
|
889
931
|
}
|
|
890
932
|
|
|
891
|
-
const
|
|
933
|
+
const { decision_index: _, ...nextResearchBase } = {
|
|
892
934
|
volatility: result.volatility,
|
|
893
935
|
expires_at: result.expires_at,
|
|
894
936
|
sources: result.sources,
|
|
@@ -898,13 +940,15 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
898
940
|
};
|
|
899
941
|
|
|
900
942
|
const refreshResult = state.research
|
|
901
|
-
? applyResearchRefresh(state,
|
|
943
|
+
? applyResearchRefresh(state, { ...nextResearchBase, decision_index })
|
|
902
944
|
: { warnings: [] };
|
|
903
945
|
|
|
946
|
+
// After applyResearchRefresh, state.research.decision_index is the merged result
|
|
947
|
+
const mergedDecisionIndex = state.research?.decision_index || decision_index;
|
|
904
948
|
state.research = {
|
|
905
949
|
...(state.research || {}),
|
|
906
|
-
...
|
|
907
|
-
decision_index:
|
|
950
|
+
...nextResearchBase,
|
|
951
|
+
decision_index: mergedDecisionIndex,
|
|
908
952
|
};
|
|
909
953
|
|
|
910
954
|
if (state.workflow_mode === 'research_refresh_needed') {
|
package/src/tools/verify.js
CHANGED
|
@@ -37,7 +37,7 @@ async function runCommand(command, args, cwd) {
|
|
|
37
37
|
return { exit_code: 0, summary: summarizeOutput(stdout, 3) };
|
|
38
38
|
} catch (err) {
|
|
39
39
|
return {
|
|
40
|
-
exit_code: typeof err.code === 'number' ? err.code :
|
|
40
|
+
exit_code: err.status ?? (typeof err.code === 'number' ? err.code : 1),
|
|
41
41
|
summary: summarizeOutput(err.stderr || err.stdout || err.message || '', 5),
|
|
42
42
|
};
|
|
43
43
|
}
|
|
@@ -65,13 +65,16 @@ export async function runLint(pm, cwd) {
|
|
|
65
65
|
return runCommand(pm, ['run', 'lint'], cwd);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export async function runTypeCheck(cwd) {
|
|
68
|
+
export async function runTypeCheck(pm, cwd) {
|
|
69
69
|
// M-8: Only run tsc if tsconfig.json exists
|
|
70
70
|
try {
|
|
71
71
|
await stat(join(cwd, 'tsconfig.json'));
|
|
72
72
|
} catch {
|
|
73
73
|
return { exit_code: 0, summary: 'skipped: no tsconfig.json found' };
|
|
74
74
|
}
|
|
75
|
+
if (pm === 'pnpm') return runCommand('pnpm', ['exec', 'tsc', '--noEmit'], cwd);
|
|
76
|
+
if (pm === 'yarn') return runCommand('yarn', ['tsc', '--noEmit'], cwd);
|
|
77
|
+
if (pm === 'bun') return runCommand('bun', ['run', 'tsc', '--noEmit'], cwd);
|
|
75
78
|
return runCommand('npx', ['tsc', '--noEmit'], cwd);
|
|
76
79
|
}
|
|
77
80
|
|
|
@@ -83,7 +86,7 @@ export async function runAll(cwd = process.cwd()) {
|
|
|
83
86
|
}
|
|
84
87
|
const [lint, typecheck, test] = await Promise.all([
|
|
85
88
|
runLint(pm, cwd),
|
|
86
|
-
runTypeCheck(cwd),
|
|
89
|
+
runTypeCheck(pm, cwd),
|
|
87
90
|
runTests(pm, cwd),
|
|
88
91
|
]);
|
|
89
92
|
return { lint, typecheck, test };
|
package/src/utils.js
CHANGED
|
@@ -1,27 +1,41 @@
|
|
|
1
|
-
import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
|
|
2
|
-
import { statSync } from 'node:fs';
|
|
1
|
+
import { readFile, writeFile, rename, mkdir, unlink, stat } from 'node:fs/promises';
|
|
3
2
|
import { join, dirname, resolve } from 'node:path';
|
|
4
3
|
import { execFile as execFileCb } from 'node:child_process';
|
|
5
4
|
import { promisify } from 'node:util';
|
|
6
5
|
|
|
7
6
|
const execFileAsync = promisify(execFileCb);
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const _gsdDirCache = new Map();
|
|
9
|
+
|
|
10
|
+
export async function getGsdDir(startDir = process.cwd()) {
|
|
11
|
+
const resolved = resolve(startDir);
|
|
12
|
+
if (_gsdDirCache.has(resolved)) return _gsdDirCache.get(resolved);
|
|
13
|
+
|
|
14
|
+
let dir = resolved;
|
|
11
15
|
while (true) {
|
|
12
16
|
const candidate = join(dir, '.gsd');
|
|
13
17
|
try {
|
|
14
|
-
const s =
|
|
15
|
-
if (s.isDirectory())
|
|
18
|
+
const s = await stat(candidate);
|
|
19
|
+
if (s.isDirectory()) {
|
|
20
|
+
_gsdDirCache.set(resolved, candidate);
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
16
23
|
} catch {}
|
|
17
24
|
const parent = dirname(dir);
|
|
18
|
-
if (parent === dir)
|
|
25
|
+
if (parent === dir) {
|
|
26
|
+
_gsdDirCache.set(resolved, null);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
19
29
|
dir = parent;
|
|
20
30
|
}
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
export function
|
|
24
|
-
|
|
33
|
+
export function clearGsdDirCache() {
|
|
34
|
+
_gsdDirCache.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getStatePath(startDir = process.cwd()) {
|
|
38
|
+
const gsdDir = await getGsdDir(startDir);
|
|
25
39
|
if (!gsdDir) return null;
|
|
26
40
|
return join(gsdDir, 'state.json');
|
|
27
41
|
}
|
|
@@ -38,8 +52,15 @@ export async function getGitHead(cwd = process.cwd()) {
|
|
|
38
52
|
}
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
let _tmpCounter = 0;
|
|
56
|
+
function tmpPath(filePath) {
|
|
57
|
+
return `${filePath}.${process.pid}-${Date.now()}-${_tmpCounter++}.tmp`;
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
export function isPlainObject(value) {
|
|
42
|
-
|
|
61
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
62
|
+
const proto = Object.getPrototypeOf(value);
|
|
63
|
+
return proto === Object.prototype || proto === null;
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
export async function ensureDir(dirPath) {
|
|
@@ -63,13 +84,13 @@ export async function readJson(filePath) {
|
|
|
63
84
|
* Atomically write JSON data (write to .tmp then rename).
|
|
64
85
|
*/
|
|
65
86
|
export async function writeJson(filePath, data) {
|
|
66
|
-
const
|
|
87
|
+
const tmp = tmpPath(filePath);
|
|
67
88
|
await ensureDir(dirname(filePath));
|
|
68
89
|
try {
|
|
69
|
-
await writeFile(
|
|
70
|
-
await rename(
|
|
90
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
91
|
+
await rename(tmp, filePath);
|
|
71
92
|
} catch (err) {
|
|
72
|
-
try { await unlink(
|
|
93
|
+
try { await unlink(tmp); } catch {}
|
|
73
94
|
throw err;
|
|
74
95
|
}
|
|
75
96
|
}
|
|
@@ -78,13 +99,13 @@ export async function writeJson(filePath, data) {
|
|
|
78
99
|
* Atomically write text content (write to .tmp then rename). [I-3]
|
|
79
100
|
*/
|
|
80
101
|
export async function writeAtomic(filePath, content) {
|
|
81
|
-
const
|
|
102
|
+
const tmp = tmpPath(filePath);
|
|
82
103
|
await ensureDir(dirname(filePath));
|
|
83
104
|
try {
|
|
84
|
-
await writeFile(
|
|
85
|
-
await rename(
|
|
105
|
+
await writeFile(tmp, content, 'utf-8');
|
|
106
|
+
await rename(tmp, filePath);
|
|
86
107
|
} catch (err) {
|
|
87
|
-
try { await unlink(
|
|
108
|
+
try { await unlink(tmp); } catch {}
|
|
88
109
|
throw err;
|
|
89
110
|
}
|
|
90
111
|
}
|