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/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
- "name": "cyber-aced",
3
- "version": "0.3.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
- "scripts": {
30
- "build": "tsdown",
31
- "check:spec": "node ../sdd/skills/check-project-specs/scripts/check-project-specs.mts",
32
- "test": "node --test \"skills/*/scripts/*.test.mts\"",
33
- "typecheck": "tsc -p tsconfig.json"
34
- },
35
- "publishConfig": {
36
- "access": "public"
37
- },
38
- "devDependencies": {
39
- "tsdown": "^0.23.0"
40
- },
41
- "engines": {
42
- "node": ">=22.11"
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 = fs.readdirSync(scriptsDir).filter((f) => !f.startsWith('.'))
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 fs.readdirSync(scriptsDir).filter((name) => !name.startsWith('.'))) {
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) {