WorkspaceClaudeMdReconciler.java
package network.ike.plugin.ws.reconcile;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.bootstrap.SubprojectInitializer;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* Keeps the workspace-root {@code CLAUDE.md} in lockstep with its
* generator (IKE-Network/ike-issues#790).
*
* <p>The cheatsheets ({@code GOALS.md}, {@code WS-REFERENCE.md}) were
* promoted to continuous reconciliation by {@link CheatsheetReconciler}
* (#452), but the generated workspace-root {@code CLAUDE.md} was left
* behind: it is written only at the tail of {@link SubprojectInitializer}'s
* initial {@code ws:scaffold-init} pass. A workspace that exists without a
* {@code CLAUDE.md} — created before the generator landed, or renamed —
* therefore never gets one, because nothing backfills it. This reconciler
* closes that gap so {@code ws:scaffold-draft} reports the drift and
* {@code ws:scaffold-publish} heals it, exactly like the cheatsheets.
*
* <p>Only the fully-generated {@code CLAUDE.md} is managed here. Hand-authored
* notes live in {@code CLAUDE-<ws>.md}, which {@link SubprojectInitializer}
* creates if absent and never overwrites — so they are intentionally outside
* this reconciler's scope.
*
* <p>Source-of-truth lives in
* {@link SubprojectInitializer#generateWorkspaceClaudeMd(String, network.ike.workspace.WorkspaceGraph)},
* already static so this reconciler can call it without additional plumbing.
*
* <p>Opt-out: {@code -DupdateClaudeMd=false}. Useful when the user has
* intentionally edited {@code CLAUDE.md} and does not want
* {@code ws:scaffold-publish} to overwrite it.
*/
public class WorkspaceClaudeMdReconciler implements Reconciler {
static final String CLAUDE_FILE = "CLAUDE.md";
@Override
public String dimension() {
return "Workspace CLAUDE.md";
}
@Override
public String optOutFlag() {
return "updateClaudeMd";
}
@Override
public DriftReport detect(WorkspaceContext ctx) {
if (isUpToDate(ctx)) {
return DriftReport.noDrift(dimension());
}
Path file = ctx.workspaceRoot().toPath().resolve(CLAUDE_FILE);
String reason = Files.exists(file)
? CLAUDE_FILE + ": content drifted from generator"
: CLAUDE_FILE + ": missing";
return new DriftReport(
dimension(),
true,
CLAUDE_FILE + " stale",
List.of(reason),
"regenerate " + CLAUDE_FILE,
"-D" + optOutFlag() + "=false");
}
@Override
public void apply(WorkspaceContext ctx) {
if (ctx.options().isOptedOut(optOutFlag())) {
ctx.log().info(" " + dimension()
+ ": skipped (opted out via -D" + optOutFlag() + "=false)");
return;
}
if (isUpToDate(ctx)) {
return;
}
Path file = ctx.workspaceRoot().toPath().resolve(CLAUDE_FILE);
try {
Files.writeString(file, generated(ctx), StandardCharsets.UTF_8);
ctx.log().info(" " + dimension() + ": regenerated " + CLAUDE_FILE);
} catch (IOException e) {
ctx.log().warn(" " + dimension()
+ ": could not write " + CLAUDE_FILE + " — " + e.getMessage());
}
}
/**
* Whether the on-disk {@code CLAUDE.md} exists and already matches the
* generator's current output. A missing file counts as stale so a
* workspace that never had one still gets it written by
* {@code ws:scaffold-publish}.
*/
private static boolean isUpToDate(WorkspaceContext ctx) {
Path file = ctx.workspaceRoot().toPath().resolve(CLAUDE_FILE);
if (!Files.exists(file)) {
return false;
}
try {
return Files.readString(file, StandardCharsets.UTF_8).equals(generated(ctx));
} catch (IOException e) {
// Unreadable file is treated as drifted — apply then attempts to
// overwrite it, surfacing the underlying IO error via its warn path.
return false;
}
}
private static String generated(WorkspaceContext ctx) {
return SubprojectInitializer.generateWorkspaceClaudeMd(
workspaceName(ctx), ctx.graph());
}
/**
* Derive the workspace name the same way {@code ws:scaffold-init} does —
* from the root POM's own {@code <artifactId>} — so the regenerated
* heading matches what the initial scaffold wrote and a correct file is
* never falsely flagged as drifted. (The manifest's typed
* {@code workspace-root} GAV is optional and absent in older workspaces,
* so the POM is the reliable source.)
*
* @param ctx the workspace context
* @return the workspace name, or {@code "Workspace"} if the POM cannot
* be read
*/
private static String workspaceName(WorkspaceContext ctx) {
File rootPom = new File(ctx.workspaceRoot(), "pom.xml");
if (rootPom.exists()) {
try {
return ReleaseSupport.readPomArtifactId(rootPom);
} catch (Exception e) {
// Fall through to the stable default.
}
}
return "Workspace";
}
}