Refactor SDK-first architecture seams (#3316)
* refactor: tighten sdk-first architecture seams Refs #3312 * refactor: finish state document seam cleanup Refs #3312 * test: harden minimal install cleanup assertion * ci: support sdk-scoped package lock * fix(3316): restore root package-lock.json and align changeset pr ref Reverts dec57a83 ("ci: support sdk-scoped package lock") and restores the root package-lock.json that c249d34d deleted. The deletion was the wrong direction: - The root package.json declares its own runtime and dev deps (@anthropic-ai/claude-agent-sdk, ws, c8). Without a root lockfile, `npm install --no-package-lock` resolves whatever satisfies semver at install time — CI today and CI in six months can install different transitive trees, defeating reproducibility. - The lockfile has been part of every release on this repo (long history on main); removing it loses the npm audit / Dependabot target without compensating benefit. - The CI workaround pattern (cache-dependency-path: sdk/package-lock.json + `npm install --no-package-lock`) papered over the symptom rather than fix the cause. Also fix the changeset pr: from 3312 (issue) to 3316 (PR). CONTEXT.md flags this exact failure mode as a recurring CodeRabbit finding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address coderabbit review findings * fix: close remaining coderabbit threads --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,16 +28,17 @@ import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
|
||||
import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js';
|
||||
import {
|
||||
comparePhaseNum,
|
||||
escapeRegex,
|
||||
normalizePhaseName,
|
||||
phaseTokenMatches,
|
||||
planningPaths,
|
||||
normalizeMd,
|
||||
stateExtractField,
|
||||
} from './helpers.js';
|
||||
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
|
||||
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } from './state-document.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']);
|
||||
|
||||
// ─── Process exit lock cleanup (D2 — match CJS state.cjs:16-23) ─────────
|
||||
|
||||
/**
|
||||
@@ -52,48 +53,7 @@ process.on('exit', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── stateReplaceField ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace a field value in STATE.md content.
|
||||
*
|
||||
* Uses separate regex instances (no g flag) to avoid lastIndex persistence.
|
||||
* Supports both **bold:** and plain: formats.
|
||||
*
|
||||
* @param content - STATE.md content
|
||||
* @param fieldName - Field name to replace
|
||||
* @param newValue - New value to set
|
||||
* @returns Updated content, or null if field not found
|
||||
*/
|
||||
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
// Try **Field:** bold format first
|
||||
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
if (boldPattern.test(content)) {
|
||||
return content.replace(new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'), (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
}
|
||||
// Try plain Field: format
|
||||
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
|
||||
if (plainPattern.test(content)) {
|
||||
return content.replace(new RegExp(`(^${escaped}:\\s*)(.*)`, 'im'), (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a field with fallback field name support.
|
||||
*
|
||||
* Tries primary first, then fallback. Returns content unchanged if neither matches.
|
||||
*/
|
||||
function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null, value: string): string {
|
||||
let result = stateReplaceField(content, primary, value);
|
||||
if (result) return result;
|
||||
if (fallback) {
|
||||
result = stateReplaceField(content, fallback, value);
|
||||
if (result) return result;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
export { stateReplaceField };
|
||||
|
||||
/**
|
||||
* Update fields within the ## Current Position section.
|
||||
@@ -234,10 +194,15 @@ export async function releaseStateLock(lockPath: string): Promise<void> {
|
||||
* Strips existing frontmatter, rebuilds from body + disk, and splices back.
|
||||
* Preserves existing status when body-derived status is 'unknown'.
|
||||
*/
|
||||
async function syncStateFrontmatter(content: string, projectDir: string): Promise<string> {
|
||||
async function syncStateFrontmatter(
|
||||
content: string,
|
||||
projectDir: string,
|
||||
workstream?: string,
|
||||
options: { preserveExistingProgress?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
const derivedFm = await buildStateFrontmatter(body, projectDir);
|
||||
const derivedFm = await buildStateFrontmatter(body, projectDir, workstream, options);
|
||||
|
||||
// Preserve existing status when body-derived is 'unknown'
|
||||
if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
|
||||
@@ -261,8 +226,10 @@ async function readModifyWriteStateMd(
|
||||
projectDir: string,
|
||||
modifier: (content: string) => string | Promise<string>,
|
||||
workstream?: string,
|
||||
options: { resync?: boolean; preserveExistingProgress?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const statePath = planningPaths(projectDir, workstream).state;
|
||||
const resync = options.resync !== false;
|
||||
const lockPath = await acquireStateLock(statePath);
|
||||
try {
|
||||
let content: string;
|
||||
@@ -274,9 +241,18 @@ async function readModifyWriteStateMd(
|
||||
// Strip frontmatter before passing to modifier so that regex replacements
|
||||
// operate on body fields only (not on YAML frontmatter keys like 'status:').
|
||||
// syncStateFrontmatter rebuilds frontmatter from the modified body + disk.
|
||||
const preFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
const modified = await modifier(body);
|
||||
const synced = await syncStateFrontmatter(modified, projectDir);
|
||||
let synced = await syncStateFrontmatter(modified, projectDir, workstream, {
|
||||
preserveExistingProgress: options.preserveExistingProgress,
|
||||
});
|
||||
if (!resync && preFm && preFm.progress) {
|
||||
const postFm = extractFrontmatter(synced);
|
||||
postFm.progress = preFm.progress;
|
||||
const yamlStr = reconstructFrontmatter(postFm);
|
||||
synced = `---\n${yamlStr}\n---\n\n${stripFrontmatter(synced)}`;
|
||||
}
|
||||
const normalized = normalizeMd(synced);
|
||||
await writeFile(statePath, normalized, 'utf-8');
|
||||
return normalized;
|
||||
@@ -305,7 +281,7 @@ export async function readModifyWriteStateMdFull(
|
||||
/* missing */
|
||||
}
|
||||
const modified = await modifier(content);
|
||||
const synced = await syncStateFrontmatter(modified, projectDir);
|
||||
const synced = await syncStateFrontmatter(modified, projectDir, workstream);
|
||||
await writeFile(statePath, normalizeMd(synced), 'utf-8');
|
||||
} finally {
|
||||
await releaseStateLock(lockPath);
|
||||
@@ -332,6 +308,7 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
|
||||
}
|
||||
|
||||
let updated = false;
|
||||
const shouldResync = PROGRESS_FRONTMATTER_FIELDS.has(field);
|
||||
await readModifyWriteStateMd(projectDir, (content) => {
|
||||
const result = stateReplaceField(content, field, value);
|
||||
if (result) {
|
||||
@@ -339,7 +316,10 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
|
||||
return result;
|
||||
}
|
||||
return content;
|
||||
}, workstream);
|
||||
}, workstream, {
|
||||
resync: shouldResync,
|
||||
preserveExistingProgress: !shouldResync,
|
||||
});
|
||||
|
||||
return { data: { updated } };
|
||||
};
|
||||
@@ -377,6 +357,7 @@ export const statePatch: QueryHandler = async (args, projectDir, workstream) =>
|
||||
|
||||
const updated: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const shouldResync = Object.keys(patches).some(field => PROGRESS_FRONTMATTER_FIELDS.has(field));
|
||||
await readModifyWriteStateMd(projectDir, (content) => {
|
||||
for (const [field, value] of Object.entries(patches)) {
|
||||
const result = stateReplaceField(content, field, String(value));
|
||||
@@ -388,7 +369,10 @@ export const statePatch: QueryHandler = async (args, projectDir, workstream) =>
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}, workstream);
|
||||
}, workstream, {
|
||||
resync: shouldResync,
|
||||
preserveExistingProgress: !shouldResync,
|
||||
});
|
||||
|
||||
return { data: { updated, failed } };
|
||||
};
|
||||
@@ -1707,4 +1691,4 @@ export const statePrune: QueryHandler = async (args, projectDir, workstream) =>
|
||||
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user