agentic-workflow-manager 6.5.2 → 8.0.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.
Files changed (93) hide show
  1. package/dist/scripts/sensor-support-matrix.js +103 -0
  2. package/dist/scripts/support-matrix.js +40 -17
  3. package/dist/src/commands/init.js +1 -3
  4. package/dist/src/commands/ledger/index.js +6 -0
  5. package/dist/src/commands/preflight/checks.js +4 -4
  6. package/dist/src/commands/preflight/index.js +2 -2
  7. package/dist/src/commands/registry/add.js +2 -2
  8. package/dist/src/commands/registry/index.js +5 -0
  9. package/dist/src/commands/registry/status.js +7 -0
  10. package/dist/src/commands/sensors/compatibility/contract.js +261 -0
  11. package/dist/src/commands/sensors/compatibility/discovery.js +207 -0
  12. package/dist/src/commands/sensors/compatibility/live.js +59 -0
  13. package/dist/src/commands/sensors/compatibility/manifest.js +197 -0
  14. package/dist/src/commands/sensors/compatibility/materialize.js +126 -0
  15. package/dist/src/commands/sensors/compatibility/pack-source.js +78 -0
  16. package/dist/src/commands/sensors/compatibility/probe.js +47 -0
  17. package/dist/src/commands/sensors/compatibility/resolve.js +96 -0
  18. package/dist/src/commands/sensors/compatibility/types.js +2 -0
  19. package/dist/src/commands/sensors/coverage/contract.js +0 -50
  20. package/dist/src/commands/sensors/coverage/empirical.js +131 -0
  21. package/dist/src/commands/sensors/coverage/evaluate.js +85 -7
  22. package/dist/src/commands/sensors/coverage/evidence.js +5 -1
  23. package/dist/src/commands/sensors/coverage/index.js +44 -7
  24. package/dist/src/commands/sensors/coverage/render.js +214 -14
  25. package/dist/src/commands/sensors/coverage/resolve.js +24 -38
  26. package/dist/src/commands/sensors/exec.js +150 -9
  27. package/dist/src/commands/sensors/index.js +18 -6
  28. package/dist/src/commands/sensors/init.js +92 -6
  29. package/dist/src/commands/sensors/run.js +71 -0
  30. package/dist/src/commands/sensors/status.js +48 -4
  31. package/dist/src/commands/sync.js +7 -0
  32. package/dist/src/core/bundles.js +38 -3
  33. package/dist/src/core/init/steps.js +7 -4
  34. package/dist/src/core/ledger/scan.js +228 -0
  35. package/dist/src/core/ledger/store.js +89 -25
  36. package/dist/src/core/ledger/types.js +54 -0
  37. package/dist/src/core/paths.js +10 -9
  38. package/dist/src/core/registries.js +134 -14
  39. package/dist/src/index.js +24 -1
  40. package/dist/tests/commands/ledger/index.test.js +11 -0
  41. package/dist/tests/commands/preflight/preflight.test.js +64 -64
  42. package/dist/tests/commands/registry/add.test.js +69 -2
  43. package/dist/tests/commands/registry/status.test.js +14 -0
  44. package/dist/tests/commands/sensors/compatibility/contract.test.js +98 -0
  45. package/dist/tests/commands/sensors/compatibility/discovery.test.js +119 -0
  46. package/dist/tests/commands/sensors/compatibility/manifest.test.js +68 -0
  47. package/dist/tests/commands/sensors/compatibility/materialize.test.js +53 -0
  48. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +55 -0
  49. package/dist/tests/commands/sensors/compatibility/probe.test.js +27 -0
  50. package/dist/tests/commands/sensors/compatibility/resolve.test.js +75 -0
  51. package/dist/tests/commands/sensors/coverage/contract.test.js +8 -22
  52. package/dist/tests/commands/sensors/coverage/empirical.test.js +111 -0
  53. package/dist/tests/commands/sensors/coverage/evaluate.test.js +29 -0
  54. package/dist/tests/commands/sensors/coverage/evidence.test.js +9 -0
  55. package/dist/tests/commands/sensors/coverage/index.test.js +40 -13
  56. package/dist/tests/commands/sensors/coverage/render.test.js +187 -12
  57. package/dist/tests/commands/sensors/coverage/resolve.test.js +25 -4
  58. package/dist/tests/commands/sensors/exec-windows.test.js +92 -2
  59. package/dist/tests/commands/sensors/exec.test.js +67 -5
  60. package/dist/tests/commands/sensors/index.test.js +8 -0
  61. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +12 -12
  62. package/dist/tests/commands/sensors/init.test.js +132 -48
  63. package/dist/tests/commands/sensors/run-inconclusive.test.js +9 -2
  64. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  65. package/dist/tests/commands/sensors/run.test.js +66 -3
  66. package/dist/tests/commands/sensors/status-windows.test.js +7 -7
  67. package/dist/tests/commands/sensors/status.test.js +74 -23
  68. package/dist/tests/commands/update.test.js +1 -1
  69. package/dist/tests/core/bundles.test.js +37 -0
  70. package/dist/tests/core/discovery.test.js +4 -0
  71. package/dist/tests/core/init/orchestrator.test.js +5 -1
  72. package/dist/tests/core/init/steps.test.js +20 -4
  73. package/dist/tests/core/ledger/scan.test.js +217 -0
  74. package/dist/tests/core/ledger/store.test.js +108 -0
  75. package/dist/tests/core/profile-pins.test.js +2 -1
  76. package/dist/tests/core/project-skill-links.test.js +22 -0
  77. package/dist/tests/core/registries-capability.test.js +10 -0
  78. package/dist/tests/core/registries-sync.test.js +16 -0
  79. package/dist/tests/core/registries.test.js +129 -1
  80. package/dist/tests/core/registry-manifest.test.js +7 -0
  81. package/dist/tests/core/same-existing-path.test.js +35 -0
  82. package/dist/tests/core/sync-gates.test.js +2 -1
  83. package/dist/tests/integration/copilot-init-isolated.test.js +18 -0
  84. package/dist/tests/integration/sensor-compatibility.e2e.test.js +141 -0
  85. package/dist/tests/integration/sensor-coverage.e2e.test.js +35 -6
  86. package/dist/tests/integration/sensor-structured-run.e2e.test.js +127 -0
  87. package/dist/tests/structural/active-documentation.test.js +128 -0
  88. package/dist/tests/structural/async-entrypoint.test.js +15 -0
  89. package/dist/tests/structural/jest-environment-is-isolated.test.js +44 -0
  90. package/dist/tests/structural/r3-cli-major-version.test.js +37 -0
  91. package/dist/tests/structural/sensor-documentation-contract.test.js +77 -0
  92. package/dist/tests/structural/support-matrix-is-current.test.js +69 -2
  93. package/package.json +8 -2
@@ -12,8 +12,75 @@ exports.archiveLedger = archiveLedger;
12
12
  const fs_1 = __importDefault(require("fs"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const child_process_1 = require("child_process");
15
+ const types_1 = require("./types");
15
16
  const cluster_1 = require("./cluster");
16
17
  const LEDGER_DIR = path_1.default.join('.awm', 'ledger');
18
+ function isWithin(root, candidate) {
19
+ const relative = path_1.default.relative(root, candidate);
20
+ return relative === '' || (!relative.startsWith(`..${path_1.default.sep}`) && relative !== '..' && !path_1.default.isAbsolute(relative));
21
+ }
22
+ function assertSafeDirectory(directory, root, name) {
23
+ const stat = fs_1.default.lstatSync(directory);
24
+ if (stat.isSymbolicLink() || !stat.isDirectory())
25
+ throw new Error(`unsafe ledger ${name} directory`);
26
+ const real = fs_1.default.realpathSync(directory);
27
+ if (!isWithin(root, real))
28
+ throw new Error(`ledger ${name} directory escapes its root`);
29
+ return real;
30
+ }
31
+ function sourceExists(source) {
32
+ try {
33
+ fs_1.default.lstatSync(source);
34
+ return true;
35
+ }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT')
38
+ return false;
39
+ throw error;
40
+ }
41
+ }
42
+ function assertSafeLedgerSource(source, ledgerRoot) {
43
+ const stat = fs_1.default.lstatSync(source);
44
+ if (stat.isSymbolicLink() || !stat.isFile())
45
+ throw new Error('unsafe ledger source');
46
+ const real = fs_1.default.realpathSync(source);
47
+ if (!isWithin(ledgerRoot, real))
48
+ throw new Error('ledger source escapes ledger directory');
49
+ }
50
+ function ledgerFilename(branch) {
51
+ if (typeof branch !== 'string'
52
+ || branch.length === 0
53
+ || branch.includes('\\')
54
+ || path_1.default.posix.isAbsolute(branch)
55
+ || path_1.default.win32.isAbsolute(branch)
56
+ || branch.split('/').some(segment => segment === '.' || segment === '..')) {
57
+ throw new Error('invalid ledger branch');
58
+ }
59
+ return branch.replace(/\//g, '__');
60
+ }
61
+ function archiveLabel(label) {
62
+ if (typeof label !== 'string'
63
+ || label.length === 0
64
+ || label === '.'
65
+ || label.includes('..')
66
+ || /[/\\]/.test(label)
67
+ || path_1.default.posix.isAbsolute(label)
68
+ || path_1.default.win32.isAbsolute(label)) {
69
+ throw new Error('invalid ledger label');
70
+ }
71
+ return label;
72
+ }
73
+ function destinationExists(destination) {
74
+ try {
75
+ fs_1.default.lstatSync(destination);
76
+ return true;
77
+ }
78
+ catch (error) {
79
+ if (error.code === 'ENOENT')
80
+ return false;
81
+ throw error;
82
+ }
83
+ }
17
84
  function detectBranch(cwd) {
18
85
  try {
19
86
  const b = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
@@ -26,28 +93,15 @@ function detectBranch(cwd) {
26
93
  }
27
94
  }
28
95
  function ledgerPath(cwd, branch) {
29
- const safe = branch.replace(/\//g, '__');
30
- return path_1.default.join(cwd, LEDGER_DIR, `${safe}.jsonl`);
96
+ return path_1.default.join(cwd, LEDGER_DIR, `${ledgerFilename(branch)}.jsonl`);
31
97
  }
32
98
  function addEntry(cwd, entry) {
99
+ const parsed = (0, types_1.parseLedgerEntry)(entry, 'ledger entry');
100
+ if (!parsed.ok)
101
+ throw new Error(`invalid ledger entry: ${parsed.reason}`);
33
102
  const p = ledgerPath(cwd, entry.branch);
34
103
  fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
35
- fs_1.default.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf-8');
36
- }
37
- /** Required LedgerEntry fields that `cluster.ts` reads unconditionally
38
- * (`signature`, `desc`, `ref` when present). A JSONL line can be syntactically
39
- * valid JSON while still being shape-invalid (e.g. missing `desc`) — that's
40
- * not a parse error, so it needs its own check, extending the same "skip
41
- * malformed line" policy this function already applies to JSON syntax errors. */
42
- function isWellFormedEntry(x) {
43
- if (!x || typeof x !== 'object')
44
- return false;
45
- const e = x;
46
- return typeof e.signature === 'string'
47
- && typeof e.desc === 'string'
48
- && typeof e.branch === 'string'
49
- && typeof e.polarity === 'string'
50
- && (e.ref === undefined || typeof e.ref === 'string');
104
+ fs_1.default.appendFileSync(p, JSON.stringify(parsed.entry) + '\n', 'utf-8');
51
105
  }
52
106
  function listEntries(cwd, branch) {
53
107
  const p = ledgerPath(cwd, branch);
@@ -59,9 +113,9 @@ function listEntries(cwd, branch) {
59
113
  if (!trimmed)
60
114
  continue;
61
115
  try {
62
- const parsed = JSON.parse(trimmed);
63
- if (isWellFormedEntry(parsed))
64
- out.push(parsed);
116
+ const parsed = (0, types_1.parseLedgerEntry)(JSON.parse(trimmed), p);
117
+ if (parsed.ok)
118
+ out.push(parsed.entry);
65
119
  }
66
120
  catch { /* skip malformed line */ }
67
121
  }
@@ -71,12 +125,22 @@ function recurring(cwd, branch, min) {
71
125
  return (0, cluster_1.clusterEntries)(listEntries(cwd, branch), min);
72
126
  }
73
127
  function archiveLedger(cwd, branch, label) {
128
+ const safeLabel = archiveLabel(label);
74
129
  const src = ledgerPath(cwd, branch);
75
- if (!fs_1.default.existsSync(src))
130
+ if (!sourceExists(src))
76
131
  return false;
77
- const safe = branch.replace(/\//g, '__');
78
- const dst = path_1.default.join(cwd, LEDGER_DIR, 'archive', `${safe}-${label}.jsonl`);
79
- fs_1.default.mkdirSync(path_1.default.dirname(dst), { recursive: true });
132
+ const safe = ledgerFilename(branch);
133
+ const projectRoot = fs_1.default.realpathSync(cwd);
134
+ const ledgerRoot = path_1.default.join(projectRoot, LEDGER_DIR);
135
+ const safeLedgerRoot = assertSafeDirectory(ledgerRoot, projectRoot, 'root');
136
+ assertSafeLedgerSource(src, safeLedgerRoot);
137
+ const archiveRoot = path_1.default.join(ledgerRoot, 'archive');
138
+ if (!sourceExists(archiveRoot))
139
+ fs_1.default.mkdirSync(archiveRoot);
140
+ const safeArchiveRoot = assertSafeDirectory(archiveRoot, safeLedgerRoot, 'archive');
141
+ const dst = path_1.default.join(safeArchiveRoot, `${safe}-${safeLabel}.jsonl`);
142
+ if (destinationExists(dst))
143
+ throw new Error(`ledger archive already exists: ${dst}`);
80
144
  fs_1.default.renameSync(src, dst);
81
145
  return true;
82
146
  }
@@ -1,2 +1,56 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFECT_CLASS = void 0;
4
+ exports.parseLedgerEntry = parseLedgerEntry;
5
+ exports.DEFECT_CLASS = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6
+ function isRecord(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ function isPolarity(value) {
10
+ return value === 'win' || value === 'finding';
11
+ }
12
+ function isLedgerClass(value) {
13
+ return value === 'structural' || value === 'logica' || value === 'proceso' || value === 'seguridad';
14
+ }
15
+ function isSeverity(value) {
16
+ return value === 'blocker' || value === 'important' || value === 'minor' || value === 'info';
17
+ }
18
+ /** Validates the durable JSONL boundary. Missing defectClass is deliberately legacy-valid. */
19
+ function parseLedgerEntry(input, source) {
20
+ if (typeof source !== 'string')
21
+ throw new Error('ledger parse source must be a string');
22
+ if (!isRecord(input))
23
+ return { ok: false, source, reason: 'not-object' };
24
+ if (!isPolarity(input.polarity) || !isLedgerClass(input.class) || !isSeverity(input.severity)) {
25
+ return { ok: false, source, reason: 'invalid-enum' };
26
+ }
27
+ for (const field of ['ts', 'branch', 'phase', 'source_skill', 'signature', 'desc']) {
28
+ if (typeof input[field] !== 'string')
29
+ return { ok: false, source, reason: 'invalid-fields' };
30
+ }
31
+ if ('ref' in input && input.ref !== undefined && typeof input.ref !== 'string') {
32
+ return { ok: false, source, reason: 'invalid-ref' };
33
+ }
34
+ if ('defectClass' in input && input.defectClass !== undefined
35
+ && (typeof input.defectClass !== 'string' || !exports.DEFECT_CLASS.test(input.defectClass))) {
36
+ return { ok: false, source, reason: 'invalid-defect-class' };
37
+ }
38
+ return {
39
+ ok: true,
40
+ entry: {
41
+ ts: input.ts,
42
+ branch: input.branch,
43
+ phase: input.phase,
44
+ source_skill: input.source_skill,
45
+ polarity: input.polarity,
46
+ class: input.class,
47
+ signature: input.signature,
48
+ severity: input.severity,
49
+ desc: input.desc,
50
+ ...(typeof input.ref === 'string' ? { ref: input.ref } : {}),
51
+ // Keep the durable legacy distinction visible to consumers: an older
52
+ // entry is valid, but has no reusable classification yet.
53
+ defectClass: typeof input.defectClass === 'string' ? input.defectClass : undefined,
54
+ },
55
+ };
56
+ }
@@ -174,10 +174,11 @@ function resolveOnPath(bin) {
174
174
  * cohorte no llegaba nunca ni a `SERIAL` ni a `ACTIVE`. Un solo bug de comparación, quince
175
175
  * tests en cascada.
176
176
  *
177
- * La identidad se toma del filesystem `(dev, ino)` que es agnóstica a separadores, a
178
- * mayúsculas y a nombres cortos. Cuando el volumen no expone inode (`ino === 0`, posible en
179
- * algunos filesystems de Windows) se cae a `realpathSync.native` que sí resuelve 8.3 vía
180
- * la API del SO normalizando separadores y capitalización.
177
+ * La identidad se toma primero de `realpathSync.native`, que resuelve la ruta con la API
178
+ * del SO (incluyendo nombres cortos 8.3) antes de normalizar separadores y capitalización.
179
+ * En POSIX, dos hard links de archivo conservan el mismo `(dev, ino)` aunque el realpath
180
+ * mantenga sus nombres distintos; ese fallback preserva su identidad. En Windows no se usa:
181
+ * dos directorios temporales distintos pueden reportar el mismo par de metadatos.
181
182
  *
182
183
  * Cualquier fallo de `stat` significa que al menos una no existe: `false`. La identidad
183
184
  * nunca se afirma sin prueba.
@@ -192,10 +193,6 @@ function sameExistingPath(a, b) {
192
193
  catch {
193
194
  return false;
194
195
  }
195
- // `ino === 0` en AMBOS lados no prueba identidad: probaría que el filesystem no la
196
- // reporta. Tratarlo como match haría coincidir dos rutas cualesquiera del mismo volumen.
197
- if (sa.ino !== 0 && sb.ino !== 0)
198
- return sa.dev === sb.dev && sa.ino === sb.ino;
199
196
  const canonical = (p) => {
200
197
  try {
201
198
  const real = fs_1.default.realpathSync.native(p).replaceAll('\\', '/');
@@ -207,5 +204,9 @@ function sameExistingPath(a, b) {
207
204
  };
208
205
  const ca = canonical(a);
209
206
  const cb = canonical(b);
210
- return ca !== null && ca === cb;
207
+ if (ca === null || cb === null)
208
+ return false;
209
+ if (ca === cb)
210
+ return true;
211
+ return !isWindowsNative() && sa.ino !== 0 && sb.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
211
212
  }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.REGISTRY_MANIFEST_NAME = exports.CONTENT_DIR_NAMES = void 0;
6
+ exports.REGISTRY_METADATA_FILE_NAMES = exports.REGISTRY_MANIFEST_NAME = exports.REGISTRY_DIR_NAMES = exports.CAPABILITY_DIR_NAMES = exports.CONTENT_DIR_NAMES = void 0;
7
7
  exports.registriesDir = registriesDir;
8
8
  exports.registriesConfigPath = registriesConfigPath;
9
9
  exports.registryContentRoot = registryContentRoot;
@@ -13,7 +13,10 @@ exports.seedBaselineRegistry = seedBaselineRegistry;
13
13
  exports.listRegistries = listRegistries;
14
14
  exports.contentRoots = contentRoots;
15
15
  exports.capabilityRoot = capabilityRoot;
16
+ exports.assertRegularRegistryFile = assertRegularRegistryFile;
17
+ exports.isSafeRegistryRoot = isSafeRegistryRoot;
16
18
  exports.validateRegistryLayout = validateRegistryLayout;
19
+ exports.registriesNeedSync = registriesNeedSync;
17
20
  exports.syncRegistries = syncRegistries;
18
21
  exports.registrySyncErrors = registrySyncErrors;
19
22
  exports.describeRegistrySyncErrors = describeRegistrySyncErrors;
@@ -44,9 +47,13 @@ function registriesConfigPath() {
44
47
  return path_1.default.join((0, paths_1.awmHome)(), 'registries.json');
45
48
  }
46
49
  exports.CONTENT_DIR_NAMES = ['skills', 'bundles', 'workflows', 'agents'];
50
+ exports.CAPABILITY_DIR_NAMES = ['hooks', 'sensor-packs'];
51
+ exports.REGISTRY_DIR_NAMES = [...exports.CONTENT_DIR_NAMES, ...exports.CAPABILITY_DIR_NAMES];
52
+ exports.REGISTRY_MANIFEST_NAME = 'awm-registry.json';
53
+ exports.REGISTRY_METADATA_FILE_NAMES = [exports.REGISTRY_MANIFEST_NAME, 'catalog.json'];
47
54
  function registryContentRoot(name) {
48
55
  const root = path_1.default.join(registriesDir(), name);
49
- if (!path_1.default.resolve(root).startsWith(path_1.default.resolve(registriesDir()) + path_1.default.sep)) {
56
+ if (!name || name === '.' || name.includes('..') || /[/\\]/.test(name) || !path_1.default.resolve(root).startsWith(path_1.default.resolve(registriesDir()) + path_1.default.sep)) {
50
57
  throw new Error(`Invalid registry name "${name}" — must not contain path separators`);
51
58
  }
52
59
  return root;
@@ -68,7 +75,7 @@ function readRegistriesConfig() {
68
75
  if (typeof entry?.name !== 'string' || typeof entry?.remote !== 'string') {
69
76
  throw new Error(`Invalid registries config at ${registriesConfigPath()}: malformed entry ${JSON.stringify(entry)}`);
70
77
  }
71
- if (entry.name === '.' || entry.name.includes('/') || entry.name.includes('\\') || entry.name.includes('..')) {
78
+ if (!entry.name || entry.name === '.' || entry.name.includes('/') || entry.name.includes('\\') || entry.name.includes('..')) {
72
79
  throw new Error(`Invalid registries config at ${registriesConfigPath()}: malformed entry name "${entry.name}" (path traversal)`);
73
80
  }
74
81
  }
@@ -95,7 +102,7 @@ function listRegistries() {
95
102
  function contentRoots() {
96
103
  const roots = [];
97
104
  for (const reg of listRegistries()) {
98
- if (fs_1.default.existsSync(reg.contentRoot))
105
+ if (isSafeRegistryRoot(reg.contentRoot))
99
106
  roots.push(reg.contentRoot);
100
107
  }
101
108
  return roots;
@@ -104,14 +111,121 @@ function contentRoots() {
104
111
  * directorio pedido ('hooks', 'sensor-packs', 'skills'…). null si ninguno. */
105
112
  function capabilityRoot(dirName) {
106
113
  for (const root of contentRoots()) {
107
- if (fs_1.default.existsSync(path_1.default.join(root, dirName)))
114
+ if (isRegularDirectory(path_1.default.join(root, dirName)))
108
115
  return root;
109
116
  }
110
117
  return null;
111
118
  }
112
- /** Un registry válido tiene ≥1 dir de contenido en su raíz. */
119
+ /** Todo symlink bajo directorios consumidos de un registry es no confiable: discovery,
120
+ * hooks y sensores no pueden consumir contenido que podría resolver fuera del clone. */
121
+ function containsManagedSymlink(dir) {
122
+ let entries;
123
+ try {
124
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
125
+ }
126
+ catch {
127
+ return true;
128
+ }
129
+ for (const entry of entries) {
130
+ const candidate = path_1.default.join(dir, entry.name);
131
+ let stat;
132
+ try {
133
+ stat = fs_1.default.lstatSync(candidate);
134
+ }
135
+ catch {
136
+ return true;
137
+ }
138
+ if (stat.isSymbolicLink())
139
+ return true;
140
+ if (stat.isDirectory() && containsManagedSymlink(candidate))
141
+ return true;
142
+ }
143
+ return false;
144
+ }
145
+ function isMissing(error) {
146
+ return error?.code === 'ENOENT';
147
+ }
148
+ function isRegularDirectory(candidate) {
149
+ try {
150
+ const stat = fs_1.default.lstatSync(candidate);
151
+ return stat.isDirectory() && !stat.isSymbolicLink();
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ function isSafeOptionalRegistryFile(candidate) {
158
+ try {
159
+ const stat = fs_1.default.lstatSync(candidate);
160
+ return stat.isFile() && !stat.isSymbolicLink();
161
+ }
162
+ catch (error) {
163
+ return isMissing(error);
164
+ }
165
+ }
166
+ /** Returns false when absent; throws when an existing registry metadata file cannot be
167
+ * safely read as a regular file. Kept public so catalog and manifest readers share the
168
+ * same trust boundary instead of relying only on an earlier layout scan. */
169
+ function assertRegularRegistryFile(file) {
170
+ let stat;
171
+ try {
172
+ stat = fs_1.default.lstatSync(file);
173
+ }
174
+ catch (error) {
175
+ if (isMissing(error))
176
+ return false;
177
+ throw new Error(`Cannot inspect registry metadata at ${file}: ${error instanceof Error ? error.message : String(error)}`);
178
+ }
179
+ if (stat.isSymbolicLink())
180
+ throw new Error(`Registry metadata at ${file} must not be a symbolic link`);
181
+ if (!stat.isFile())
182
+ throw new Error(`Registry metadata at ${file} must be a regular file`);
183
+ return true;
184
+ }
185
+ /** Verifica que la raíz y cada directorio que AWM consume sean regulares y no tengan
186
+ * symlinks anidados. Los registries capability-only siguen siendo válidos. */
187
+ function inspectRegistrySafety(root) {
188
+ if (!isRegularDirectory(root))
189
+ return { safe: false, hasManagedDirectory: false };
190
+ let hasManagedDirectory = false;
191
+ for (const d of exports.REGISTRY_DIR_NAMES) {
192
+ const candidate = path_1.default.join(root, d);
193
+ try {
194
+ const stat = fs_1.default.lstatSync(candidate);
195
+ if (stat.isSymbolicLink())
196
+ return { safe: false, hasManagedDirectory: false };
197
+ if (!stat.isDirectory())
198
+ continue;
199
+ hasManagedDirectory = true;
200
+ if (containsManagedSymlink(candidate))
201
+ return { safe: false, hasManagedDirectory: false };
202
+ }
203
+ catch (error) {
204
+ if (isMissing(error))
205
+ continue;
206
+ return { safe: false, hasManagedDirectory: false };
207
+ }
208
+ }
209
+ for (const file of exports.REGISTRY_METADATA_FILE_NAMES) {
210
+ if (!isSafeOptionalRegistryFile(path_1.default.join(root, file))) {
211
+ return { safe: false, hasManagedDirectory: false };
212
+ }
213
+ }
214
+ return { safe: true, hasManagedDirectory };
215
+ }
216
+ function isSafeRegistryRoot(root) {
217
+ return inspectRegistrySafety(root).safe;
218
+ }
219
+ /** Un registry válido tiene ≥1 directorio de contenido o capability regular y ningún
220
+ * symlink en las rutas que AWM consume. */
113
221
  function validateRegistryLayout(root) {
114
- return exports.CONTENT_DIR_NAMES.some((d) => fs_1.default.existsSync(path_1.default.join(root, d)));
222
+ const state = inspectRegistrySafety(root);
223
+ return state.safe && state.hasManagedDirectory;
224
+ }
225
+ /** Whether machine setup must synchronize before any registry-backed discovery.
226
+ * An empty root is safe to keep configured, but cannot yet supply AWM content. */
227
+ function registriesNeedSync() {
228
+ return listRegistries().some((registry) => !validateRegistryLayout(registry.contentRoot));
115
229
  }
116
230
  /** Sincroniza cada registry configurado al ref resuelto (pin > último tag > HEAD);
117
231
  * re-clona si falta el dir. Errores por-registry NO fatales: se reportan en el resultado. */
@@ -120,6 +234,9 @@ async function syncRegistries() {
120
234
  for (const reg of listRegistries()) {
121
235
  try {
122
236
  const freshClone = !fs_1.default.existsSync(reg.contentRoot);
237
+ if (!freshClone && !isSafeRegistryRoot(reg.contentRoot)) {
238
+ throw new Error(`Unsafe registry layout at ${reg.contentRoot}`);
239
+ }
123
240
  if (freshClone) {
124
241
  fs_1.default.mkdirSync(registriesDir(), { recursive: true });
125
242
  try {
@@ -140,6 +257,9 @@ async function syncRegistries() {
140
257
  await git.checkout(resolved.ref);
141
258
  if (resolved.kind !== 'tag')
142
259
  await git.pull('origin', resolved.ref);
260
+ if (!validateRegistryLayout(reg.contentRoot)) {
261
+ throw new Error(`Unsafe registry layout at ${reg.contentRoot}`);
262
+ }
143
263
  }
144
264
  catch (e) {
145
265
  if (freshClone)
@@ -169,13 +289,14 @@ function describeRegistrySyncErrors(errors) {
169
289
  return errors.map((e) => `${e.name}: ${e.error}`).join('; ');
170
290
  }
171
291
  /**
172
- * Registries that both errored during sync AND have no content on disk
173
- * afterwards — i.e. genuinely unusable, as opposed to merely stale.
292
+ * Registries that both errored during sync AND no longer have a usable content
293
+ * layout afterwards — i.e. genuinely unusable, as opposed to merely stale.
174
294
  *
175
295
  * `syncRegistries()` deliberately reports per-registry failures as results
176
296
  * rather than throwing, so a flaky secondary registry never aborts a whole
177
297
  * run. Callers that go on to READ registry content (init) still need to know
178
- * whether what they are about to read exists: otherwise a missing registry
298
+ * whether what they are about to read remains usable: otherwise a missing,
299
+ * empty, or unsafe registry
179
300
  * resurfaces much later as an unrelated step's failure, with its real cause
180
301
  * already discarded. Note that "usable" is deliberately about content on disk,
181
302
  * not about being a healthy git clone — a seeded content root with no `.git`
@@ -188,7 +309,7 @@ function unusableSyncedRegistries(results) {
188
309
  const roots = new Map(listRegistries().map((r) => [r.name, r.contentRoot]));
189
310
  return errors.filter((e) => {
190
311
  const root = roots.get(e.name);
191
- return root === undefined || !fs_1.default.existsSync(root);
312
+ return root === undefined || !validateRegistryLayout(root);
192
313
  });
193
314
  }
194
315
  /** `unusableSyncedRegistries` as a guard: throws naming every unusable registry. */
@@ -196,12 +317,11 @@ function assertSyncedRegistriesUsable(results) {
196
317
  const unusable = unusableSyncedRegistries(results);
197
318
  if (unusable.length === 0)
198
319
  return;
199
- throw new Error(`registry sync failed and left no content on disk — ${describeRegistrySyncErrors(unusable)}`);
320
+ throw new Error(`registry sync failed and left no usable content on disk — ${describeRegistrySyncErrors(unusable)}`);
200
321
  }
201
- exports.REGISTRY_MANIFEST_NAME = 'awm-registry.json';
202
322
  function readRegistryManifest(root) {
203
323
  const file = path_1.default.join(root, exports.REGISTRY_MANIFEST_NAME);
204
- if (!fs_1.default.existsSync(file))
324
+ if (!assertRegularRegistryFile(file))
205
325
  return { overrides: new Set() };
206
326
  let raw;
207
327
  try {
package/dist/src/index.js CHANGED
@@ -118,6 +118,15 @@ program.command('add [name]')
118
118
  if (r.action === 'error')
119
119
  console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
120
120
  }
121
+ try {
122
+ (0, registries_1.assertSyncedRegistriesUsable)(results);
123
+ }
124
+ catch (e) {
125
+ s.stop('Registry sync failed.');
126
+ console.error(picocolors_1.default.red(e.message));
127
+ process.exitCode = 1;
128
+ return;
129
+ }
121
130
  // 1b. If `name` matches a bundle, run the bundle-activation flow and exit.
122
131
  if (name) {
123
132
  const allBundles = (0, bundles_1.discoverAllBundles)();
@@ -437,6 +446,14 @@ program.command('list [package]')
437
446
  if (r.action === 'error')
438
447
  console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
439
448
  }
449
+ try {
450
+ (0, registries_1.assertSyncedRegistriesUsable)(listSyncResults);
451
+ }
452
+ catch (e) {
453
+ console.error(picocolors_1.default.red(e.message));
454
+ process.exitCode = 1;
455
+ return;
456
+ }
440
457
  const fullView = (0, registry_view_1.buildPackageView)((0, discovery_1.discoverSkills)(), (0, discovery_1.discoverWorkflows)(), (0, discovery_1.discoverAgents)(), (0, bundles_1.discoverAllBundles)());
441
458
  const view = options.all ? fullView : fullView.filter((p) => p.visibility !== 'private');
442
459
  if (view.length === 0) {
@@ -716,4 +733,10 @@ miroCmd.command('sync <storyMapPath>')
716
733
  (0, job_1.registerJobCommand)(program);
717
734
  (0, watch_1.registerWatchCommand)(program);
718
735
  (0, track_1.registerTrackCommand)(program);
719
- program.parse();
736
+ // Commander only waits for async action handlers through parseAsync(). The CLI has
737
+ // async commands (including `sensors coverage`), so returning its promise keeps the
738
+ // process alive until their JSON/output contract has been completed.
739
+ program.parseAsync().catch((error) => {
740
+ console.error(error instanceof Error ? error.message : String(error));
741
+ process.exitCode = 1;
742
+ });
@@ -40,6 +40,17 @@ describe('awm ledger CLI', () => {
40
40
  expect(entries[0]).toMatchObject({ signature: 'sig-1', polarity: 'finding', class: 'logica' });
41
41
  expect(entries[0].ts).toMatch(/\d{4}-\d{2}-\d{2}T/);
42
42
  });
43
+ test('add persists an optional reusable defect class', () => {
44
+ run(['add', '--branch', 'feat-x', '--polarity', 'finding', '--class', 'logica',
45
+ '--signature', 'sig-1', '--severity', 'blocker', '--desc', 'boom', '--defect-class', 'lint-errors'], cwd);
46
+ expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ defectClass: 'lint-errors' })]);
47
+ });
48
+ test.each(['', 'Bad_ID', '../escape', 'a b', '-leading', 'trailing-'])('add rejects invalid defect class %p before writing', (defectClass) => {
49
+ expect(() => run(['add', '--branch', 'feat-x', '--polarity', 'finding', '--class', 'logica',
50
+ '--signature', 'sig-1', '--severity', 'blocker', '--desc', 'boom', '--defect-class', defectClass], cwd))
51
+ .toThrow(/defect-class.*kebab-case/i);
52
+ expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([]);
53
+ });
43
54
  test('list emits the branch entries as JSON', () => {
44
55
  run(['add', '--branch', 'feat-x', '--polarity', 'win', '--class', 'proceso',
45
56
  '--signature', 'good', '--severity', 'info', '--desc', 'nice'], cwd);