all-hands-cli 0.1.13 → 0.1.14

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/.internal.json CHANGED
@@ -29,7 +29,8 @@
29
29
  ".reposearch/**",
30
30
  "concerns.md",
31
31
  "specs/**",
32
- ".allhands/docs.local.json"
32
+ ".allhands/docs.local.json",
33
+ ".allhands/.sync-state.json"
33
34
  ],
34
35
  "initOnly": [
35
36
  ".allhands/skills/**",
package/bin/sync-cli.js CHANGED
@@ -4856,9 +4856,9 @@ var Yargs = YargsFactory(esm_default);
4856
4856
  var yargs_default = Yargs;
4857
4857
 
4858
4858
  // src/commands/sync.ts
4859
- import { copyFileSync, existsSync as existsSync9, mkdirSync, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
4859
+ import { copyFileSync, existsSync as existsSync10, mkdirSync as mkdirSync2, readFileSync as readFileSync9, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
4860
4860
  import { homedir } from "os";
4861
- import { basename as basename4, dirname as dirname7, join as join7, resolve as resolve6 } from "path";
4861
+ import { basename as basename4, dirname as dirname8, join as join8, resolve as resolve6 } from "path";
4862
4862
 
4863
4863
  // src/lib/git.ts
4864
4864
  import { execSync, spawnSync } from "child_process";
@@ -4909,6 +4909,14 @@ function getFileBlobHash(filePath, repoPath) {
4909
4909
  const result = git(["hash-object", filePath], repoPath);
4910
4910
  return result.success ? result.stdout.trim() : null;
4911
4911
  }
4912
+ function getHeadCommit(repoPath) {
4913
+ const result = git(["rev-parse", "HEAD"], repoPath);
4914
+ return result.success ? result.stdout.trim() : null;
4915
+ }
4916
+ function hasUncommittedChanges(repoPath) {
4917
+ const result = git(["status", "--porcelain"], repoPath);
4918
+ return result.success && result.stdout.length > 0;
4919
+ }
4912
4920
  function fileExistsInHistory(relPath, blobHash, repoPath) {
4913
4921
  const result = git(["rev-list", "HEAD", "--objects", "--", relPath], repoPath);
4914
4922
  if (!result.success || !result.stdout) return false;
@@ -6789,7 +6797,8 @@ function getNextBackupPath(filePath) {
6789
6797
 
6790
6798
  // src/lib/constants.ts
6791
6799
  var SYNC_CONFIG_FILENAME = ".allhands-sync-config.json";
6792
- var PUSH_BLOCKLIST = ["CLAUDE.project.md", ".allhands-sync-config.json"];
6800
+ var SYNC_STATE_FILENAME = ".allhands/.sync-state.json";
6801
+ var PUSH_BLOCKLIST = ["CLAUDE.project.md", ".allhands-sync-config.json", ".allhands/.sync-state.json"];
6793
6802
  var SYNC_CONFIG_TEMPLATE = {
6794
6803
  $comment: "Customization for claude-all-hands push command",
6795
6804
  includes: [],
@@ -6884,6 +6893,52 @@ function ensureTargetLines(targetRoot, verbose = false) {
6884
6893
  return anyChanged;
6885
6894
  }
6886
6895
 
6896
+ // src/lib/sync-state.ts
6897
+ import { existsSync as existsSync9, mkdirSync, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
6898
+ import { dirname as dirname7, join as join7 } from "path";
6899
+ function writeSyncState(targetRoot, allhandsRoot, syncedFiles) {
6900
+ const files = {};
6901
+ for (const relPath of [...syncedFiles].sort()) {
6902
+ const sourceFile = join7(allhandsRoot, relPath);
6903
+ if (!existsSync9(sourceFile)) continue;
6904
+ const hash = getFileBlobHash(sourceFile, allhandsRoot);
6905
+ if (hash) {
6906
+ files[relPath] = hash;
6907
+ }
6908
+ }
6909
+ const state = {
6910
+ version: 1,
6911
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString(),
6912
+ sourceCommit: getHeadCommit(allhandsRoot),
6913
+ dirty: hasUncommittedChanges(allhandsRoot),
6914
+ files
6915
+ };
6916
+ const outPath = join7(targetRoot, SYNC_STATE_FILENAME);
6917
+ mkdirSync(dirname7(outPath), { recursive: true });
6918
+ writeFileSync3(outPath, JSON.stringify(state, null, 2) + "\n");
6919
+ }
6920
+ function readSyncState(targetRoot) {
6921
+ const stateFile = join7(targetRoot, SYNC_STATE_FILENAME);
6922
+ if (!existsSync9(stateFile)) return null;
6923
+ try {
6924
+ const content = readFileSync8(stateFile, "utf-8");
6925
+ const parsed = JSON.parse(content);
6926
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1 || !parsed.files || typeof parsed.files !== "object") {
6927
+ return null;
6928
+ }
6929
+ return parsed;
6930
+ } catch {
6931
+ return null;
6932
+ }
6933
+ }
6934
+ function wasModifiedSinceSync(targetFilePath, relPath, syncState, repoPath) {
6935
+ const manifestHash = syncState.files[relPath];
6936
+ if (!manifestHash) return null;
6937
+ const targetHash = getFileBlobHash(targetFilePath, repoPath);
6938
+ if (!targetHash) return true;
6939
+ return targetHash !== manifestHash;
6940
+ }
6941
+
6887
6942
  // src/commands/sync.ts
6888
6943
  var AH_SHIM_SCRIPT = `#!/bin/bash
6889
6944
  # AllHands CLI shim - finds and executes project-local .allhands/harness/ah
@@ -6902,34 +6957,34 @@ echo "hint: run 'npx all-hands sync .' to initialize this project" >&2
6902
6957
  exit 1
6903
6958
  `;
6904
6959
  function setupAhShim() {
6905
- const localBin = join7(homedir(), ".local", "bin");
6906
- const shimPath = join7(localBin, "ah");
6960
+ const localBin = join8(homedir(), ".local", "bin");
6961
+ const shimPath = join8(localBin, "ah");
6907
6962
  const pathEnv = process.env.PATH || "";
6908
6963
  const inPath = pathEnv.split(":").some(
6909
- (p) => p === localBin || p === join7(homedir(), ".local/bin")
6964
+ (p) => p === localBin || p === join8(homedir(), ".local/bin")
6910
6965
  );
6911
- if (existsSync9(shimPath)) {
6912
- const existing = readFileSync8(shimPath, "utf-8");
6966
+ if (existsSync10(shimPath)) {
6967
+ const existing = readFileSync9(shimPath, "utf-8");
6913
6968
  if (existing.includes(".allhands/harness/ah")) {
6914
6969
  return { installed: false, path: shimPath, inPath };
6915
6970
  }
6916
6971
  }
6917
- mkdirSync(localBin, { recursive: true });
6918
- writeFileSync3(shimPath, AH_SHIM_SCRIPT, { mode: 493 });
6972
+ mkdirSync2(localBin, { recursive: true });
6973
+ writeFileSync4(shimPath, AH_SHIM_SCRIPT, { mode: 493 });
6919
6974
  return { installed: true, path: shimPath, inPath };
6920
6975
  }
6921
6976
  async function cmdSync(target = ".", autoYes = false, init = false) {
6922
6977
  const resolvedTarget = resolve6(process.cwd(), target);
6923
6978
  const allhandsRoot = getAllhandsRoot();
6924
- const targetAllhandsDir = join7(resolvedTarget, ".allhands");
6925
- const isFirstTime = !existsSync9(targetAllhandsDir);
6979
+ const targetAllhandsDir = join8(resolvedTarget, ".allhands");
6980
+ const isFirstTime = !existsSync10(targetAllhandsDir);
6926
6981
  if (isFirstTime) {
6927
6982
  console.log(`Initializing allhands in: ${resolvedTarget}`);
6928
6983
  } else {
6929
6984
  console.log(`Updating allhands in: ${resolvedTarget}`);
6930
6985
  }
6931
6986
  console.log(`Source: ${allhandsRoot}`);
6932
- if (!existsSync9(resolvedTarget)) {
6987
+ if (!existsSync10(resolvedTarget)) {
6933
6988
  console.error(`Error: Target directory does not exist: ${resolvedTarget}`);
6934
6989
  return 1;
6935
6990
  }
@@ -6973,15 +7028,15 @@ async function cmdSync(target = ".", autoYes = false, init = false) {
6973
7028
  const conflicts = [];
6974
7029
  const deletedInSource = [];
6975
7030
  for (const relPath of distributable) {
6976
- const sourceFile = join7(allhandsRoot, relPath);
6977
- const targetFile = join7(resolvedTarget, relPath);
6978
- if (!existsSync9(sourceFile)) {
6979
- if (!isFirstTime && existsSync9(targetFile)) {
7031
+ const sourceFile = join8(allhandsRoot, relPath);
7032
+ const targetFile = join8(resolvedTarget, relPath);
7033
+ if (!existsSync10(sourceFile)) {
7034
+ if (!isFirstTime && existsSync10(targetFile)) {
6980
7035
  deletedInSource.push(relPath);
6981
7036
  }
6982
7037
  continue;
6983
7038
  }
6984
- if (existsSync9(targetFile)) {
7039
+ if (existsSync10(targetFile)) {
6985
7040
  if (filesAreDifferent(sourceFile, targetFile)) {
6986
7041
  conflicts.push(relPath);
6987
7042
  }
@@ -7002,7 +7057,7 @@ Auto-overwriting ${conflicts.length} conflicting files (--yes mode)`);
7002
7057
  if (resolution === "backup") {
7003
7058
  console.log("\nCreating backups...");
7004
7059
  for (const relPath of conflicts) {
7005
- const targetFile = join7(resolvedTarget, relPath);
7060
+ const targetFile = join8(resolvedTarget, relPath);
7006
7061
  const bkPath = getNextBackupPath(targetFile);
7007
7062
  copyFileSync(targetFile, bkPath);
7008
7063
  console.log(` ${relPath} \u2192 ${basename4(bkPath)}`);
@@ -7011,12 +7066,14 @@ Auto-overwriting ${conflicts.length} conflicting files (--yes mode)`);
7011
7066
  }
7012
7067
  console.log("\nCopying allhands files...");
7013
7068
  console.log(`Found ${distributable.size} files to distribute`);
7069
+ const syncedFiles = /* @__PURE__ */ new Set();
7014
7070
  for (const relPath of [...distributable].sort()) {
7015
- const sourceFile = join7(allhandsRoot, relPath);
7016
- const targetFile = join7(resolvedTarget, relPath);
7017
- if (!existsSync9(sourceFile)) continue;
7018
- mkdirSync(dirname7(targetFile), { recursive: true });
7019
- if (existsSync9(targetFile)) {
7071
+ const sourceFile = join8(allhandsRoot, relPath);
7072
+ const targetFile = join8(resolvedTarget, relPath);
7073
+ if (!existsSync10(sourceFile)) continue;
7074
+ syncedFiles.add(relPath);
7075
+ mkdirSync2(dirname8(targetFile), { recursive: true });
7076
+ if (existsSync10(targetFile)) {
7020
7077
  if (!filesAreDifferent(sourceFile, targetFile)) {
7021
7078
  skipped++;
7022
7079
  continue;
@@ -7029,6 +7086,7 @@ Auto-overwriting ${conflicts.length} conflicting files (--yes mode)`);
7029
7086
  }
7030
7087
  }
7031
7088
  restoreDotfiles(resolvedTarget);
7089
+ writeSyncState(resolvedTarget, allhandsRoot, syncedFiles);
7032
7090
  if (!isFirstTime && deletedInSource.length > 0) {
7033
7091
  console.log(`
7034
7092
  ${deletedInSource.length} files removed from allhands source:`);
@@ -7038,8 +7096,8 @@ ${deletedInSource.length} files removed from allhands source:`);
7038
7096
  const shouldDelete = autoYes || await confirm("Delete these from target?");
7039
7097
  if (shouldDelete) {
7040
7098
  for (const f of deletedInSource) {
7041
- const targetFile = join7(resolvedTarget, f);
7042
- if (existsSync9(targetFile)) {
7099
+ const targetFile = join8(resolvedTarget, f);
7100
+ if (existsSync10(targetFile)) {
7043
7101
  unlinkSync(targetFile);
7044
7102
  console.log(` Deleted: ${f}`);
7045
7103
  }
@@ -7050,9 +7108,9 @@ ${deletedInSource.length} files removed from allhands source:`);
7050
7108
  const targetLinesUpdated = ensureTargetLines(resolvedTarget, true);
7051
7109
  const envExamples = [".env.example", ".env.ai.example"];
7052
7110
  for (const envExample of envExamples) {
7053
- const sourceEnv = join7(allhandsRoot, envExample);
7054
- const targetEnv = join7(resolvedTarget, envExample);
7055
- if (existsSync9(sourceEnv)) {
7111
+ const sourceEnv = join8(allhandsRoot, envExample);
7112
+ const targetEnv = join8(resolvedTarget, envExample);
7113
+ if (existsSync10(sourceEnv)) {
7056
7114
  console.log(`Copying ${envExample}`);
7057
7115
  copyFileSync(sourceEnv, targetEnv);
7058
7116
  }
@@ -7074,15 +7132,15 @@ ${deletedInSource.length} files removed from allhands source:`);
7074
7132
  }
7075
7133
  let syncConfigCreated = false;
7076
7134
  if (isFirstTime) {
7077
- const syncConfigPath = join7(resolvedTarget, SYNC_CONFIG_FILENAME);
7078
- if (existsSync9(syncConfigPath)) {
7135
+ const syncConfigPath = join8(resolvedTarget, SYNC_CONFIG_FILENAME);
7136
+ if (existsSync10(syncConfigPath)) {
7079
7137
  console.log(`
7080
7138
  ${SYNC_CONFIG_FILENAME} already exists - skipping`);
7081
7139
  } else if (!autoYes) {
7082
7140
  console.log("\nThe push command lets you contribute changes back to all-hands.");
7083
7141
  console.log("A sync config file lets you customize which files to include/exclude.");
7084
7142
  if (await confirm(`Create ${SYNC_CONFIG_FILENAME}?`)) {
7085
- writeFileSync3(syncConfigPath, JSON.stringify(SYNC_CONFIG_TEMPLATE, null, 2) + "\n");
7143
+ writeFileSync4(syncConfigPath, JSON.stringify(SYNC_CONFIG_TEMPLATE, null, 2) + "\n");
7086
7144
  syncConfigCreated = true;
7087
7145
  console.log(` Created ${SYNC_CONFIG_FILENAME}`);
7088
7146
  }
@@ -7113,21 +7171,21 @@ ${"=".repeat(60)}`);
7113
7171
  }
7114
7172
 
7115
7173
  // src/commands/pull-manifest.ts
7116
- import { existsSync as existsSync10, writeFileSync as writeFileSync4 } from "fs";
7117
- import { join as join8 } from "path";
7174
+ import { existsSync as existsSync11, writeFileSync as writeFileSync5 } from "fs";
7175
+ import { join as join9 } from "path";
7118
7176
  async function cmdPullManifest() {
7119
7177
  const cwd = process.cwd();
7120
7178
  if (!isGitRepo(cwd)) {
7121
7179
  console.error("Error: Not in a git repository");
7122
7180
  return 1;
7123
7181
  }
7124
- const configPath = join8(cwd, SYNC_CONFIG_FILENAME);
7125
- if (existsSync10(configPath)) {
7182
+ const configPath = join9(cwd, SYNC_CONFIG_FILENAME);
7183
+ if (existsSync11(configPath)) {
7126
7184
  console.error(`Error: ${SYNC_CONFIG_FILENAME} already exists`);
7127
7185
  console.error("Remove it first if you want to regenerate");
7128
7186
  return 1;
7129
7187
  }
7130
- writeFileSync4(configPath, JSON.stringify(SYNC_CONFIG_TEMPLATE, null, 2) + "\n");
7188
+ writeFileSync5(configPath, JSON.stringify(SYNC_CONFIG_TEMPLATE, null, 2) + "\n");
7131
7189
  console.log(`Created ${SYNC_CONFIG_FILENAME}`);
7132
7190
  console.log("\nUsage:");
7133
7191
  console.log(' - Add file paths to "includes" to push additional files');
@@ -7137,9 +7195,9 @@ async function cmdPullManifest() {
7137
7195
  }
7138
7196
 
7139
7197
  // src/commands/push.ts
7140
- import { copyFileSync as copyFileSync2, existsSync as existsSync11, mkdirSync as mkdirSync2, readFileSync as readFileSync9, rmSync } from "fs";
7198
+ import { copyFileSync as copyFileSync2, existsSync as existsSync12, mkdirSync as mkdirSync3, readFileSync as readFileSync10, rmSync } from "fs";
7141
7199
  import { tmpdir } from "os";
7142
- import { dirname as dirname8, join as join9 } from "path";
7200
+ import { dirname as dirname9, join as join10 } from "path";
7143
7201
  import * as readline2 from "readline";
7144
7202
 
7145
7203
  // src/lib/gh.ts
@@ -7175,12 +7233,12 @@ function getGhUser() {
7175
7233
 
7176
7234
  // src/commands/push.ts
7177
7235
  function loadSyncConfig(cwd) {
7178
- const configPath = join9(cwd, SYNC_CONFIG_FILENAME);
7179
- if (!existsSync11(configPath)) {
7236
+ const configPath = join10(cwd, SYNC_CONFIG_FILENAME);
7237
+ if (!existsSync12(configPath)) {
7180
7238
  return null;
7181
7239
  }
7182
7240
  try {
7183
- const content = readFileSync9(configPath, "utf-8");
7241
+ const content = readFileSync10(configPath, "utf-8");
7184
7242
  return JSON.parse(content);
7185
7243
  } catch (e) {
7186
7244
  throw new Error(`Failed to parse ${SYNC_CONFIG_FILENAME}: ${e instanceof Error ? e.message : String(e)}`);
@@ -7233,8 +7291,12 @@ function checkPrerequisites(cwd) {
7233
7291
  }
7234
7292
  return { success: true, ghUser };
7235
7293
  }
7236
- function wasModifiedByTargetRepo(cwd, relPath, allhandsRoot) {
7237
- const localFile = join9(cwd, relPath);
7294
+ function wasModifiedByTargetRepo(cwd, relPath, allhandsRoot, syncState) {
7295
+ const localFile = join10(cwd, relPath);
7296
+ if (syncState) {
7297
+ const manifestResult = wasModifiedSinceSync(localFile, relPath, syncState, cwd);
7298
+ if (manifestResult !== null) return manifestResult;
7299
+ }
7238
7300
  const localBlobHash = getFileBlobHash(localFile, allhandsRoot);
7239
7301
  if (!localBlobHash) return true;
7240
7302
  return !fileExistsInHistory(relPath, localBlobHash, allhandsRoot);
@@ -7243,6 +7305,7 @@ function collectFilesToPush(cwd, finalIncludes, finalExcludes) {
7243
7305
  const allhandsRoot = getAllhandsRoot();
7244
7306
  const manifest = new Manifest(allhandsRoot);
7245
7307
  const upstreamFiles = manifest.getDistributableFiles();
7308
+ const syncState = readSyncState(cwd);
7246
7309
  const filesToPush = [];
7247
7310
  const localGitFiles = new Set(getGitFiles(cwd));
7248
7311
  const deletedFiles = /* @__PURE__ */ new Set();
@@ -7268,11 +7331,11 @@ function collectFilesToPush(cwd, finalIncludes, finalExcludes) {
7268
7331
  if (!localGitFiles.has(relPath) && !deletedFiles.has(relPath)) {
7269
7332
  continue;
7270
7333
  }
7271
- const localFile = join9(cwd, relPath);
7272
- const upstreamFile = join9(allhandsRoot, relPath);
7273
- if (existsSync11(localFile)) {
7334
+ const localFile = join10(cwd, relPath);
7335
+ const upstreamFile = join10(allhandsRoot, relPath);
7336
+ if (existsSync12(localFile)) {
7274
7337
  if (filesAreDifferent(localFile, upstreamFile)) {
7275
- if (wasModifiedByTargetRepo(cwd, relPath, allhandsRoot)) {
7338
+ if (wasModifiedByTargetRepo(cwd, relPath, allhandsRoot, syncState)) {
7276
7339
  filesToPush.push({ path: relPath, type: "M" });
7277
7340
  }
7278
7341
  }
@@ -7287,9 +7350,9 @@ function collectFilesToPush(cwd, finalIncludes, finalExcludes) {
7287
7350
  if (PUSH_BLOCKLIST.includes(relPath)) continue;
7288
7351
  if (finalExcludes.some((p) => minimatch(relPath, p, { dot: true }))) continue;
7289
7352
  if (filesToPush.some((f) => f.path === relPath)) continue;
7290
- const localFile = join9(cwd, relPath);
7291
- const upstreamFile = join9(allhandsRoot, relPath);
7292
- if (existsSync11(upstreamFile) && !filesAreDifferent(localFile, upstreamFile)) {
7353
+ const localFile = join10(cwd, relPath);
7354
+ const upstreamFile = join10(allhandsRoot, relPath);
7355
+ if (existsSync12(upstreamFile) && !filesAreDifferent(localFile, upstreamFile)) {
7293
7356
  continue;
7294
7357
  }
7295
7358
  filesToPush.push({ path: relPath, type: "A" });
@@ -7322,8 +7385,8 @@ async function createPullRequest(cwd, ghUser, filesToPush, title, body) {
7322
7385
  return 1;
7323
7386
  }
7324
7387
  }
7325
- const tempDir = join9(tmpdir(), `allhands-push-${Date.now()}`);
7326
- mkdirSync2(tempDir, { recursive: true });
7388
+ const tempDir = join10(tmpdir(), `allhands-push-${Date.now()}`);
7389
+ mkdirSync3(tempDir, { recursive: true });
7327
7390
  try {
7328
7391
  console.log("Cloning fork...");
7329
7392
  const cloneResult = gh(["repo", "clone", `${ghUser}/${repoName}`, tempDir, "--", "--depth=1"]);
@@ -7354,9 +7417,9 @@ async function createPullRequest(cwd, ghUser, filesToPush, title, body) {
7354
7417
  if (file.type === "D") {
7355
7418
  git(["rm", "--ignore-unmatch", file.path], tempDir);
7356
7419
  } else {
7357
- const src = join9(cwd, file.path);
7358
- const dest = join9(tempDir, file.path);
7359
- mkdirSync2(dirname8(dest), { recursive: true });
7420
+ const src = join10(cwd, file.path);
7421
+ const dest = join10(tempDir, file.path);
7422
+ mkdirSync3(dirname9(dest), { recursive: true });
7360
7423
  copyFileSync2(src, dest);
7361
7424
  }
7362
7425
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "all-hands-cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Agentic harness for model-first software development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@ import { Manifest, filesAreDifferent } from '../lib/manifest.js';
9
9
  import { getAllhandsRoot, UPSTREAM_REPO } from '../lib/paths.js';
10
10
  import { askQuestion, confirm } from '../lib/ui.js';
11
11
  import { PUSH_BLOCKLIST, SYNC_CONFIG_FILENAME } from '../lib/constants.js';
12
+ import { readSyncState, wasModifiedSinceSync, SyncState } from '../lib/sync-state.js';
12
13
 
13
14
  interface SyncConfig {
14
15
  includes?: string[];
@@ -98,12 +99,25 @@ function checkPrerequisites(cwd: string): PrerequisiteResult {
98
99
  * Determine if a file was actually modified by the target repo,
99
100
  * vs simply being out of date because upstream moved forward.
100
101
  *
101
- * Compares the target file's content against all historical versions
102
- * in the upstream repo. If it matches any previous version, the target
103
- * repo hasn't modified it.
102
+ * Prefers the sync-state manifest (written during sync) which records the
103
+ * exact blob hash of each source file at sync time. Falls back to git
104
+ * history search for repos synced before the manifest existed.
104
105
  */
105
- function wasModifiedByTargetRepo(cwd: string, relPath: string, allhandsRoot: string): boolean {
106
+ function wasModifiedByTargetRepo(
107
+ cwd: string,
108
+ relPath: string,
109
+ allhandsRoot: string,
110
+ syncState: SyncState | null
111
+ ): boolean {
106
112
  const localFile = join(cwd, relPath);
113
+
114
+ // Prefer manifest check when available
115
+ if (syncState) {
116
+ const manifestResult = wasModifiedSinceSync(localFile, relPath, syncState, cwd);
117
+ if (manifestResult !== null) return manifestResult;
118
+ }
119
+
120
+ // Fall back to git history for legacy repos or files not in manifest
107
121
  const localBlobHash = getFileBlobHash(localFile, allhandsRoot);
108
122
 
109
123
  if (!localBlobHash) return true; // safe default: assume modified on error
@@ -119,6 +133,7 @@ function collectFilesToPush(
119
133
  const allhandsRoot = getAllhandsRoot();
120
134
  const manifest = new Manifest(allhandsRoot);
121
135
  const upstreamFiles = manifest.getDistributableFiles();
136
+ const syncState = readSyncState(cwd);
122
137
  const filesToPush: FileEntry[] = [];
123
138
 
124
139
  // Get non-ignored files in user's repo (respects .gitignore)
@@ -156,7 +171,7 @@ function collectFilesToPush(
156
171
 
157
172
  if (existsSync(localFile)) {
158
173
  if (filesAreDifferent(localFile, upstreamFile)) {
159
- if (wasModifiedByTargetRepo(cwd, relPath, allhandsRoot)) {
174
+ if (wasModifiedByTargetRepo(cwd, relPath, allhandsRoot, syncState)) {
160
175
  filesToPush.push({ path: relPath, type: 'M' });
161
176
  }
162
177
  }
@@ -8,6 +8,7 @@ import { ConflictResolution, askConflictResolution, confirm, getNextBackupPath }
8
8
  import { SYNC_CONFIG_FILENAME, SYNC_CONFIG_TEMPLATE } from '../lib/constants.js';
9
9
  import { restoreDotfiles } from '../lib/dotfiles.js';
10
10
  import { ensureTargetLines } from '../lib/target-lines.js';
11
+ import { writeSyncState } from '../lib/sync-state.js';
11
12
 
12
13
  const AH_SHIM_SCRIPT = `#!/bin/bash
13
14
  # AllHands CLI shim - finds and executes project-local .allhands/harness/ah
@@ -169,12 +170,16 @@ export async function cmdSync(target: string = '.', autoYes: boolean = false, in
169
170
  console.log('\nCopying allhands files...');
170
171
  console.log(`Found ${distributable.size} files to distribute`);
171
172
 
173
+ const syncedFiles = new Set<string>();
174
+
172
175
  for (const relPath of [...distributable].sort()) {
173
176
  const sourceFile = join(allhandsRoot, relPath);
174
177
  const targetFile = join(resolvedTarget, relPath);
175
178
 
176
179
  if (!existsSync(sourceFile)) continue;
177
180
 
181
+ syncedFiles.add(relPath);
182
+
178
183
  mkdirSync(dirname(targetFile), { recursive: true });
179
184
 
180
185
  if (existsSync(targetFile)) {
@@ -193,6 +198,9 @@ export async function cmdSync(target: string = '.', autoYes: boolean = false, in
193
198
  // Restore dotfiles (gitignore → .gitignore, etc.)
194
199
  restoreDotfiles(resolvedTarget);
195
200
 
201
+ // Write sync-state manifest for push false-positive detection
202
+ writeSyncState(resolvedTarget, allhandsRoot, syncedFiles);
203
+
196
204
  // Update-only: Handle deleted files
197
205
  if (!isFirstTime && deletedInSource.length > 0) {
198
206
  console.log(`\n${deletedInSource.length} files removed from allhands source:`);
@@ -1,7 +1,8 @@
1
1
  export const SYNC_CONFIG_FILENAME = '.allhands-sync-config.json';
2
+ export const SYNC_STATE_FILENAME = '.allhands/.sync-state.json';
2
3
 
3
4
  // Files that should never be pushed back to upstream
4
- export const PUSH_BLOCKLIST = ['CLAUDE.project.md', '.allhands-sync-config.json'];
5
+ export const PUSH_BLOCKLIST = ['CLAUDE.project.md', '.allhands-sync-config.json', '.allhands/.sync-state.json'];
5
6
 
6
7
  export const SYNC_CONFIG_TEMPLATE = {
7
8
  $comment: 'Customization for claude-all-hands push command',
package/src/lib/git.ts CHANGED
@@ -70,6 +70,22 @@ export function getFileBlobHash(filePath: string, repoPath: string): string | nu
70
70
  return result.success ? result.stdout.trim() : null;
71
71
  }
72
72
 
73
+ /**
74
+ * Get the HEAD commit hash of a repo. Returns null if no commits exist.
75
+ */
76
+ export function getHeadCommit(repoPath: string): string | null {
77
+ const result = git(['rev-parse', 'HEAD'], repoPath);
78
+ return result.success ? result.stdout.trim() : null;
79
+ }
80
+
81
+ /**
82
+ * Check if a repo has uncommitted changes (staged or unstaged).
83
+ */
84
+ export function hasUncommittedChanges(repoPath: string): boolean {
85
+ const result = git(['status', '--porcelain'], repoPath);
86
+ return result.success && result.stdout.length > 0;
87
+ }
88
+
73
89
  /**
74
90
  * Check if a specific blob hash appears in the git history of a file path.
75
91
  * Uses a single `rev-list --objects` call instead of per-commit lookups.
@@ -0,0 +1,93 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { getFileBlobHash } from './git.js';
4
+ import { getHeadCommit, hasUncommittedChanges } from './git.js';
5
+ import { SYNC_STATE_FILENAME } from './constants.js';
6
+
7
+ export interface SyncState {
8
+ version: 1;
9
+ syncedAt: string;
10
+ sourceCommit: string | null;
11
+ dirty: boolean;
12
+ files: Record<string, string>;
13
+ }
14
+
15
+ /**
16
+ * Write a sync-state manifest recording the blob hash of each synced file.
17
+ * This allows push to detect whether a target file was modified locally
18
+ * without relying on git history (which fails for uncommitted working-tree copies).
19
+ */
20
+ export function writeSyncState(
21
+ targetRoot: string,
22
+ allhandsRoot: string,
23
+ syncedFiles: Set<string>
24
+ ): void {
25
+ const files: Record<string, string> = {};
26
+
27
+ for (const relPath of [...syncedFiles].sort()) {
28
+ const sourceFile = join(allhandsRoot, relPath);
29
+ if (!existsSync(sourceFile)) continue;
30
+ const hash = getFileBlobHash(sourceFile, allhandsRoot);
31
+ if (hash) {
32
+ files[relPath] = hash;
33
+ }
34
+ }
35
+
36
+ const state: SyncState = {
37
+ version: 1,
38
+ syncedAt: new Date().toISOString(),
39
+ sourceCommit: getHeadCommit(allhandsRoot),
40
+ dirty: hasUncommittedChanges(allhandsRoot),
41
+ files,
42
+ };
43
+
44
+ const outPath = join(targetRoot, SYNC_STATE_FILENAME);
45
+ mkdirSync(dirname(outPath), { recursive: true });
46
+ writeFileSync(outPath, JSON.stringify(state, null, 2) + '\n');
47
+ }
48
+
49
+ /**
50
+ * Read the sync-state manifest from a target repo.
51
+ * Returns null if the manifest does not exist or cannot be parsed.
52
+ */
53
+ export function readSyncState(targetRoot: string): SyncState | null {
54
+ const stateFile = join(targetRoot, SYNC_STATE_FILENAME);
55
+ if (!existsSync(stateFile)) return null;
56
+ try {
57
+ const content = readFileSync(stateFile, 'utf-8');
58
+ const parsed = JSON.parse(content);
59
+ if (
60
+ !parsed ||
61
+ typeof parsed !== 'object' ||
62
+ parsed.version !== 1 ||
63
+ !parsed.files ||
64
+ typeof parsed.files !== 'object'
65
+ ) {
66
+ return null;
67
+ }
68
+ return parsed as SyncState;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check whether a target file was modified since the last sync.
76
+ * Returns null if the file is not in the manifest (caller should fall back).
77
+ * Returns true if the target's hash differs from the manifest.
78
+ * Returns false if the target's hash matches the manifest.
79
+ */
80
+ export function wasModifiedSinceSync(
81
+ targetFilePath: string,
82
+ relPath: string,
83
+ syncState: SyncState,
84
+ repoPath: string
85
+ ): boolean | null {
86
+ const manifestHash = syncState.files[relPath];
87
+ if (!manifestHash) return null;
88
+
89
+ const targetHash = getFileBlobHash(targetFilePath, repoPath);
90
+ if (!targetHash) return true; // safe default: assume modified on error
91
+
92
+ return targetHash !== manifestHash;
93
+ }