nccgs 3.0.0-rc.2 → 3.0.0-rc.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +14 -4
  3. package/bin/nccgs.mjs +10 -1
  4. package/cli/doctor.mjs +7 -2
  5. package/cli/install.mjs +37 -2
  6. package/cli/run.mjs +1 -1
  7. package/cli/upgrade.mjs +74 -0
  8. package/package.json +2 -1
  9. package/runtime/core/update-check.mjs +117 -0
  10. package/runtime/hooks/session-start.mjs +10 -1
  11. package/runtime/task/ledger.mjs +7 -2
  12. package/runtime/unity/unity.mjs +45 -17
  13. package/template/CLAUDE.md +1 -1
  14. package/template/protocols/unity-cli-notes.md +22 -4
  15. package/template/skills/adopt/SKILL.md +63 -0
  16. package/toolchain.json +11 -0
  17. package/unity-skills/build-gtk/SECURITY.md +16 -0
  18. package/unity-skills/build-gtk/SKILL.md +249 -215
  19. package/unity-skills/build-gtk/references/graph-api-gaps.md +96 -0
  20. package/unity-skills/build-gtk/references/runtime-and-visualization.md +23 -28
  21. package/unity-skills/build-gtk/references/state-machine-api.md +34 -50
  22. package/unity-skills/craft-game-floors/references/game-shell.md +1 -1
  23. package/unity-skills/create-tile-palette-or-rule-tiles/SKILL.md +85 -2
  24. package/unity-skills/create-tile-palette-or-rule-tiles/references/hexagonalruletile.md +3 -3
  25. package/unity-skills/create-tile-palette-or-rule-tiles/references/palette-create.md +9 -9
  26. package/unity-skills/create-tile-palette-or-rule-tiles/references/ruletile-createempty.md +8 -6
  27. package/unity-skills/create-tile-palette-or-rule-tiles/references/ruletile-createfromsegment.md +20 -13
  28. package/unity-skills/create-tile-palette-or-rule-tiles/references/ruletile.md +11 -6
  29. package/unity-skills/create-tile-palette-or-rule-tiles/scripts/TilemapRuleTileCreateFromSegment.cs +1 -1
  30. package/unity-skills/fix-3d-physics-collisions/SECURITY.md +17 -0
  31. package/unity-skills/fix-3d-physics-collisions/SKILL.md +199 -0
  32. package/unity-skills/fix-3d-physics-collisions/references/collision-checklist.md +80 -0
  33. package/unity-skills/fix-3d-physics-collisions/references/collision-debugger.md +18 -0
  34. package/unity-skills/fix-3d-physics-collisions/references/other-symptoms.md +29 -0
  35. package/unity-skills/fix-3d-physics-collisions/references/raycast-checklist.md +8 -0
  36. package/unity-skills/fix-3d-physics-collisions/references/trigger-checklist.md +9 -0
  37. package/unity-skills/fix-3d-physics-collisions/scripts/CollisionDebugger.cs +561 -0
  38. package/unity-skills/implement-in-app-purchases/SKILL.md +106 -98
  39. package/unity-skills/implement-in-app-purchases/references/api-notes.md +56 -510
  40. package/unity-skills/implement-in-app-purchases/references/codeless-catalog.md +35 -169
  41. package/unity-skills/implement-in-app-purchases/references/convert-adapty.md +2 -0
  42. package/unity-skills/implement-in-app-purchases/references/convert-revenuecat.md +2 -0
  43. package/unity-skills/implement-in-app-purchases/references/migration-v4-to-v5.md +31 -189
  44. package/unity-skills/implement-in-app-purchases/references/path-add-iap-to-new-project.md +8 -9
  45. package/unity-skills/implement-in-app-purchases/references/path-convert-native-google-billing.md +14 -68
  46. package/unity-skills/implement-in-app-purchases/references/path-convert-native-storekit.md +22 -111
  47. package/unity-skills/implement-in-app-purchases/references/path-implement-iap-d2c.md +77 -539
  48. package/unity-skills/implement-in-app-purchases/references/platform-notes.md +37 -186
  49. package/unity-skills/implement-in-app-purchases/references/pre-check.md +2 -0
  50. package/unity-skills/initialize-ai-navigation/SKILL.md +101 -23
  51. package/unity-skills/initialize-ai-navigation/references/navigation-system.md +5 -6
  52. package/unity-skills/localize-unity-project/SKILL.md +242 -0
  53. package/unity-skills/localize-unity-project/references/api-notes.md +15 -0
  54. package/unity-skills/localize-unity-project/references/cjk-fonts.md +83 -0
  55. package/unity-skills/localize-unity-project/references/docs-map.md +42 -0
  56. package/unity-skills/localize-unity-project/references/table-completeness-check.md +47 -0
  57. package/unity-skills/localize-unity-project/references/translation-strategy.md +41 -0
  58. package/unity-skills/localize-unity-project/references/troubleshooting.md +12 -0
  59. package/unity-skills/manage-cloud-build/SKILL.md +83 -0
  60. package/unity-skills/manage-cloud-build/references/analyze-build-failure.md +141 -0
  61. package/unity-skills/manage-cloud-build/references/create-build-target.md +196 -0
  62. package/unity-skills/manage-cloud-build/references/example-report.md +27 -0
  63. package/unity-skills/manage-cloud-build/references/failure-knowledge.md +126 -0
  64. package/unity-skills/manage-cloud-build/references/source-control.md +66 -0
  65. package/unity-skills/manage-cloud-build/references/topic-map.md +44 -0
  66. package/unity-skills/manifest.json +148 -71
  67. package/unity-skills/new-unity-project/SKILL.md +2 -2
  68. package/unity-skills/optimize-text-mesh-pro/SKILL.md +103 -46
  69. package/unity-skills/setup-vivox-voice-chat/SECURITY.md +16 -0
  70. package/unity-skills/setup-vivox-voice-chat/SKILL.md +92 -11
  71. package/unity-skills/setup-vivox-voice-chat/references/voice-channels.md +3 -1
  72. package/unity-skills/sprite-segment-3x3grid/SKILL.md +94 -18
  73. package/unity-skills/sprite-segment-3x3grid/scripts/SpriteGridAnalysis.cs +3 -3
  74. package/unity-skills/unity-cli/CHANGELOG.md +9 -0
  75. package/unity-skills/unity-cli/SKILL.md +38 -16
  76. package/unity-skills/unity-cli/references/cloud-automation.md +84 -1
  77. package/unity-skills/unity-cli/references/diagnostics-maintenance.md +7 -9
  78. package/unity-skills/unity-cli/references/editors-install.md +14 -0
  79. package/unity-skills/unity-cli/references/integration-advanced.md +5 -5
  80. package/unity-skills/unity-cli/references/playmode-verification-loop.md +15 -15
  81. package/unity-skills/unity-cli/references/projects-templates.md +7 -6
  82. package/unity-skills/unity-package-management/SKILL.md +1 -1
  83. package/unity-skills/unity-pipeline/SKILL.md +88 -98
  84. package/cli/update-check.mjs +0 -66
  85. package/unity-skills/build-gtk/references/graph-api.md +0 -324
  86. package/unity-skills/build-gtk/references/pitfalls.md +0 -86
  87. package/unity-skills/implement-in-app-purchases/README.md +0 -233
  88. package/unity-skills/localization/SKILL.md +0 -347
  89. package/unity-skills/localization/references/api-notes.md +0 -80
  90. package/unity-skills/optimize-web/resources/WebOptimizer.cs +0 -21
  91. package/unity-skills/optimize-web/resources/toktx-examples.sh +0 -11
  92. package/unity-skills/physics-3d-collision/SKILL.md +0 -442
  93. package/unity-skills/physics-3d-collision/references/troubleshooting.md +0 -8
  94. package/unity-skills/physics-3d-collision/resources/CollisionDebugger.cs +0 -33
  95. package/unity-skills/setup-vivox-voice-chat/evals/.env.example +0 -6
  96. package/unity-skills/setup-vivox-voice-chat/evals/README.md +0 -101
  97. package/unity-skills/setup-vivox-voice-chat/evals/promptfooconfig.yaml +0 -32
  98. package/unity-skills/setup-vivox-voice-chat/evals/tests/init-and-login.yaml +0 -76
  99. package/unity-skills/setup-vivox-voice-chat/evals/tests/text-chat.yaml +0 -65
  100. package/unity-skills/setup-vivox-voice-chat/evals/tests/voice-channels.yaml +0 -74
  101. /package/unity-skills/{localization/resources → localize-unity-project/scripts}/L10nBatchProcessor.cs +0 -0
  102. /package/unity-skills/{localization/resources → localize-unity-project/scripts}/LocalizedFontAsset.cs +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0-rc.4 — 2026-11-07
4
+
5
+ - Pipeline 0.10.0-exp.1 (3 Nov) renamed every command parameter to kebab-case (`--async-tests`, `--since-session`, `--enabled`, `--output-path`) and rejects the 0.9 spellings. The runtime now reads parameter names from `unity command --detail full` (cache `pipeline-commands-v2-<version>.json`) and maps its own names onto them, so `task verify` works on 0.9 and 0.10 without a flag. Verified against the real 0.10 catalog from 6000.3.17f1 (the catalog is a test fixture; the fake CLI has a strict 0.10 mode).
6
+ - Unity CLI beta.17 prints Pipeline results bare (`--format json` = the old `data.result`, the listing as `{ commands }`); the runtime reads both envelope shapes (`unwrap()`), so results, errors and the catalog work on beta.14 and beta.17. Verified live on 2026-11-07: TestNCCGS1 task PT-010 closed 5/5 on 6000.3.17f1 + beta.17 + 0.10.0-exp.1 (EditMode 43/43, PlayMode 19/19, console, timed observe, 45 s playtest).
7
+ - `unity-cli-notes.md`: the 0.9 → 0.10 parameter table, CLI beta.16/17 facts (streamed `unity command`, new exit codes, `pipeline editors`, `editor dialog`, `unity open` wanting `--editor-version`, `pipeline install --version`).
8
+ - Unity Skills re-synced to upstream `60f95e6` (6 Nov): 36 skills; `localization` → `localize-unity-project`, `physics-3d-collision` → `fix-3d-physics-collisions`, new `manage-cloud-build`; `unity-cli` and `unity-pipeline` updated to the kebab-case parameters.
9
+ - `nccgs upgrade --project . [--dry-run] [--skip cli,pipeline,files]`: managed files, then `unity self-update --target` and `unity pipeline install --version … --force` to the versions in `toolchain.json` (the ones this release was verified with), then `doctor`; never downgrades; every step reported, `nccgs.upgrade` event logged.
10
+ - Update check runs by itself: the SessionStart hook refreshes `.nccgs/cache/update-check.json` in a detached process when it is older than `updates.check` (daily default) and shows the cached notices with the `upgrade` hint. Sources: npm dist-tags (rc tag for prereleases), Unity Skills HEAD, `unity self-update --check`, `unity pipeline list-versions`. A CLI or Pipeline newer than the verified one is reported as a caution, not an upgrade hint. `doctor` gained a `toolchain` check.
11
+ - Fix: a project's own `CLAUDE.md` is never replaced by `update`/`--force` and never listed as an override; only a `CLAUDE.md` NCCGS wrote itself is refreshed, and only while unedited (`authored` in install-state).
12
+ - `nccgs update` now retires managed files the new version no longer ships (unchanged ones are deleted, edited ones stay and are listed as overrides), so renamed skills do not pile up in `.claude/skills/`.
13
+
14
+ ## 3.0.0-rc.3 — 2026-11-06
15
+
16
+ - `/adopt` skill for a project that already has code, scenes, tests and design documents: inventory (versions, asmdefs, tests, scenes, the team's own documents, existing Claude rules), `brief.md` and `systems.md` written from the project as it is, decisions recorded in `state.md`, and one `ADOPT` task (existing EditMode tests, console ground truth, Play Mode + one screenshot) that proves the loop runs there. Nothing in the game changes without the user agreeing to the item. `/unity-foundation` stays the empty-project version.
17
+ - Coming from NCCGS 2.x: `install`/`update` now recognise a 2.x install state and refuse with the exact steps (`npx nccgs@<2.x> uninstall`, then `npx nccgs@rc install`, then `/adopt`) instead of stacking two frameworks' hooks and skills. 2.x task records left in `.nccgs/tasks/` (schema 1/2) are kept as history and never listed, so they cannot block a session, a close or an uninstall.
18
+
3
19
  ## 3.0.0-rc.2 — 2026-11-05
4
20
 
5
21
  - Feedback with consent and transport: `feedback.consent` in project.json is `ask` until the person decides (`auto` / `manual` / `off`); the first session where an issue shows up asks once through Claude. With `auto`, a bundle is POSTed to `feedback.endpoint` when diagnostics cross a threshold (any task crash or hook error, 3 failed Unity calls, 5 failed verifications), when a task closes partial/blocked/failed, or every `periodicDays`; with `manual`, NCCGS suggests `/feedback` at most once per 6 h. Every send is logged in `.nccgs/feedback/sent.jsonl`; a copy of each bundle stays local. `nccgs feedback --send | --consent <level>`. Receiver: `tools/feedback-worker` (Cloudflare Worker → GitHub issue).
package/README.md CHANGED
@@ -7,7 +7,7 @@ brief → options for the user → contract → edit → run (tests, Play Mode,
7
7
  → look → fix or ask → independent review → close → one-page report with real token cost
8
8
  ```
9
9
 
10
- **3.0.0-rc.2.** Built and verified on Unity 6000.3 and 6000.6 with Unity CLI 1.0.0-beta.14 and com.unity.pipeline 0.9, Claude Code 2.1.3xx, Windows. The sample game (a 2D endless runner) went from an empty scene to a vertical slice in four tasks with this loop; its reports are the reference for what a closed task looks like.
10
+ **3.0.0-rc.4.** Built and verified on Unity 6000.3 and 6000.6 with Unity CLI 1.0.0-beta.14–17 and com.unity.pipeline 0.9 and 0.10, Claude Code 2.1.3xx, Windows. The sample game (a 2D endless runner) went from an empty scene to a vertical slice in four tasks with this loop; its reports are the reference for what a closed task looks like.
11
11
 
12
12
  ## Requirements
13
13
 
@@ -22,7 +22,7 @@ npx nccgs doctor --project D:\Games\MyGame
22
22
  npx nccgs run --project D:\Games\MyGame
23
23
  ```
24
24
 
25
- `install` adds `CLAUDE.md` (kept if you have one), `.claude/skills/` (11 NCCGS skills + 35 official Unity Skills), `.claude/agents/nccgs-*.md`, `.claude/nccgs/` (runtime, protocols, Ponytail) and `.nccgs/` (project config, tasks, evidence, reports). It merges hooks into `.claude/settings.json` without touching your other settings. `update` replaces managed files you did not change and records the ones you did; `uninstall` removes managed files, keeps `.nccgs/` and backs up what it removes. `run` launches Claude Code with the permission mode and MCP policy NCCGS expects (`acceptEdits` by default; `--autonomy full` for `bypassPermissions`).
25
+ `install` adds `CLAUDE.md` (kept if you have one), `.claude/skills/` (12 NCCGS skills + 36 official Unity Skills), `.claude/agents/nccgs-*.md`, `.claude/nccgs/` (runtime, protocols, Ponytail) and `.nccgs/` (project config, tasks, evidence, reports). It merges hooks into `.claude/settings.json` without touching your other settings. `update` replaces managed files you did not change and records the ones you did; `uninstall` removes managed files, keeps `.nccgs/` and backs up what it removes. On a project that still carries NCCGS 2.x, `install` stops and prints the path (`npx nccgs@2.1.1 uninstall --project .`, then `install`, then `/adopt`); 2.x records under `.nccgs/` stay as history. `run` launches Claude Code with the permission mode and MCP policy NCCGS expects (`acceptEdits` by default; `--autonomy full` for `bypassPermissions`).
26
26
 
27
27
  ## Daily use
28
28
 
@@ -35,6 +35,7 @@ Open the project in the Editor (screenshots and live tests need it; without it t
35
35
  | `/status` | where are we | `.nccgs/project.html` and ten lines of status |
36
36
  | `/brainstorm` → `/map-systems` → `/vertical-slice` | a new game or a multi-task feature | `brief.md`, `systems.md`, `slice.json`, then one `/work` per planned task |
37
37
  | `/unity-foundation` | a fresh project, or tests cannot run | asmdefs, test assemblies, input, pipeline as a measured task |
38
+ | `/adopt` | a project that already has code, scenes, tests, documents | `brief.md` + `systems.md` from what exists, `foundation.md`, one `ADOPT` task proving the loop runs here |
38
39
  | `/playtest` | does it feel right, tuning, difficulty | telemetry artifact, the playtester's judgement, your decision |
39
40
  | `/art-bible`, `/asset-spec` | before and after art | visual rules; import settings checked against them |
40
41
  | `/feedback` | something went wrong, or after a few days of use | a local bundle (`.nccgs/feedback/`) of what ran, failed and was blocked, to send for review |
@@ -62,7 +63,7 @@ npx nccgs mode use mine --project .
62
63
 
63
64
  ## Unity facts the runtime relies on
64
65
 
65
- Verified live, kept in `.claude/nccgs/protocols/unity-cli-notes.md` and `docs/EVIDENCE-VERIFICATION.md`: Pipeline command names are discovered at runtime, never hard-coded; `--editor-version` is always pinned from `ProjectVersion.txt`; entering Play Mode drops the Pipeline server for ~3 s on 6.3 and the runtime waits; PlayMode tests run async and are polled; `simulate_key` delivers key-down and key-up in one input update, so input is read through `InputAction`s; every Pipeline round trip costs ~0.9 s, so anything that must happen within a second of a key press is scheduled inside the Editor by one `script eval`; Run In Background is switched on for every run so an unfocused Editor does not freeze.
66
+ Verified live, kept in `.claude/nccgs/protocols/unity-cli-notes.md` and `docs/EVIDENCE-VERIFICATION.md`: Pipeline command names and parameter names are discovered at runtime, never hard-coded (0.9 snake_case and 0.10 kebab-case both work); `--editor-version` is always pinned from `ProjectVersion.txt`; entering Play Mode drops the Pipeline server for ~3 s on 6.3 and the runtime waits; PlayMode tests run async and are polled; `simulate_key` delivers key-down and key-up in one input update, so input is read through `InputAction`s; every Pipeline round trip costs ~0.9 s, so anything that must happen within a second of a key press is scheduled inside the Editor by one `script eval`; Run In Background is switched on for every run so an unfocused Editor does not freeze.
66
67
 
67
68
  ## Layout
68
69
 
@@ -80,7 +81,16 @@ Failures are recorded locally in `.nccgs/diagnostics.jsonl` (task CLI errors, fa
80
81
 
81
82
  ## Updating
82
83
 
83
- `nccgs doctor --check-updates` reports newer NCCGS, Unity Skills, Unity CLI and Pipeline versions; nothing is applied on its own. `nccgs update` refreshes managed files; `--skills-only` refreshes just the vendored Unity Skills (pinned to an upstream commit in `unity-skills/manifest.json`).
84
+ Every new Claude Code session checks for updates in the background (`updates.check` in `.nccgs/project.json`: `daily` by default, `weekly`, or `never`) and the next session says what is newer: NCCGS on npm, the Unity Skills upstream, the Unity CLI and `com.unity.pipeline`. Nothing is applied on its own. `nccgs doctor --check-updates` runs the same check now, and `doctor` warns when the project's CLI or Pipeline differs from the versions this release was verified with (`toolchain.json`).
85
+
86
+ To move a project to a new NCCGS release and the toolchain it was verified with:
87
+
88
+ ```
89
+ npx nccgs@rc upgrade --project . --dry-run
90
+ npx nccgs@rc upgrade --project .
91
+ ```
92
+
93
+ `upgrade` runs, in order: the managed files (`nccgs update`: runtime, skills, agents, hooks; edited files are kept as overrides, files the new version no longer ships are retired), `unity self-update --target <verified CLI>`, `unity pipeline install --version <verified Pipeline> --force`, then `doctor`. It never downgrades: a newer CLI or Pipeline than the verified one is kept and reported. `--skip cli,pipeline,files` leaves a step out; with the Editor open, Unity resolves the new package on focus (restart the Editor if the Pipeline server does not come back). `nccgs update` alone refreshes managed files; `--skills-only` just the vendored Unity Skills.
84
94
 
85
95
  ## Licenses
86
96
 
package/bin/nccgs.mjs CHANGED
@@ -13,6 +13,7 @@ import { allModes, createMode, setModeAgent, useMode, BUILTIN } from "../runtime
13
13
  import { renderProject } from "../runtime/task/project.mjs";
14
14
  import { release } from "../cli/release.mjs";
15
15
  import { feedback } from "../cli/feedback.mjs";
16
+ import { upgrade } from "../cli/upgrade.mjs";
16
17
 
17
18
  const argv = process.argv.slice(2);
18
19
  const command = argv[0];
@@ -32,7 +33,8 @@ const die = m => { process.stderr.write(`nccgs ${command}: ${m}\n`); process.exi
32
33
  const HELP = `nccgs ${PKG.version} - Unity-first evidence loop for Claude Code
33
34
 
34
35
  nccgs install --project DIR [--mode BALANCE] [--rigor production] [--autonomy guarded] [--dry-run]
35
- nccgs update --project DIR [--dry-run] [--force] [--skills-only]
36
+ nccgs update --project DIR [--dry-run] [--force] [--skills-only] managed files only
37
+ nccgs upgrade --project DIR [--dry-run] [--skip cli,pipeline,files] managed files + Unity CLI + com.unity.pipeline to the verified versions, then doctor
36
38
  nccgs doctor --project DIR [--check-models] [--check-updates]
37
39
  nccgs run --project DIR [--mode M] [--autonomy guarded|full] [--dry-run] [-- claude args]
38
40
  nccgs uninstall --project DIR [--dry-run] [--force]
@@ -49,6 +51,13 @@ try {
49
51
  case "version": case "--version": case "-v": process.stdout.write(PKG.version + "\n"); break;
50
52
  case "install": out(install(root, { dryRun: !!opt["dry-run"], force: !!opt.force, mode: str(opt.mode), autonomy: str(opt.autonomy), rigor: str(opt.rigor) })); break;
51
53
  case "update": out(install(root, { update: true, dryRun: !!opt["dry-run"], force: !!opt.force, skillsOnly: !!opt["skills-only"] })); break;
54
+ case "upgrade": {
55
+ const r = await upgrade(root, { dryRun: !!opt["dry-run"], force: !!opt.force, skip: String(str(opt.skip) ?? "").split(",").map(s => s.trim()).filter(Boolean), runDoctor: !opt["no-doctor"] });
56
+ for (const s of r.steps) process.stderr.write(`${s.action.toUpperCase().padEnd(8)} ${s.id.padEnd(10)} ${s.from ?? "-"} -> ${s.to ?? "-"}${s.detail ? ` ${typeof s.detail === "string" ? s.detail : JSON.stringify(s.detail)}` : ""}\n`);
57
+ if (r.health) process.stderr.write(`doctor: ${r.health.pass} pass · ${r.health.warn} warn · ${r.health.fail} fail${r.health.failed.length ? ` (${r.health.failed.join("; ")})` : ""}\n`);
58
+ if (opt.json || opt["dry-run"]) out(r);
59
+ process.exit(r.ok ? 0 : 1);
60
+ }
52
61
  case "doctor": {
53
62
  const r = await doctor(root, { checkModels: !!opt["check-models"], checkUpdatesNow: !!opt["check-updates"] });
54
63
  for (const c of r.checks) process.stderr.write(`${c.status.toUpperCase().padEnd(4)} ${c.id.padEnd(20)} ${c.message}\n`);
package/cli/doctor.mjs CHANGED
@@ -9,7 +9,7 @@ import { at, isUnityProject, loadConfig, editorVersion, pipelineVersion, version
9
9
  import { allModes, BUILTIN, validateAgentSetting } from "../runtime/core/modes.mjs";
10
10
  import * as unity from "../runtime/unity/unity.mjs";
11
11
  import { PKG } from "./install.mjs";
12
- import { checkUpdates } from "./update-check.mjs";
12
+ import { checkUpdates } from "../runtime/core/update-check.mjs";
13
13
  import { available as jobObjectAvailable } from "../runtime/core/job-object.mjs";
14
14
 
15
15
  const MIN_CLAUDE = [2, 1, 295];
@@ -37,6 +37,11 @@ export async function doctor(root, { checkModels = false, checkUpdatesNow = fals
37
37
  for (const [rel, sha] of Object.entries(state.files ?? {})) { const f = path.join(root, rel); if (!exists(f)) drift.push(`missing ${rel}`); else if (sha256File(f) !== sha) drift.push(`changed ${rel}`); }
38
38
  add("inventory", drift.length ? "warn" : "pass", drift.length ? `${drift.length} managed file(s) differ: ${drift.slice(0, 5).join(", ")}${drift.length > 5 ? "…" : ""}` : "managed files match the inventory", { drift, overrides: state.overrides });
39
39
  }
40
+ const toolchain = readJson(path.join(root, ".claude", "nccgs", "toolchain.json"), null) ?? readJson(path.join(TEMPLATE, "..", "toolchain.json"), {});
41
+ const tcDiff = [];
42
+ if (cliVersion && toolchain.unityCli && cliVersion !== toolchain.unityCli) tcDiff.push(`Unity CLI ${cliVersion} (verified ${toolchain.unityCli})`);
43
+ if (pipelineVersion(root) && toolchain.pipeline && pipelineVersion(root) !== toolchain.pipeline) tcDiff.push(`com.unity.pipeline ${pipelineVersion(root)} (verified ${toolchain.pipeline})`);
44
+ add("toolchain", tcDiff.length ? "warn" : "pass", tcDiff.length ? `${tcDiff.join(", ")}: run \`nccgs upgrade\` to move to the verified versions` : `matches the verified toolchain (CLI ${toolchain.unityCli ?? "?"}, Pipeline ${toolchain.pipeline ?? "?"}, ${toolchain.verifiedAt ?? "?"})`);
40
45
  const settings = exists(at(root, "settings")) ? readJson(at(root, "settings")) : {};
41
46
  const hookCount = Object.values(settings.hooks ?? {}).flat().filter(e => (e.hooks ?? []).some(h => (h.args ?? []).some(a => String(a).includes("/.claude/nccgs/runtime/hooks/")))).length;
42
47
  const expectedHooks = Object.values(readJsonFile(path2.join(TEMPLATE, "settings.fragment.json")).hooks).flat().length;
@@ -75,7 +80,7 @@ export async function doctor(root, { checkModels = false, checkUpdatesNow = fals
75
80
  }
76
81
  }
77
82
  if (checkUpdatesNow) {
78
- const u = await checkUpdates(root, { force: true, cliVersion });
83
+ const u = await checkUpdates(root, { force: true });
79
84
  add("updates", u.notices.length ? "warn" : "pass", u.notices.length ? u.notices.map(n => `${n.component} ${n.current} -> ${n.latest}`).join("; ") : "everything current (or unreachable)", u);
80
85
  }
81
86
  return { root, version: PKG.version, checks, summary: { pass: checks.filter(c => c.status === "pass").length, warn: checks.filter(c => c.status === "warn").length, fail: checks.filter(c => c.status === "fail").length } };
package/cli/install.mjs CHANGED
@@ -26,6 +26,7 @@ function managedFiles() {
26
26
  add(path.join(TEMPLATE, "ponytail"), ".claude/nccgs/ponytail");
27
27
  add(path.join(PKG_ROOT, "unity-skills"), ".claude/skills", rel => rel !== "manifest.json");
28
28
  list.push({ from: path.join(PKG_ROOT, "unity-skills", "manifest.json"), to: ".claude/nccgs/unity-skills-manifest.json" });
29
+ list.push({ from: path.join(PKG_ROOT, "toolchain.json"), to: ".claude/nccgs/toolchain.json" });
29
30
  list.push({ from: path.join(TEMPLATE, "CLAUDE.md"), to: "CLAUDE.md", keepIfExists: true });
30
31
  return list;
31
32
  }
@@ -34,10 +35,12 @@ export function install(root, { update = false, force = false, dryRun = false, m
34
35
  if (!isUnityProject(root)) throw new Error(`${root} is not a Unity project (needs Assets/ and ProjectSettings/ProjectVersion.txt)`);
35
36
  const stateFile = at(root, "installState");
36
37
  const previous = exists(stateFile) ? readJson(stateFile) : null;
38
+ if (previous && isLegacy(previous)) throw new Error(legacyMessage(previous));
37
39
  if (update && !previous) throw new Error("NCCGS is not installed here; use `install`");
38
40
  if (!update && previous && !force) throw new Error(`NCCGS ${previous.version} is already installed; use \`update\``);
39
41
  const actions = []; // { action: add|replace|keep|override|skip, path }
40
42
  const inventory = {};
43
+ const authored = {}; // keepIfExists files we wrote ourselves (sha at write time)
41
44
  for (const item of managedFiles()) {
42
45
  if (skillsOnly && !item.to.startsWith(".claude/skills/") && !item.to.endsWith("unity-skills-manifest.json")) continue;
43
46
  const target = path.join(root, item.to);
@@ -48,17 +51,30 @@ export function install(root, { update = false, force = false, dryRun = false, m
48
51
  let action;
49
52
  if (!existsNow) action = "add";
50
53
  else if (current === incoming) action = "keep";
51
- else if (item.keepIfExists && !recorded) action = "keep-user"; // e.g. the user's own CLAUDE.md
54
+ // keepIfExists (the project's CLAUDE.md): ours only if we wrote it and nobody edited it since; the user's own
55
+ // file is kept as theirs on every install/update, never counted as an override, never replaced by --force.
56
+ else if (item.keepIfExists) action = previous?.authored?.[item.to] && current === previous.authored[item.to] ? "replace" : "keep-user";
52
57
  else if (recorded && current === recorded) action = "replace"; // unchanged managed file, new version
53
58
  else if (force) action = "replace-forced";
54
59
  else action = "override"; // user changed it: keep theirs, record it
55
60
  actions.push({ action, path: item.to });
56
61
  inventory[item.to] = ["add", "replace", "replace-forced", "keep"].includes(action) ? incoming : (current ?? incoming);
62
+ if (item.keepIfExists && ["add", "replace", "keep"].includes(action)) authored[item.to] = incoming;
57
63
  if (!dryRun && ["add", "replace", "replace-forced"].includes(action)) {
58
64
  fs.mkdirSync(path.dirname(target), { recursive: true });
59
65
  fs.copyFileSync(item.from, target);
60
66
  }
61
67
  }
68
+ // Managed files the previous version shipped and this one does not (a renamed skill, a dropped protocol):
69
+ // removed when still unchanged, kept and listed as an override when the user edited them.
70
+ for (const [rel, sha] of Object.entries(previous?.files ?? {})) {
71
+ if (rel in inventory) continue;
72
+ if (skillsOnly && !rel.startsWith(".claude/skills/")) continue;
73
+ const target = path.join(root, rel);
74
+ if (!exists(target)) continue;
75
+ if (sha256File(target) === sha) { actions.push({ action: "retire", path: rel }); if (!dryRun) { fs.unlinkSync(target); pruneEmptyDirs(path.dirname(target), root); } }
76
+ else { actions.push({ action: "override", path: rel }); inventory[rel] = sha256File(target); }
77
+ }
62
78
  // Scaffold .nccgs (never overwritten), config, modes.
63
79
  if (!dryRun && !skillsOnly) {
64
80
  ensureLayout(root);
@@ -76,11 +92,30 @@ export function install(root, { update = false, force = false, dryRun = false, m
76
92
  }
77
93
  const overrides = actions.filter(a => a.action === "override").map(a => a.path);
78
94
  if (!dryRun) {
79
- writeJson(stateFile, { schemaVersion: 3, version: PKG.version, installedAt: previous?.installedAt ?? nowIso(), updatedAt: nowIso(), editor: editorVersion(root), files: inventory, overrides });
95
+ writeJson(stateFile, { schemaVersion: 3, version: PKG.version, installedAt: previous?.installedAt ?? nowIso(), updatedAt: nowIso(), editor: editorVersion(root), files: inventory, overrides, authored });
80
96
  }
81
97
  return { version: PKG.version, dryRun, counts: count(actions), overrides, actions: dryRun ? actions.filter(a => a.action !== "keep") : undefined };
82
98
  }
83
99
 
100
+ // NCCGS 2.x wrote the same state file with `frameworkVersion` and a `files` array. Its hooks live in
101
+ // .claude/nccgs/hooks/ (not runtime/hooks/), so a 3.x install on top would leave two frameworks running.
102
+ export const isLegacy = state => !!state && (typeof state.frameworkVersion === "string" || Array.isArray(state.files) || typeof state.version !== "string");
103
+ function legacyMessage(state) {
104
+ const v = state.frameworkVersion ?? "2.x";
105
+ return [
106
+ `NCCGS ${v} is installed here. 3.x does not upgrade a 2.x install in place (its hooks, 58 skills, agents, rules and .nccgs/project.yaml would stay active next to the new ones).`,
107
+ `Remove 2.x with its own package first, then install 3.x:`,
108
+ ` npx nccgs@${/^\d/.test(v) ? v : "2"} uninstall --project . --dry-run`,
109
+ ` npx nccgs@${/^\d/.test(v) ? v : "2"} uninstall --project .`,
110
+ ` npx nccgs@rc install --project .`,
111
+ `2.x uninstall keeps .nccgs/ (state.md, tasks, reviews, evidence) and your CLAUDE.md; 3.x reads state.md and leaves the rest in place. Then run /adopt in Claude Code to bring the project under the 3.x loop.`
112
+ ].join("\n");
113
+ }
114
+
115
+ function pruneEmptyDirs(dir, stopAt) {
116
+ while (dir.startsWith(stopAt) && dir !== stopAt && exists(dir) && !fs.readdirSync(dir).length) { fs.rmdirSync(dir); dir = path.dirname(dir); }
117
+ }
118
+
84
119
  function count(actions) { return actions.reduce((acc, a) => { acc[a.action] = (acc[a.action] ?? 0) + 1; return acc; }, {}); }
85
120
 
86
121
  // Merge our hooks and deny rules into .claude/settings.json without touching anything else.
package/cli/run.mjs CHANGED
@@ -4,7 +4,7 @@ import { spawnTool } from "./spawn.mjs";
4
4
  import { readJson, exists } from "../runtime/core/fs.mjs";
5
5
  import { at, isUnityProject, loadConfig } from "../runtime/core/project.mjs";
6
6
  import { allModes } from "../runtime/core/modes.mjs";
7
- import { checkUpdates } from "./update-check.mjs";
7
+ import { checkUpdates } from "../runtime/core/update-check.mjs";
8
8
 
9
9
  const OWNED = /^(--permission-mode|--dangerously-skip-permissions|--allow-dangerously-skip-permissions|--strict-mcp-config|--mcp-config|--model)(=|$)/;
10
10
 
@@ -0,0 +1,74 @@
1
+ // nccgs upgrade: bring an installed project to the toolchain this NCCGS release was verified with, in order:
2
+ // 1. managed files (= `nccgs update`: runtime, skills, agents, hooks, toolchain.json)
3
+ // 2. Unity CLI -> toolchain.unityCli (`unity self-update --target <v>`; never downgrades)
4
+ // 3. com.unity.pipeline -> toolchain.pipeline (`unity pipeline install --version <v> --force`; never downgrades)
5
+ // 4. doctor
6
+ // Every step is reported; --dry-run plans without touching anything; --skip cli,pipeline,files leaves a step out.
7
+ import path from "node:path";
8
+ import { readJson, exists, nowIso, appendLine } from "../runtime/core/fs.mjs";
9
+ import { at, isUnityProject, pipelineVersion, manifestPackages } from "../runtime/core/project.mjs";
10
+ import { run } from "../runtime/core/process-tree.mjs";
11
+ import { UNITY_BIN, unityArgv, cliVersion, instanceFor } from "../runtime/unity/unity.mjs";
12
+ import { newer } from "../runtime/core/update-check.mjs";
13
+ import { install, isLegacy, PKG, PKG_ROOT } from "./install.mjs";
14
+ import { doctor } from "./doctor.mjs";
15
+
16
+ export const TOOLCHAIN = readJson(path.join(PKG_ROOT, "toolchain.json"), {});
17
+
18
+ export async function upgrade(root, { dryRun = false, skip = [], force = false, runDoctor = true } = {}) {
19
+ if (!isUnityProject(root)) throw new Error(`${root} is not a Unity project`);
20
+ const stateFile = at(root, "installState");
21
+ const previous = exists(stateFile) ? readJson(stateFile) : null;
22
+ if (!previous) throw new Error("NCCGS is not installed here; use `nccgs install`");
23
+ if (isLegacy(previous)) throw new Error(`NCCGS ${previous.frameworkVersion ?? "2.x"} is installed here: remove it first (npx nccgs@${previous.frameworkVersion ?? "2"} uninstall --project .), then \`nccgs install\``);
24
+ const steps = [];
25
+ const step = (id, from, to, action, detail) => { steps.push({ id, from, to, action, ...(detail ? { detail } : {}) }); return steps.at(-1); };
26
+
27
+ // 1. managed files
28
+ if (!skip.includes("files")) {
29
+ const r = install(root, { update: true, dryRun, force });
30
+ step("files", previous.version, PKG.version, dryRun ? "planned" : "updated", { counts: r.counts, overrides: r.overrides });
31
+ } else step("files", previous.version, PKG.version, "skipped");
32
+
33
+ // 2. Unity CLI
34
+ const want = TOOLCHAIN.unityCli ?? null;
35
+ const have = await cliVersion();
36
+ if (skip.includes("cli")) step("unity-cli", have, want, "skipped");
37
+ else if (!have) step("unity-cli", null, want, "failed", "unity CLI not on PATH (install: https://unity.com/en/blog/meet-the-unity-cli)");
38
+ else if (!want || have === want) step("unity-cli", have, want, "kept");
39
+ else if (newer(have, want)) step("unity-cli", have, want, "kept", `installed CLI is newer than the verified ${want}; not downgraded`);
40
+ else if (dryRun) step("unity-cli", have, want, "planned", `unity self-update --target ${want}`);
41
+ else {
42
+ let r = await run(UNITY_BIN, unityArgv(["self-update", "--target", want, "--format", "json"]), { timeoutMs: 300000 });
43
+ if (r.code !== 0) r = await run(UNITY_BIN, unityArgv(["self-update", ...(TOOLCHAIN.unityCliChannel ? ["--channel", TOOLCHAIN.unityCliChannel] : []), "--format", "json"]), { timeoutMs: 300000 });
44
+ const now = await cliVersion();
45
+ step("unity-cli", have, want, now && now !== have ? "updated" : "failed", now && now !== have ? `now ${now}` : `self-update exit ${r.code}: ${(r.stderr || r.stdout || "").trim().slice(-300)}`);
46
+ }
47
+
48
+ // 3. Pipeline package
49
+ const wantP = TOOLCHAIN.pipeline ?? null;
50
+ const haveP = pipelineVersion(root);
51
+ if (skip.includes("pipeline")) step("pipeline", haveP, wantP, "skipped");
52
+ else if (!wantP || haveP === wantP) step("pipeline", haveP, wantP, "kept");
53
+ else if (haveP && newer(haveP, wantP)) step("pipeline", haveP, wantP, "kept", `project has a newer com.unity.pipeline than the verified ${wantP}; not downgraded`);
54
+ else {
55
+ const open = await instanceFor(root).catch(() => null);
56
+ const note = open ? "the Editor is open: Unity resolves the new package when the window regains focus (watch the Console; restart the Editor if the Pipeline server does not come back)" : null;
57
+ if (dryRun) step("pipeline", haveP, wantP, "planned", [`unity pipeline install --version ${wantP} --force --project-path .`, note].filter(Boolean).join("; "));
58
+ else {
59
+ const r = await run(UNITY_BIN, unityArgv(["pipeline", "install", "--version", wantP, "--force", "--project-path", root, "--format", "json"]), { timeoutMs: 120000 });
60
+ const nowP = pipelineVersion(root);
61
+ step("pipeline", haveP, wantP, nowP === wantP ? "updated" : "failed", nowP === wantP ? note ?? undefined : `pipeline install exit ${r.code}: ${(r.stderr || r.stdout || "").trim().slice(-300)}`);
62
+ }
63
+ }
64
+
65
+ // 4. doctor
66
+ let health = null;
67
+ if (runDoctor && !dryRun) {
68
+ const d = await doctor(root);
69
+ health = { pass: d.summary?.pass ?? 0, warn: d.summary?.warn ?? 0, fail: d.summary?.fail ?? 0, failed: d.checks.filter(c => c.status === "fail").map(c => `${c.id}: ${c.message}`) };
70
+ }
71
+ if (!dryRun) appendLine(at(root, "events"), JSON.stringify({ at: nowIso(), type: "nccgs.upgrade", from: previous.version, to: PKG.version, steps: steps.map(s => `${s.id}:${s.action}`) }));
72
+ const failed = steps.filter(s => s.action === "failed");
73
+ return { version: PKG.version, from: previous.version, toolchain: { unityCli: want, pipeline: wantP, unitySkills: TOOLCHAIN.unitySkills?.slice(0, 7) ?? null, verifiedAt: TOOLCHAIN.verifiedAt ?? null }, dryRun, steps, health, ok: failed.length === 0 && !(health?.fail), packages: manifestPackages(root)["com.unity.pipeline"] ?? null };
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nccgs",
3
- "version": "3.0.0-rc.2",
3
+ "version": "3.0.0-rc.4",
4
4
  "description": "NCCGS 3 - a Unity-first evidence loop for Claude Code: contract, run, look, review, report.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "scripts/",
20
20
  "docs/",
21
21
  "tools/",
22
+ "toolchain.json",
22
23
  "README.md",
23
24
  "CHANGELOG.md",
24
25
  "LICENSE",
@@ -0,0 +1,117 @@
1
+ // Update notices: NCCGS (npm dist-tags), Unity Skills (GitHub HEAD), Unity CLI (its own check), Pipeline
2
+ // (`unity pipeline list-versions`). Notify only; `nccgs upgrade` applies. Cached under .nccgs/cache per the
3
+ // `updates.check` interval (daily | weekly | never). Every source has a short timeout and network failures
4
+ // are silent. The session-start hook runs this file detached (`node update-check.mjs --project <root>`) when
5
+ // the cache is stale, so a session never waits on the network.
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { readJson, writeJson, exists } from "./fs.mjs";
9
+ import { at, loadConfig, pipelineVersion, projectRoot } from "./project.mjs";
10
+ import { run } from "./process-tree.mjs";
11
+ import { UNITY_BIN, unityArgv } from "../unity/unity.mjs";
12
+
13
+ const DAY = 86400000;
14
+ export const intervalMs = config => config.updates?.check === "weekly" ? 7 * DAY : config.updates?.check === "never" ? Infinity : DAY;
15
+
16
+ // semver compare with prerelease: 3.0.0-rc.3 < 3.0.0-rc.4 < 3.0.0 ; 2.1.1 < 3.0.0-rc.1
17
+ export function newer(latest, current) {
18
+ const parse = v => { const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([\w.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null; };
19
+ const a = parse(latest), b = parse(current); if (!a || !b) return false;
20
+ for (let i = 0; i < 3; i++) if (a.n[i] !== b.n[i]) return a.n[i] > b.n[i];
21
+ if (a.pre === b.pre) return false; if (a.pre === null) return true; if (b.pre === null) return false;
22
+ const pa = a.pre.split("."), pb = b.pre.split(".");
23
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
24
+ if (pa[i] === pb[i]) continue; if (pa[i] === undefined) return false; if (pb[i] === undefined) return true;
25
+ const na = Number(pa[i]), nb = Number(pb[i]);
26
+ return Number.isFinite(na) && Number.isFinite(nb) ? na > nb : pa[i] > pb[i];
27
+ }
28
+ return false;
29
+ }
30
+ export const isPrerelease = v => /-/.test(String(v));
31
+
32
+ async function fetchJson(url, timeoutMs = 3000, ua = "nccgs") {
33
+ const ctl = new AbortController();
34
+ const t = setTimeout(() => ctl.abort(), timeoutMs);
35
+ try {
36
+ const r = await fetch(url, { signal: ctl.signal, headers: { "user-agent": ua, accept: "application/json" } });
37
+ if (!r.ok) return null;
38
+ return await r.json();
39
+ } catch { return null; } finally { clearTimeout(t); }
40
+ }
41
+ const parseJson = s => { try { return JSON.parse(String(s ?? "").replace(/^/, "")); } catch { return null; } };
42
+
43
+ // What is installed here (from the managed copies, so the runtime works without the npm package around).
44
+ export function installed(root) {
45
+ const state = exists(at(root, "installState")) ? readJson(at(root, "installState")) : {};
46
+ const manifest = readJson(path.join(root, ".claude", "nccgs", "unity-skills-manifest.json"), {});
47
+ const toolchain = readJson(path.join(root, ".claude", "nccgs", "toolchain.json"), {});
48
+ return { nccgs: state.version ?? null, unitySkills: manifest.source_revision ?? null, toolchain, pipeline: pipelineVersion(root) };
49
+ }
50
+
51
+ // Sources: live, or a stub file (NCCGS_UPDATE_STUB) for tests.
52
+ async function sources(root, cur) {
53
+ const stub = process.env.NCCGS_UPDATE_STUB;
54
+ if (stub) return readJson(stub, {});
55
+ const out = {};
56
+ const tags = await fetchJson("https://registry.npmjs.org/-/package/nccgs/dist-tags", 3000, `nccgs/${cur.nccgs ?? "?"}`);
57
+ if (tags) out.nccgsTags = tags;
58
+ const head = await fetchJson("https://api.github.com/repos/Unity-Technologies/skills/commits/main", 3000, `nccgs/${cur.nccgs ?? "?"}`);
59
+ if (head?.sha) out.unitySkillsHead = head.sha;
60
+ const ver = await run(UNITY_BIN, unityArgv(["--version"]), { timeoutMs: 15000 });
61
+ if (ver.code === 0) out.unityCli = ver.stdout.trim();
62
+ const su = await run(UNITY_BIN, unityArgv(["self-update", "--check", "--format", "json"]), { timeoutMs: 15000 });
63
+ const sj = parseJson(su.stdout);
64
+ const latest = sj?.data?.latest ?? sj?.latest ?? sj?.data?.latestVersion ?? sj?.latestVersion ?? null;
65
+ if (latest) out.unityCliLatest = String(latest);
66
+ const pl = await run(UNITY_BIN, unityArgv(["pipeline", "list-versions", "--format", "json"]), { timeoutMs: 15000 });
67
+ const pj = parseJson(pl.stdout);
68
+ const pLatest = pj?.data?.latest ?? pj?.latest ?? null;
69
+ if (pLatest) out.pipelineLatest = String(pLatest);
70
+ return out;
71
+ }
72
+
73
+ export async function checkUpdates(root, { force = false } = {}) {
74
+ const config = loadConfig(root);
75
+ const file = at(root, "updateCheck");
76
+ const cached = exists(file) ? readJson(file) : null;
77
+ if (!force && cached && Date.now() - Date.parse(cached.checkedAt) < intervalMs(config)) return cached;
78
+ if (config.updates?.check === "never" && !force) return { checkedAt: new Date().toISOString(), notices: [], skipped: true };
79
+ const channels = new Set(config.updates?.channels ?? ["nccgs", "unity-skills", "unity-cli", "pipeline"]);
80
+ const cur = installed(root);
81
+ const src = await sources(root, cur);
82
+ const notices = [];
83
+ const tag = cur.nccgs && isPrerelease(cur.nccgs) ? "rc" : "latest";
84
+ if (channels.has("nccgs") && cur.nccgs && src.nccgsTags) {
85
+ const candidates = [src.nccgsTags.latest, tag === "rc" ? src.nccgsTags.rc : null].filter(Boolean);
86
+ const best = candidates.sort((a, b) => (newer(a, b) ? -1 : 1))[0];
87
+ if (best && newer(best, cur.nccgs)) notices.push({ component: "nccgs", current: cur.nccgs, latest: best, hint: `npx nccgs@${best} upgrade --project .` });
88
+ }
89
+ if (channels.has("unity-skills") && cur.unitySkills && src.unitySkillsHead && !src.unitySkillsHead.startsWith(cur.unitySkills) && cur.unitySkills !== "unknown-local-copy") {
90
+ notices.push({ component: "unity-skills", current: cur.unitySkills.slice(0, 7), latest: src.unitySkillsHead.slice(0, 7), hint: "ships with the next nccgs release (vendored and pinned)" });
91
+ }
92
+ if (channels.has("unity-cli") && src.unityCli && src.unityCliLatest && newer(src.unityCliLatest, src.unityCli)) {
93
+ const verified = cur.toolchain?.unityCli;
94
+ notices.push({ component: "unity-cli", current: src.unityCli, latest: src.unityCliLatest, hint: verified && newer(src.unityCliLatest, verified) ? `newer than the version NCCGS verified (${verified}); \`unity self-update\` at your own risk` : "npx nccgs upgrade --project ." });
95
+ }
96
+ if (channels.has("pipeline") && cur.pipeline && src.pipelineLatest && newer(src.pipelineLatest, cur.pipeline)) {
97
+ const verified = cur.toolchain?.pipeline;
98
+ notices.push({ component: "pipeline", current: cur.pipeline, latest: src.pipelineLatest, hint: verified && newer(src.pipelineLatest, verified) ? `newer than the version NCCGS verified (${verified}); wait for the next nccgs release` : "npx nccgs upgrade --project ." });
99
+ }
100
+ const value = { checkedAt: new Date().toISOString(), notices, sources: src, installed: { nccgs: cur.nccgs, unitySkills: cur.unitySkills, pipeline: cur.pipeline, toolchain: cur.toolchain } };
101
+ writeJson(file, value);
102
+ return value;
103
+ }
104
+
105
+ export function staleCache(root) {
106
+ const config = loadConfig(root);
107
+ if (config.updates?.check === "never") return false;
108
+ const file = at(root, "updateCheck");
109
+ if (!exists(file)) return true;
110
+ return Date.now() - Date.parse(readJson(file).checkedAt ?? 0) >= intervalMs(config);
111
+ }
112
+
113
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
114
+ const i = process.argv.indexOf("--project");
115
+ const root = projectRoot(i >= 0 ? process.argv[i + 1] : undefined);
116
+ checkUpdates(root, { force: process.argv.includes("--force") }).then(v => { if (process.argv.includes("--print")) process.stdout.write(JSON.stringify(v, null, 2) + "\n"); }).catch(() => {});
117
+ }
@@ -7,6 +7,9 @@ import { openTasks } from "../task/ledger.mjs";
7
7
  import { readJson, exists, readText } from "../core/fs.mjs";
8
8
  import { autoFeedback } from "../task/feedback.mjs";
9
9
  import { markSuggested, CONSENT_TEXT } from "../core/feedback-policy.mjs";
10
+ import { staleCache } from "../core/update-check.mjs";
11
+ import { spawn } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
10
13
 
11
14
  const input = await readInput();
12
15
  const project = root();
@@ -27,7 +30,13 @@ try {
27
30
  if (exists(check)) {
28
31
  const u = readJson(check);
29
32
  const notices = (u.notices ?? []).map(n => `${n.component} ${n.current} -> ${n.latest} (${n.hint})`);
30
- if (notices.length) lines.push(`Updates available (not applied): ${notices.join("; ")}`);
33
+ if (notices.length) lines.push(`Updates available (not applied; checked ${String(u.checkedAt).slice(0, 10)}): ${notices.join("; ")}. Mention it to the user in one line; \`npx nccgs@<version> upgrade --project .\` applies NCCGS, Unity CLI and Pipeline together.`);
34
+ }
35
+ // Refresh the cache in the background (daily by default; `updates.check` in project.json) so no session waits on the network.
36
+ if (staleCache(project) && !process.env.NCCGS_NO_UPDATE_CHECK) {
37
+ const script = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "core", "update-check.mjs");
38
+ const child = spawn(process.execPath, [script, "--project", project], { detached: true, stdio: "ignore", windowsHide: true, env: process.env });
39
+ child.unref();
31
40
  }
32
41
  } catch {}
33
42
  try {
@@ -15,13 +15,18 @@ export const taskFile = (root, id) => path.join(at(root, "tasks"), `${id}.json`)
15
15
  export function loadTask(root, id) {
16
16
  const file = taskFile(root, id);
17
17
  if (!exists(file)) throw new Error(`Task ${id} not found (${file})`);
18
- return readJson(file);
18
+ const task = readJson(file);
19
+ if (!isOurs(task)) throw new Error(`Task ${id} is an NCCGS ${task.schemaVersion ?? "1"}.x record (${file}); 3.x reads only schema 3. It is kept as history and not opened or closed by this version.`);
20
+ return task;
19
21
  }
22
+ // NCCGS 2.x kept its own records in the same folder (schema 1/2, `closed` flag). They stay as history
23
+ // and are never listed, so a leftover 2.x task cannot block a session or an uninstall.
24
+ const isOurs = task => task?.schemaVersion === 3 && typeof task.status === "string";
20
25
  export function saveTask(root, task) { task.updatedAt = nowIso(); writeJson(taskFile(root, task.id), task); return task; }
21
26
  export function listTasks(root) {
22
27
  const dir = at(root, "tasks");
23
28
  if (!exists(dir)) return [];
24
- return fs.readdirSync(dir).filter(f => f.endsWith(".json")).map(f => readJson(path.join(dir, f))).sort((a, b) => (a.openedAt < b.openedAt ? 1 : -1));
29
+ return fs.readdirSync(dir).filter(f => f.endsWith(".json")).map(f => readJson(path.join(dir, f))).filter(isOurs).sort((a, b) => (a.openedAt < b.openedAt ? 1 : -1));
25
30
  }
26
31
  export function openTasks(root) { return listTasks(root).filter(t => t.status !== "closed"); }
27
32
 
@@ -1,6 +1,6 @@
1
1
  // Unity CLI + Pipeline wrapper. Every call goes through here so the rest of the
2
2
  // runtime never spells a Pipeline command name: names are discovered at runtime
3
- // (`unity command --detail compact`) and cached per package version.
3
+ // (`unity command --detail full`, names and parameter names) and cached per package version.
4
4
  //
5
5
  // Verified on 2026-11-04 against Unity CLI 1.0.0-beta.14 and com.unity.pipeline 0.9.x
6
6
  // with Editors 6000.3.17f1 and 6000.6.7f1 (see docs/EVIDENCE-VERIFICATION.md).
@@ -69,34 +69,61 @@ export async function waitReady(root, { timeoutMs = 15000, intervalMs = 1000 } =
69
69
 
70
70
  // ---- Pipeline commands -----------------------------------------------------
71
71
 
72
- export async function command(root, name, params = {}, { timeoutMs = 120000, retries = 2 } = {}) {
72
+ export async function command(root, name, params = {}, { timeoutMs = 120000, retries = 2, parameters = null } = {}) {
73
73
  const args = ["command", ...name.split(" ")];
74
- for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null) args.push(`--${k}`, String(v)); }
74
+ for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null) args.push(`--${paramName(k, parameters)}`, String(v)); }
75
75
  args.push("--project-path", root);
76
76
  let r;
77
77
  for (let attempt = 0; attempt <= retries; attempt++) {
78
78
  r = await cli(args, { timeoutMs });
79
- const code = r.json?.errors?.[0]?.code;
79
+ const code = r.json?.errors?.[0]?.code ?? r.json?.errorCode;
80
80
  if (code !== "EDITOR_NOT_RUNNING" || attempt === retries) break;
81
81
  await waitReady(root, { timeoutMs: 8000 });
82
82
  }
83
- const ok = r.json?.success === true;
84
- return { ok, result: r.json?.data?.result, errors: r.json?.errors ?? [], raw: r.json, code: r.code, stderr: r.stderr };
83
+ return { ...unwrap(r), raw: r.json, code: r.code, stderr: r.stderr };
84
+ }
85
+
86
+ // Two envelope shapes. CLI beta.14: { success, command, data: { result }, errors }. CLI beta.17 prints the
87
+ // Pipeline result bare (what used to be data.result; a Pipeline-side failure is { success: false, errorCode,
88
+ // error, errorDetails }, exit 6) and keeps the old envelope only for CLI-side errors (EDITOR_NOT_RUNNING…).
89
+ export function unwrap(r) {
90
+ const j = r.json;
91
+ if (j === null || j === undefined) return { ok: false, result: undefined, errors: [{ code: r.timedOut ? "TIMEOUT" : "NO_JSON", message: (r.stderr || r.stdout || "").slice(0, 300) }] };
92
+ if (typeof j !== "object") return { ok: r.code === 0 && !r.timedOut, result: j, errors: [] }; // a bare primitive result ("Entered play mode")
93
+ const enveloped = "data" in j && "success" in j;
94
+ if (enveloped) return { ok: j.success === true, result: j.data?.result, errors: j.errors ?? [] };
95
+ if (j.success === false) return { ok: false, result: j, errors: [{ code: j.errorCode ?? "PIPELINE_ERROR", message: j.error ?? j.errorDetails ?? "command failed", details: j.errorDetails ?? null }] };
96
+ return { ok: r.code === 0 && !r.timedOut, result: j, errors: [] };
85
97
  }
86
98
 
87
99
  // Discover the command catalog; cached under .nccgs/cache keyed by pipeline version.
88
100
  export async function catalog(root, { refresh = false } = {}) {
89
101
  const version = pipelineVersion(root) || "unknown";
90
- const file = path.join(at(root, "cache"), `pipeline-commands-${version.replace(/[^\w.-]/g, "_")}.json`);
102
+ const file = path.join(at(root, "cache"), `pipeline-commands-v2-${version.replace(/[^\w.-]/g, "_")}.json`);
91
103
  if (!refresh && exists(file)) return readJson(file);
92
- const r = await cli(["command", "--detail", "compact", "--limit", "500", "--project-path", root], { timeoutMs: 60000 });
93
- const commands = r.json?.data?.commands ?? [];
94
- if (!commands.length) return { version, commands: [], discoveredAt: null, error: r.json?.errors?.[0]?.message ?? "no commands" };
95
- const value = { version, discoveredAt: new Date().toISOString(), commands: commands.map(c => ({ command: c.command, group: c.group, name: c.name, description: c.description })) };
104
+ // --detail full carries each command's parameter names; Pipeline 0.10 renamed them to kebab-case
105
+ // (async_tests -> async-tests, since_session -> since-session, enable -> enabled, output -> output-path).
106
+ const r = await cli(["command", "--detail", "full", "--limit", "500", "--project-path", root], { timeoutMs: 60000 });
107
+ // CLI beta.17 prints the listing bare ({ commands, count, ... }); beta.14 wrapped it in { success, data: { commands } }.
108
+ const commands = r.json?.data?.commands ?? r.json?.commands ?? [];
109
+ if (!commands.length) return { version, commands: [], discoveredAt: null, error: r.json?.errors?.[0]?.message ?? (r.json ? `no commands in listing (keys: ${Object.keys(r.json).join(",")})` : `no JSON from unity command (exit ${r.code}): ${(r.stderr || r.stdout || "").slice(0, 200)}`) };
110
+ const value = { version, discoveredAt: new Date().toISOString(), commands: commands.map(c => ({ command: c.command, group: c.group, name: c.name, description: c.description, parameters: (c.parameters ?? []).map(p => p.name) })) };
96
111
  writeJson(file, value);
97
112
  return value;
98
113
  }
99
114
 
115
+ // Map the parameter name the runtime uses to the one this Pipeline version declares. Spelling is
116
+ // matched ignoring case, `-` and `_`; a few names changed meaning-preserving words between versions.
117
+ const PARAM_ALIASES = { enable: ["enabled"], output: ["output-path", "output_path", "save_path", "path"] };
118
+ export function paramName(key, declared) {
119
+ if (!Array.isArray(declared) || !declared.length) return key;
120
+ const norm = s => String(s).toLowerCase().replace(/[-_]/g, "");
121
+ const exact = declared.find(d => d === key); if (exact) return exact;
122
+ const same = declared.find(d => norm(d) === norm(key)); if (same) return same;
123
+ for (const alias of PARAM_ALIASES[key] ?? []) { const hit = declared.find(d => norm(d) === norm(alias)); if (hit) return hit; }
124
+ return key;
125
+ }
126
+
100
127
  // Resolve a capability to the real command name (handles renames between Pipeline versions).
101
128
  const CAPABILITIES = {
102
129
  playStart: [/^editor\.playmode start$/, /^editor_play$/],
@@ -117,18 +144,19 @@ const CAPABILITIES = {
117
144
  recompileStatus: [/^script recompile_status$/]
118
145
  };
119
146
 
120
- export async function resolve(root, capability) {
147
+ export async function resolve(root, capability, { withParameters = false } = {}) {
121
148
  const cat = await catalog(root);
122
149
  const patterns = CAPABILITIES[capability];
123
150
  if (!patterns) throw new Error(`Unknown capability ${capability}`);
124
151
  const hit = cat.commands.find(c => patterns.some(p => p.test(c.command)));
125
- return hit?.command ?? null;
152
+ if (!hit) return null;
153
+ return withParameters ? { command: hit.command, parameters: hit.parameters ?? [] } : hit.command;
126
154
  }
127
155
 
128
- async function call(root, capability, params, options) {
129
- const name = await resolve(root, capability);
130
- if (!name) return { ok: false, errors: [{ code: "CAPABILITY_MISSING", message: `No Pipeline command for ${capability}` }] };
131
- return command(root, name, params, options);
156
+ async function call(root, capability, params, options = {}) {
157
+ const hit = await resolve(root, capability, { withParameters: true });
158
+ if (!hit) return { ok: false, errors: [{ code: "CAPABILITY_MISSING", message: `No Pipeline command for ${capability}` }] };
159
+ return command(root, hit.command, params, { ...options, parameters: hit.parameters });
132
160
  }
133
161
 
134
162
  // ---- High-level operations used by `task verify` -------------------------------