nccgs 3.0.0-rc.1 → 3.0.0-rc.2
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 +7 -0
- package/README.md +7 -2
- package/bin/nccgs.mjs +10 -0
- package/cli/feedback.mjs +13 -0
- package/package.json +2 -1
- package/runtime/core/diag.mjs +43 -0
- package/runtime/core/feedback-policy.mjs +54 -0
- package/runtime/core/project.mjs +7 -1
- package/runtime/hooks/common.mjs +4 -1
- package/runtime/hooks/session-start.mjs +8 -0
- package/runtime/task/feedback.mjs +136 -0
- package/runtime/task/task.mjs +9 -3
- package/runtime/unity/unity.mjs +8 -1
- package/template/CLAUDE.md +1 -1
- package/template/skills/feedback/SKILL.md +16 -0
- package/tools/feedback-worker/README.md +12 -0
- package/tools/feedback-worker/worker.js +39 -0
- package/tools/feedback-worker/wrangler.toml +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.0.0-rc.2 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- 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).
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- `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.
|
|
9
|
+
|
|
3
10
|
## 3.0.0-rc.1 — 2026-11-05 — phase 5: clean-up and release candidate
|
|
4
11
|
|
|
5
12
|
- 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.
|
|
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.
|
|
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/` (
|
|
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`).
|
|
26
26
|
|
|
27
27
|
## Daily use
|
|
28
28
|
|
|
@@ -37,6 +37,7 @@ Open the project in the Editor (screenshots and live tests need it; without it t
|
|
|
37
37
|
| `/unity-foundation` | a fresh project, or tests cannot run | asmdefs, test assemblies, input, pipeline as a measured task |
|
|
38
38
|
| `/playtest` | does it feel right, tuning, difficulty | telemetry artifact, the playtester's judgement, your decision |
|
|
39
39
|
| `/art-bible`, `/asset-spec` | before and after art | visual rules; import settings checked against them |
|
|
40
|
+
| `/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
41
|
|
|
41
42
|
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
43
|
|
|
@@ -73,6 +74,10 @@ Verified live, kept in `.claude/nccgs/protocols/unity-cli-notes.md` and `docs/EV
|
|
|
73
74
|
.nccgs/ project.json, modes.json, state.md, design/, tasks/, evidence/, reports/, project.html
|
|
74
75
|
```
|
|
75
76
|
|
|
77
|
+
## Feedback
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
76
81
|
## Updating
|
|
77
82
|
|
|
78
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`).
|
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];
|
package/cli/feedback.mjs
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nccgs",
|
|
3
|
-
"version": "3.0.0-rc.
|
|
3
|
+
"version": "3.0.0-rc.2",
|
|
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
|
+
};
|
package/runtime/core/project.mjs
CHANGED
|
@@ -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) {
|
package/runtime/hooks/common.mjs
CHANGED
|
@@ -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
|
+
}
|
package/runtime/task/task.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/runtime/unity/unity.mjs
CHANGED
|
@@ -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
|
-
|
|
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() {
|
package/template/CLAUDE.md
CHANGED
|
@@ -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. `/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.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: feedback
|
|
3
|
+
description: Send or prepare a feedback bundle for the NCCGS maintainers (what ran, what failed, what was blocked, tokens per task) with the person's note, and set the consent level (auto / manual / off). Use when something went wrong, when NCCGS suggests it, or when the session asks for consent.
|
|
4
|
+
allowed-tools: Read, Bash, AskUserQuestion
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /feedback
|
|
8
|
+
|
|
9
|
+
A bundle holds versions, doctor, per-task numbers, diagnostics and the person's note. No source code, screenshots, transcripts or credentials; project name and task titles are included. Where it goes is `feedback.endpoint` in `.nccgs/project.json`; a copy always stays under `.nccgs/feedback/`.
|
|
10
|
+
|
|
11
|
+
1. **Consent first.** If `.nccgs/project.json` has `feedback.consent: "ask"` (or the session told you consent is not set), ask once with AskUserQuestion — options `auto` (send by itself when an issue is detected or every 7 days), `manual` (only suggest), `off` (never) — then `npx nccgs feedback --project . --consent <choice>`. `off` ends here.
|
|
12
|
+
2. **What happened.** Ask in one question what to report (a problem now, an impression after some days, or the periodic bundle) and whether to include reports.
|
|
13
|
+
3. `npx nccgs feedback --project . --since <days> --note "<their words>" [--with-reports] --send` (omit `--send` when they only want the file). The `.md` path and the send result print on stderr.
|
|
14
|
+
4. Tell the person the three numbers (tasks closed / failed verifications / diagnostics), the top diagnostic line, whether it was sent (and the id) or where the file is.
|
|
15
|
+
|
|
16
|
+
Never add `--send` without the person's yes in this session unless consent is `auto`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# NCCGS feedback receiver
|
|
2
|
+
|
|
3
|
+
One Cloudflare Worker that turns a feedback bundle into a GitHub issue, so bundles can be read, labelled and closed like any other issue.
|
|
4
|
+
|
|
5
|
+
Setup (once, by the maintainer):
|
|
6
|
+
|
|
7
|
+
1. Create the repository `LacBird/nccgs-feedback` (private is fine). Labels are created on demand by the API.
|
|
8
|
+
2. Create a fine-grained personal access token limited to that repository with **Issues: read and write** only.
|
|
9
|
+
3. `npm i -g wrangler && wrangler login`, then in this folder: `wrangler secret put GITHUB_TOKEN`, optionally `wrangler secret put INGEST_KEY`, and `wrangler deploy`. The URL is `https://nccgs-feedback.<account>.workers.dev/v1/bundle`.
|
|
10
|
+
4. Put that URL in `DEFAULT_CONFIG.feedback.endpoint` (`runtime/core/project.mjs`) before the next release, or users set `feedback.endpoint` in `.nccgs/project.json`.
|
|
11
|
+
|
|
12
|
+
What a client sends: `{ bundle, markdown }` as built by `nccgs feedback` (≤ 512 KB). What the issue contains: the Markdown summary and, under a collapsed block, the JSON bundle. No code, screenshots, transcripts or credentials are in a bundle; project name and task titles are.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// NCCGS feedback receiver — a Cloudflare Worker. POST /v1/bundle {bundle, markdown} → one GitHub issue
|
|
2
|
+
// in FEEDBACK_REPO. Secrets: GITHUB_TOKEN (fine-grained, Issues: read/write on that repo only).
|
|
3
|
+
// Vars: FEEDBACK_REPO ("owner/name"). Optional: INGEST_KEY (clients send header x-nccgs-key).
|
|
4
|
+
// Limits: 512 KB body, 20 bundles per IP per day (KV binding RATE optional; without it no limit).
|
|
5
|
+
export default {
|
|
6
|
+
async fetch(request, env) {
|
|
7
|
+
const url = new URL(request.url);
|
|
8
|
+
if (request.method === "GET" && url.pathname === "/") return new Response("nccgs feedback receiver", { status: 200 });
|
|
9
|
+
if (request.method !== "POST" || url.pathname !== "/v1/bundle") return json({ error: "not found" }, 404);
|
|
10
|
+
if (env.INGEST_KEY && request.headers.get("x-nccgs-key") !== env.INGEST_KEY) return json({ error: "unauthorized" }, 401);
|
|
11
|
+
const len = Number(request.headers.get("content-length") || 0);
|
|
12
|
+
if (len > 512 * 1024) return json({ error: "too large" }, 413);
|
|
13
|
+
let payload; try { payload = await request.json(); } catch { return json({ error: "bad json" }, 400); }
|
|
14
|
+
const b = payload?.bundle;
|
|
15
|
+
if (!b || b.schemaVersion !== 1 || typeof payload.markdown !== "string") return json({ error: "bad bundle" }, 400);
|
|
16
|
+
if (env.RATE) {
|
|
17
|
+
const ip = request.headers.get("cf-connecting-ip") || "unknown";
|
|
18
|
+
const key = `rate:${ip}:${new Date().toISOString().slice(0, 10)}`;
|
|
19
|
+
const n = Number(await env.RATE.get(key) || 0);
|
|
20
|
+
if (n >= 20) return json({ error: "rate limited" }, 429);
|
|
21
|
+
await env.RATE.put(key, String(n + 1), { expirationTtl: 86400 });
|
|
22
|
+
}
|
|
23
|
+
const t = b.totals || {};
|
|
24
|
+
const topDiag = Object.entries(t.diagnostics || {}).sort((a, c) => c[1] - a[1])[0];
|
|
25
|
+
const title = `[feedback] nccgs ${b.nccgs} · ${(b.platform || "").split(" ")[0]} · ${t.closed ?? 0}/${t.tasks ?? 0} closed · ${t.failedVerifications ?? 0} failed verify${topDiag ? ` · ${topDiag[0]}×${topDiag[1]}` : ""}${b.trigger ? ` · ${b.trigger}` : ""}`;
|
|
26
|
+
const jsonText = JSON.stringify(b, null, 1);
|
|
27
|
+
const details = jsonText.length < 60000 ? `\n\n<details><summary>bundle.json</summary>\n\n\`\`\`json\n${jsonText}\n\`\`\`\n</details>` : `\n\n_bundle.json omitted (${jsonText.length} bytes); diagnostics count ${(b.diagnostics || []).length}._`;
|
|
28
|
+
const labels = ["feedback", `v${b.nccgs}`, ...(b.trigger && b.trigger !== "manual" ? ["auto"] : []), ...((t.failedVerifications ?? 0) > 0 || topDiag ? ["has-issues"] : [])];
|
|
29
|
+
const gh = await fetch(`https://api.github.com/repos/${env.FEEDBACK_REPO}/issues`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { authorization: `Bearer ${env.GITHUB_TOKEN}`, accept: "application/vnd.github+json", "content-type": "application/json", "user-agent": "nccgs-feedback-worker", "x-github-api-version": "2022-11-28" },
|
|
32
|
+
body: JSON.stringify({ title: title.slice(0, 250), body: payload.markdown.slice(0, 60000) + details, labels })
|
|
33
|
+
});
|
|
34
|
+
if (!gh.ok) return json({ error: "github", status: gh.status, detail: (await gh.text()).slice(0, 300) }, 502);
|
|
35
|
+
const issue = await gh.json();
|
|
36
|
+
return json({ id: issue.number, url: issue.html_url }, 201);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const json = (o, status = 200) => new Response(JSON.stringify(o), { status, headers: { "content-type": "application/json" } });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name = "nccgs-feedback"
|
|
2
|
+
main = "worker.js"
|
|
3
|
+
compatibility_date = "2026-01-01"
|
|
4
|
+
|
|
5
|
+
[vars]
|
|
6
|
+
FEEDBACK_REPO = "LacBird/nccgs-feedback"
|
|
7
|
+
|
|
8
|
+
# secrets (set with `wrangler secret put`): GITHUB_TOKEN, optionally INGEST_KEY
|
|
9
|
+
# optional rate limit:
|
|
10
|
+
# [[kv_namespaces]]
|
|
11
|
+
# binding = "RATE"
|
|
12
|
+
# id = "<kv id>"
|