cadet-agent 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.34.0",
4
- "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
- "type": "module",
6
- "bin": {
7
- "cadet-agent": "bin/cli.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test test/*.test.mjs",
11
- "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
- "verify": "npm test && npm run lint"
13
- },
14
- "files": [
15
- "bin/",
16
- "src/"
17
- ],
18
- "keywords": [
19
- "cadet",
20
- "cadet-agent",
21
- "unity",
22
- "game-development",
23
- "ai-agent",
24
- "copilot",
25
- "cursor",
26
- "claude-code"
27
- ],
28
- "license": "CC-BY-4.0",
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/naishtech/cadet-agent.git"
32
- },
33
- "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
- "engines": {
35
- "node": ">=18.0.0"
36
- }
37
- }
1
+ {
2
+ "name": "cadet-agent",
3
+ "version": "0.35.0",
4
+ "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadet-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs",
11
+ "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
+ "verify": "npm test && npm run lint"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/"
17
+ ],
18
+ "keywords": [
19
+ "cadet",
20
+ "cadet-agent",
21
+ "unity",
22
+ "game-development",
23
+ "ai-agent",
24
+ "copilot",
25
+ "cursor",
26
+ "claude-code"
27
+ ],
28
+ "license": "CC-BY-4.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/naishtech/cadet-agent.git"
32
+ },
33
+ "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
package/src/cli.mjs CHANGED
@@ -90,7 +90,10 @@ function parseArgs(argv) {
90
90
  case '--environment': opts.environment = argv[++i]; break;
91
91
  case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
92
92
  case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
93
- case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
93
+ // Track that the flag was supplied even when its value is empty, so an
94
+ // empty `--files ""` is rejected rather than silently falling back to the
95
+ // working-tree scan (which could bind evidence to Cadet's own files).
96
+ case '--files': opts.filesGiven = true; opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
94
97
  case '--story': opts.story = argv[++i]; break;
95
98
  case '--report': opts.report = argv[++i]; break;
96
99
  case '--write-coverage': opts.writeCoverage = true; break;
@@ -320,6 +323,9 @@ async function cmdHarness(opts) {
320
323
  // Freshness binding mirrors `harness verify`: never record a gate against an
321
324
  // unknown input tree unless the repository explicitly opted out.
322
325
  const allowEmpty = policy?.allowEmptyFreshness === true;
326
+ if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
327
+ fail(opts, '--files was given with no paths. Pass a comma-separated list of the files this gate covers (e.g. --files src/Foo.cs,test/FooTests.cs), or omit --files to auto-detect changed files.', () => 1, { ok: false, gate, code: 'empty-files' });
328
+ }
323
329
  let relevantFiles;
324
330
  if (opts.files && opts.files.length) {
325
331
  relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
@@ -459,6 +465,9 @@ async function cmdHarness(opts) {
459
465
  const allowEmpty = policy?.allowEmptyFreshness === true;
460
466
  let relevantFiles;
461
467
  let filesSource;
468
+ if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
469
+ fail(opts, '--files was given with no paths. Pass a comma-separated list of the files this gate covers (e.g. --files src/Foo.cs,test/FooTests.cs), or omit --files to auto-detect changed files.', () => 1, { ok: false, gate, code: 'empty-files' });
470
+ }
462
471
  if (opts.files && opts.files.length) {
463
472
  relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
464
473
  filesSource = 'explicit';
@@ -1,149 +1,172 @@
1
- /**
2
- * Shared harness primitives: UUIDv4, SHA-256 over UTF-8 bytes, tree hashing,
3
- * deterministic JSON serialization, and UTC timestamps.
4
- *
5
- * Contract: docs/core/HarnessContract.md §2 (identifiers, hashes).
6
- */
7
-
8
- import { createHash, randomUUID } from 'node:crypto';
9
- import { readFileSync, existsSync } from 'node:fs';
10
- import { spawnSync } from 'node:child_process';
11
-
12
- /** UUIDv4 identifier. */
13
- export function newId() {
14
- return randomUUID();
15
- }
16
-
17
- export function isUuid(value) {
18
- return typeof value === 'string'
19
- && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
20
- }
21
-
22
- /** SHA-256 hex digest over UTF-8 bytes. */
23
- export function sha256(value) {
24
- return createHash('sha256').update(value, 'utf-8').digest('hex');
25
- }
26
-
27
- /** SHA-256 hex digest over raw bytes (Buffers are hashed as-is). */
28
- export function sha256Bytes(buf) {
29
- return createHash('sha256').update(buf).digest('hex');
30
- }
31
-
32
- /** Hash of a file's exact bytes, or null when the file is missing. */
33
- export function hashFile(path) {
34
- if (!existsSync(path)) return null;
35
- try {
36
- return sha256Bytes(readFileSync(path));
37
- } catch {
38
- return null;
39
- }
40
- }
41
-
42
- /**
43
- * Deterministic hash of a set of `(relativePath, fileHash)` pairs.
44
- * Pairs are sorted by path so the hash is order-independent and stable across
45
- * platforms. Files without a resolvable hash are recorded as `missing`.
46
- */
47
- export function hashTree(pairs) {
48
- const normalized = [...pairs]
49
- .map(({ path, hash }) => ({
50
- path: String(path).replace(/\\/g, '/'),
51
- hash: hash || 'missing',
52
- }))
53
- .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
54
- return sha256(JSON.stringify(normalized));
55
- }
56
-
57
- /**
58
- * Hash an acceptance-criteria document or list. `criteria` may be a string or an
59
- * array of strings; a stable serialization is used either way.
60
- */
61
- export function hashCriteria(criteria) {
62
- if (criteria === null || criteria === undefined) return sha256('[]');
63
- const arr = Array.isArray(criteria) ? criteria.map(String) : [String(criteria)];
64
- return sha256(JSON.stringify(arr));
65
- }
66
-
67
- /** ISO-8601 UTC timestamp for a Date or "now". */
68
- export function timestamp(at = new Date()) {
69
- return (at instanceof Date ? at : new Date(at)).toISOString();
70
- }
71
-
72
- /** Current time provider; injectable for deterministic tests. */
73
- export function nowMs() {
74
- return Date.now();
75
- }
76
-
77
- /**
78
- * Canonical JSON with sorted keys. Used for stable hashes and for comparing
79
- * evidence records without key-order noise.
80
- */
81
- export function canonicalJson(value) {
82
- return JSON.stringify(sortKeys(value));
83
- }
84
-
85
- function sortKeys(value) {
86
- if (Array.isArray(value)) return value.map(sortKeys);
87
- if (value && typeof value === 'object') {
88
- const out = {};
89
- for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
90
- return out;
91
- }
92
- return value;
93
- }
94
-
95
- export { canonicalJson as stableStringify };
96
-
97
- /**
98
- * List the files changed in the working tree relative to HEAD, using git.
99
- * Returns forward-slash relative paths. Returns an empty array when git is
100
- * unavailable or the directory is not a repository — callers must not assume
101
- * freshness coverage in that case; use `gitChangedFiles` when the distinction
102
- * between "no changes" and "no git" matters.
103
- */
104
- export function changedFiles(cwd, { runner = defaultGitRunner } = {}) {
105
- return gitChangedFiles(cwd, { runner }).files;
106
- }
107
-
108
- /**
109
- * List changed files and report whether git was actually queryable.
110
- * Returns `{ available, files, reason }`. `available: false` means freshness
111
- * coverage could not be established and callers must fail safe.
112
- */
113
- export function gitChangedFiles(cwd, { runner = defaultGitRunner } = {}) {
114
- let res;
115
- try {
116
- res = runner('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all']);
117
- } catch (err) {
118
- return { available: false, files: [], reason: `git invocation failed: ${err.message}` };
119
- }
120
- if (!res) {
121
- return { available: false, files: [], reason: 'git is not available' };
122
- }
123
- if (res.error || res.status === null) {
124
- return { available: false, files: [], reason: 'git is not installed or could not be executed' };
125
- }
126
- if (res.status !== 0) {
127
- // Not a repository, or git refused the query.
128
- return { available: false, files: [], reason: String(res.stderr || '').trim() || `git exited ${res.status}` };
129
- }
130
- const files = new Set();
131
- for (const line of String(res.stdout || '').split(/\r?\n/)) {
132
- if (!line.trim()) continue;
133
- // Porcelain v1: XY<space>path (rename: "old -> new").
134
- let path = line.slice(3).trim();
135
- if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
136
- path = path.replace(/^"|"$/g, '');
137
- if (path) files.add(path.replace(/\\/g, '/'));
138
- }
139
- return { available: true, files: [...files].sort(), reason: null };
140
- }
141
-
142
- function defaultGitRunner(cmd, args) {
143
- try {
144
- return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
145
- } catch {
146
- return null;
147
- }
148
- }
149
-
1
+ /**
2
+ * Shared harness primitives: UUIDv4, SHA-256 over UTF-8 bytes, tree hashing,
3
+ * deterministic JSON serialization, and UTC timestamps.
4
+ *
5
+ * Contract: docs/core/HarnessContract.md §2 (identifiers, hashes).
6
+ */
7
+
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { readFileSync, existsSync } from 'node:fs';
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ /** UUIDv4 identifier. */
13
+ export function newId() {
14
+ return randomUUID();
15
+ }
16
+
17
+ export function isUuid(value) {
18
+ return typeof value === 'string'
19
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
20
+ }
21
+
22
+ /** SHA-256 hex digest over UTF-8 bytes. */
23
+ export function sha256(value) {
24
+ return createHash('sha256').update(value, 'utf-8').digest('hex');
25
+ }
26
+
27
+ /** SHA-256 hex digest over raw bytes (Buffers are hashed as-is). */
28
+ export function sha256Bytes(buf) {
29
+ return createHash('sha256').update(buf).digest('hex');
30
+ }
31
+
32
+ /** Hash of a file's exact bytes, or null when the file is missing. */
33
+ export function hashFile(path) {
34
+ if (!existsSync(path)) return null;
35
+ try {
36
+ return sha256Bytes(readFileSync(path));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Deterministic hash of a set of `(relativePath, fileHash)` pairs.
44
+ * Pairs are sorted by path so the hash is order-independent and stable across
45
+ * platforms. Files without a resolvable hash are recorded as `missing`.
46
+ */
47
+ export function hashTree(pairs) {
48
+ const normalized = [...pairs]
49
+ .map(({ path, hash }) => ({
50
+ path: String(path).replace(/\\/g, '/'),
51
+ hash: hash || 'missing',
52
+ }))
53
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
54
+ return sha256(JSON.stringify(normalized));
55
+ }
56
+
57
+ /**
58
+ * Hash an acceptance-criteria document or list. `criteria` may be a string or an
59
+ * array of strings; a stable serialization is used either way.
60
+ */
61
+ export function hashCriteria(criteria) {
62
+ if (criteria === null || criteria === undefined) return sha256('[]');
63
+ const arr = Array.isArray(criteria) ? criteria.map(String) : [String(criteria)];
64
+ return sha256(JSON.stringify(arr));
65
+ }
66
+
67
+ /** ISO-8601 UTC timestamp for a Date or "now". */
68
+ export function timestamp(at = new Date()) {
69
+ return (at instanceof Date ? at : new Date(at)).toISOString();
70
+ }
71
+
72
+ /** Current time provider; injectable for deterministic tests. */
73
+ export function nowMs() {
74
+ return Date.now();
75
+ }
76
+
77
+ /**
78
+ * Canonical JSON with sorted keys. Used for stable hashes and for comparing
79
+ * evidence records without key-order noise.
80
+ */
81
+ export function canonicalJson(value) {
82
+ return JSON.stringify(sortKeys(value));
83
+ }
84
+
85
+ function sortKeys(value) {
86
+ if (Array.isArray(value)) return value.map(sortKeys);
87
+ if (value && typeof value === 'object') {
88
+ const out = {};
89
+ for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
90
+ return out;
91
+ }
92
+ return value;
93
+ }
94
+
95
+ export { canonicalJson as stableStringify };
96
+
97
+ /**
98
+ * List the files changed in the working tree relative to HEAD, using git.
99
+ * Returns forward-slash relative paths. Returns an empty array when git is
100
+ * unavailable or the directory is not a repository — callers must not assume
101
+ * freshness coverage in that case; use `gitChangedFiles` when the distinction
102
+ * between "no changes" and "no git" matters.
103
+ */
104
+ export function changedFiles(cwd, { runner = defaultGitRunner } = {}) {
105
+ return gitChangedFiles(cwd, { runner }).files;
106
+ }
107
+
108
+ /**
109
+ * Cadet's own bookkeeping — never a meaningful verification input.
110
+ *
111
+ * `state.json` is rewritten by the very command that records a gate, and
112
+ * `runs/*.json` gains a new ledger on every harness invocation. If either were
113
+ * auto-detected as a relevant file, the evidence hash would describe a file the
114
+ * recording itself mutates: the gate would be stale the moment it was written,
115
+ * and the resulting record would certify no story code. Excluded here, at the
116
+ * single scan used by both `harness verify` and `harness confirm`.
117
+ */
118
+ const CADET_MACHINERY = ['.cadet/state.json', '.cadet/runs/'];
119
+
120
+ /** True when a repository-relative path is Cadet's own bookkeeping. */
121
+ function isCadetMachinery(relPath) {
122
+ return CADET_MACHINERY.some((p) => (p.endsWith('/') ? relPath.startsWith(p) : relPath === p));
123
+ }
124
+
125
+ /**
126
+ * List changed files and report whether git was actually queryable.
127
+ * Returns `{ available, files, reason }`. `available: false` means freshness
128
+ * coverage could not be established and callers must fail safe.
129
+ *
130
+ * Cadet's own machinery (`.cadet/state.json`, `.cadet/runs/**`) is filtered out
131
+ * of `files`; see `CADET_MACHINERY`.
132
+ */
133
+ export function gitChangedFiles(cwd, { runner = defaultGitRunner } = {}) {
134
+ let res;
135
+ try {
136
+ res = runner('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all']);
137
+ } catch (err) {
138
+ return { available: false, files: [], reason: `git invocation failed: ${err.message}` };
139
+ }
140
+ if (!res) {
141
+ return { available: false, files: [], reason: 'git is not available' };
142
+ }
143
+ if (res.error || res.status === null) {
144
+ return { available: false, files: [], reason: 'git is not installed or could not be executed' };
145
+ }
146
+ if (res.status !== 0) {
147
+ // Not a repository, or git refused the query.
148
+ return { available: false, files: [], reason: String(res.stderr || '').trim() || `git exited ${res.status}` };
149
+ }
150
+ const files = new Set();
151
+ for (const line of String(res.stdout || '').split(/\r?\n/)) {
152
+ if (!line.trim()) continue;
153
+ // Porcelain v1: XY<space>path (rename: "old -> new").
154
+ let path = line.slice(3).trim();
155
+ if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
156
+ path = path.replace(/^"|"$/g, '');
157
+ if (!path) continue;
158
+ const rel = path.replace(/\\/g, '/');
159
+ if (isCadetMachinery(rel)) continue;
160
+ files.add(rel);
161
+ }
162
+ return { available: true, files: [...files].sort(), reason: null };
163
+ }
164
+
165
+ function defaultGitRunner(cmd, args) {
166
+ try {
167
+ return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+