everything-claude-code 1.4.3 → 1.4.4

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.
@@ -7,6 +7,17 @@ use std::process::{Command, ExitCode, Stdio};
7
7
  use time::format_description::well_known::Rfc3339;
8
8
  use time::OffsetDateTime;
9
9
 
10
+ const PROTOCOL_VERSION: i32 = 1;
11
+ const COMMANDS: &[&str] = &[
12
+ "worktree.ensure",
13
+ "worktree.remove",
14
+ "patch.apply",
15
+ "git.commit_all",
16
+ "verify.run",
17
+ "protocol.version",
18
+ "repo.info",
19
+ ];
20
+
10
21
  fn now_iso() -> String {
11
22
  OffsetDateTime::now_utc()
12
23
  .format(&Rfc3339)
@@ -502,6 +513,127 @@ fn commit_all(input: CommitAllIn) -> Result<CommitAllOut, String> {
502
513
  })
503
514
  }
504
515
 
516
+ #[derive(Deserialize)]
517
+ struct ProtocolVersionIn {}
518
+
519
+ #[derive(Serialize)]
520
+ struct ProtocolVersionOut {
521
+ version: i32,
522
+ protocol: i32,
523
+ kernelVersion: String,
524
+ commands: Vec<String>,
525
+ }
526
+
527
+ fn protocol_version(_input: ProtocolVersionIn) -> Result<ProtocolVersionOut, String> {
528
+ Ok(ProtocolVersionOut {
529
+ version: 1,
530
+ protocol: PROTOCOL_VERSION,
531
+ kernelVersion: env!("CARGO_PKG_VERSION").to_string(),
532
+ commands: COMMANDS.iter().map(|s| s.to_string()).collect(),
533
+ })
534
+ }
535
+
536
+ #[derive(Deserialize)]
537
+ struct RepoInfoIn {
538
+ cwd: String,
539
+ }
540
+
541
+ #[derive(Serialize)]
542
+ struct RepoInfoOut {
543
+ version: i32,
544
+ repoRoot: Option<String>,
545
+ branch: String,
546
+ sha: String,
547
+ clean: bool,
548
+ }
549
+
550
+ fn repo_info(input: RepoInfoIn) -> Result<RepoInfoOut, String> {
551
+ let cwd = PathBuf::from(input.cwd);
552
+
553
+ let out_root = run_git(
554
+ &vec![
555
+ "-C".to_string(),
556
+ cwd.display().to_string(),
557
+ "rev-parse".to_string(),
558
+ "--show-toplevel".to_string(),
559
+ ],
560
+ None,
561
+ )?;
562
+
563
+ if !out_root.ok {
564
+ return Ok(RepoInfoOut {
565
+ version: 1,
566
+ repoRoot: None,
567
+ branch: "".to_string(),
568
+ sha: "".to_string(),
569
+ clean: false,
570
+ });
571
+ }
572
+
573
+ let repo_root = out_root.stdout.trim().to_string();
574
+
575
+ let out_branch = run_git(
576
+ &vec![
577
+ "-C".to_string(),
578
+ repo_root.clone(),
579
+ "rev-parse".to_string(),
580
+ "--abbrev-ref".to_string(),
581
+ "HEAD".to_string(),
582
+ ],
583
+ None,
584
+ )?;
585
+ if !out_branch.ok {
586
+ return Err(out_branch
587
+ .stderr
588
+ .is_empty()
589
+ .then(|| "git rev-parse --abbrev-ref HEAD failed".to_string())
590
+ .unwrap_or(out_branch.stderr));
591
+ }
592
+
593
+ let out_sha = run_git(
594
+ &vec![
595
+ "-C".to_string(),
596
+ repo_root.clone(),
597
+ "rev-parse".to_string(),
598
+ "HEAD".to_string(),
599
+ ],
600
+ None,
601
+ )?;
602
+ if !out_sha.ok {
603
+ return Err(out_sha
604
+ .stderr
605
+ .is_empty()
606
+ .then(|| "git rev-parse HEAD failed".to_string())
607
+ .unwrap_or(out_sha.stderr));
608
+ }
609
+
610
+ let out_status = run_git(
611
+ &vec![
612
+ "-C".to_string(),
613
+ repo_root.clone(),
614
+ "status".to_string(),
615
+ "--porcelain".to_string(),
616
+ "--untracked-files=no".to_string(),
617
+ ],
618
+ None,
619
+ )?;
620
+ if !out_status.ok {
621
+ return Err(out_status
622
+ .stderr
623
+ .is_empty()
624
+ .then(|| "git status --porcelain failed".to_string())
625
+ .unwrap_or(out_status.stderr));
626
+ }
627
+
628
+ Ok(RepoInfoOut {
629
+ version: 1,
630
+ repoRoot: Some(repo_root),
631
+ branch: out_branch.stdout.trim().to_string(),
632
+ sha: out_sha.stdout.trim().to_string(),
633
+ clean: out_status.stdout.trim().is_empty(),
634
+ })
635
+ }
636
+
505
637
  #[derive(Deserialize)]
506
638
  struct VerifyCmdIn {
507
639
  name: String,
@@ -628,6 +760,7 @@ fn verify_run(input: VerifyRunIn) -> Result<VerifySummaryOut, String> {
628
760
  }
629
761
 
630
762
  fn usage() {
763
+ let cmds = COMMANDS.join("\n ");
631
764
  eprintln!(
632
765
  r#"ecc-kernel
633
766
 
@@ -635,12 +768,10 @@ Usage:
635
768
  ecc-kernel <command> (JSON input on stdin; JSON output on stdout)
636
769
 
637
770
  Commands:
638
- worktree.ensure
639
- worktree.remove
640
- patch.apply
641
- git.commit_all
642
- verify.run
771
+ {cmds}
643
772
  "#
773
+ ,
774
+ cmds = cmds
644
775
  );
645
776
  }
646
777
 
@@ -686,6 +817,16 @@ fn real_main() -> Result<(), String> {
686
817
  let out = commit_all(input)?;
687
818
  write_stdout_json(&out)
688
819
  }
820
+ "protocol.version" => {
821
+ let input: ProtocolVersionIn = read_stdin_json()?;
822
+ let out = protocol_version(input)?;
823
+ write_stdout_json(&out)
824
+ }
825
+ "repo.info" => {
826
+ let input: RepoInfoIn = read_stdin_json()?;
827
+ let out = repo_info(input)?;
828
+ write_stdout_json(&out)
829
+ }
689
830
  "verify.run" => {
690
831
  let input: VerifyRunIn = read_stdin_json()?;
691
832
  let out = verify_run(input)?;
@@ -708,3 +849,111 @@ fn main() -> ExitCode {
708
849
  }
709
850
  }
710
851
  }
852
+
853
+ #[cfg(test)]
854
+ mod tests {
855
+ use super::*;
856
+
857
+ fn tmp_dir(prefix: &str) -> PathBuf {
858
+ let mut dir = std::env::temp_dir();
859
+ dir.push(format!(
860
+ "{}-{}-{}",
861
+ prefix,
862
+ std::process::id(),
863
+ OffsetDateTime::now_utc().unix_timestamp_nanos()
864
+ ));
865
+ dir
866
+ }
867
+
868
+ fn git(repo: &Path, args: &[&str]) -> Result<CmdOut, String> {
869
+ let mut v: Vec<String> = Vec::new();
870
+ v.push("-C".to_string());
871
+ v.push(repo.display().to_string());
872
+ for a in args {
873
+ v.push(a.to_string());
874
+ }
875
+ run_git(&v, None)
876
+ }
877
+
878
+ fn init_git_repo(repo: &Path) -> Result<(), String> {
879
+ fs::create_dir_all(repo).map_err(|e| format!("mkdir failed: {e}"))?;
880
+ let out = git(repo, &["init"])?;
881
+ if !out.ok {
882
+ return Err(out.stderr);
883
+ }
884
+
885
+ // Ensure commits work in CI.
886
+ let _ = git(repo, &["config", "user.email", "ecc@example.com"])?;
887
+ let _ = git(repo, &["config", "user.name", "ECC"])?;
888
+
889
+ fs::write(repo.join("base.txt"), "base\n").map_err(|e| format!("write failed: {e}"))?;
890
+ let out_add = git(repo, &["add", "-A"])?;
891
+ if !out_add.ok {
892
+ return Err(out_add.stderr);
893
+ }
894
+ let out_commit = git(repo, &["commit", "-m", "init"])?;
895
+ if !out_commit.ok {
896
+ return Err(out_commit.stderr);
897
+ }
898
+ Ok(())
899
+ }
900
+
901
+ #[test]
902
+ fn protocol_version_has_required_fields() {
903
+ let out = protocol_version(ProtocolVersionIn {}).unwrap();
904
+ assert_eq!(out.version, 1);
905
+ assert_eq!(out.protocol, PROTOCOL_VERSION);
906
+ assert!(!out.kernelVersion.trim().is_empty());
907
+
908
+ let cmds: std::collections::BTreeSet<String> = out.commands.into_iter().collect();
909
+ for c in COMMANDS.iter() {
910
+ assert!(cmds.contains(&c.to_string()), "missing command: {c}");
911
+ }
912
+ }
913
+
914
+ #[test]
915
+ fn repo_info_returns_repo_details_and_cleanliness() {
916
+ let dir = tmp_dir("ecc-kernel-test-repo");
917
+ let res = init_git_repo(&dir);
918
+ assert!(res.is_ok(), "init_git_repo failed: {:?}", res.err());
919
+
920
+ let info = repo_info(RepoInfoIn {
921
+ cwd: dir.display().to_string(),
922
+ })
923
+ .unwrap();
924
+
925
+ assert_eq!(info.version, 1);
926
+ let got_root = info.repoRoot.clone().unwrap();
927
+ let expected_abs = fs::canonicalize(&dir).unwrap();
928
+ let got_abs = fs::canonicalize(std::path::Path::new(&got_root)).unwrap();
929
+ assert_eq!(got_abs, expected_abs);
930
+ assert!(!info.branch.trim().is_empty());
931
+ assert_eq!(info.sha.len(), 40);
932
+ assert_eq!(info.clean, true);
933
+
934
+ // Modifying a tracked file should mark repo as dirty.
935
+ fs::write(dir.join("base.txt"), "changed\n").unwrap();
936
+ let info2 = repo_info(RepoInfoIn {
937
+ cwd: dir.display().to_string(),
938
+ })
939
+ .unwrap();
940
+ assert_eq!(info2.clean, false);
941
+
942
+ let _ = fs::remove_dir_all(&dir);
943
+ }
944
+
945
+ #[test]
946
+ fn repo_info_outside_repo_is_null() {
947
+ let dir = tmp_dir("ecc-kernel-test-norepo");
948
+ fs::create_dir_all(&dir).unwrap();
949
+
950
+ let info = repo_info(RepoInfoIn {
951
+ cwd: dir.display().to_string(),
952
+ })
953
+ .unwrap();
954
+ assert_eq!(info.version, 1);
955
+ assert!(info.repoRoot.is_none());
956
+
957
+ let _ = fs::remove_dir_all(&dir);
958
+ }
959
+ }
@@ -0,0 +1,106 @@
1
+ # ecc-kernel Protocol
2
+
3
+ `ecc-kernel` is a small Rust binary that offloads ECC "kernel" operations:
4
+ - git worktree creation/removal
5
+ - safe patch apply with ownership checks
6
+ - verify command runner (writes evidence files)
7
+
8
+ All commands use:
9
+ - **stdin**: JSON
10
+ - **stdout**: JSON
11
+ - **stderr**: human-readable diagnostics
12
+
13
+ ---
14
+
15
+ ## Versioning and Compatibility
16
+
17
+ - Node and kernel communicate via a protocol handshake.
18
+ - Node expects `protocol=1` and the required command set.
19
+ - `ECC_KERNEL=auto` falls back to JS if handshake fails.
20
+ - `ECC_KERNEL=rust` fails fast if handshake fails.
21
+
22
+ ---
23
+
24
+ ## Command: `protocol.version`
25
+
26
+ ### Input (stdin JSON)
27
+
28
+ ```json
29
+ {}
30
+ ```
31
+
32
+ ### Output (stdout JSON)
33
+
34
+ ```json
35
+ {
36
+ "version": 1,
37
+ "protocol": 1,
38
+ "kernelVersion": "0.1.0",
39
+ "commands": [
40
+ "worktree.ensure",
41
+ "worktree.remove",
42
+ "patch.apply",
43
+ "git.commit_all",
44
+ "verify.run",
45
+ "protocol.version",
46
+ "repo.info"
47
+ ]
48
+ }
49
+ ```
50
+
51
+ Fields:
52
+ - `version`: output schema version (currently `1`)
53
+ - `protocol`: protocol version (currently `1`)
54
+ - `kernelVersion`: Rust crate version
55
+ - `commands`: supported command identifiers
56
+
57
+ ---
58
+
59
+ ## Command: `repo.info`
60
+
61
+ Returns git repo information for a given `cwd`.
62
+
63
+ ### Input
64
+
65
+ ```json
66
+ { "cwd": "/path/to/dir" }
67
+ ```
68
+
69
+ ### Output (inside a git repo)
70
+
71
+ ```json
72
+ {
73
+ "version": 1,
74
+ "repoRoot": "/path/to/repo",
75
+ "branch": "main",
76
+ "sha": "0123456789abcdef0123456789abcdef01234567",
77
+ "clean": true
78
+ }
79
+ ```
80
+
81
+ ### Output (not a git repo)
82
+
83
+ ```json
84
+ {
85
+ "version": 1,
86
+ "repoRoot": null,
87
+ "branch": "",
88
+ "sha": "",
89
+ "clean": false
90
+ }
91
+ ```
92
+
93
+ Notes:
94
+ - `clean` ignores untracked files (equivalent to `git status --porcelain --untracked-files=no`).
95
+
96
+ ---
97
+
98
+ ## Other Commands
99
+
100
+ The remaining commands are internal engine plumbing and are documented by source:
101
+ - `worktree.ensure`
102
+ - `worktree.remove`
103
+ - `patch.apply`
104
+ - `git.commit_all`
105
+ - `verify.run`
106
+
@@ -0,0 +1,82 @@
1
+ # ECC Release Guide
2
+
3
+ This repo ships ECC as a Node CLI with an optional Rust kernel (`ecc-kernel`).
4
+
5
+ The release chain is designed to make installs reliable:
6
+ - Tag push builds and uploads prebuilt kernels to a GitHub Release
7
+ - npm publish validates those assets exist (so installs can download them)
8
+ - Release smoke tests validate real install + download + run across OSes
9
+
10
+ ---
11
+
12
+ ## Versioning
13
+
14
+ - `package.json.version` must match the git tag: `v${version}`
15
+ - Use patch bumps by default.
16
+
17
+ ---
18
+
19
+ ## Release Steps (vX.Y.Z)
20
+
21
+ 1. Ensure `main` is green (CI passes).
22
+ 2. Bump version (on `main`):
23
+
24
+ ```bash
25
+ npm version patch --no-git-tag-version
26
+ ```
27
+
28
+ 3. Commit:
29
+
30
+ ```bash
31
+ git commit -am "chore(release): vX.Y.Z"
32
+ ```
33
+
34
+ 4. Tag + push:
35
+
36
+ ```bash
37
+ git tag vX.Y.Z
38
+ git push origin main
39
+ git push origin vX.Y.Z
40
+ ```
41
+
42
+ 5. Verify GitHub Release:
43
+ - Workflow: `.github/workflows/release.yml`
44
+ - Assets must include `ecc-kernel-<os>-<arch>[.exe]` plus `.sha256` files.
45
+
46
+ 6. Verify Release Smoke:
47
+ - Workflow: `.github/workflows/release-smoke.yml`
48
+ - Runs a real install from a packed tarball and exercises:
49
+ - `ECC_KERNEL_INSTALL=required` + `ECC_KERNEL=rust`
50
+ - `ECC_KERNEL_INSTALL=0` + `ECC_KERNEL=node`
51
+
52
+ 7. Publish to npm:
53
+ - Workflow: `.github/workflows/publish-npm.yml`
54
+ - Uses npm Trusted Publishing (OIDC) if configured, with optional `NPM_TOKEN` fallback.
55
+
56
+ ---
57
+
58
+ ## Troubleshooting
59
+
60
+ ### Release created but assets missing / 404 during install
61
+ - Kernel upload can be briefly eventual-consistent.
62
+ - Re-run smoke (workflow_dispatch) after a minute:
63
+
64
+ ```bash
65
+ gh workflow run "Release Smoke" -f tag=vX.Y.Z
66
+ ```
67
+
68
+ ### npm publish fails (OIDC not configured)
69
+ - Configure npm "Trusted Publisher" for:
70
+ - owner: `sumulige`
71
+ - repo: `everything-claude-code`
72
+ - workflow file: `publish-npm.yml`
73
+ - Re-run publish:
74
+
75
+ ```bash
76
+ gh workflow run "Publish (npm)" -f tag=vX.Y.Z
77
+ ```
78
+
79
+ ### Rollback
80
+ - If a bad version is published to npm: publish a new patch version.
81
+ - If a GitHub Release is broken: re-run `Release` workflow for the same tag, or create a new tag.
82
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "everything-claude-code",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Everything Claude Code + ECC (Engineering Change Conveyor) CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,8 @@
27
27
  "schemas/",
28
28
  "prompts/ecc/",
29
29
  "docs/ecc.md",
30
+ "docs/ecc-release.md",
31
+ "docs/ecc-kernel-protocol.md",
30
32
  "README.md",
31
33
  "LICENSE"
32
34
  ],
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const { spawnSync } = require('child_process');
5
5
 
6
6
  const { runKernel } = require('./kernel');
7
+ const { validateRepoInfoOutput } = require('./kernel-contract');
7
8
 
8
9
  function runGit(args, opts = {}) {
9
10
  const res = spawnSync('git', args, {
@@ -123,9 +124,34 @@ function commitAll({ repoRoot, message }) {
123
124
  return sha;
124
125
  }
125
126
 
127
+ function getRepoInfo(cwd) {
128
+ const kernelOut = runKernel('repo.info', { cwd });
129
+ if (kernelOut !== null) {
130
+ const errors = validateRepoInfoOutput(kernelOut);
131
+ if (errors.length) {
132
+ throw new Error(`ecc-kernel repo.info returned invalid output: ${errors.join('; ')}`);
133
+ }
134
+ return kernelOut;
135
+ }
136
+
137
+ const repoRoot = getRepoRoot(cwd);
138
+ if (!repoRoot) {
139
+ return { version: 1, repoRoot: null, branch: '', sha: '', clean: false };
140
+ }
141
+
142
+ return {
143
+ version: 1,
144
+ repoRoot,
145
+ branch: getCurrentBranch(repoRoot),
146
+ sha: getHeadSha(repoRoot),
147
+ clean: isClean(repoRoot)
148
+ };
149
+ }
150
+
126
151
  module.exports = {
127
152
  runGit,
128
153
  getRepoRoot,
154
+ getRepoInfo,
129
155
  getHeadSha,
130
156
  getCurrentBranch,
131
157
  isClean,
@@ -21,6 +21,8 @@ const crypto = require('crypto');
21
21
  const { spawnSync } = require('child_process');
22
22
  const { URL } = require('url');
23
23
 
24
+ const { EXPECTED_PROTOCOL, validateProtocolVersionOutput } = require('./kernel-contract');
25
+
24
26
  function debugEnabled() {
25
27
  return !!(process.env.ECC_KERNEL_DEBUG && String(process.env.ECC_KERNEL_DEBUG).trim());
26
28
  }
@@ -247,6 +249,34 @@ function validateRuns(filePath) {
247
249
  }
248
250
  }
249
251
 
252
+ function validateProtocol(filePath) {
253
+ const res = spawnSync(filePath, ['protocol.version'], {
254
+ encoding: 'utf8',
255
+ input: '{}',
256
+ stdio: ['pipe', 'pipe', 'pipe']
257
+ });
258
+ if (res.error) throw new Error(`kernel protocol.version failed: ${res.error.message}`);
259
+ if (res.status !== 0) {
260
+ const stderr = (res.stderr || '').trim();
261
+ throw new Error(`kernel protocol.version failed (exit ${res.status})${stderr ? `: ${stderr}` : ''}`);
262
+ }
263
+
264
+ const stdout = (res.stdout || '').trim();
265
+ if (!stdout) throw new Error('kernel protocol.version returned empty output');
266
+
267
+ let obj;
268
+ try {
269
+ obj = JSON.parse(stdout);
270
+ } catch (_err) {
271
+ throw new Error('kernel protocol.version returned non-JSON output');
272
+ }
273
+
274
+ const errors = validateProtocolVersionOutput(obj, { expectedProtocol: EXPECTED_PROTOCOL });
275
+ if (errors.length) {
276
+ throw new Error(`kernel protocol.version invalid: ${errors.join('; ')}`);
277
+ }
278
+ }
279
+
250
280
  async function main() {
251
281
  const { mode, envSet } = parseInstallMode();
252
282
  if (mode === 'off') return;
@@ -273,7 +303,15 @@ async function main() {
273
303
 
274
304
  if (fs.existsSync(destBin)) {
275
305
  logDebug(`kernel already present: ${destBin}`);
276
- return;
306
+ try {
307
+ ensureExecutable(destBin);
308
+ validateRuns(destBin);
309
+ validateProtocol(destBin);
310
+ return;
311
+ } catch (err) {
312
+ logDebug(`existing kernel invalid/outdated, reinstalling: ${err.message}`);
313
+ try { fs.rmSync(destBin, { force: true }); } catch (_err) { /* ignore */ }
314
+ }
277
315
  }
278
316
 
279
317
  const baseUrl = defaultBaseUrl(version, pkg);
@@ -327,6 +365,7 @@ async function main() {
327
365
  }
328
366
 
329
367
  validateRuns(destBin);
368
+ validateProtocol(destBin);
330
369
  } catch (err) {
331
370
  try { fs.rmSync(destBin, { force: true }); } catch (_err) { /* ignore */ }
332
371
  if (mode === 'required') throw err;
@@ -0,0 +1,63 @@
1
+ function isObj(v) {
2
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
3
+ }
4
+
5
+ function isInt(v) {
6
+ return Number.isInteger(v);
7
+ }
8
+
9
+ const EXPECTED_PROTOCOL = 1;
10
+
11
+ // Commands that the Node engine expects the Rust kernel to support.
12
+ const REQUIRED_COMMANDS = [
13
+ 'worktree.ensure',
14
+ 'worktree.remove',
15
+ 'patch.apply',
16
+ 'git.commit_all',
17
+ 'verify.run',
18
+ 'protocol.version',
19
+ 'repo.info'
20
+ ];
21
+
22
+ function validateProtocolVersionOutput(obj, { expectedProtocol = EXPECTED_PROTOCOL } = {}) {
23
+ const errors = [];
24
+ if (!isObj(obj)) return ['expected object'];
25
+
26
+ if (obj.version !== 1) errors.push('expected version: 1');
27
+
28
+ if (!isInt(obj.protocol)) errors.push('expected protocol: integer');
29
+ else if (obj.protocol !== expectedProtocol) errors.push(`protocol mismatch: expected ${expectedProtocol}, got ${obj.protocol}`);
30
+
31
+ if (typeof obj.kernelVersion !== 'string' || !obj.kernelVersion.trim()) errors.push('expected kernelVersion: non-empty string');
32
+
33
+ if (!Array.isArray(obj.commands)) {
34
+ errors.push('expected commands: array');
35
+ } else {
36
+ const missing = REQUIRED_COMMANDS.filter(c => !obj.commands.includes(c));
37
+ if (missing.length) errors.push(`missing commands: ${missing.join(', ')}`);
38
+ }
39
+
40
+ return errors;
41
+ }
42
+
43
+ function validateRepoInfoOutput(obj) {
44
+ const errors = [];
45
+ if (!isObj(obj)) return ['expected object'];
46
+
47
+ if (obj.version !== 1) errors.push('expected version: 1');
48
+
49
+ if (obj.repoRoot !== null && typeof obj.repoRoot !== 'string') errors.push('expected repoRoot: string|null');
50
+ if (typeof obj.branch !== 'string') errors.push('expected branch: string');
51
+ if (typeof obj.sha !== 'string') errors.push('expected sha: string');
52
+ if (typeof obj.clean !== 'boolean') errors.push('expected clean: boolean');
53
+
54
+ return errors;
55
+ }
56
+
57
+ module.exports = {
58
+ EXPECTED_PROTOCOL,
59
+ REQUIRED_COMMANDS,
60
+ validateProtocolVersionOutput,
61
+ validateRepoInfoOutput
62
+ };
63
+
@@ -2,6 +2,8 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
4
 
5
+ const { EXPECTED_PROTOCOL, validateProtocolVersionOutput } = require('./kernel-contract');
6
+
5
7
  function isFile(p) {
6
8
  try {
7
9
  return fs.statSync(p).isFile();
@@ -47,36 +49,139 @@ function tryKernelFromPath() {
47
49
  encoding: 'utf8',
48
50
  stdio: ['ignore', 'pipe', 'pipe']
49
51
  });
50
- if (res.error) return null;
51
- if (res.status === 0) return 'ecc-kernel';
52
- return null;
52
+ if (res.error) {
53
+ // ENOENT means not on PATH; treat as no candidate.
54
+ if (res.error && res.error.code === 'ENOENT') return null;
55
+ // Other spawn errors still indicate an attemptable candidate (will be probed for a better error).
56
+ return 'ecc-kernel';
57
+ }
58
+ return 'ecc-kernel';
59
+ }
60
+
61
+ function runKernelJson(bin, command, inputObj) {
62
+ const res = spawnSync(bin, [command], {
63
+ encoding: 'utf8',
64
+ input: JSON.stringify(inputObj || {}),
65
+ stdio: ['pipe', 'pipe', 'pipe']
66
+ });
67
+
68
+ if (res.error) return { ok: false, error: `spawn failed: ${res.error.message}` };
69
+ const stdout = (res.stdout || '').trim();
70
+ const stderr = (res.stderr || '').trim();
71
+
72
+ if (res.status !== 0) {
73
+ const detail = stderr ? `stderr: ${stderr}` : (stdout ? `stdout: ${stdout}` : '');
74
+ return { ok: false, error: `exit ${res.status}${detail ? ` (${detail})` : ''}` };
75
+ }
76
+
77
+ if (!stdout) return { ok: false, error: 'empty stdout' };
78
+ try {
79
+ return { ok: true, value: JSON.parse(stdout) };
80
+ } catch (err) {
81
+ const msg = err && err.message ? err.message : String(err);
82
+ return { ok: false, error: `non-JSON stdout (${msg})` };
83
+ }
84
+ }
85
+
86
+ function probeKernel(bin) {
87
+ const res = runKernelJson(bin, 'protocol.version', {});
88
+ if (!res.ok) return { ok: false, error: `protocol.version failed: ${res.error}` };
89
+
90
+ const errors = validateProtocolVersionOutput(res.value, { expectedProtocol: EXPECTED_PROTOCOL });
91
+ if (errors.length) {
92
+ return { ok: false, error: `invalid protocol.version output: ${errors.join('; ')}` };
93
+ }
94
+
95
+ return {
96
+ ok: true,
97
+ protocol: res.value.protocol,
98
+ kernelVersion: res.value.kernelVersion,
99
+ commands: res.value.commands
100
+ };
53
101
  }
54
102
 
55
- function findKernelBinary() {
103
+ function candidateList() {
104
+ const candidates = [];
105
+
56
106
  if (process.env.ECC_KERNEL_PATH) {
57
- const p = path.resolve(String(process.env.ECC_KERNEL_PATH));
58
- if (isFile(p)) return p;
107
+ candidates.push({
108
+ label: 'ECC_KERNEL_PATH',
109
+ bin: path.resolve(String(process.env.ECC_KERNEL_PATH)),
110
+ requiresFile: true,
111
+ explicit: true
112
+ });
59
113
  }
60
114
 
61
115
  // Preferred location for prebuilt binaries installed via postinstall.
62
116
  const key = platformArchKey();
63
117
  if (key) {
64
- const packaged = path.join(__dirname, 'bin', key, binName());
65
- if (isFile(packaged)) return packaged;
118
+ candidates.push({
119
+ label: 'package',
120
+ bin: path.join(__dirname, 'bin', key, binName()),
121
+ requiresFile: true,
122
+ explicit: false
123
+ });
66
124
  }
67
125
 
126
+ // PATH lookup.
68
127
  const fromPath = tryKernelFromPath();
69
- if (fromPath) return fromPath;
128
+ if (fromPath) {
129
+ candidates.push({ label: 'PATH', bin: fromPath, requiresFile: false, explicit: false });
130
+ }
70
131
 
132
+ // Local dev build (repo).
71
133
  const root = path.resolve(__dirname, '..', '..');
72
- const candidates = [
73
- path.join(root, 'crates', 'ecc-kernel', 'target', 'release', binName()),
74
- path.join(root, 'crates', 'ecc-kernel', 'target', 'debug', binName())
75
- ];
76
- for (const p of candidates) {
77
- if (isFile(p)) return p;
134
+ candidates.push({
135
+ label: 'repo-release',
136
+ bin: path.join(root, 'crates', 'ecc-kernel', 'target', 'release', binName()),
137
+ requiresFile: true,
138
+ explicit: false
139
+ });
140
+ candidates.push({
141
+ label: 'repo-debug',
142
+ bin: path.join(root, 'crates', 'ecc-kernel', 'target', 'debug', binName()),
143
+ requiresFile: true,
144
+ explicit: false
145
+ });
146
+
147
+ const seen = new Set();
148
+ return candidates.filter(c => {
149
+ if (seen.has(c.bin)) return false;
150
+ seen.add(c.bin);
151
+ return true;
152
+ });
153
+ }
154
+
155
+ function selectCompatibleKernel({ mode }) {
156
+ const candidates = candidateList();
157
+ const errors = [];
158
+
159
+ for (const c of candidates) {
160
+ if (c.requiresFile && !isFile(c.bin)) {
161
+ if (c.explicit && mode === 'rust') {
162
+ throw new Error(`ECC kernel required but ECC_KERNEL_PATH is not a file: ${c.bin}`);
163
+ }
164
+ continue;
165
+ }
166
+
167
+ const probe = probeKernel(c.bin);
168
+ if (probe.ok) return { enabled: true, bin: c.bin, ...probe };
169
+
170
+ errors.push(`${c.label}: ${probe.error}`);
171
+ if (c.explicit && mode === 'rust') break;
172
+ }
173
+
174
+ if (mode === 'rust') {
175
+ const detail = errors.length ? `\n\nHandshake errors:\n- ${errors.join('\n- ')}` : '';
176
+ throw new Error(
177
+ 'ECC kernel required but not found or incompatible.\n' +
178
+ 'Install or build a compatible ecc-kernel, then re-run, or set ECC_KERNEL=node to force JS fallback.' +
179
+ detail
180
+ );
78
181
  }
79
- return null;
182
+
183
+ // mode=auto: fall back to JS. If we saw a kernel but it was incompatible, keep the reason for doctor output.
184
+ return { enabled: false, bin: null, reason: errors.length ? errors[0] : null };
80
185
  }
81
186
 
82
187
  let _cached = null;
@@ -86,20 +191,25 @@ function getKernel() {
86
191
 
87
192
  const mode = getKernelMode();
88
193
  if (mode === 'node') {
89
- _cached = { mode, enabled: false, bin: null };
194
+ _cached = { mode, enabled: false, bin: null, reason: null };
90
195
  return _cached;
91
196
  }
92
197
 
93
- const bin = findKernelBinary();
94
- if (mode === 'rust' && !bin) {
95
- throw new Error(
96
- 'ECC kernel required but not found. Build it with:\n' +
97
- ' cargo build --release --manifest-path crates/ecc-kernel/Cargo.toml\n' +
98
- 'Then re-run, or set ECC_KERNEL=node to force JS fallback.'
99
- );
198
+ const selected = selectCompatibleKernel({ mode });
199
+ if (!selected.enabled) {
200
+ _cached = { mode, enabled: false, bin: null, reason: selected.reason };
201
+ return _cached;
100
202
  }
101
203
 
102
- _cached = { mode, enabled: !!bin, bin };
204
+ _cached = {
205
+ mode,
206
+ enabled: true,
207
+ bin: selected.bin,
208
+ protocol: selected.protocol,
209
+ kernelVersion: selected.kernelVersion,
210
+ commands: selected.commands,
211
+ reason: null
212
+ };
103
213
  return _cached;
104
214
  }
105
215
 
@@ -140,5 +250,6 @@ function runKernel(command, inputObj) {
140
250
 
141
251
  module.exports = {
142
252
  getKernel,
143
- runKernel
253
+ runKernel,
254
+ validateProtocolVersionOutput
144
255
  };
package/scripts/ecc.js CHANGED
@@ -82,7 +82,8 @@ Environment:
82
82
 
83
83
  function resolveProjectRoot(cwd) {
84
84
  try {
85
- return git.getRepoRoot(cwd) || cwd;
85
+ const info = git.getRepoInfo(cwd);
86
+ return info && info.repoRoot ? info.repoRoot : cwd;
86
87
  } catch (_err) {
87
88
  return cwd;
88
89
  }
@@ -181,8 +182,15 @@ function cmdDoctor() {
181
182
 
182
183
  try {
183
184
  const k = kernel.getKernel();
184
- const detail = k.enabled ? `rust (${k.bin})` : 'js fallback';
185
- checks.push({ name: 'kernel', ok: true, detail });
185
+ if (k.enabled) {
186
+ const proto = k.protocol !== undefined ? ` protocol=${k.protocol}` : '';
187
+ const ver = k.kernelVersion ? ` kernelVersion=${k.kernelVersion}` : '';
188
+ checks.push({ name: 'kernel', ok: true, detail: `rust (${k.bin})${proto}${ver}` });
189
+ } else if (k.reason) {
190
+ checks.push({ name: 'kernel', ok: false, detail: `BAD (${k.reason})` });
191
+ } else {
192
+ checks.push({ name: 'kernel', ok: true, detail: 'js fallback' });
193
+ }
186
194
  } catch (err) {
187
195
  const msg = err && err.message ? err.message : String(err);
188
196
  checks.push({ name: 'kernel', ok: false, detail: msg });
@@ -191,20 +199,24 @@ function cmdDoctor() {
191
199
  const gitVer = runCmd('git', ['--version']);
192
200
  checks.push({ name: 'git', ok: gitVer.ok, detail: gitVer.ok ? gitVer.stdout : gitVer.stderr });
193
201
 
194
- const repoRoot = gitVer.ok ? git.getRepoRoot(projectRoot) : null;
195
- checks.push({ name: 'repo', ok: !!repoRoot, detail: repoRoot ? repoRoot : 'not a git repo' });
196
-
197
- if (repoRoot) {
198
- let clean = false;
199
- let detail = '';
202
+ if (gitVer.ok) {
203
+ let info = null;
200
204
  try {
201
- clean = git.isClean(repoRoot);
202
- detail = clean ? 'clean' : 'dirty';
205
+ info = git.getRepoInfo(projectRoot);
206
+ checks.push({
207
+ name: 'repo',
208
+ ok: !!info.repoRoot,
209
+ detail: info.repoRoot ? info.repoRoot : 'not a git repo'
210
+ });
211
+ if (info.repoRoot) {
212
+ checks.push({ name: 'clean', ok: !!info.clean, detail: info.clean ? 'clean' : 'dirty' });
213
+ }
203
214
  } catch (err) {
204
- clean = false;
205
- detail = err && err.message ? err.message : String(err);
215
+ const msg = err && err.message ? err.message : String(err);
216
+ checks.push({ name: 'repo', ok: false, detail: msg });
206
217
  }
207
- checks.push({ name: 'clean', ok: clean, detail });
218
+ } else {
219
+ checks.push({ name: 'repo', ok: false, detail: 'git not available' });
208
220
  }
209
221
 
210
222
  const codex = runCmd('codex', ['--version']);
@@ -266,9 +278,9 @@ async function cmdPlan(args) {
266
278
  const runIdBase = requestedRunId || idMod.defaultRunId(intent);
267
279
  const runId = requestedRunId ? idMod.ensureUniqueRunId(projectRoot, requestedRunId) : idMod.ensureUniqueRunId(projectRoot, runIdBase);
268
280
 
269
- const repoRoot = git.getRepoRoot(projectRoot);
270
- const base = repoRoot
271
- ? { repoRoot, branch: git.getCurrentBranch(repoRoot), sha: git.getHeadSha(repoRoot) }
281
+ const info = git.getRepoInfo(projectRoot);
282
+ const base = info.repoRoot
283
+ ? { repoRoot: info.repoRoot, branch: info.branch, sha: info.sha }
272
284
  : { repoRoot: projectRoot, branch: '', sha: '' };
273
285
 
274
286
  const { run } = runMod.initRun({
@@ -381,7 +393,8 @@ function cmdVerify(args) {
381
393
  );
382
394
  }
383
395
 
384
- const repoRoot = git.getRepoRoot(projectRoot);
396
+ const info = git.getRepoInfo(projectRoot);
397
+ const repoRoot = info.repoRoot;
385
398
  if (!repoRoot) throw new Error('verify: requires a git repository');
386
399
 
387
400
  const wt = git.ensureWorktree({
@@ -427,9 +440,9 @@ async function cmdRun(args) {
427
440
  const runIdBase = requestedRunId || idMod.defaultRunId(intent);
428
441
  const runId = requestedRunId ? idMod.ensureUniqueRunId(projectRoot, requestedRunId) : idMod.ensureUniqueRunId(projectRoot, runIdBase);
429
442
 
430
- const repoRoot = git.getRepoRoot(projectRoot);
431
- const base = repoRoot
432
- ? { repoRoot, branch: git.getCurrentBranch(repoRoot), sha: git.getHeadSha(repoRoot) }
443
+ const info = git.getRepoInfo(projectRoot);
444
+ const base = info.repoRoot
445
+ ? { repoRoot: info.repoRoot, branch: info.branch, sha: info.sha }
433
446
  : { repoRoot: projectRoot, branch: '', sha: '' };
434
447
 
435
448
  const { run } = runMod.initRun({