nccgs 3.0.0-rc.1 → 3.0.0-rc.3

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
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0-rc.3 — 2026-11-06
4
+
5
+ - `/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.
6
+ - 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.
7
+
8
+ ## 3.0.0-rc.2 — 2026-11-05
9
+
10
+ - 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).
11
+
12
+ - Diagnostics (`.nccgs/diagnostics.jsonl`, local, rotated at 5 MB): every task CLI error, failed verification, failed Unity CLI/Pipeline call (args without the project path, exit code, error codes, last stderr) and hook block or crash is recorded with a timestamp.
13
+ - `nccgs feedback [--since 14] [--note "..."] [--with-reports]` and the `/feedback` skill: a local `.md` + `.json` bundle under `.nccgs/feedback/` with versions, doctor, per-task metadata (rigor, outcome, verifications by kind and failures, reviews and finding counts, fix rounds, out-of-scope edits, minutes, tokens by agent), diagnostics grouped by cause, pending updates, the person's note. No source, screenshots, transcripts or credentials; nothing is uploaded.
14
+
3
15
  ## 3.0.0-rc.1 — 2026-11-05 — phase 5: clean-up and release candidate
4
16
 
5
17
  - Windows: process trees run inside a Job Object through `koffi` (optional dependency; `npx nccgs` installs it). A timed-out batch Editor dies with every descendant, including re-parented ones; `unity open`'s Editor is never touched because kill-on-close is used only for `unity test`/`build`. Without koffi the taskkill /T + ppid walk stays. `doctor` reports which layer is active; `scripts/check-job-object.mjs` proves it on a machine.
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.1.** 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.3.** 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.
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/` (10 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 + 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. 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,8 +35,10 @@ 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 |
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 |
40
42
 
41
43
  The loop refuses to lie: `task close --outcome success` is rejected when a criterion fails, the source changed after the last verify, or the review is missing; `partial` and `blocked` are always allowed and reported as such.
42
44
 
@@ -73,6 +75,10 @@ Verified live, kept in `.claude/nccgs/protocols/unity-cli-notes.md` and `docs/EV
73
75
  .nccgs/ project.json, modes.json, state.md, design/, tasks/, evidence/, reports/, project.html
74
76
  ```
75
77
 
78
+ ## Feedback
79
+
80
+ Failures are recorded locally in `.nccgs/diagnostics.jsonl` (task CLI errors, failed verifications, failed Unity calls, hook blocks). A bundle (`nccgs feedback`) holds versions, doctor, per-task numbers and diagnostics grouped by cause — no code, screenshots, transcripts or credentials; project name and task titles are included. The first session where something fails asks you once: `auto` sends bundles to the maintainers by itself when an issue is detected or every 7 days, `manual` only suggests `/feedback`, `off` never does either. Change it any time with `npx nccgs feedback --project . --consent <auto|manual|off>`; every send is listed in `.nccgs/feedback/sent.jsonl` and a copy stays local.
81
+
76
82
  ## Updating
77
83
 
78
84
  `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`).
package/bin/nccgs.mjs CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "node:child_process";
6
6
  import { projectRoot } from "../runtime/core/project.mjs";
7
+ import { readText } from "../runtime/core/fs.mjs";
7
8
  import { PKG, TEMPLATE, install } from "../cli/install.mjs";
8
9
  import { doctor } from "../cli/doctor.mjs";
9
10
  import { launch } from "../cli/run.mjs";
@@ -11,6 +12,7 @@ import { uninstall } from "../cli/uninstall.mjs";
11
12
  import { allModes, createMode, setModeAgent, useMode, BUILTIN } from "../runtime/core/modes.mjs";
12
13
  import { renderProject } from "../runtime/task/project.mjs";
13
14
  import { release } from "../cli/release.mjs";
15
+ import { feedback } from "../cli/feedback.mjs";
14
16
 
15
17
  const argv = process.argv.slice(2);
16
18
  const command = argv[0];
@@ -37,6 +39,7 @@ const HELP = `nccgs ${PKG.version} - Unity-first evidence loop for Claude Code
37
39
  nccgs mode list | show NAME | create --from BALANCE --name X | set --name X --agent reviewer --model opus --effort high | use NAME
38
40
  nccgs task <open|verify|review|close|inspect|list|fix|report|project> ... (same as node .claude/nccgs/runtime/task/task.mjs)
39
41
  nccgs status --project DIR writes .nccgs/project.html (all tasks, open findings, cost) and prints the summary
42
+ nccgs feedback --project DIR [--since 14] [--note "..."] [--with-reports] [--send] [--consent auto|manual|off] bundle of what ran, failed and was blocked; --send posts it
40
43
  nccgs release --project DIR [--from TAG|DATE] [--to TAG|DATE|now] [--name v1.2.0] changelog from tasks closed in the range
41
44
  `;
42
45
 
@@ -62,6 +65,13 @@ try {
62
65
  if (opt.json) out({ file: r.file, closed: m.closed.length, success: m.success.length, open: m.open.map(x => x.task.id), findings: m.bySeverity, tokens: m.tokens });
63
66
  break;
64
67
  }
68
+ case "feedback": {
69
+ const r = await feedback(root, { sinceDays: Number(opt.since) || 14, note: str(opt.note) ?? "", withReports: !!opt["with-reports"], runDoctor: !opt["no-doctor"], send: !!opt.send, consent: str(opt.consent) ?? null, dryRun: !!opt["dry-run"] });
70
+ if (r.consent) { process.stdout.write(`feedback consent: ${r.consent.consent}\n`); break; }
71
+ process.stderr.write(`${r.mdFile}\n${r.jsonFile}\n${r.sent ? (r.sent.ok ? `sent (id ${r.sent.id ?? "?"}, ${r.sent.bytes} bytes)` : `not sent: ${r.sent.reason ?? r.sent.status}`) : "not sent (add --send)"}\n`);
72
+ if (opt.json) out({ md: r.mdFile, json: r.jsonFile, totals: r.bundle.totals, sent: r.sent ?? null, decision: r.decision }); else process.stdout.write(readText(r.mdFile));
73
+ break;
74
+ }
65
75
  case "release": { const r = release(root, { from: str(opt.from), to: str(opt.to) ?? "now", name: str(opt.name), write: !opt["dry-run"] }); process.stdout.write(r.markdown); if (r.file) process.stderr.write(`\n${r.file}\n`); break; }
66
76
  case "mode": {
67
77
  const sub = positional[0];
@@ -0,0 +1,13 @@
1
+ // nccgs feedback: build the local bundle (with doctor), send it or set consent. See runtime/task/feedback.mjs.
2
+ import { buildBundle, sendBundle } from "../runtime/task/feedback.mjs";
3
+ import { evaluate, setConsent, CONSENT_TEXT } from "../runtime/core/feedback-policy.mjs";
4
+ import { readText } from "../runtime/core/fs.mjs";
5
+ import { doctor } from "./doctor.mjs";
6
+
7
+ export async function feedback(root, { sinceDays = 14, note = "", withReports = false, runDoctor = true, send = false, consent = null, dryRun = false } = {}) {
8
+ if (consent) return { consent: setConsent(root, consent), text: CONSENT_TEXT.en };
9
+ const r = await buildBundle(root, { sinceDays, note, withReports, doctor: runDoctor ? doctor : null, trigger: "manual", reasons: evaluate(root).reasons });
10
+ if (send && !dryRun) r.sent = await sendBundle(root, { bundle: r.bundle, markdown: readText(r.mdFile, "") });
11
+ r.decision = evaluate(root);
12
+ return r;
13
+ }
package/cli/install.mjs CHANGED
@@ -34,6 +34,7 @@ export function install(root, { update = false, force = false, dryRun = false, m
34
34
  if (!isUnityProject(root)) throw new Error(`${root} is not a Unity project (needs Assets/ and ProjectSettings/ProjectVersion.txt)`);
35
35
  const stateFile = at(root, "installState");
36
36
  const previous = exists(stateFile) ? readJson(stateFile) : null;
37
+ if (previous && isLegacy(previous)) throw new Error(legacyMessage(previous));
37
38
  if (update && !previous) throw new Error("NCCGS is not installed here; use `install`");
38
39
  if (!update && previous && !force) throw new Error(`NCCGS ${previous.version} is already installed; use \`update\``);
39
40
  const actions = []; // { action: add|replace|keep|override|skip, path }
@@ -81,6 +82,21 @@ export function install(root, { update = false, force = false, dryRun = false, m
81
82
  return { version: PKG.version, dryRun, counts: count(actions), overrides, actions: dryRun ? actions.filter(a => a.action !== "keep") : undefined };
82
83
  }
83
84
 
85
+ // NCCGS 2.x wrote the same state file with `frameworkVersion` and a `files` array. Its hooks live in
86
+ // .claude/nccgs/hooks/ (not runtime/hooks/), so a 3.x install on top would leave two frameworks running.
87
+ export const isLegacy = state => !!state && (typeof state.frameworkVersion === "string" || Array.isArray(state.files) || typeof state.version !== "string");
88
+ function legacyMessage(state) {
89
+ const v = state.frameworkVersion ?? "2.x";
90
+ return [
91
+ `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).`,
92
+ `Remove 2.x with its own package first, then install 3.x:`,
93
+ ` npx nccgs@${/^\d/.test(v) ? v : "2"} uninstall --project . --dry-run`,
94
+ ` npx nccgs@${/^\d/.test(v) ? v : "2"} uninstall --project .`,
95
+ ` npx nccgs@rc install --project .`,
96
+ `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.`
97
+ ].join("\n");
98
+ }
99
+
84
100
  function count(actions) { return actions.reduce((acc, a) => { acc[a.action] = (acc[a.action] ?? 0) + 1; return acc; }, {}); }
85
101
 
86
102
  // Merge our hooks and deny rules into .claude/settings.json without touching anything else.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nccgs",
3
- "version": "3.0.0-rc.1",
3
+ "version": "3.0.0-rc.3",
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",
@@ -18,6 +18,7 @@
18
18
  "unity-skills/",
19
19
  "scripts/",
20
20
  "docs/",
21
+ "tools/",
21
22
  "README.md",
22
23
  "CHANGELOG.md",
23
24
  "LICENSE",
@@ -0,0 +1,43 @@
1
+ // Diagnostics: .nccgs/diagnostics.jsonl, one line per thing that went wrong or was blocked —
2
+ // runtime errors, failed verifications, Unity CLI/Pipeline failures, hook blocks. Local only; nothing
3
+ // leaves the machine unless the person runs `nccgs feedback` and sends the bundle themselves.
4
+ // Rotated at 5 MB (the previous file is kept once as diagnostics.1.jsonl).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { at } from "./project.mjs";
8
+ import { appendLine, readText } from "./fs.mjs";
9
+
10
+ const MAX = 5 * 1024 * 1024;
11
+ export const diagFile = root => path.join(at(root, "nccgs"), "diagnostics.jsonl");
12
+
13
+ export function diag(root, kind, data = {}) {
14
+ try {
15
+ const file = diagFile(root);
16
+ try { if (fs.existsSync(file) && fs.statSync(file).size > MAX) fs.renameSync(file, file.replace(/\.jsonl$/, ".1.jsonl")); } catch {}
17
+ appendLine(file, JSON.stringify({ ...clean(data), at: new Date().toISOString(), kind, pid: process.pid }));
18
+ } catch {}
19
+ }
20
+
21
+ // Keep entries small and free of secrets: strings capped, absolute user paths trimmed, env never copied.
22
+ function clean(data) {
23
+ const out = {};
24
+ for (const [k, v] of Object.entries(data)) {
25
+ if (v === undefined) continue;
26
+ if (typeof v === "string") out[k] = v.length > 2000 ? v.slice(0, 2000) + "…" : v;
27
+ else if (v instanceof Error) out[k] = { message: v.message, stack: String(v.stack ?? "").split("\n").slice(0, 6).join("\n") };
28
+ else if (Array.isArray(v)) out[k] = v.slice(0, 50).map(x => typeof x === "string" && x.length > 500 ? x.slice(0, 500) + "…" : x);
29
+ else out[k] = v;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function readDiag(root, { sinceMs = 0 } = {}) {
35
+ const out = [];
36
+ for (const f of [diagFile(root).replace(/\.jsonl$/, ".1.jsonl"), diagFile(root)]) {
37
+ for (const line of readText(f, "").split("\n")) {
38
+ if (!line.trim()) continue;
39
+ try { const j = JSON.parse(line); if (Date.parse(j.at) >= sinceMs) out.push(j); } catch {}
40
+ }
41
+ }
42
+ return out;
43
+ }
@@ -0,0 +1,54 @@
1
+ // When to suggest or send feedback. Pure functions over the ledger and diagnostics; the hooks and the
2
+ // task CLI call `evaluate()` and act on consent. Everything is rate-limited so a person is asked at
3
+ // most once per 6 hours and the periodic bundle goes at most once per `periodicDays`.
4
+ import { loadConfig, saveConfig } from "./project.mjs";
5
+ import { readDiag } from "./diag.mjs";
6
+ import { listTasks } from "../task/ledger.mjs";
7
+
8
+ const HOUR = 3600000, DAY = 86400000;
9
+ const ISSUE_KINDS = { "task.exception": 1, "hook.error": 1, "unity.failed": 3, "verify.failed": 5, "task.error": 8 };
10
+
11
+ export function evaluate(root, { now = Date.now(), config = loadConfig(root) } = {}) {
12
+ const fb = config.feedback ?? {};
13
+ if (fb.consent === "off") return { action: "none", reasons: [] };
14
+ const since = Date.parse(fb.lastSentAt ?? 0) || 0;
15
+ const diags = readDiag(root, { sinceMs: since });
16
+ const counts = {};
17
+ for (const d of diags) counts[d.kind] = (counts[d.kind] ?? 0) + 1;
18
+ const reasons = [];
19
+ for (const [kind, threshold] of Object.entries(ISSUE_KINDS)) if ((counts[kind] ?? 0) >= threshold) reasons.push(`${counts[kind]} × ${kind}`);
20
+ const bad = listTasks(root).filter(t => t.status === "closed" && t.outcome !== "success" && Date.parse(t.closedAt) > since && !/^T-|^PT-/.test(t.id));
21
+ if (bad.length) reasons.push(`${bad.length} task(s) closed ${[...new Set(bad.map(t => t.outcome))].join("/")}: ${bad.map(t => t.id).slice(0, 5).join(", ")}`);
22
+ const periodicDue = (fb.periodicDays ?? 7) > 0 && now - since >= (fb.periodicDays ?? 7) * DAY && listTasks(root).some(t => Date.parse(t.updatedAt ?? t.openedAt) > since);
23
+ if (periodicDue) reasons.push(`periodic (${fb.periodicDays ?? 7} days)`);
24
+ if (!reasons.length) return { action: "none", reasons: [], counts };
25
+ if (fb.consent === "ask") return { action: "ask-consent", reasons, counts };
26
+ if (fb.consent === "auto") return { action: "send", reasons, counts };
27
+ const suggestedRecently = now - (Date.parse(fb.lastSuggestedAt ?? 0) || 0) < 6 * HOUR;
28
+ return { action: suggestedRecently ? "none" : "suggest", reasons, counts };
29
+ }
30
+
31
+ export function setConsent(root, consent) {
32
+ if (!["ask", "auto", "manual", "off"].includes(consent)) throw new Error("consent must be ask | auto | manual | off");
33
+ const config = loadConfig(root);
34
+ config.feedback = { ...(config.feedback ?? {}), consent };
35
+ saveConfig(root, config);
36
+ return config.feedback;
37
+ }
38
+
39
+ export function markSuggested(root, now = new Date().toISOString()) {
40
+ const config = loadConfig(root);
41
+ config.feedback = { ...(config.feedback ?? {}), lastSuggestedAt: now };
42
+ saveConfig(root, config);
43
+ }
44
+
45
+ export function markSent(root, now = new Date().toISOString()) {
46
+ const config = loadConfig(root);
47
+ config.feedback = { ...(config.feedback ?? {}), lastSentAt: now };
48
+ saveConfig(root, config);
49
+ }
50
+
51
+ export const CONSENT_TEXT = {
52
+ en: "NCCGS can send anonymous-ish feedback bundles to its maintainers when something fails or every 7 days: versions, doctor output, per-task numbers (criteria, tests, review verdicts, fix rounds, minutes, tokens), diagnostics and any note you add. Project name and task titles are included; source code, screenshots, transcripts and credentials never are. Options: auto (send by itself), manual (only suggest `/feedback`), off (never). Change later with `npx nccgs feedback --consent <auto|manual|off>`.",
53
+ vi: "NCCGS có thể gửi gói feedback cho nhóm phát triển khi có lỗi hoặc mỗi 7 ngày: phiên bản, kết quả doctor, số liệu từng task (criteria, test, verdict review, vòng sửa, phút, token), diagnostics và ghi chú của bạn. Tên dự án và tiêu đề task có trong gói; mã nguồn, ảnh, transcript và credential không bao giờ có. Lựa chọn: auto (tự gửi), manual (chỉ gợi ý `/feedback`), off (không bao giờ). Đổi sau bằng `npx nccgs feedback --consent <auto|manual|off>`."
54
+ };
@@ -45,7 +45,13 @@ export const DEFAULT_CONFIG = {
45
45
  unitySkills: { disabled: [] },
46
46
  // USD per million tokens, keyed by a substring of the model id. Empty rows = tokens only, no dollars.
47
47
  // Fill in from https://www.anthropic.com/pricing when you want estimates; subscription plans do not bill per token.
48
- pricing: {}
48
+ pricing: {},
49
+ // Feedback to the NCCGS maintainers. consent: "ask" (not decided yet: the next session asks once),
50
+ // "auto" (bundles are sent when an issue is detected or every periodicDays), "manual" (NCCGS only
51
+ // suggests `/feedback`; nothing is sent by itself), "off" (never suggests, never sends).
52
+ // A bundle holds versions, doctor, per-task numbers, diagnostics and your note: no source, no
53
+ // screenshots, no transcripts, no credentials. Project name and task titles are included.
54
+ feedback: { consent: "ask", endpoint: "https://nccgs-feedback.buihoanghachau.workers.dev/v1/bundle", periodicDays: 7, includeReports: false, lastSentAt: null, lastSuggestedAt: null }
49
55
  };
50
56
 
51
57
  export function projectRoot(explicit) {
@@ -1,7 +1,9 @@
1
1
  // Shared hook plumbing. Hooks are workflow guards, not a sandbox: they block the
2
2
  // obviously destructive, warn on the risky, and log the rest.
3
+ import path from "node:path";
3
4
  import { projectRoot, at } from "../core/project.mjs";
4
5
  import { appendLine } from "../core/fs.mjs";
6
+ import { diag } from "../core/diag.mjs";
5
7
 
6
8
  export async function readInput() {
7
9
  let text = "";
@@ -9,7 +11,8 @@ export async function readInput() {
9
11
  try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
10
12
  }
11
13
  export const root = () => projectRoot();
12
- export function block(reason) { process.stderr.write(`NCCGS blocked: ${reason}\n`); process.exit(2); }
14
+ export function block(reason, extra = {}) { diag(root(), "hook.block", { hook: path.basename(process.argv[1] ?? "hook"), reason, ...extra }); process.stderr.write(`NCCGS blocked: ${reason}\n`); process.exit(2); }
15
+ process.on("uncaughtException", e => { try { diag(root(), "hook.error", { hook: path.basename(process.argv[1] ?? "hook"), error: e }); } catch {} process.exit(0); });
13
16
  export function warn(hookEventName, message) {
14
17
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: `NCCGS warning: ${message}` } }) + "\n");
15
18
  process.exit(0);
@@ -5,6 +5,8 @@ import { readInput, root, log } from "./common.mjs";
5
5
  import { versionLine, loadConfig, at } from "../core/project.mjs";
6
6
  import { openTasks } from "../task/ledger.mjs";
7
7
  import { readJson, exists, readText } from "../core/fs.mjs";
8
+ import { autoFeedback } from "../task/feedback.mjs";
9
+ import { markSuggested, CONSENT_TEXT } from "../core/feedback-policy.mjs";
8
10
 
9
11
  const input = await readInput();
10
12
  const project = root();
@@ -28,5 +30,11 @@ try {
28
30
  if (notices.length) lines.push(`Updates available (not applied): ${notices.join("; ")}`);
29
31
  }
30
32
  } catch {}
33
+ try {
34
+ const fb = await autoFeedback(project, { trigger: "session-start" });
35
+ if (fb.action === "ask-consent") lines.push(`FEEDBACK CONSENT NOT SET. Before other work, ask the user once with AskUserQuestion (options: auto / manual / off) using this text: ${CONSENT_TEXT.en} Then run \`npx nccgs feedback --project . --consent <choice>\` and continue. Reasons it came up now: ${fb.reasons.join("; ")}.`);
36
+ else if (fb.action === "suggest") { markSuggested(project); lines.push(`NCCGS noticed issues since the last feedback (${fb.reasons.join("; ")}). Suggest to the user, once, that \`/feedback\` would send a bundle for review; do not run it without their yes.`); }
37
+ else if (fb.action === "send") lines.push(fb.sent?.ok ? `Feedback bundle sent automatically (consent auto): ${fb.reasons.join("; ")}. Mention it in one line.` : `Feedback bundle could not be sent (${fb.sent?.reason ?? "unknown"}); it is saved under .nccgs/feedback/. Mention it in one line.`);
38
+ } catch {}
31
39
  log("session.start", { sessionId: input?.session_id ?? null, transcriptPath: input?.transcript_path ?? null, model: input?.model ?? null });
32
40
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }) + "\n");
@@ -0,0 +1,136 @@
1
+ // nccgs feedback: one bundle a person can send after days of use — what ran, what failed, what was
2
+ // blocked, how long and how many tokens each task took, the doctor's view and any note they add.
3
+ // Everything comes from local files (ledger, events, diagnostics, reports); no code, no screenshots,
4
+ // no transcripts, no secrets. Nothing is uploaded: the bundle is written under .nccgs/feedback/ and
5
+ // the person decides where it goes.
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ import { writeJson, writeText, readText, exists, toPosix, appendLine } from "../core/fs.mjs";
9
+ import { at, loadConfig, editorVersion, pipelineVersion } from "../core/project.mjs";
10
+ import { listTasks } from "../task/ledger.mjs";
11
+ import { readEvents, taskCost, fmtTokens } from "../task/cost.mjs";
12
+ import { readDiag } from "../core/diag.mjs";
13
+ import { inScope } from "../core/snapshot.mjs";
14
+ import { readJson } from "../core/fs.mjs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { evaluate, markSent } from "../core/feedback-policy.mjs";
17
+
18
+ // the runtime is installed into the project; its version is in install-state.json, falling back to the package
19
+ function nccgsVersion(root) { try { return readJson(at(root, "installState")).version; } catch { try { return readJson(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json")).version; } catch { return "unknown"; } } }
20
+
21
+ const DAY = 86400000;
22
+ const count = (arr, key) => arr.reduce((m, x) => { const k = key(x); m[k] = (m[k] ?? 0) + 1; return m; }, {});
23
+
24
+ export async function buildBundle(root, { sinceDays = 14, note = "", withReports = false, doctor = null, trigger = "manual", reasons = [] } = {}) {
25
+ const sinceMs = Date.now() - sinceDays * DAY;
26
+ const config = loadConfig(root);
27
+ const events = readEvents(root).filter(e => Date.parse(e.at) >= sinceMs);
28
+ const diags = readDiag(root, { sinceMs });
29
+ const tasks = listTasks(root).filter(t => Date.parse(t.updatedAt ?? t.openedAt) >= sinceMs);
30
+ const taskRows = tasks.map(t => {
31
+ let cost = null; try { cost = taskCost(root, t); } catch {}
32
+ const verifications = t.verifications ?? [];
33
+ return {
34
+ id: t.id, title: t.title, rigor: t.rigor, mode: t.mode, status: t.status, outcome: t.outcome ?? null, fromPlan: !!t.fromPlan,
35
+ openedAt: t.openedAt, closedAt: t.closedAt ?? null, minutes: t.closedAt ? Math.round((Date.parse(t.closedAt) - Date.parse(t.openedAt)) / 60000) : null,
36
+ criteria: (t.criteria ?? []).length, manualCriteria: (t.criteria ?? []).filter(c => c.kind === "manual").length,
37
+ verifications: { total: verifications.length, failed: verifications.filter(v => !v.ok).length, byKind: count(verifications, v => v.kind) },
38
+ reviews: (t.reviews ?? []).map(r => ({ verdict: r.verdict, model: r.model, findings: count(r.findings ?? [], f => (f.severity ?? "INFO").toUpperCase()), resolved: (r.findings ?? []).filter(f => f.resolved).length })),
39
+ fixRounds: `${t.fixRounds}/${t.maxFixRounds}`,
40
+ changedFiles: (t.changedFiles ?? []).length, outsideContract: (t.changedFiles ?? []).filter(f => !inScope(f, t.files)).length,
41
+ tokens: cost?.total?.messages ? { in: cost.total.input + cost.total.cacheWrite + cost.total.cacheRead, cached: cost.total.cacheRead, out: cost.total.output, messages: cost.total.messages, byAgent: Object.fromEntries(Object.entries(cost.byAgent).map(([k, v]) => [k, v.output])) } : null
42
+ };
43
+ });
44
+ let doc = null;
45
+ if (doctor) { try { doc = await doctor(root); doc = { summary: doc.summary, checks: doc.checks.map(c => ({ id: c.id, status: c.status, message: c.message })) }; } catch (e) { doc = { error: e.message }; } }
46
+ const bundle = {
47
+ schemaVersion: 1, generatedAt: new Date().toISOString(), sinceDays, trigger, reasons,
48
+ nccgs: nccgsVersion(root), node: process.version, platform: `${os.platform()} ${os.release()} ${os.arch()}`,
49
+ project: { editor: editorVersion(root), pipeline: pipelineVersion(root), mode: config.mode, rigor: config.rigor, autonomy: config.autonomy, reportLanguage: config.report?.language ?? "en" },
50
+ doctor: doc,
51
+ totals: {
52
+ tasks: taskRows.length, closed: taskRows.filter(t => t.status === "closed").length, byOutcome: count(taskRows.filter(t => t.status === "closed"), t => t.outcome),
53
+ verifications: taskRows.reduce((a, t) => a + t.verifications.total, 0), failedVerifications: taskRows.reduce((a, t) => a + t.verifications.failed, 0),
54
+ fixRounds: taskRows.reduce((a, t) => a + Number(t.fixRounds.split("/")[0]), 0),
55
+ tokensOut: taskRows.reduce((a, t) => a + (t.tokens?.out ?? 0), 0),
56
+ sessions: events.filter(e => e.type === "session.start").length,
57
+ agentRuns: count(events.filter(e => e.type === "agent.start"), e => e.agent || "unknown"),
58
+ diagnostics: count(diags, d => d.kind)
59
+ },
60
+ tasks: taskRows,
61
+ diagnostics: diags.slice(-300),
62
+ updateNotices: exists(at(root, "updateCheck")) ? JSON.parse(readText(at(root, "updateCheck"), "{}")).notices ?? [] : [],
63
+ state: readText(at(root, "state"), "").split("\n").filter(l => l.startsWith("- ")).slice(0, 30),
64
+ note: note || null,
65
+ reports: withReports ? Object.fromEntries(tasks.filter(t => t.report && exists(path.resolve(root, t.report))).map(t => [t.id, readText(path.resolve(root, t.report), "")])) : undefined
66
+ };
67
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-").slice(0, 16);
68
+ const dir = path.join(at(root, "nccgs"), "feedback");
69
+ const jsonFile = path.join(dir, `nccgs-feedback-${stamp}.json`);
70
+ const mdFile = path.join(dir, `nccgs-feedback-${stamp}.md`);
71
+ writeJson(jsonFile, bundle);
72
+ writeText(mdFile, renderSummary(bundle));
73
+ return { jsonFile, mdFile, bundle };
74
+ }
75
+
76
+ const MAX_BYTES = 512 * 1024;
77
+
78
+ // POST the bundle to the configured endpoint. Never throws; the outcome is appended to
79
+ // .nccgs/feedback/sent.jsonl so a person can see what left the machine and when.
80
+ export async function sendBundle(root, { bundle, markdown, endpoint = loadConfig(root).feedback?.endpoint, timeoutMs = 10000 } = {}) {
81
+ const log = path.join(at(root, "nccgs"), "feedback", "sent.jsonl");
82
+ const record = async r => { try { appendLine(log, JSON.stringify({ at: new Date().toISOString(), endpoint, ...r })); } catch {} return r; };
83
+ if (!endpoint) return record({ ok: false, reason: "no endpoint configured" });
84
+ let body = JSON.stringify({ bundle, markdown });
85
+ if (body.length > MAX_BYTES) { body = JSON.stringify({ bundle: { ...bundle, diagnostics: bundle.diagnostics.slice(-50), reports: undefined, truncated: true }, markdown }); }
86
+ if (body.length > MAX_BYTES) return record({ ok: false, reason: `bundle too large (${body.length} bytes)` });
87
+ const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), timeoutMs);
88
+ try {
89
+ const r = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", "user-agent": `nccgs/${bundle.nccgs}` }, body, signal: ctl.signal });
90
+ const text = await r.text().catch(() => "");
91
+ let id = null; try { id = JSON.parse(text).id ?? null; } catch {}
92
+ if (!r.ok) return record({ ok: false, status: r.status, reason: text.slice(0, 200) });
93
+ markSent(root);
94
+ return record({ ok: true, status: r.status, id, bytes: body.length });
95
+ } catch (e) { return record({ ok: false, reason: e.name === "AbortError" ? "timeout" : e.message }); }
96
+ finally { clearTimeout(t); }
97
+ }
98
+
99
+ // Called by hooks and `task close`: decide, and either send (consent auto) or return a suggestion.
100
+ // Returns { action, reasons, sent? } and never throws.
101
+ export async function autoFeedback(root, { trigger = "hook" } = {}) {
102
+ try {
103
+ const d = evaluate(root);
104
+ if (d.action !== "send") return d;
105
+ const { bundle, mdFile } = await buildBundle(root, { sinceDays: 14, trigger, reasons: d.reasons });
106
+ const sent = await sendBundle(root, { bundle, markdown: readText(mdFile, "") });
107
+ return { ...d, sent };
108
+ } catch (e) { return { action: "none", reasons: [], error: e.message }; }
109
+ }
110
+
111
+ export function renderSummary(b) {
112
+ const L = [];
113
+ L.push(`# NCCGS feedback · ${b.generatedAt.slice(0, 10)} · last ${b.sinceDays} days`, "");
114
+ L.push(`NCCGS ${b.nccgs} · Node ${b.node} · ${b.platform} · Unity ${b.project.editor ?? "?"} · Pipeline ${b.project.pipeline ?? "?"} · mode ${b.project.mode} · rigor ${b.project.rigor} · autonomy ${b.project.autonomy}`, "");
115
+ const t = b.totals;
116
+ L.push(`**Totals.** ${t.tasks} tasks (${t.closed} closed: ${Object.entries(t.byOutcome).map(([k, v]) => `${v} ${k}`).join(", ") || "none"}) · ${t.verifications} verifications (${t.failedVerifications} failed) · ${t.fixRounds} fix rounds · ${fmtTokens(t.tokensOut)} tokens out · ${t.sessions} sessions · agents: ${Object.entries(t.agentRuns).map(([k, v]) => `${k} ×${v}`).join(", ") || "none"}`, "");
117
+ if (b.doctor?.summary) L.push(`**Doctor.** ${b.doctor.summary.pass} pass · ${b.doctor.summary.warn} warn · ${b.doctor.summary.fail} fail${b.doctor.checks.filter(c => c.status !== "pass").map(c => `\n- ${c.status.toUpperCase()} ${c.id}: ${c.message}`).join("")}`, "");
118
+ L.push(`## Tasks`, "", `| Task | Rigor | Outcome | Verify (failed) | Review | Fix | Outside scope | Minutes | Tokens out |`, `| --- | --- | --- | --- | --- | --- | --- | --- | --- |`);
119
+ for (const x of b.tasks) L.push(`| ${x.id} ${x.title.slice(0, 40)} | ${x.rigor} | ${x.status === "closed" ? x.outcome : x.status} | ${x.verifications.total} (${x.verifications.failed}) | ${x.reviews.map(r => `${r.verdict}${Object.keys(r.findings).length ? ` ${Object.entries(r.findings).map(([k, v]) => `${k[0]}${v}`).join("/")}` : ""}`).join(" → ") || "–"} | ${x.fixRounds} | ${x.outsideContract} | ${x.minutes ?? "–"} | ${x.tokens ? fmtTokens(x.tokens.out) : "–"} |`);
120
+ L.push("");
121
+ const kinds = Object.entries(t.diagnostics);
122
+ L.push(`## Diagnostics (${b.diagnostics.length})`, "");
123
+ if (!kinds.length) L.push("Nothing recorded: no runtime errors, failed verifications, Unity failures or hook blocks in the window.", "");
124
+ else {
125
+ L.push(kinds.map(([k, v]) => `${k} ×${v}`).join(" · "), "");
126
+ const groups = {};
127
+ for (const d of b.diagnostics) { const key = `${d.kind}: ${(d.reason ?? d.message ?? d.error?.message ?? (d.errors?.[0]?.code ?? d.errors?.[0]?.message) ?? d.name ?? "").toString().slice(0, 100)}`; groups[key] = (groups[key] ?? 0) + 1; }
128
+ for (const [k, v] of Object.entries(groups).sort((a, b) => b[1] - a[1]).slice(0, 30)) L.push(`- ×${v} ${k}`);
129
+ L.push("");
130
+ }
131
+ if (b.updateNotices?.length) L.push(`## Updates pending`, "", ...b.updateNotices.map(n => `- ${n.component} ${n.current} → ${n.latest}`), "");
132
+ if (b.note) L.push(`## Note from the person`, "", b.note, "");
133
+ L.push(`## Recent closes`, "", ...(b.state.length ? b.state : ["(none)"]), "");
134
+ L.push("_Local bundle. Contains task metadata, counts, diagnostics and the note above; no source code, screenshots, transcripts or credentials. The JSON beside this file has the detail._");
135
+ return L.join("\n");
136
+ }
@@ -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
 
@@ -14,6 +14,8 @@ import { checkAssets } from "../unity/assets.mjs";
14
14
  import { openTask, loadTask, saveTask, listTasks, assess, recordVerification, readyToClose, event, reviewsNeeded, resolveFinding } from "./ledger.mjs";
15
15
  import { renderReport, appendState } from "./report.mjs";
16
16
  import { renderProject } from "./project.mjs";
17
+ import { diag } from "../core/diag.mjs";
18
+ import { autoFeedback } from "./feedback.mjs";
17
19
  import { loadPlan, planStatus, nextPlanned, planItem, savePlan } from "./plan.mjs";
18
20
 
19
21
  const argv = process.argv.slice(2);
@@ -31,7 +33,7 @@ for (let i = 0; i < own.length; i++) {
31
33
  }
32
34
  const root = projectRoot(opt.project);
33
35
  const out = value => process.stdout.write(JSON.stringify(value, null, 2) + "\n");
34
- const fail = message => { process.stderr.write(`task ${command}: ${message}\n`); process.exit(1); };
36
+ const fail = message => { try { diag(root, "task.error", { command, args: own.slice(0, 20), message }); } catch {} process.stderr.write(`task ${command}: ${message}\n`); process.exit(1); };
35
37
  const list = v => (typeof v === "string" ? v.split(",").map(s => s.trim()).filter(Boolean) : []);
36
38
 
37
39
  try {
@@ -61,7 +63,7 @@ try {
61
63
  event(root, "task.fix", { id: task.id, round: task.fixRounds });
62
64
  out({ id: task.id, fixRounds: task.fixRounds, maxFixRounds: task.maxFixRounds, exhausted: task.fixRounds >= task.maxFixRounds });
63
65
  } else fail(`unknown command ${command}`);
64
- } catch (error) { fail(error.message); }
66
+ } catch (error) { try { diag(root, "task.exception", { command, args: own.slice(0, 20), error }); } catch {} fail(error.message); }
65
67
 
66
68
  // Content diff against the snapshot taken at open; mtime fallback for tasks opened before alpha.3.
67
69
  function changesSinceOpen(task) { return changedFiles(root, task.snapshotAtOpen?.sha, { sinceMs: Date.parse(task.openedAt) }); }
@@ -146,6 +148,7 @@ async function cmdVerify() {
146
148
  record = { kind, name, ok: r.code === 0 && !r.timedOut, artifact: toPosix(path.relative(root, file)), summary: { exitCode: r.code, timedOut: r.timedOut } };
147
149
  } else fail(`unknown --kind ${kind}`);
148
150
  recordVerification(root, task, record);
151
+ if (!record.ok) diag(root, "verify.failed", { id: task.id, verifyKind: kind, name: record.name, summary: record.summary });
149
152
  const a = assess(root, task);
150
153
  out({ id: task.id, verification: record, criteria: a.evaluation.summary, rows: a.evaluation.rows.map(r => ({ id: r.id, status: r.status, reasons: r.assertions.filter(x => x.status !== "PASS").map(x => `${x.artifact}${x.pointer ?? ""}: ${x.reason}`) })) });
151
154
  }
@@ -228,5 +231,8 @@ async function cmdClose() {
228
231
  event(root, "task.close", { id: task.id, outcome });
229
232
  let project = null;
230
233
  try { project = toPosix(path.relative(root, renderProject(root).file)); } catch {}
231
- out({ id: task.id, outcome, report: task.report, reportHtml: task.reportHtml, project, tokensOut: report.model.cost?.total?.output ?? null, criteria: ready.evaluation.summary, changedFiles: changed.length, outsideContract: outside(task, changed) });
234
+ // consent auto: a non-success close or accumulated diagnostics send a bundle now (best effort, 10 s cap)
235
+ let feedback = null;
236
+ try { const fb = await autoFeedback(root, { trigger: `task.close:${outcome}` }); feedback = fb.action === "send" ? (fb.sent?.ok ? `sent (${fb.reasons.join("; ")})` : `not sent: ${fb.sent?.reason ?? "?"}`) : fb.action === "none" ? null : `${fb.action}: ${fb.reasons.join("; ")}`; } catch {}
237
+ out({ id: task.id, outcome, report: task.report, reportHtml: task.reportHtml, project, feedback, tokensOut: report.model.cost?.total?.output ?? null, criteria: ready.evaluation.summary, changedFiles: changed.length, outsideContract: outside(task, changed) });
232
238
  }
@@ -9,6 +9,7 @@ import fs from "node:fs";
9
9
  import { run, sleep } from "../core/process-tree.mjs";
10
10
  import { readJson, writeJson, exists } from "../core/fs.mjs";
11
11
  import { at, editorVersion, pipelineVersion } from "../core/project.mjs";
12
+ import { diag } from "../core/diag.mjs";
12
13
 
13
14
  export const UNITY_BIN = process.env.NCCGS_UNITY_BIN || "unity";
14
15
  // Test seam: NCCGS_UNITY_ARGS prepends fixed arguments (used to point NCCGS_UNITY_BIN=node at a fake CLI).
@@ -27,7 +28,13 @@ function parseEnvelope(result) {
27
28
  export async function cli(args, { cwd, timeoutMs = 120000 } = {}) {
28
29
  const ownTree = ["test", "build"].includes(args[0]);
29
30
  const result = await run(UNITY_BIN, unityArgv([...args, "--format", "json"]), { cwd, timeoutMs, ownTree });
30
- return parseEnvelope(result);
31
+ const env = parseEnvelope(result);
32
+ if (result.timedOut || result.error || (env.json && env.json.success === false) || (!env.json && result.code !== 0)) {
33
+ const i = args.indexOf("--project-path");
34
+ const shown = i >= 0 ? [...args.slice(0, i), ...args.slice(i + 2)] : args;
35
+ diag(i >= 0 ? args[i + 1] : (process.env.NCCGS_PROJECT_ROOT ?? process.cwd()), "unity.failed", { args: shown.map(a => String(a).slice(0, 200)), code: result.code, timedOut: result.timedOut, errors: env.json?.errors ?? null, stderr: result.stderr?.slice(-500) ?? null, jobObject: result.jobObject });
36
+ }
37
+ return env;
31
38
  }
32
39
 
33
40
  export async function cliVersion() {
@@ -10,7 +10,7 @@ This Unity project runs under NCCGS 3: a task is done when the game has run and
10
10
  5. `.claude/nccgs/protocols/*.md`, then the official Unity Skills in `.claude/skills/`, then your own inference.
11
11
 
12
12
  ## Skills
13
- `/brainstorm` → `/map-systems` → `/vertical-slice` for a new game or a multi-task feature (brief, systems map + slice plan, then `/work` per planned task). `/unity-foundation` on a fresh project. `/playtest` for feel and tuning (telemetry + playtester judgement + user decision). `/art-bible` and `/asset-spec` before and after art. `/status` for where we are.
13
+ `/brainstorm` → `/map-systems` → `/vertical-slice` for a new game or a multi-task feature (brief, systems map + slice plan, then `/work` per planned task). `/unity-foundation` on a fresh project; `/adopt` on a project that already has code, scenes and tests (brief and systems map from what exists, one task that proves the loop runs). `/playtest` for feel and tuning (telemetry + playtester judgement + user decision). `/art-bible` and `/asset-spec` before and after art. `/status` for where we are. `/feedback` when something went wrong or after a few days of use.
14
14
 
15
15
  ## The loop (skill `/work`)
16
16
  Brief → options for the user when the decision is subjective → `task open` → edit → `task verify` → **look at the screenshots and console** → fix or ask → `task review` (rigor ≥ production) → `task close` → report.