gsd-lite 0.3.1 → 0.3.2
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/package.json +1 -1
- package/src/schema.js +19 -1
- package/src/tools/orchestrator.js +67 -46
- package/src/tools/state.js +19 -11
- package/src/tools/verify.js +5 -2
- package/src/utils.js +18 -14
package/package.json
CHANGED
package/src/schema.js
CHANGED
|
@@ -212,6 +212,12 @@ export function validateState(state) {
|
|
|
212
212
|
errors.push(...decisionIndexValidation.errors.map((error) => `research.${error}`));
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
+
if (state.current_task !== null && typeof state.current_task !== 'string') {
|
|
216
|
+
errors.push('current_task must be a string or null');
|
|
217
|
+
}
|
|
218
|
+
if (state.current_review !== null && !isPlainObject(state.current_review)) {
|
|
219
|
+
errors.push('current_review must be an object or null');
|
|
220
|
+
}
|
|
215
221
|
if (!isPlainObject(state.evidence)) {
|
|
216
222
|
errors.push('evidence must be an object');
|
|
217
223
|
}
|
|
@@ -354,6 +360,12 @@ export function validateReviewerResult(r) {
|
|
|
354
360
|
if (!Array.isArray(r.rework_tasks)) errors.push('rework_tasks must be array');
|
|
355
361
|
if (!Array.isArray(r.evidence)) errors.push('evidence must be array');
|
|
356
362
|
|
|
363
|
+
if (Array.isArray(r.accepted_tasks) && Array.isArray(r.rework_tasks)) {
|
|
364
|
+
const overlap = r.accepted_tasks.filter(id => r.rework_tasks.includes(id));
|
|
365
|
+
if (overlap.length > 0) {
|
|
366
|
+
errors.push(`accepted_tasks and rework_tasks must be disjoint; overlap: ${overlap.join(', ')}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
357
369
|
for (const issue of r.critical_issues || []) {
|
|
358
370
|
if (!isPlainObject(issue)) {
|
|
359
371
|
errors.push('critical_issues entries must be objects');
|
|
@@ -421,12 +433,18 @@ export function validateDebuggerResult(r) {
|
|
|
421
433
|
}
|
|
422
434
|
|
|
423
435
|
export function createInitialState({ project, phases }) {
|
|
424
|
-
// Validate task names before creating state
|
|
436
|
+
// Validate task names and uniqueness before creating state
|
|
437
|
+
const seenIds = new Set();
|
|
425
438
|
for (const [pi, p] of (phases || []).entries()) {
|
|
426
439
|
for (const [ti, t] of (p.tasks || []).entries()) {
|
|
427
440
|
if (!t.name || typeof t.name !== 'string') {
|
|
428
441
|
return { error: true, message: `Phase ${pi + 1} task ${ti + 1}: name is required (got ${JSON.stringify(t.name)})` };
|
|
429
442
|
}
|
|
443
|
+
const id = `${pi + 1}.${t.index || ti + 1}`;
|
|
444
|
+
if (seenIds.has(id)) {
|
|
445
|
+
return { error: true, message: `Duplicate task ID: ${id} in phase ${pi + 1}` };
|
|
446
|
+
}
|
|
447
|
+
seenIds.add(id);
|
|
430
448
|
}
|
|
431
449
|
}
|
|
432
450
|
return {
|
|
@@ -27,7 +27,7 @@ function parseTimestamp(value) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function readContextHealth(basePath) {
|
|
30
|
-
const gsdDir = getGsdDir(basePath);
|
|
30
|
+
const gsdDir = await getGsdDir(basePath);
|
|
31
31
|
if (!gsdDir) return null;
|
|
32
32
|
try {
|
|
33
33
|
const raw = await readFile(join(gsdDir, '.context-health'), 'utf-8');
|
|
@@ -75,7 +75,7 @@ async function detectPlanDrift(basePath, lastSession) {
|
|
|
75
75
|
const lastSessionTs = parseTimestamp(lastSession);
|
|
76
76
|
if (lastSessionTs === null) return [];
|
|
77
77
|
|
|
78
|
-
const gsdDir = getGsdDir(basePath);
|
|
78
|
+
const gsdDir = await getGsdDir(basePath);
|
|
79
79
|
if (!gsdDir) return [];
|
|
80
80
|
|
|
81
81
|
const candidates = [join(gsdDir, 'plan.md')];
|
|
@@ -106,41 +106,37 @@ async function evaluatePreflight(state, basePath) {
|
|
|
106
106
|
return { override: null };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const hints = [];
|
|
110
|
+
|
|
109
111
|
const currentGitHead = await getGitHead(basePath);
|
|
110
112
|
if (state.git_head && currentGitHead && state.git_head !== currentGitHead) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
},
|
|
120
|
-
};
|
|
113
|
+
hints.push({
|
|
114
|
+
workflow_mode: 'reconcile_workspace',
|
|
115
|
+
action: 'await_manual_intervention',
|
|
116
|
+
updates: { workflow_mode: 'reconcile_workspace' },
|
|
117
|
+
saved_git_head: state.git_head,
|
|
118
|
+
current_git_head: currentGitHead,
|
|
119
|
+
message: 'Saved git_head does not match the current workspace HEAD',
|
|
120
|
+
});
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
const changed_files = await detectPlanDrift(basePath, state.context?.last_session);
|
|
124
124
|
if (changed_files.length > 0) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
if (state.workflow_mode === 'awaiting_user' && state.current_review?.stage === 'direction_drift') {
|
|
137
|
-
return { override: null };
|
|
125
|
+
hints.push({
|
|
126
|
+
workflow_mode: 'replan_required',
|
|
127
|
+
action: 'await_manual_intervention',
|
|
128
|
+
updates: { workflow_mode: 'replan_required' },
|
|
129
|
+
changed_files,
|
|
130
|
+
message: 'Plan artifacts changed after the last recorded session',
|
|
131
|
+
});
|
|
138
132
|
}
|
|
139
133
|
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
134
|
+
const skipDirectionDrift = state.workflow_mode === 'awaiting_user'
|
|
135
|
+
&& state.current_review?.stage === 'direction_drift';
|
|
136
|
+
if (!skipDirectionDrift) {
|
|
137
|
+
const driftPhase = getDirectionDriftPhase(state);
|
|
138
|
+
if (driftPhase) {
|
|
139
|
+
hints.push({
|
|
144
140
|
workflow_mode: 'awaiting_user',
|
|
145
141
|
action: 'awaiting_user',
|
|
146
142
|
updates: {
|
|
@@ -155,24 +151,27 @@ async function evaluatePreflight(state, basePath) {
|
|
|
155
151
|
},
|
|
156
152
|
drift_phase: { id: driftPhase.id, name: driftPhase.name },
|
|
157
153
|
message: `Direction drift detected for phase ${driftPhase.id}; user decision required before resuming`,
|
|
158
|
-
}
|
|
159
|
-
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
160
156
|
}
|
|
161
157
|
|
|
162
158
|
const expired_research = collectExpiredResearch(state);
|
|
163
159
|
if (expired_research.length > 0) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
},
|
|
172
|
-
};
|
|
160
|
+
hints.push({
|
|
161
|
+
workflow_mode: 'research_refresh_needed',
|
|
162
|
+
action: 'dispatch_researcher',
|
|
163
|
+
updates: { workflow_mode: 'research_refresh_needed' },
|
|
164
|
+
expired_research,
|
|
165
|
+
message: 'Research cache expired and must be refreshed before execution resumes',
|
|
166
|
+
});
|
|
173
167
|
}
|
|
174
168
|
|
|
175
|
-
return { override: null };
|
|
169
|
+
if (hints.length === 0) return { override: null };
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
override: hints[0],
|
|
173
|
+
hints: hints.length > 1 ? hints.map(h => h.message) : undefined,
|
|
174
|
+
};
|
|
176
175
|
}
|
|
177
176
|
|
|
178
177
|
function getCurrentPhase(state) {
|
|
@@ -503,6 +502,7 @@ export async function resumeWorkflow({ basePath = process.cwd() } = {}) {
|
|
|
503
502
|
...(preflight.override.current_git_head ? { current_git_head: preflight.override.current_git_head } : {}),
|
|
504
503
|
...(preflight.override.changed_files ? { changed_files: preflight.override.changed_files } : {}),
|
|
505
504
|
...(preflight.override.expired_research ? { expired_research: preflight.override.expired_research } : {}),
|
|
505
|
+
...(preflight.hints ? { pending_issues: preflight.hints } : {}),
|
|
506
506
|
};
|
|
507
507
|
}
|
|
508
508
|
|
|
@@ -700,8 +700,9 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
|
|
|
700
700
|
}
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
-
//
|
|
704
|
-
|
|
703
|
+
// Auto-accept: L0 tasks or tasks with review_required: false
|
|
704
|
+
const autoAccept = isL0 || task.review_required === false;
|
|
705
|
+
if (autoAccept) {
|
|
705
706
|
const acceptError = await persist(basePath, {
|
|
706
707
|
phases: [{
|
|
707
708
|
id: phase.id,
|
|
@@ -719,7 +720,7 @@ export async function handleExecutorResult({ result, basePath = process.cwd() }
|
|
|
719
720
|
task_id: task.id,
|
|
720
721
|
review_level: reviewLevel,
|
|
721
722
|
current_review,
|
|
722
|
-
auto_accepted:
|
|
723
|
+
auto_accepted: autoAccept,
|
|
723
724
|
};
|
|
724
725
|
}
|
|
725
726
|
|
|
@@ -825,6 +826,18 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
|
|
|
825
826
|
|
|
826
827
|
if (result.outcome === 'failed' || result.architecture_concern === true) {
|
|
827
828
|
const phaseFailed = result.architecture_concern === true;
|
|
829
|
+
|
|
830
|
+
// Determine effective workflow mode: if no tasks can make progress, escalate
|
|
831
|
+
let effectiveWorkflowMode;
|
|
832
|
+
if (phaseFailed) {
|
|
833
|
+
effectiveWorkflowMode = 'failed';
|
|
834
|
+
} else {
|
|
835
|
+
const hasProgressable = (phase.todo || []).some(t =>
|
|
836
|
+
t.id !== task.id && !['accepted', 'failed'].includes(t.lifecycle),
|
|
837
|
+
);
|
|
838
|
+
effectiveWorkflowMode = hasProgressable ? 'executing_task' : 'awaiting_user';
|
|
839
|
+
}
|
|
840
|
+
|
|
828
841
|
const phasePatch = { id: phase.id };
|
|
829
842
|
if (phaseFailed) {
|
|
830
843
|
phasePatch.lifecycle = 'failed';
|
|
@@ -832,7 +845,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
|
|
|
832
845
|
phasePatch.todo = [{ id: task.id, lifecycle: 'failed', debug_context }];
|
|
833
846
|
|
|
834
847
|
const persistError = await persist(basePath, {
|
|
835
|
-
workflow_mode:
|
|
848
|
+
workflow_mode: effectiveWorkflowMode,
|
|
836
849
|
current_task: null,
|
|
837
850
|
current_review: null,
|
|
838
851
|
phases: [phasePatch],
|
|
@@ -842,7 +855,7 @@ export async function handleDebuggerResult({ result, basePath = process.cwd() }
|
|
|
842
855
|
return {
|
|
843
856
|
success: true,
|
|
844
857
|
action: phaseFailed ? 'phase_failed' : 'task_failed',
|
|
845
|
-
workflow_mode:
|
|
858
|
+
workflow_mode: effectiveWorkflowMode,
|
|
846
859
|
phase_id: phase.id,
|
|
847
860
|
task_id: task.id,
|
|
848
861
|
};
|
|
@@ -914,6 +927,11 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
|
|
|
914
927
|
}
|
|
915
928
|
}
|
|
916
929
|
|
|
930
|
+
// Snapshot accepted task IDs before propagation (for done counter adjustment)
|
|
931
|
+
const acceptedBeforePropagation = new Set(
|
|
932
|
+
(phase.todo || []).filter(t => t.lifecycle === 'accepted').map(t => t.id),
|
|
933
|
+
);
|
|
934
|
+
|
|
917
935
|
// Propagation for critical issues with invalidates_downstream
|
|
918
936
|
for (const issue of (result.critical_issues || [])) {
|
|
919
937
|
if (issue.invalidates_downstream && issue.task_id) {
|
|
@@ -925,6 +943,9 @@ export async function handleReviewerResult({ result, basePath = process.cwd() }
|
|
|
925
943
|
for (const task of (phase.todo || [])) {
|
|
926
944
|
if (task.lifecycle === 'needs_revalidation' && !taskPatches.some((p) => p.id === task.id)) {
|
|
927
945
|
taskPatches.push({ id: task.id, lifecycle: 'needs_revalidation', evidence_refs: [] });
|
|
946
|
+
if (acceptedBeforePropagation.has(task.id)) {
|
|
947
|
+
doneDecrement += 1;
|
|
948
|
+
}
|
|
928
949
|
}
|
|
929
950
|
}
|
|
930
951
|
|
package/src/tools/state.js
CHANGED
|
@@ -101,7 +101,7 @@ export async function init({ project, phases, research, force = false, basePath
|
|
|
101
101
|
* Read state.json, optionally filtering to specific fields.
|
|
102
102
|
*/
|
|
103
103
|
export async function read({ fields, basePath = process.cwd() } = {}) {
|
|
104
|
-
const statePath = getStatePath(basePath);
|
|
104
|
+
const statePath = await getStatePath(basePath);
|
|
105
105
|
if (!statePath) {
|
|
106
106
|
return { error: true, message: 'No .gsd directory found' };
|
|
107
107
|
}
|
|
@@ -143,7 +143,7 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
const statePath = getStatePath(basePath);
|
|
146
|
+
const statePath = await getStatePath(basePath);
|
|
147
147
|
if (!statePath) {
|
|
148
148
|
return { error: true, message: 'No .gsd directory found' };
|
|
149
149
|
}
|
|
@@ -236,7 +236,6 @@ export async function update({ updates, basePath = process.cwd() } = {}) {
|
|
|
236
236
|
*/
|
|
237
237
|
function verificationPassed(verification) {
|
|
238
238
|
if (!verification || typeof verification !== 'object') return false;
|
|
239
|
-
if ('passed' in verification) return verification.passed === true;
|
|
240
239
|
return ['lint', 'typecheck', 'test'].every((key) => (
|
|
241
240
|
verification[key]
|
|
242
241
|
&& typeof verification[key].exit_code === 'number'
|
|
@@ -271,7 +270,7 @@ export async function phaseComplete({
|
|
|
271
270
|
if (direction_ok !== undefined && typeof direction_ok !== 'boolean') {
|
|
272
271
|
return { error: true, message: 'direction_ok must be a boolean when provided' };
|
|
273
272
|
}
|
|
274
|
-
const statePath = getStatePath(basePath);
|
|
273
|
+
const statePath = await getStatePath(basePath);
|
|
275
274
|
if (!statePath) {
|
|
276
275
|
return { error: true, message: 'No .gsd directory found' };
|
|
277
276
|
}
|
|
@@ -358,10 +357,11 @@ export async function phaseComplete({
|
|
|
358
357
|
}
|
|
359
358
|
await writeJson(statePath, state);
|
|
360
359
|
return {
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
success: true,
|
|
361
|
+
action: 'direction_drift',
|
|
363
362
|
workflow_mode: 'awaiting_user',
|
|
364
363
|
phase_id: phase.id,
|
|
364
|
+
message: 'Direction drift detected; awaiting user decision before phase can complete',
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
367
|
|
|
@@ -410,7 +410,7 @@ export async function addEvidence({ id, data, basePath = process.cwd() }) {
|
|
|
410
410
|
return { error: true, message: 'data.scope must be a string' };
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
-
const statePath = getStatePath(basePath);
|
|
413
|
+
const statePath = await getStatePath(basePath);
|
|
414
414
|
if (!statePath) {
|
|
415
415
|
return { error: true, message: 'No .gsd directory found' };
|
|
416
416
|
}
|
|
@@ -472,7 +472,7 @@ async function _pruneEvidenceFromState(state, currentPhase, gsdDir) {
|
|
|
472
472
|
* Scope format is "task:X.Y" where X is the phase number.
|
|
473
473
|
*/
|
|
474
474
|
export async function pruneEvidence({ currentPhase, basePath = process.cwd() }) {
|
|
475
|
-
const statePath = getStatePath(basePath);
|
|
475
|
+
const statePath = await getStatePath(basePath);
|
|
476
476
|
if (!statePath) {
|
|
477
477
|
return { error: true, message: 'No .gsd directory found' };
|
|
478
478
|
}
|
|
@@ -521,6 +521,11 @@ export function selectRunnableTask(phase, state, { maxRetry = DEFAULT_MAX_RETRY
|
|
|
521
521
|
if (!phase || !Array.isArray(phase.todo)) {
|
|
522
522
|
return { error: true, message: 'Phase todo must be an array' };
|
|
523
523
|
}
|
|
524
|
+
// D-4: Zero-task phase — immediately trigger review so phase can advance
|
|
525
|
+
if (phase.todo.length === 0) {
|
|
526
|
+
return { mode: 'trigger_review' };
|
|
527
|
+
}
|
|
528
|
+
|
|
524
529
|
const runnableTasks = [];
|
|
525
530
|
|
|
526
531
|
for (const task of phase.todo) {
|
|
@@ -723,7 +728,8 @@ export function reclassifyReviewLevel(task, executorResult) {
|
|
|
723
728
|
|
|
724
729
|
// Check for explicit [LEVEL-UP] in decisions
|
|
725
730
|
const hasLevelUp = (executorResult.decisions || []).some(d =>
|
|
726
|
-
typeof d === 'string' && d.includes('[LEVEL-UP]')
|
|
731
|
+
(typeof d === 'string' && d.includes('[LEVEL-UP]'))
|
|
732
|
+
|| (d && typeof d === 'object' && typeof d.summary === 'string' && d.summary.includes('[LEVEL-UP]'))
|
|
727
733
|
);
|
|
728
734
|
if (hasLevelUp) return 'L2';
|
|
729
735
|
|
|
@@ -867,7 +873,7 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
867
873
|
return { error: true, message: `Invalid research decision_index: ${decisionIndexValidation.errors.join('; ')}` };
|
|
868
874
|
}
|
|
869
875
|
|
|
870
|
-
const statePath = getStatePath(basePath);
|
|
876
|
+
const statePath = await getStatePath(basePath);
|
|
871
877
|
if (!statePath) {
|
|
872
878
|
return { error: true, message: 'No .gsd directory found' };
|
|
873
879
|
}
|
|
@@ -901,10 +907,12 @@ export async function storeResearch({ result, artifacts, decision_index, basePat
|
|
|
901
907
|
? applyResearchRefresh(state, nextResearch)
|
|
902
908
|
: { warnings: [] };
|
|
903
909
|
|
|
910
|
+
// D-2: Compute merged decision_index explicitly before spread to avoid key-ordering fragility
|
|
911
|
+
const mergedDecisionIndex = state.research?.decision_index || decision_index;
|
|
904
912
|
state.research = {
|
|
905
913
|
...(state.research || {}),
|
|
906
914
|
...nextResearch,
|
|
907
|
-
decision_index:
|
|
915
|
+
decision_index: mergedDecisionIndex,
|
|
908
916
|
};
|
|
909
917
|
|
|
910
918
|
if (state.workflow_mode === 'research_refresh_needed') {
|
package/src/tools/verify.js
CHANGED
|
@@ -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,17 +1,16 @@
|
|
|
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
|
-
export function getGsdDir(startDir = process.cwd()) {
|
|
8
|
+
export async function getGsdDir(startDir = process.cwd()) {
|
|
10
9
|
let dir = resolve(startDir);
|
|
11
10
|
while (true) {
|
|
12
11
|
const candidate = join(dir, '.gsd');
|
|
13
12
|
try {
|
|
14
|
-
const s =
|
|
13
|
+
const s = await stat(candidate);
|
|
15
14
|
if (s.isDirectory()) return candidate;
|
|
16
15
|
} catch {}
|
|
17
16
|
const parent = dirname(dir);
|
|
@@ -20,8 +19,8 @@ export function getGsdDir(startDir = process.cwd()) {
|
|
|
20
19
|
}
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export function getStatePath(startDir = process.cwd()) {
|
|
24
|
-
const gsdDir = getGsdDir(startDir);
|
|
22
|
+
export async function getStatePath(startDir = process.cwd()) {
|
|
23
|
+
const gsdDir = await getGsdDir(startDir);
|
|
25
24
|
if (!gsdDir) return null;
|
|
26
25
|
return join(gsdDir, 'state.json');
|
|
27
26
|
}
|
|
@@ -38,6 +37,11 @@ export async function getGitHead(cwd = process.cwd()) {
|
|
|
38
37
|
}
|
|
39
38
|
}
|
|
40
39
|
|
|
40
|
+
let _tmpCounter = 0;
|
|
41
|
+
function tmpPath(filePath) {
|
|
42
|
+
return `${filePath}.${process.pid}-${Date.now()}-${_tmpCounter++}.tmp`;
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
export function isPlainObject(value) {
|
|
42
46
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
43
47
|
}
|
|
@@ -63,13 +67,13 @@ export async function readJson(filePath) {
|
|
|
63
67
|
* Atomically write JSON data (write to .tmp then rename).
|
|
64
68
|
*/
|
|
65
69
|
export async function writeJson(filePath, data) {
|
|
66
|
-
const
|
|
70
|
+
const tmp = tmpPath(filePath);
|
|
67
71
|
await ensureDir(dirname(filePath));
|
|
68
72
|
try {
|
|
69
|
-
await writeFile(
|
|
70
|
-
await rename(
|
|
73
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
74
|
+
await rename(tmp, filePath);
|
|
71
75
|
} catch (err) {
|
|
72
|
-
try { await unlink(
|
|
76
|
+
try { await unlink(tmp); } catch {}
|
|
73
77
|
throw err;
|
|
74
78
|
}
|
|
75
79
|
}
|
|
@@ -78,13 +82,13 @@ export async function writeJson(filePath, data) {
|
|
|
78
82
|
* Atomically write text content (write to .tmp then rename). [I-3]
|
|
79
83
|
*/
|
|
80
84
|
export async function writeAtomic(filePath, content) {
|
|
81
|
-
const
|
|
85
|
+
const tmp = tmpPath(filePath);
|
|
82
86
|
await ensureDir(dirname(filePath));
|
|
83
87
|
try {
|
|
84
|
-
await writeFile(
|
|
85
|
-
await rename(
|
|
88
|
+
await writeFile(tmp, content, 'utf-8');
|
|
89
|
+
await rename(tmp, filePath);
|
|
86
90
|
} catch (err) {
|
|
87
|
-
try { await unlink(
|
|
91
|
+
try { await unlink(tmp); } catch {}
|
|
88
92
|
throw err;
|
|
89
93
|
}
|
|
90
94
|
}
|