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 +37 -37
- package/src/cli.mjs +10 -1
- package/src/harness/util.mjs +172 -149
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cadet-agent",
|
|
3
|
-
"version": "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
|
-
|
|
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';
|
package/src/harness/util.mjs
CHANGED
|
@@ -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
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
|
|
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
|
+
|