agentic-workflow-manager 6.5.2 → 8.1.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 (95) 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 +382 -0
  11. package/dist/src/commands/sensors/compatibility/discovery.js +207 -0
  12. package/dist/src/commands/sensors/compatibility/live.js +92 -0
  13. package/dist/src/commands/sensors/compatibility/manifest.js +207 -0
  14. package/dist/src/commands/sensors/compatibility/materialize.js +134 -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 +99 -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 +244 -9
  27. package/dist/src/commands/sensors/index.js +18 -6
  28. package/dist/src/commands/sensors/init.js +95 -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 +193 -0
  45. package/dist/tests/commands/sensors/compatibility/discovery.test.js +119 -0
  46. package/dist/tests/commands/sensors/compatibility/live.test.js +124 -0
  47. package/dist/tests/commands/sensors/compatibility/manifest.test.js +77 -0
  48. package/dist/tests/commands/sensors/compatibility/materialize.test.js +72 -0
  49. package/dist/tests/commands/sensors/compatibility/pack-source.test.js +55 -0
  50. package/dist/tests/commands/sensors/compatibility/probe.test.js +37 -0
  51. package/dist/tests/commands/sensors/compatibility/python-venv-fixture.js +29 -0
  52. package/dist/tests/commands/sensors/compatibility/resolve.test.js +83 -0
  53. package/dist/tests/commands/sensors/coverage/contract.test.js +8 -22
  54. package/dist/tests/commands/sensors/coverage/empirical.test.js +111 -0
  55. package/dist/tests/commands/sensors/coverage/evaluate.test.js +29 -0
  56. package/dist/tests/commands/sensors/coverage/evidence.test.js +9 -0
  57. package/dist/tests/commands/sensors/coverage/index.test.js +40 -13
  58. package/dist/tests/commands/sensors/coverage/render.test.js +187 -12
  59. package/dist/tests/commands/sensors/coverage/resolve.test.js +25 -4
  60. package/dist/tests/commands/sensors/exec-windows.test.js +140 -2
  61. package/dist/tests/commands/sensors/exec.test.js +115 -5
  62. package/dist/tests/commands/sensors/index.test.js +8 -0
  63. package/dist/tests/commands/sensors/init-pack-unavailable.test.js +12 -12
  64. package/dist/tests/commands/sensors/init.test.js +204 -48
  65. package/dist/tests/commands/sensors/run-inconclusive.test.js +9 -2
  66. package/dist/tests/commands/sensors/run-is-read-only.test.js +0 -0
  67. package/dist/tests/commands/sensors/run.test.js +66 -3
  68. package/dist/tests/commands/sensors/status-windows.test.js +7 -7
  69. package/dist/tests/commands/sensors/status.test.js +74 -23
  70. package/dist/tests/commands/update.test.js +1 -1
  71. package/dist/tests/core/bundles.test.js +37 -0
  72. package/dist/tests/core/discovery.test.js +4 -0
  73. package/dist/tests/core/init/orchestrator.test.js +5 -1
  74. package/dist/tests/core/init/steps.test.js +20 -4
  75. package/dist/tests/core/ledger/scan.test.js +217 -0
  76. package/dist/tests/core/ledger/store.test.js +108 -0
  77. package/dist/tests/core/profile-pins.test.js +2 -1
  78. package/dist/tests/core/project-skill-links.test.js +22 -0
  79. package/dist/tests/core/registries-capability.test.js +10 -0
  80. package/dist/tests/core/registries-sync.test.js +16 -0
  81. package/dist/tests/core/registries.test.js +129 -1
  82. package/dist/tests/core/registry-manifest.test.js +7 -0
  83. package/dist/tests/core/same-existing-path.test.js +35 -0
  84. package/dist/tests/core/sync-gates.test.js +2 -1
  85. package/dist/tests/integration/copilot-init-isolated.test.js +18 -0
  86. package/dist/tests/integration/sensor-compatibility.e2e.test.js +141 -0
  87. package/dist/tests/integration/sensor-coverage.e2e.test.js +35 -6
  88. package/dist/tests/integration/sensor-structured-run.e2e.test.js +127 -0
  89. package/dist/tests/structural/active-documentation.test.js +128 -0
  90. package/dist/tests/structural/async-entrypoint.test.js +15 -0
  91. package/dist/tests/structural/jest-environment-is-isolated.test.js +44 -0
  92. package/dist/tests/structural/r3-cli-major-version.test.js +37 -0
  93. package/dist/tests/structural/sensor-documentation-contract.test.js +77 -0
  94. package/dist/tests/structural/support-matrix-is-current.test.js +69 -2
  95. package/package.json +8 -2
@@ -7,6 +7,8 @@ exports.computeSensorStatus = computeSensorStatus;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const paths_1 = require("../../core/paths");
10
+ const manifest_1 = require("./compatibility/manifest");
11
+ const live_1 = require("./compatibility/live");
10
12
  /** First non-flag token after `npx` — the tool the command actually runs. */
11
13
  function npxTool(parts) {
12
14
  for (let i = 1; i < parts.length; i++) {
@@ -53,16 +55,57 @@ function checkCmd(cmd, cwd) {
53
55
  }
54
56
  return configCheck(parts, cwd) ?? { ok: true, detail: bin };
55
57
  }
56
- function computeSensorStatus(cwd = process.cwd()) {
58
+ async function computeSensorStatus(cwd = process.cwd()) {
57
59
  const manifestPath = path_1.default.join(cwd, '.awm', 'sensors.json');
58
60
  if (!fs_1.default.existsSync(manifestPath)) {
59
61
  return { overall: 'NOT_CONFIGURED', pack: null, checks: {} };
60
62
  }
61
63
  let manifest;
62
64
  try {
63
- manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
65
+ const raw = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
66
+ const parsed = (0, manifest_1.parseSensorManifest)(raw, manifestPath);
67
+ if (parsed.kind === 'v2') {
68
+ const checks = {};
69
+ let live;
70
+ try {
71
+ live = await (0, live_1.resolveLiveCompatibility)(cwd, parsed.pack.pack, parsed.pack.registryRoot, { packSelection: parsed.pack.packSelection });
72
+ }
73
+ catch (error) {
74
+ const detail = `compatibility revalidation failed: ${error instanceof Error ? error.message : String(error)}`;
75
+ for (const [name, sensor] of Object.entries(parsed.pack.sensors)) {
76
+ checks[name] = sensor.enabled === false ? { ok: true, detail: 'disabled' } : { ok: false, detail };
77
+ }
78
+ return { overall: 'DEGRADED', pack: parsed.pack.pack, checks };
79
+ }
80
+ for (const [name, sensor] of Object.entries(parsed.pack.sensors)) {
81
+ const state = live.sensors[name];
82
+ checks[name] = sensor.enabled === false
83
+ ? { ok: true, detail: 'disabled' }
84
+ : !state
85
+ ? { ok: false, detail: 'variant-drift: sensor no longer exists in the live pack; run `awm sensors init`' }
86
+ : state.variantId !== sensor.variantId
87
+ ? { ok: false, detail: `variant-drift: manifest ${sensor.variantId}, live ${state.variantId ?? 'none'}; run \`awm sensors init\`` }
88
+ : state.state === 'certified'
89
+ ? { ok: true, detail: `certified (${state.variantId})` }
90
+ : state.state === 'not-applicable'
91
+ ? { ok: true, detail: 'not applicable' }
92
+ : { ok: false, detail: `${state.state}: ${state.reason}` };
93
+ }
94
+ return { overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'HEALTHY' : 'DEGRADED', pack: parsed.pack.pack, checks };
95
+ }
96
+ manifest = parsed.pack;
64
97
  }
65
98
  catch {
99
+ // Preserve the historic diagnostic path for a hand-edited legacy manifest:
100
+ // malformed v2 is fail-closed, while a legacy `sensors:null` is surfaced as
101
+ // degraded rather than crashing the status command.
102
+ try {
103
+ const raw = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
104
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && !('schemaVersion' in raw) && typeof raw.pack === 'string') {
105
+ return { overall: 'DEGRADED', pack: raw.pack, checks: {} };
106
+ }
107
+ }
108
+ catch { /* malformed JSON is not configured */ }
66
109
  return { overall: 'NOT_CONFIGURED', pack: null, checks: {} };
67
110
  }
68
111
  const checks = {};
@@ -84,6 +127,7 @@ function computeSensorStatus(cwd = process.cwd()) {
84
127
  if (Object.keys(manifest.sensors ?? {}).length === 0) {
85
128
  return { overall: 'DEGRADED', pack: manifest.pack, checks };
86
129
  }
87
- const allOk = Object.values(checks).every(c => c.ok);
88
- return { overall: allOk ? 'HEALTHY' : 'DEGRADED', pack: manifest.pack, checks };
130
+ // A legacy manifest may be operational (the checks are still useful), but it
131
+ // has no versioned/structured contract and must never present as certified.
132
+ return { overall: 'DEGRADED', pack: manifest.pack, checks };
89
133
  }
@@ -77,6 +77,13 @@ async function runSyncCore(options, deps = {}) {
77
77
  if (r.action === 'error')
78
78
  console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
79
79
  }
80
+ try {
81
+ (0, registries_1.assertSyncedRegistriesUsable)(syncResults);
82
+ }
83
+ catch (e) {
84
+ console.error(picocolors_1.default.red(e.message));
85
+ return { code: 1, selectedAgents };
86
+ }
80
87
  // Gate minCliVersion (WS-4) before pins (contract gates first — CONSTITUTION).
81
88
  try {
82
89
  (0, registries_1.assertRegistryGates)(d.verifyMinCliVersions());
@@ -16,6 +16,41 @@ const registries_1 = require("./registries");
16
16
  function catalogPath(contentDir) {
17
17
  return path_1.default.join(contentDir, 'catalog.json');
18
18
  }
19
+ function bundleManifestPath(contentDir, source) {
20
+ const root = path_1.default.resolve(contentDir);
21
+ const manifest = path_1.default.resolve(root, source, 'bundle.json');
22
+ const relative = path_1.default.relative(root, manifest);
23
+ if (relative === '..' || relative.startsWith(`..${path_1.default.sep}`) || path_1.default.isAbsolute(relative)) {
24
+ throw new Error(`Invalid bundle source "${source}" in ${catalogPath(contentDir)}: it must remain inside the registry content root.`);
25
+ }
26
+ let rootStat;
27
+ try {
28
+ rootStat = fs_1.default.lstatSync(root);
29
+ }
30
+ catch (error) {
31
+ throw new Error(`Cannot inspect registry content root ${root}: ${error instanceof Error ? error.message : String(error)}`);
32
+ }
33
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
34
+ throw new Error(`Registry content root ${root} must be a regular directory`);
35
+ }
36
+ let current = root;
37
+ for (const segment of relative.split(path_1.default.sep)) {
38
+ current = path_1.default.join(current, segment);
39
+ let stat;
40
+ try {
41
+ stat = fs_1.default.lstatSync(current);
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ break;
46
+ throw new Error(`Cannot inspect registry bundle path ${current}: ${error instanceof Error ? error.message : String(error)}`);
47
+ }
48
+ if (stat.isSymbolicLink()) {
49
+ throw new Error(`Registry bundle path ${current} must not be a symbolic link`);
50
+ }
51
+ }
52
+ return manifest;
53
+ }
19
54
  /** Lee `catalog.json` de un registry.
20
55
  *
21
56
  * El contenido de un registry es INPUT NO CONFIABLE (un registry de equipo,
@@ -28,7 +63,7 @@ function catalogPath(contentDir) {
28
63
  * dos eran los que faltaban. */
29
64
  function readCatalog(contentDir) {
30
65
  const file = catalogPath(contentDir);
31
- if (!fs_1.default.existsSync(file))
66
+ if (!(0, registries_1.assertRegularRegistryFile)(file))
32
67
  return [];
33
68
  let parsed;
34
69
  try {
@@ -74,8 +109,8 @@ function discoverBundles(contentDir) {
74
109
  const entries = readCatalog(contentDir);
75
110
  const bundles = [];
76
111
  for (const entry of entries) {
77
- const manifestPath = path_1.default.join(contentDir, entry.source, 'bundle.json');
78
- if (!fs_1.default.existsSync(manifestPath))
112
+ const manifestPath = bundleManifestPath(contentDir, entry.source);
113
+ if (!(0, registries_1.assertRegularRegistryFile)(manifestPath))
79
114
  continue;
80
115
  let raw;
81
116
  try {
@@ -65,8 +65,8 @@ exports.defaultActions = {
65
65
  method: o.method,
66
66
  contentDir: o.contentDir,
67
67
  }),
68
- initSensors: (o) => {
69
- const result = (0, init_1.initSensors)({ cwd: o.cwd, registryRoot: o.registryRoot, configure: o.configure });
68
+ initSensors: async (o) => {
69
+ const result = await (0, init_1.initSensors)({ cwd: o.cwd, registryRoot: o.registryRoot, configure: o.configure });
70
70
  // `unavailablePack`/`manifest` viajan a proposito: `stepSensors` los reporta.
71
71
  // Este wrapper proyectaba solo `detection`, asi que cualquier hallazgo nuevo de
72
72
  // initSensors moria aca en silencio antes de llegar al step que lo muestra.
@@ -216,6 +216,9 @@ function stepDevCore(d) {
216
216
  // `awm doctor` decia `healthy`. Su propio `globalUnsupportedReason` ya dice
217
217
  // que hay que instalar por proyecto, asi que eso es lo que se hace.
218
218
  const localOnly = (0, providers_1.providerFor)(d.agent).skill.global === null;
219
+ if (localOnly && d.machineOnly) {
220
+ return ok('machine.devCore', 'machine', 'skipped', `${(0, providers_1.providerFor)(d.agent).label} provides skills at project scope — deferred until project initialization`);
221
+ }
219
222
  for (const bundleName of toInstall) {
220
223
  d.actions.installBundle({
221
224
  bundleName,
@@ -316,13 +319,13 @@ function stepActivation(d) {
316
319
  return ok('project.activation', 'project', 'applied');
317
320
  }
318
321
  /** Step 7 – Initialize the sensor manifest if absent. */
319
- function stepSensors(d) {
322
+ async function stepSensors(d) {
320
323
  const proj = d.ctx.project;
321
324
  if (!proj)
322
325
  return ok('project.sensors', 'project', 'skipped', 'no project');
323
326
  if (proj.sensors.present)
324
327
  return ok('project.sensors', 'project', 'skipped');
325
- const res = d.actions.initSensors({ cwd: proj.root, registryRoot: d.sensorPacksRoot, configure: true });
328
+ const res = await d.actions.initSensors({ cwd: proj.root, registryRoot: d.sensorPacksRoot, configure: true });
326
329
  // A registry without the detected pack yields a fallback (or empty) manifest. The
327
330
  // step still succeeded — a manifest was written — but reporting a bare 'applied'
328
331
  // would let `awm init` hand back a quality gate that measures less than the
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_LEDGER_SCAN_LIMITS = void 0;
7
+ exports.scanProjectLedgers = scanProjectLedgers;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const types_1 = require("./types");
11
+ exports.DEFAULT_LEDGER_SCAN_LIMITS = {
12
+ maxFiles: 256,
13
+ maxFileBytes: 4 * 1024 * 1024,
14
+ maxEntries: 20_000,
15
+ maxLineBytes: 64 * 1024,
16
+ maxJsonDepth: 16,
17
+ maxRefsPerClass: 128,
18
+ };
19
+ function isWithin(root, candidate) {
20
+ const relative = path_1.default.relative(root, candidate);
21
+ return relative === '' || (!relative.startsWith(`..${path_1.default.sep}`) && relative !== '..' && !path_1.default.isAbsolute(relative));
22
+ }
23
+ function assertDirectoryInside(root, directory) {
24
+ if (!fs_1.default.existsSync(directory))
25
+ return false;
26
+ const stat = fs_1.default.lstatSync(directory);
27
+ if (stat.isSymbolicLink() || !stat.isDirectory())
28
+ throw new Error(`ledger directory is unsafe: ${directory}`);
29
+ return isWithin(root, fs_1.default.realpathSync(directory));
30
+ }
31
+ function isJsonDepthWithinLimit(raw, maxDepth) {
32
+ let depth = 0;
33
+ let quoted = false;
34
+ let escaped = false;
35
+ for (const char of raw) {
36
+ if (quoted) {
37
+ if (escaped)
38
+ escaped = false;
39
+ else if (char === '\\')
40
+ escaped = true;
41
+ else if (char === '"')
42
+ quoted = false;
43
+ continue;
44
+ }
45
+ if (char === '"')
46
+ quoted = true;
47
+ else if (char === '{' || char === '[') {
48
+ depth += 1;
49
+ if (depth > maxDepth)
50
+ return false;
51
+ }
52
+ else if (char === '}' || char === ']')
53
+ depth -= 1;
54
+ }
55
+ return true;
56
+ }
57
+ function boundedLimits(overrides) {
58
+ if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
59
+ throw new Error('ledger scan limits must be an object');
60
+ }
61
+ const limits = { ...exports.DEFAULT_LEDGER_SCAN_LIMITS, ...overrides };
62
+ for (const value of Object.values(limits)) {
63
+ if (!Number.isSafeInteger(value) || value < 1)
64
+ throw new Error('ledger scan limits must be positive safe integers');
65
+ }
66
+ return limits;
67
+ }
68
+ function relativeSource(root, target, line) {
69
+ return `${relativePath(root, target)}:${line}`;
70
+ }
71
+ function relativePath(root, target) {
72
+ return path_1.default.relative(root, target).split(path_1.default.sep).join('/');
73
+ }
74
+ function compareCandidates(a, b) {
75
+ return a.sourcePath < b.sourcePath ? -1 : a.sourcePath > b.sourcePath ? 1 : 0;
76
+ }
77
+ /**
78
+ * Reads one global bounded window across the direct ledger directories, then
79
+ * sorts only the retained candidates. This deliberately does not claim to
80
+ * find a globally lexical prefix in an unbounded directory: the extra
81
+ * candidate is a truncation witness, and no later candidate is opened or
82
+ * retained from either active or archive storage.
83
+ */
84
+ function collectBoundedCandidates(root, directories, maxFiles) {
85
+ const candidates = [];
86
+ const capacity = maxFiles + 1;
87
+ let truncated = false;
88
+ for (const { directory, archive } of directories) {
89
+ if (candidates.length >= capacity)
90
+ break;
91
+ const handle = fs_1.default.opendirSync(directory);
92
+ try {
93
+ let item;
94
+ while ((item = handle.readSync()) !== null) {
95
+ if (!item.name.endsWith('.jsonl'))
96
+ continue;
97
+ const target = path_1.default.join(directory, item.name);
98
+ candidates.push({ isArchive: archive, target, sourcePath: relativePath(root, target) });
99
+ if (candidates.length === capacity) {
100
+ truncated = true;
101
+ break;
102
+ }
103
+ }
104
+ }
105
+ finally {
106
+ handle.closeSync();
107
+ }
108
+ }
109
+ candidates.sort(compareCandidates);
110
+ return { candidates, truncated: truncated || candidates.length > maxFiles };
111
+ }
112
+ /** Reads only direct .jsonl children of active/archive ledger roots; never follows links. */
113
+ function scanProjectLedgers(projectRoot, overrides = {}) {
114
+ if (typeof projectRoot !== 'string' || !path_1.default.isAbsolute(projectRoot))
115
+ throw new Error('projectRoot must be an absolute path');
116
+ const limits = boundedLimits(overrides);
117
+ const root = fs_1.default.realpathSync(projectRoot);
118
+ const ledgerRoot = path_1.default.join(root, '.awm', 'ledger');
119
+ const sources = {
120
+ activeFiles: 0, archivedFiles: 0, validEntries: 0, validFindings: 0, skippedFindings: 0, skippedByReason: {},
121
+ };
122
+ const entries = [];
123
+ const skip = (reason) => {
124
+ sources.skippedFindings += 1;
125
+ sources.skippedByReason[reason] = (sources.skippedByReason[reason] ?? 0) + 1;
126
+ };
127
+ if (!fs_1.default.existsSync(ledgerRoot))
128
+ return { entries, sources, omittedEvidenceRefs: 0 };
129
+ if (!assertDirectoryInside(root, path_1.default.join(root, '.awm')) || !assertDirectoryInside(root, ledgerRoot)) {
130
+ throw new Error('ledger directory escapes project root');
131
+ }
132
+ const directories = [{ directory: ledgerRoot, archive: false }];
133
+ const archive = path_1.default.join(ledgerRoot, 'archive');
134
+ if (fs_1.default.existsSync(archive)) {
135
+ if (!assertDirectoryInside(root, archive))
136
+ throw new Error('ledger archive directory escapes project root');
137
+ directories.push({ directory: archive, archive: true });
138
+ }
139
+ let filesSeen = 0;
140
+ let linesSeen = 0;
141
+ let entryLimitReached = false;
142
+ let omittedEvidenceRefs = 0;
143
+ const evidenceRefsByClass = new Map();
144
+ const collected = collectBoundedCandidates(root, directories, limits.maxFiles);
145
+ for (const { isArchive, target } of collected.candidates.slice(0, limits.maxFiles)) {
146
+ if (entryLimitReached)
147
+ break;
148
+ filesSeen += 1;
149
+ const stat = fs_1.default.lstatSync(target);
150
+ if (stat.isSymbolicLink()) {
151
+ skip('symlink-entry');
152
+ continue;
153
+ }
154
+ if (!stat.isFile()) {
155
+ skip('nonregular-entry');
156
+ continue;
157
+ }
158
+ const real = fs_1.default.realpathSync(target);
159
+ if (!isWithin(root, real)) {
160
+ skip('path-escape');
161
+ continue;
162
+ }
163
+ if (stat.size > limits.maxFileBytes) {
164
+ skip('file-too-large');
165
+ continue;
166
+ }
167
+ if (isArchive)
168
+ sources.archivedFiles += 1;
169
+ else
170
+ sources.activeFiles += 1;
171
+ const lines = fs_1.default.readFileSync(target, 'utf-8').split('\n');
172
+ for (let index = 0; index < lines.length; index += 1) {
173
+ const raw = lines[index];
174
+ if (!raw.trim())
175
+ continue;
176
+ if (linesSeen >= limits.maxEntries) {
177
+ skip('entry-limit');
178
+ entryLimitReached = true;
179
+ break;
180
+ }
181
+ linesSeen += 1;
182
+ if (Buffer.byteLength(raw, 'utf-8') > limits.maxLineBytes) {
183
+ skip('line-too-large');
184
+ continue;
185
+ }
186
+ if (!isJsonDepthWithinLimit(raw, limits.maxJsonDepth)) {
187
+ skip('json-too-deep');
188
+ continue;
189
+ }
190
+ let value;
191
+ try {
192
+ value = JSON.parse(raw);
193
+ }
194
+ catch {
195
+ skip('invalid-json');
196
+ continue;
197
+ }
198
+ const source = relativeSource(root, target, index + 1);
199
+ const parsed = (0, types_1.parseLedgerEntry)(value, source);
200
+ if (!parsed.ok) {
201
+ skip(parsed.reason);
202
+ continue;
203
+ }
204
+ sources.validEntries += 1;
205
+ if (parsed.entry.polarity === 'finding') {
206
+ sources.validFindings += 1;
207
+ const defectClass = parsed.entry.defectClass ?? 'unclassified';
208
+ const count = evidenceRefsByClass.get(defectClass) ?? 0;
209
+ const evidenceRef = count < limits.maxRefsPerClass ? source : null;
210
+ if (evidenceRef === null) {
211
+ omittedEvidenceRefs += 1;
212
+ // The finding remains available for recurrence analysis, but
213
+ // its public evidence is deliberately omitted. Record the
214
+ // truncation through the same typed source contract as every
215
+ // other scan bound so downstream renderers cannot call it a
216
+ // complete report.
217
+ skip('evidence-ref-limit');
218
+ }
219
+ else
220
+ evidenceRefsByClass.set(defectClass, count + 1);
221
+ entries.push({ entry: parsed.entry, source, evidenceRef });
222
+ }
223
+ }
224
+ }
225
+ if (!entryLimitReached && collected.truncated)
226
+ skip('file-limit');
227
+ return { entries, sources, omittedEvidenceRefs };
228
+ }
@@ -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
  }