cyber-aced 0.3.0 → 0.4.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +18 -0
- package/.plugin/plugin.json +1 -1
- package/dist/aced-bench.js +1256 -0
- package/license +21 -0
- package/package.json +43 -43
- package/skills/check-freshness/README.md +10 -1
- package/skills/check-freshness/SKILL.md +12 -0
- package/skills/check-freshness/scripts/check-freshness.mts +134 -1
- package/skills/improve-skill/scripts/validate.mts +31 -3
package/license
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Unional
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
2
|
+
"name": "cyber-aced",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Agent Config Evaluation & Development. Spec, evaluate, and improve agent configurations: skills, AGENTS.md sections, subagent definitions, and commands.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/cyberuni/cyber-sdd.git",
|
|
8
|
+
"directory": "plugins/aced"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/cyberuni/cyber-sdd",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"aced-bench": "./dist/aced-bench.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"skills",
|
|
19
|
+
"agents",
|
|
20
|
+
"governances",
|
|
21
|
+
".plugin",
|
|
22
|
+
".codex-plugin",
|
|
23
|
+
".claude-plugin",
|
|
24
|
+
"!skills/**/*.test.mts"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cyberuni/agent-harness": "0.5.0"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"tsdown": "^0.23.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.11"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"check:spec": "node ../sdd/skills/check-project-specs/scripts/check-project-specs.mts",
|
|
41
|
+
"test": "node --test \"skills/*/scripts/*.test.mts\"",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -21,6 +21,14 @@ A directory the run expanded carries both its own entry and a `file` entry per f
|
|
|
21
21
|
yielded — the directory hash covers names only, so without the per-file entries an edit inside that
|
|
22
22
|
directory would read `current` forever.
|
|
23
23
|
|
|
24
|
+
## Measured records
|
|
25
|
+
|
|
26
|
+
The bench engine writes a measured record per arm to `.agents/aced/results/bench/<suite>/` with an
|
|
27
|
+
`evaluated` set in the same entry shape but no `target`. `--suite <suite> --arm <label>` finds those
|
|
28
|
+
records by the `suite` and `arm` they record, picks the greatest recorded `createdAt`, and compares.
|
|
29
|
+
Every recorded input — `tasks.json`, a check, a `file` arm's source — changes what was measured, so
|
|
30
|
+
any mismatch is `stale`; there is no `incomplete` here.
|
|
31
|
+
|
|
24
32
|
## Why the record and not the tree
|
|
25
33
|
|
|
26
34
|
The first attempt at this capability inferred the subject's dependencies from outside: it guessed
|
|
@@ -50,7 +58,8 @@ oversight.
|
|
|
50
58
|
## Tests
|
|
51
59
|
|
|
52
60
|
`scripts/check-freshness.test.mts` — one test per frozen scenario, with fixtures chosen so a
|
|
53
|
-
guessing implementation cannot pass: the result is filed under a deliberately wrong directory name
|
|
61
|
+
guessing implementation cannot pass: the result is filed under a deliberately wrong directory name (a
|
|
62
|
+
measured record under a file name ending in the wrong arm label),
|
|
54
63
|
alphabetical filename order is the reverse of recorded timestamp order, and one fixture rewrites a
|
|
55
64
|
file's modification time while leaving its bytes identical.
|
|
56
65
|
|
|
@@ -26,6 +26,18 @@ node "<skill>/scripts/check-freshness.mts" --node <node-dir>
|
|
|
26
26
|
`.feature`. Exit status is **zero only for `current`**; every other verdict, and every fail-closed
|
|
27
27
|
path, exits non-zero.
|
|
28
28
|
|
|
29
|
+
For a **measured record** the bench engine wrote, name the suite and arm instead:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
node "<skill>/scripts/check-freshness.mts" --suite <suite> --arm <label>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It reads `.agents/aced/results/bench/<suite>/`, keeps the records whose own `suite` and `arm` fields
|
|
36
|
+
match (never the file name), takes the greatest recorded `createdAt`, and compares its evaluated set —
|
|
37
|
+
the suite's `tasks.json`, its `checks/`, and a `file` arm's source. A measured record has no frozen
|
|
38
|
+
suite, so any moved input reads `stale`; it never reads `incomplete`. Run it from inside the
|
|
39
|
+
repository. `--suite` without `--arm`, or the reverse, fails closed with no verdict.
|
|
40
|
+
|
|
29
41
|
It also exposes the hashing routine, so `run` records entries with the same implementation that
|
|
30
42
|
reads them back — never a second one that merely agrees today:
|
|
31
43
|
|
|
@@ -282,6 +282,119 @@ export function findRepoRoot(from: string): string | null {
|
|
|
282
282
|
|
|
283
283
|
export const RESULTS_DIR = join('.agents', 'aced', 'results')
|
|
284
284
|
|
|
285
|
+
// ─── measured records ─────────────────────────────────────────────────────────
|
|
286
|
+
//
|
|
287
|
+
// The bench engine writes one measured record per arm to `.agents/aced/results/bench/<suite>/`.
|
|
288
|
+
// It names no `target`, so it is found by the `suite` and `arm` it RECORDS — never by its file
|
|
289
|
+
// name — and compared with the same hash rule. Every recorded input moves what was measured (the
|
|
290
|
+
// prompts, what `pass` means, the arm itself), so there is no frozen suite to split off and no
|
|
291
|
+
// `incomplete`: any moved input reads `stale`.
|
|
292
|
+
|
|
293
|
+
export const MEASURED_RESULTS_DIR = join(RESULTS_DIR, 'bench')
|
|
294
|
+
const MEASURED_BENCH_DIR = join('.agents', 'aced', 'bench')
|
|
295
|
+
|
|
296
|
+
export interface MeasuredRecord {
|
|
297
|
+
layer?: string
|
|
298
|
+
suite?: string
|
|
299
|
+
arm?: string
|
|
300
|
+
createdAt?: string
|
|
301
|
+
subject?: { kind?: string; from?: string }
|
|
302
|
+
evaluated?: EvaluatedEntry[]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface MeasuredScan {
|
|
306
|
+
forArm: Array<{ file: string; record: MeasuredRecord }>
|
|
307
|
+
unreadable: string[]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Keeps the measured records whose OWN `suite` and `arm` fields match. A file that parses but is
|
|
312
|
+
* not a measured record — a compare record sitting beside them — is simply not a candidate.
|
|
313
|
+
*/
|
|
314
|
+
export function scanMeasured(files: string[], suite: string, arm: string, read: (f: string) => string): MeasuredScan {
|
|
315
|
+
const forArm: MeasuredScan['forArm'] = []
|
|
316
|
+
const unreadable: string[] = []
|
|
317
|
+
for (const file of files) {
|
|
318
|
+
let record: MeasuredRecord
|
|
319
|
+
try {
|
|
320
|
+
record = JSON.parse(read(file)) as MeasuredRecord
|
|
321
|
+
} catch {
|
|
322
|
+
unreadable.push(file)
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
if (record && record.layer === 'measured' && record.suite === suite && record.arm === arm)
|
|
326
|
+
forArm.push({ file, record })
|
|
327
|
+
}
|
|
328
|
+
return { forArm, unreadable }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Newest by RECORDED `createdAt`, for the same reason `selectNewest` ignores file names. */
|
|
332
|
+
export function selectNewestMeasured(records: MeasuredScan['forArm']): MeasuredScan['forArm'][number] | null {
|
|
333
|
+
let best: MeasuredScan['forArm'][number] | null = null
|
|
334
|
+
for (const r of records) {
|
|
335
|
+
const t = r.record.createdAt ?? ''
|
|
336
|
+
if (!best || t > (best.record.createdAt ?? '')) best = r
|
|
337
|
+
}
|
|
338
|
+
return best
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The bench engine always records the suite's `tasks.json`, and records a `file` arm's source
|
|
343
|
+
* whenever the arm reads it from a path. A set omitting either contradicts the record carrying it.
|
|
344
|
+
*/
|
|
345
|
+
export function measuredIncoherence(record: MeasuredRecord, suite: string): string | null {
|
|
346
|
+
const paths = new Set((record.evaluated ?? []).map((e) => e.path))
|
|
347
|
+
const tasks = join(MEASURED_BENCH_DIR, suite, 'tasks.json')
|
|
348
|
+
if (!paths.has(tasks)) {
|
|
349
|
+
return `the recorded provenance contradicts the record it accompanies: it carries no entry for ${tasks}`
|
|
350
|
+
}
|
|
351
|
+
const from = record.subject?.kind === 'file' ? record.subject.from : undefined
|
|
352
|
+
if (from?.startsWith('path:')) {
|
|
353
|
+
const source = from.slice(5)
|
|
354
|
+
if (!paths.has(source)) {
|
|
355
|
+
return `the recorded provenance omits the file arm's source: no entry for ${source}`
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function measuredMain(repoRoot: string, suite: string, arm: string): number {
|
|
362
|
+
process.stdout.write(`suite: ${suite}\narm: ${arm}\n`)
|
|
363
|
+
|
|
364
|
+
const dir = join(repoRoot, MEASURED_RESULTS_DIR, suite)
|
|
365
|
+
if (!existsSync(dir)) {
|
|
366
|
+
report({ verdict: 'absent', reason: 'nothing is measured for this suite', mismatched: [], skipped: [] })
|
|
367
|
+
return 1
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const scan = scanMeasured(collectResultFiles(dir), suite, arm, (f) => readFileSync(f, 'utf8'))
|
|
371
|
+
const skipped = scan.unreadable.map((f) => relative(repoRoot, f))
|
|
372
|
+
const newest = selectNewestMeasured(scan.forArm)
|
|
373
|
+
if (!newest) {
|
|
374
|
+
report({
|
|
375
|
+
verdict: 'absent',
|
|
376
|
+
reason: 'no measured record is recorded for this arm',
|
|
377
|
+
mismatched: [],
|
|
378
|
+
skipped,
|
|
379
|
+
})
|
|
380
|
+
return 1
|
|
381
|
+
}
|
|
382
|
+
if (!newest.record.evaluated) {
|
|
383
|
+
report({ verdict: 'absent', reason: 'the record carries no recorded provenance', mismatched: [], skipped })
|
|
384
|
+
return 1
|
|
385
|
+
}
|
|
386
|
+
const bad = measuredIncoherence(newest.record, suite)
|
|
387
|
+
if (bad) {
|
|
388
|
+
report({ verdict: 'absent', reason: bad, mismatched: [], skipped })
|
|
389
|
+
return 1
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// No frozen suite: every entry is a subject input, so the verdict is `stale` or `current`.
|
|
393
|
+
const decision = decideVerdict(newest.record, repoRoot, '', skipped)
|
|
394
|
+
report(decision)
|
|
395
|
+
return decision.verdict === 'current' ? 0 : 1
|
|
396
|
+
}
|
|
397
|
+
|
|
285
398
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
286
399
|
|
|
287
400
|
function report(d: Decision): void {
|
|
@@ -314,10 +427,30 @@ export function main(argv: string[]): number {
|
|
|
314
427
|
return 0
|
|
315
428
|
}
|
|
316
429
|
|
|
430
|
+
const suiteIdx = argv.indexOf('--suite')
|
|
431
|
+
const armIdx = argv.indexOf('--arm')
|
|
432
|
+
if (suiteIdx !== -1 || armIdx !== -1) {
|
|
433
|
+
// Fail closed, as the --node path does: one option without the other emits no verdict.
|
|
434
|
+
const suite = suiteIdx === -1 ? undefined : argv[suiteIdx + 1]
|
|
435
|
+
const arm = armIdx === -1 ? undefined : argv[armIdx + 1]
|
|
436
|
+
if (!suite || !arm) {
|
|
437
|
+
console.error(
|
|
438
|
+
`✗ a measured check needs both --suite <suite> and --arm <label>; missing ${suite ? '--arm' : '--suite'}; no verdict`,
|
|
439
|
+
)
|
|
440
|
+
return 1
|
|
441
|
+
}
|
|
442
|
+
const repoRoot = findRepoRoot(process.cwd())
|
|
443
|
+
if (!repoRoot) {
|
|
444
|
+
console.error('✗ no repository root above the working directory; no verdict')
|
|
445
|
+
return 1
|
|
446
|
+
}
|
|
447
|
+
return measuredMain(repoRoot, suite, arm)
|
|
448
|
+
}
|
|
449
|
+
|
|
317
450
|
const nodeIdx = argv.indexOf('--node')
|
|
318
451
|
const nodeArg = nodeIdx === -1 ? undefined : argv[nodeIdx + 1]
|
|
319
452
|
if (!nodeArg) {
|
|
320
|
-
console.error('✗ usage: check-freshness --node <node-dir>')
|
|
453
|
+
console.error('✗ usage: check-freshness --node <node-dir> | --suite <suite> --arm <label>')
|
|
321
454
|
return 1
|
|
322
455
|
}
|
|
323
456
|
const nodeDir = resolve(nodeArg)
|
|
@@ -172,6 +172,31 @@ export function recognizedScanRoots(cwd: string, dirGlobs: string[] = []): Set<s
|
|
|
172
172
|
return roots
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
// Every non-hidden file under a skill's scripts/, as a /-separated path relative to it. Recurses
|
|
176
|
+
// into subfolders (e.g. scripts/vendor/) so the content checks see nested scripts too; does not
|
|
177
|
+
// follow a symlinked folder, so a link cycle cannot loop the walk.
|
|
178
|
+
export function listScriptFiles(scriptsDir: string, rel = ''): string[] {
|
|
179
|
+
const files: string[] = []
|
|
180
|
+
for (const entry of fs.readdirSync(path.join(scriptsDir, rel), { withFileTypes: true })) {
|
|
181
|
+
if (entry.name.startsWith('.')) continue
|
|
182
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name
|
|
183
|
+
if (entry.isDirectory()) {
|
|
184
|
+
files.push(...listScriptFiles(scriptsDir, relPath))
|
|
185
|
+
} else if (entry.isFile() || (entry.isSymbolicLink() && isFileTarget(path.join(scriptsDir, relPath)))) {
|
|
186
|
+
files.push(relPath)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return files.sort()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isFileTarget(filePath: string): boolean {
|
|
193
|
+
try {
|
|
194
|
+
return fs.statSync(filePath).isFile()
|
|
195
|
+
} catch {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
175
200
|
export function findSkillFiles(dirs: string[], cwd: string): string[] {
|
|
176
201
|
const seen = new Set<string>()
|
|
177
202
|
const results: string[] = []
|
|
@@ -509,6 +534,9 @@ export function runChecks(filePath: string, scanRoots?: Set<string>): CheckResul
|
|
|
509
534
|
const stripped = stripExamples(content)
|
|
510
535
|
const invisibleInSkill = findInvisibleUnicode(content)
|
|
511
536
|
const isPartialSkill = fmInternal
|
|
537
|
+
// ADR-0031: the exact description "By name only" is the by-name mechanism, independent of
|
|
538
|
+
// user-invocable (visibility only). A visible by-name skill must not be pushed to pad it (#81).
|
|
539
|
+
const isByNameDescription = fmDesc === 'By name only'
|
|
512
540
|
const isPublicShippedSkill = parent === 'skills' && skillBaseParent !== '.agents' && !fmInternal
|
|
513
541
|
|
|
514
542
|
// S1: the SKILL.md must sit in its own named subdirectory directly under a recognized scan root.
|
|
@@ -609,7 +637,7 @@ export function runChecks(filePath: string, scanRoots?: Set<string>): CheckResul
|
|
|
609
637
|
}
|
|
610
638
|
|
|
611
639
|
if (fmDesc) {
|
|
612
|
-
if (!isPartialSkill) {
|
|
640
|
+
if (!isPartialSkill && !isByNameDescription) {
|
|
613
641
|
const wordCount = fmDesc.split(/\s+/).filter(Boolean).length
|
|
614
642
|
if (wordCount < 12) {
|
|
615
643
|
warn(
|
|
@@ -712,7 +740,7 @@ export function runChecks(filePath: string, scanRoots?: Set<string>): CheckResul
|
|
|
712
740
|
}
|
|
713
741
|
|
|
714
742
|
if (hasScripts) {
|
|
715
|
-
const scriptFiles =
|
|
743
|
+
const scriptFiles = listScriptFiles(scriptsDir)
|
|
716
744
|
const hasInteractive = scriptFiles.some((f) => {
|
|
717
745
|
const src = fs.readFileSync(path.join(scriptsDir, f), 'utf8')
|
|
718
746
|
return /readline|createInterface|\.question\(/.test(src)
|
|
@@ -784,7 +812,7 @@ export function runChecks(filePath: string, scanRoots?: Set<string>): CheckResul
|
|
|
784
812
|
}
|
|
785
813
|
|
|
786
814
|
if (hasScripts) {
|
|
787
|
-
for (const f of
|
|
815
|
+
for (const f of listScriptFiles(scriptsDir)) {
|
|
788
816
|
const src = fs.readFileSync(path.join(scriptsDir, f), 'utf8')
|
|
789
817
|
const invisibleInScript = findInvisibleUnicode(src)
|
|
790
818
|
if (invisibleInScript) {
|