canon-ai 3.0.0 → 3.1.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.1.0] — 2026-09-05
8
+
9
+ ### Changed
10
+
11
+ - **`canon update` installs stable releases from the npm registry.** It resolves the latest release tag on GitHub as before, confirms that version is published to npm, then installs `canon-ai@<version>` from the registry; a project-local install is pinned to the exact version. A tagged release that has not reached npm yet refuses with a retry note and the `--ref v<version>` GitHub fallback. `--channel main`, `--ref`, and `CANON_UPSTREAM_REPO` fork overrides still install from GitHub, since those builds are not on the registry.
12
+
13
+ ### Removed
14
+
15
+ - **No install scripts in the published package.** The `postinstall` hook installer only ever set up canon-ai's own contributor pre-commit hook and did nothing in adopter repos, but it triggered npm's install-scripts warning on every install. Contributors run `npm run hooks` once after cloning instead.
16
+
7
17
  ## [3.0.0] — 2026-09-04
8
18
 
9
19
  ### Changed
package/README.md CHANGED
@@ -98,7 +98,7 @@ npm install -g --install-links github:tstraub89/canon-ai
98
98
 
99
99
  > When installing from GitHub, `--install-links` is required because npm otherwise symlinks the global install to its git cache rather than copying the committed `dist/`, which leaves the `canon` bin pointing at a transient path and command-not-found after the install reports success. The flag packs+installs as a regular dependency, which is what you want for a stable global CLI.
100
100
 
101
- > **Updating.** Once installed, use `canon update` rather than re-running `npm install` by hand — it resolves the exact install this binary is running from, pins to the latest tagged release by default (or a labeled development commit via `--channel main` / `--ref <ref|sha>`), and records what it installed in `provenance.json` under `.canon` for future tooling to read.
101
+ > **Updating.** Once installed, use `canon update` rather than re-running `npm install` by hand — it resolves the exact install this binary is running from and installs the latest release from the npm registry by default. Use `--channel main` or `--ref <ref|sha>` for a GitHub development commit; each update records what it installed in `provenance.json` under `.canon` for future tooling to read. A project-local update pins the exact version (`--save-exact`); a global update has no manifest to pin.
102
102
 
103
103
  ### Set up in a repo
104
104
 
@@ -254,7 +254,7 @@ Run canon on repositories where you would be comfortable running your test suite
254
254
  | `canon run <id> --dry-run` | Print each planned phase and exit without spawning any agent |
255
255
  | `canon stop <id>` | Stop a detached canon run (SIGTERM → SIGKILL after 10s). Waits up to 30s — override via `CANON_STOP_WAIT_MS` — for the orchestrator's first heartbeat to verify the PID before signaling. |
256
256
  | `canon upgrade` | Sync vendored canon-owned files to match the installed version. It does not touch adopter-owned `AGENTS.md` or `CLAUDE.md`. Refuses to overwrite canon-owned targets that are locally modified, untracked but present, or whose git state cannot be verified unless `--force` is set. Use `--check` (or `--dry-run`) to preview, `--no-stage` to skip the auto-`git add`. |
257
- | `canon update` | Update the canon-ai package itself. Targets the install's own root (never the invocation directory) and pins to the latest final release by default; refuses rather than installing an unpinned branch. `--channel main` / `--ref <ref\|sha>` pin a labeled development commit instead. Writes `provenance.json` in `.canon` after a successful install — recorded for future tooling, nothing reads it yet. |
257
+ | `canon update` | Update the canon-ai package itself. Targets the install's own root (never the invocation directory) and installs the latest final release from npm by default. `--channel main` / `--ref <ref\|sha>` install a pinned GitHub development commit instead. Writes `provenance.json` in `.canon` after a successful install — recorded for future tooling, nothing reads it yet. |
258
258
 
259
259
  Full `canon task` subcommand reference is in `docs/pipeline-orchestrator.md`.
260
260
 
package/dist/cli/index.js CHANGED
@@ -1245,7 +1245,7 @@ function checkTemplates(cwd) {
1245
1245
  }
1246
1246
  function checkCanonVersion(cwd) {
1247
1247
  const versionPath = join(cwd, ".canon", "version");
1248
- const installedVersion = "3.0.0";
1248
+ const installedVersion = "3.1.0";
1249
1249
  if (!existsSync(versionPath)) {
1250
1250
  return { label: ".canon/version", status: "warn", detail: "missing \u2014 run `canon upgrade`" };
1251
1251
  }
@@ -1715,7 +1715,7 @@ function initCmd(_args) {
1715
1715
  }
1716
1716
  function writeCanonVersion(cwd) {
1717
1717
  const versionPath = join2(cwd, ".canon", "version");
1718
- const version = "3.0.0";
1718
+ const version = "3.1.0";
1719
1719
  mkdirSync(dirname2(versionPath), { recursive: true });
1720
1720
  writeFileSync(versionPath, version + "\n");
1721
1721
  }
@@ -2709,7 +2709,7 @@ function isInstalledSourcePath(sourcePath) {
2709
2709
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
2710
2710
  }
2711
2711
  function resolveCanonVersion(explicit) {
2712
- return explicit ?? "3.0.0" ?? "dev";
2712
+ return explicit ?? "3.1.0" ?? "dev";
2713
2713
  }
2714
2714
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
2715
2715
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
@@ -4628,7 +4628,6 @@ function defaultGitRunner(args2) {
4628
4628
  var CANON_AI_DEP_KEYS = ["dependencies", "devDependencies", "optionalDependencies"];
4629
4629
  var STRICT_FINAL_TAG_RE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
4630
4630
  var FULL_SHA_RE = /^[0-9a-f]{40}$/i;
4631
- var CANONICAL_NPX_SOURCE = "github:tstraub89/canon-ai";
4632
4631
  function layoutGate(installRoot) {
4633
4632
  if (!existsSync4(join4(installRoot, "package.json"))) {
4634
4633
  return {
@@ -4775,10 +4774,6 @@ function resolveNamedRef(slug, refspec, runGit2) {
4775
4774
  }
4776
4775
  return { ok: true, sha: [...distinctShas][0] };
4777
4776
  }
4778
- function resolveEffectiveSlug() {
4779
- const envSlug = process.env.CANON_UPSTREAM_REPO?.trim();
4780
- return envSlug ? envSlug : CANON_UPSTREAM_REPO;
4781
- }
4782
4777
  function currentPinFromManifest(manifest) {
4783
4778
  for (const key of CANON_AI_DEP_KEYS) {
4784
4779
  const block = manifest[key];
@@ -4787,11 +4782,50 @@ function currentPinFromManifest(manifest) {
4787
4782
  if (typeof value !== "string") continue;
4788
4783
  const match = /#([0-9a-f]{40})$/i.exec(value.trim());
4789
4784
  if (match) return match[1].toLowerCase();
4785
+ const versionMatch = /^[~^]?((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?)$/.exec(value.trim());
4786
+ if (versionMatch) return versionMatch[1];
4790
4787
  }
4791
4788
  return "unknown";
4792
4789
  }
4793
4790
  function bakedVersion() {
4794
- return "3.0.0";
4791
+ return "3.1.0";
4792
+ }
4793
+ function ownPackageName(pkgDir) {
4794
+ try {
4795
+ const manifest = JSON.parse(readFileSync3(join4(pkgDir, "package.json"), "utf8"));
4796
+ return typeof manifest.name === "string" && manifest.name ? manifest.name : "canon-ai";
4797
+ } catch {
4798
+ return "canon-ai";
4799
+ }
4800
+ }
4801
+ var NPM_VIEW_TIMEOUT_MS = 3e4;
4802
+ function defaultNpmViewRunner(args2, cwd) {
4803
+ const result = spawnSync8("npm", args2, { encoding: "utf8", timeout: NPM_VIEW_TIMEOUT_MS, ...cwd ? { cwd } : {} });
4804
+ if (result.error) return { status: null, stdout: "", stderr: result.error.message };
4805
+ return { status: result.status, stdout: result.stdout ?? "", stderr: (result.stderr ?? "").trim() };
4806
+ }
4807
+ function checkRegistryVersion(pkgName, version, runner, cwd, global = false) {
4808
+ const args2 = global ? ["view", "--global", `${pkgName}@${version}`, "version", "--json"] : ["view", `${pkgName}@${version}`, "version", "--json"];
4809
+ const result = runner(args2, cwd);
4810
+ let parsed;
4811
+ try {
4812
+ parsed = JSON.parse(result.stdout);
4813
+ } catch {
4814
+ parsed = void 0;
4815
+ }
4816
+ if (result.status === 0 && typeof parsed === "string" && parsed === version) return { ok: true };
4817
+ if (typeof parsed === "object" && parsed !== null && parsed.error?.code === "E404") {
4818
+ return {
4819
+ ok: false,
4820
+ absent: true,
4821
+ message: `canon update: ${pkgName}@${version} is not yet on the npm registry. This release exists on GitHub but has not reached npm yet \u2014 retry shortly, or install it directly with \`canon update --ref v${version}\`.`
4822
+ };
4823
+ }
4824
+ return {
4825
+ ok: false,
4826
+ absent: false,
4827
+ message: `canon update: could not verify ${pkgName}@${version} on the npm registry (${result.stderr || "no output"}). Aborting \u2014 no npm install run.`
4828
+ };
4795
4829
  }
4796
4830
  function formatAnnouncement(input) {
4797
4831
  const where = input.installType === "local" ? `local install at ${input.installRoot}` : "global install";
@@ -4819,6 +4853,7 @@ function updateCmd(args2, deps = {}) {
4819
4853
  console.error(message);
4820
4854
  });
4821
4855
  const pkgDir = deps.packageDir ?? packageDir3;
4856
+ const pkgName = ownPackageName(pkgDir);
4822
4857
  const cwd = deps.cwd ?? process.cwd();
4823
4858
  const spawn2 = deps.spawnRunner ?? ((cmd, cmdArgs, opts) => spawnSync8(cmd, cmdArgs, { stdio: "inherit", cwd: opts.cwd }));
4824
4859
  const runGit2 = deps.gitRunner ?? defaultGitRunner;
@@ -4834,7 +4869,7 @@ function updateCmd(args2, deps = {}) {
4834
4869
  if (detection.type === "npx") {
4835
4870
  stdout("\nRunning via npx \u2014 no persistent install to update.");
4836
4871
  stdout("To apply the latest templates, re-run from the latest source:\n");
4837
- stdout(` npx --install-links ${CANONICAL_NPX_SOURCE} upgrade
4872
+ stdout(` npx ${pkgName}@latest upgrade
4838
4873
  `);
4839
4874
  return;
4840
4875
  }
@@ -4855,7 +4890,10 @@ function updateCmd(args2, deps = {}) {
4855
4890
  manifest = dependency.manifest;
4856
4891
  dependencyBlock = dependency.dependencyBlock;
4857
4892
  }
4858
- const slug = resolveEffectiveSlug();
4893
+ const upstreamOverride = process.env.CANON_UPSTREAM_REPO?.trim();
4894
+ const slug = upstreamOverride ? upstreamOverride : CANON_UPSTREAM_REPO;
4895
+ const usesRegistry = options.channel !== "main" && !options.ref && !upstreamOverride;
4896
+ const npmView = deps.npmViewRunner ?? defaultNpmViewRunner;
4859
4897
  let channel;
4860
4898
  let resolvedSha;
4861
4899
  let stableVersion;
@@ -4888,7 +4926,15 @@ function updateCmd(args2, deps = {}) {
4888
4926
  resolvedSha = result.sha;
4889
4927
  stableVersion = result.version;
4890
4928
  }
4891
- const target = `github:${slug}#${resolvedSha}`;
4929
+ if (usesRegistry) {
4930
+ const registryCwd = detection.type === "local" ? detection.installRoot : cwd;
4931
+ const registryCheck = checkRegistryVersion(pkgName, stableVersion, npmView, registryCwd, detection.type !== "local");
4932
+ if (!registryCheck.ok) {
4933
+ stderr(registryCheck.message);
4934
+ return exit(1);
4935
+ }
4936
+ }
4937
+ const target = usesRegistry ? `${pkgName}@${stableVersion}` : `github:${slug}#${resolvedSha}`;
4892
4938
  const currentSha = manifest ? currentPinFromManifest(manifest) : "unknown";
4893
4939
  stdout(formatAnnouncement({
4894
4940
  installType: detection.type,
@@ -4909,7 +4955,8 @@ function updateCmd(args2, deps = {}) {
4909
4955
  if (detection.type === "local") {
4910
4956
  const installRoot = detection.installRoot;
4911
4957
  const saveFlag = dependencyBlock === "dependencies" ? "--save" : dependencyBlock === "optionalDependencies" ? "--save-optional" : "--save-dev";
4912
- const result = spawn2("npm", ["install", saveFlag, "--install-links", target], { cwd: installRoot });
4958
+ const installArgs = usesRegistry ? ["install", saveFlag, "--save-exact", target] : ["install", saveFlag, "--install-links", target];
4959
+ const result = spawn2("npm", installArgs, { cwd: installRoot });
4913
4960
  if (result.status !== 0) return exit(result.status ?? 1);
4914
4961
  try {
4915
4962
  writeProvenance(installRoot, provenance);
@@ -4918,7 +4965,8 @@ function updateCmd(args2, deps = {}) {
4918
4965
  stderr(`canon update: npm install succeeded, but provenance could not be recorded at ${join4(installRoot, ".canon", "provenance.json")}${detail}.`);
4919
4966
  }
4920
4967
  } else {
4921
- const result = spawn2("npm", ["install", "-g", "--install-links", target], { cwd });
4968
+ const installArgs = usesRegistry ? ["install", "-g", target] : ["install", "-g", "--install-links", target];
4969
+ const result = spawn2("npm", installArgs, { cwd });
4922
4970
  if (result.status !== 0) return exit(result.status ?? 1);
4923
4971
  if (existsSync4(join4(cwd, ".canon"))) {
4924
4972
  try {
@@ -5206,7 +5254,7 @@ function runUpgrade(cwd, pkgDir, options = {}) {
5206
5254
  cutoverWarnings.push(docsRefsCheckRel);
5207
5255
  }
5208
5256
  const versionPath = join5(cwd, ".canon", "version");
5209
- const newVersion = "3.0.0";
5257
+ const newVersion = "3.1.0";
5210
5258
  const currentVersion = existsSync5(versionPath) ? readFileSync4(versionPath, "utf8").trim() : null;
5211
5259
  if (currentVersion !== newVersion) {
5212
5260
  pending.push({ rel: ".canon/version", projectPath: versionPath, content: newVersion + "\n" });
@@ -5513,7 +5561,7 @@ Global:
5513
5561
  `);
5514
5562
  }
5515
5563
  function printVersion() {
5516
- console.log("3.0.0");
5564
+ console.log("3.1.0");
5517
5565
  }
5518
5566
  switch (command) {
5519
5567
  case "doctor":
@@ -4880,7 +4880,7 @@ function isInstalledSourcePath(sourcePath) {
4880
4880
  return sourcePath.includes("/node_modules/") || sourcePath.includes("\\node_modules\\") || sourcePath.includes("/_npx/") || sourcePath.includes("\\_npx\\");
4881
4881
  }
4882
4882
  function resolveCanonVersion(explicit) {
4883
- return explicit ?? "3.0.0" ?? "dev";
4883
+ return explicit ?? "3.1.0" ?? "dev";
4884
4884
  }
4885
4885
  function resolveOrchestratorCommit(repoRoot, upstreamCommit, runGitAt) {
4886
4886
  const ownToplevel = captureGitOutput(repoRoot, ["rev-parse", "--show-toplevel"], runGitAt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canon-ai",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "type": "module",
5
5
  "description": "A spec-first, multi-agent coding pipeline you drop into any repo. The law your agents work under.",
6
6
  "engines": {
@@ -12,13 +12,12 @@
12
12
  "files": [
13
13
  "dist/",
14
14
  "templates/",
15
- "scripts/install-git-hooks.mjs",
16
15
  "CHANGELOG.md"
17
16
  ],
18
17
  "scripts": {
19
18
  "build": "tsup",
20
19
  "postbuild": "node scripts/normalize-dist-paths.mjs",
21
- "postinstall": "node scripts/install-git-hooks.mjs",
20
+ "hooks": "node scripts/install-git-hooks.mjs",
22
21
  "test": "node --test --import ./tests/md-loader-register.mjs --import tsx tests/*.test.ts",
23
22
  "sync-templates": "tsx scripts/sync-canon-templates.mjs",
24
23
  "sync-templates:check": "tsx scripts/sync-canon-templates.mjs --check",
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * install-git-hooks.mjs
4
- *
5
- * Postinstall wrapper around `simple-git-hooks` that handles environments
6
- * where the bare CLI would fail.
7
- *
8
- * Skip cases (silent exit 0 — install proceeds, no hooks installed):
9
- *
10
- * 1. **No `.git/` at all.** Adopters consuming canon-ai as a dependency
11
- * (`npm install canon-ai` in their project, or `npm install -g
12
- * canon-ai`) have no `.git/` at this script's cwd (which is canon-ai's
13
- * install dir under `node_modules/canon-ai/`). Adopters should never
14
- * have canon-ai-dev's pre-commit hook installed.
15
- *
16
- * 2. **`simple-git-hooks` binary not installed.** Global install
17
- * (`npm install -g canon-ai`) strips devDependencies; the bare CLI
18
- * would emit "command not found" (exit 127) and tank the install.
19
- *
20
- * 3. **`.git` is a file (git worktree).** simple-git-hooks's `_setHook`
21
- * uses `path.join(projectRoot, '.git', 'hooks')` unconditionally
22
- * (see `node_modules/simple-git-hooks/simple-git-hooks.js:177-198`)
23
- * and fails with ENOTDIR on the worktree's `.git` file. Skip
24
- * silently here — running the CLI would tank the install.
25
- *
26
- * Known limitation of skip case 3: a developer who only ever `npm install`s
27
- * in a worktree (and never in the main checkout) ends up with a clean
28
- * install but no pre-commit hook in the shared gitdir. Mitigation: canon
29
- * dev workflow includes running `npm install` in the main checkout at
30
- * least once; once the hook is in `<main>/.git/hooks/pre-commit`, every
31
- * worktree inherits it via git's hook resolution. A more aggressive fix
32
- * (writing the worktree's hook to the shared gitdir directly) was rejected
33
- * because it would break commits in main pre-merge of any task that adds
34
- * a new hook command — `<main>/.git/hooks/pre-commit` would call a script
35
- * that doesn't exist in main's `package.json` yet.
36
- *
37
- * Lives in `scripts/` (not in `CANON_OWNED`) — ships in the npm tarball
38
- * via the `files` glob but isn't installed into adopter repos.
39
- */
40
-
41
- import { existsSync, statSync } from 'node:fs';
42
- import { spawnSync } from 'node:child_process';
43
- import path from 'node:path';
44
-
45
- const cwd = process.cwd();
46
- const gitPath = path.join(cwd, '.git');
47
-
48
- // Skip case 1: no `.git/` at all — adopter / global install.
49
- if (!existsSync(gitPath)) process.exit(0);
50
-
51
- // Skip case 2: simple-git-hooks not installed (devDeps stripped).
52
- const binPath = path.join(cwd, 'node_modules', '.bin', 'simple-git-hooks');
53
- if (!existsSync(binPath)) process.exit(0);
54
-
55
- // Skip case 3: worktree (`.git` is a file). simple-git-hooks would
56
- // fail with ENOTDIR. See the docstring above for the known limitation.
57
- if (!statSync(gitPath).isDirectory()) process.exit(0);
58
-
59
- // Main checkout: delegate to simple-git-hooks CLI.
60
- // `status === null` when the child couldn't be spawned or was killed by
61
- // a signal — treat as failure so npm doesn't silently report success on
62
- // a broken hook install. (Previously `?? 0` masked this — Codex P3 on
63
- // canon-docs-dedup PR #102.)
64
- const result = spawnSync(binPath, [], { cwd, stdio: 'inherit' });
65
- process.exit(result.status === null ? 1 : result.status);