agentic-workflow-manager 9.0.2 → 9.2.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 (31) hide show
  1. package/dist/src/commands/context/index.js +47 -0
  2. package/dist/src/commands/ledger/index.js +13 -0
  3. package/dist/src/commands/process/index.js +58 -0
  4. package/dist/src/core/command-result.js +17 -0
  5. package/dist/src/core/context/provider.js +27 -18
  6. package/dist/src/core/dashboard/collect.js +30 -5
  7. package/dist/src/core/dashboard/sanitize.js +24 -2
  8. package/dist/src/core/orchestrators.js +24 -3
  9. package/dist/src/core/process/body.js +181 -0
  10. package/dist/src/core/process/discover.js +94 -0
  11. package/dist/src/core/process/model.js +109 -0
  12. package/dist/src/core/process/types.js +5 -0
  13. package/dist/src/core/text.js +58 -0
  14. package/dist/src/index.js +4 -0
  15. package/dist/tests/commands/context.test.js +64 -0
  16. package/dist/tests/commands/ledger/index.test.js +11 -0
  17. package/dist/tests/commands/process.test.js +88 -0
  18. package/dist/tests/core/context/provider.test.js +49 -0
  19. package/dist/tests/core/dashboard/collect.test.js +25 -21
  20. package/dist/tests/core/dashboard/processes-section.test.js +71 -0
  21. package/dist/tests/core/dashboard/production-adapters.test.js +58 -0
  22. package/dist/tests/core/orchestrators.test.js +38 -0
  23. package/dist/tests/core/process/body.test.js +239 -0
  24. package/dist/tests/core/process/discover.test.js +139 -0
  25. package/dist/tests/core/process/model.test.js +83 -0
  26. package/dist/tests/core/process/no-regression.test.js +57 -0
  27. package/dist/tests/core/text.test.js +29 -0
  28. package/dist/tests/integration/context-orchestrators-e2e.test.js +144 -0
  29. package/dist/tests/integration/process-model-e2e.test.js +161 -0
  30. package/dist/tests/structural/process-model-single-parser.test.js +63 -0
  31. package/package.json +1 -1
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runContextOrchestrators = runContextOrchestrators;
4
+ exports.registerContextCommand = registerContextCommand;
5
+ const orchestrators_1 = require("../../core/orchestrators");
6
+ const provider_1 = require("../../core/context/provider");
7
+ const text_1 = require("../../core/text");
8
+ const command_result_1 = require("../../core/command-result");
9
+ function runContextOrchestrators(collected, opts) {
10
+ const stderr = (0, command_result_1.diagnosticsToStderr)(collected.diagnostics);
11
+ const composed = (0, provider_1.composedOrchestrators)(collected.declared);
12
+ if (opts.verify !== undefined) {
13
+ // opts.verify es el argumento CRUDO tal como lo tipeo el usuario; composed[].name
14
+ // ya paso por sanitizeDeclaredField. Sin normalizar este lado con la MISMA funcion,
15
+ // un usuario que tipee el nombre declarado exacto (con un caracter que el saneo
16
+ // quita, p.ej. `_` o un byte de control) se lleva un falso "not composed" (R3.5/R3.6).
17
+ const verifyNormalized = (0, text_1.sanitizeDeclaredField)(opts.verify);
18
+ const found = composed.some((o) => o.name === verifyNormalized);
19
+ if (!found) {
20
+ const available = composed.map((o) => o.name).join(', ') || '(none)';
21
+ return {
22
+ code: 2, stdout: '',
23
+ stderr: `${stderr}awm context orchestrators: "${verifyNormalized}" is not composed — available: ${available}\n`,
24
+ };
25
+ }
26
+ return { code: 0, stdout: `"${verifyNormalized}" is composed into the session context.\n`, stderr };
27
+ }
28
+ if (opts.json) {
29
+ return { code: 0, stdout: `${JSON.stringify({ orchestrators: composed }, null, 2)}\n`, stderr };
30
+ }
31
+ if (composed.length === 0) {
32
+ return { code: 0, stdout: 'No declared orchestrators in the installed registries.\n', stderr };
33
+ }
34
+ const rows = composed
35
+ .map((o) => `${o.name} applies when: ${o.appliesWhen} -> ${o.terminatesTo}`)
36
+ .join('\n');
37
+ return { code: 0, stdout: `${rows}\n`, stderr };
38
+ }
39
+ function registerContextCommand(program) {
40
+ const context = program.command('context').description('read-only view of the context AWM delivers to every agent session');
41
+ context
42
+ .command('orchestrators')
43
+ .description('list the declared orchestrators as they are composed into the session context')
44
+ .option('--json', 'emit the composed list as JSON')
45
+ .option('--verify <name>', 'exit 0 only if that orchestrator is composed; 2 otherwise')
46
+ .action((opts) => (0, command_result_1.emit)(runContextOrchestrators((0, orchestrators_1.collectDeclaredOrchestrators)(), { json: opts.json === true, verify: opts.verify })));
47
+ }
@@ -6,6 +6,15 @@ const types_1 = require("../../core/ledger/types");
6
6
  function archiveLabel() {
7
7
  return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '');
8
8
  }
9
+ // R1a's own retro: across this session, reviewer subagents repeatedly verified
10
+ // this command's flags by issuing a live `ledger add` with a placeholder
11
+ // --desc ("test", "test-check", "test-x", ...) instead of reading --help,
12
+ // polluting the real branch ledger at least six separate times despite
13
+ // explicit prompt instructions not to. --help already documents every flag —
14
+ // the missing piece was a structural backstop, since prompt text alone kept
15
+ // failing to prevent it. This denylist catches the exact placeholder shapes
16
+ // observed; it is not a general anti-test-data filter.
17
+ const PLACEHOLDER_DESC = /^(test|test[-_]?\w{0,10}|placeholder|foo|bar|example|todo|tbd)$/i;
9
18
  function registerLedgerCommand(program) {
10
19
  const ledger = program.command('ledger').description('persistent per-branch findings ledger (working memory for harness-retro)');
11
20
  ledger
@@ -25,6 +34,10 @@ function registerLedgerCommand(program) {
25
34
  if (opts.defectClass !== undefined && !types_1.DEFECT_CLASS.test(opts.defectClass)) {
26
35
  throw new Error('--defect-class requires a lowercase kebab-case value');
27
36
  }
37
+ if (PLACEHOLDER_DESC.test(opts.desc.trim())) {
38
+ throw new Error(`--desc "${opts.desc}" looks like a placeholder, not a real finding — ` +
39
+ 'run "awm ledger add --help" to check flag syntax instead of a live add.');
40
+ }
28
41
  const cwd = process.cwd();
29
42
  const branch = opts.branch ?? (0, store_1.detectBranch)(cwd);
30
43
  const entry = {
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runProcessList = runProcessList;
4
+ exports.runProcessShow = runProcessShow;
5
+ exports.registerProcessCommand = registerProcessCommand;
6
+ const discover_1 = require("../../core/process/discover");
7
+ const text_1 = require("../../core/text");
8
+ const command_result_1 = require("../../core/command-result");
9
+ /** `source` es un path absoluto del filesystem local: identifica la máquina, no
10
+ * el proceso. Se usa internamente para diagnósticos, y se omite de toda salida
11
+ * destinada a ser compartida o consumida por otro programa. */
12
+ function publicView(model) {
13
+ const { source: _source, ...rest } = model;
14
+ return rest;
15
+ }
16
+ function runProcessList(discovered) {
17
+ const stderr = (0, command_result_1.diagnosticsToStderr)(discovered.diagnostics);
18
+ if (discovered.models.length === 0) {
19
+ return { code: 0, stdout: 'No process models declared by the installed registries.\n', stderr };
20
+ }
21
+ const rows = discovered.models
22
+ .map((m) => `${m.name} ${m.status} ${m.entryPoint ? 'entry-point' : 'phase'} -> ${m.terminatesTo}`)
23
+ .join('\n');
24
+ return { code: 0, stdout: `${rows}\n`, stderr };
25
+ }
26
+ function runProcessShow(discovered, name, json) {
27
+ const stderr = (0, command_result_1.diagnosticsToStderr)(discovered.diagnostics);
28
+ const found = discovered.models.find((m) => m.name === name);
29
+ if (!found) {
30
+ const available = discovered.models.map((m) => m.name).join(', ') || '(none)';
31
+ return { code: 2, stdout: '', stderr: `${stderr}awm process show: no process named "${name}" — available: ${available}\n` };
32
+ }
33
+ if (!json) {
34
+ // Vista mínima deliberada: name/status/objective/structure. appliesWhen,
35
+ // routing, termination y unverified no se renderizan aquí — el detalle
36
+ // completo del body está disponible vía --json (ver Task 4 del plan R1A).
37
+ const view = publicView(found);
38
+ const structure = view.body.structure
39
+ .map((sg) => [
40
+ `${(0, text_1.stripControlChars)(sg.id)} — ${(0, text_1.stripControlChars)(sg.text)}`,
41
+ ...sg.operations.map((op) => ` ${(0, text_1.stripControlChars)(op.id)} — ${(0, text_1.stripControlChars)(op.text)}`),
42
+ ].join('\n')).join('\n');
43
+ return { code: 0, stdout: `${view.name} (${view.status})\n\n${(0, text_1.stripControlChars)(view.body.objective)}\n\n${structure}\n`, stderr };
44
+ }
45
+ return { code: 0, stdout: `${JSON.stringify(publicView(found), null, 2)}\n`, stderr };
46
+ }
47
+ function registerProcessCommand(program) {
48
+ const process_ = program.command('process').description('declared process models (the CLI is their only parser)');
49
+ process_
50
+ .command('list')
51
+ .description('list process models declared by the installed registries')
52
+ .action(() => (0, command_result_1.emit)(runProcessList((0, discover_1.discoverProcessModels)())));
53
+ process_
54
+ .command('show <name>')
55
+ .description('show one process model')
56
+ .option('--json', 'emit the parsed model as JSON')
57
+ .action((name, opts) => (0, command_result_1.emit)(runProcessShow((0, discover_1.discoverProcessModels)(), name, opts.json === true)));
58
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.diagnosticsToStderr = diagnosticsToStderr;
4
+ exports.emit = emit;
5
+ /** Formatea diagnósticos como líneas `warning: ...\n` listas para stderr.
6
+ * Compartido entre comandos para no divergir el formato. */
7
+ function diagnosticsToStderr(diagnostics) {
8
+ return diagnostics.map((d) => `warning: ${d}\n`).join('');
9
+ }
10
+ function emit(result) {
11
+ if (result.stderr)
12
+ process.stderr.write(result.stderr);
13
+ if (result.stdout)
14
+ process.stdout.write(result.stdout);
15
+ if (result.code !== 0)
16
+ process.exitCode = result.code;
17
+ }
@@ -4,11 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.sha256 = sha256;
7
+ exports.composedOrchestrators = composedOrchestrators;
7
8
  exports.buildContext = buildContext;
8
9
  // cli/src/core/context/provider.ts
9
10
  const crypto_1 = __importDefault(require("crypto"));
10
11
  const fs_1 = __importDefault(require("fs"));
11
12
  const path_1 = __importDefault(require("path"));
13
+ const text_1 = require("../text");
12
14
  function sha256(input) {
13
15
  return crypto_1.default.createHash('sha256').update(input, 'utf-8').digest('hex');
14
16
  }
@@ -17,29 +19,36 @@ function parseVersion(skill) {
17
19
  return m ? m[1].trim() : '0.0.0';
18
20
  }
19
21
  /**
20
- * Neutraliza contenido no confiable proveniente de registries declarados
21
- * (name/appliesWhen/terminatesTo) antes de interpolarlo en markdown.
22
- * Sin esto, un registry malicioso/comprometido podria inyectar saltos de
23
- * linea, marcadores markdown (##, `, *, _) o pseudo-tags XML/HTML (<, >)
24
- * para forjar una seccion nueva o un bloque instruccional dentro del
25
- * payload de contexto que consume el proveedor de IA — un vector de
26
- * prompt-injection. `readDeclaredOrchestrators` solo valida que los
27
- * campos sean strings no vacios; el saneo pertenece a esta frontera de
28
- * render, no a la validacion de lectura.
22
+ * La lista de orquestadores declarados TAL COMO entra al payload de contexto:
23
+ * saneada campo por campo con `sanitizeDeclaredField` (core/text.ts) — que
24
+ * neutraliza tanto marcado markdown/saltos de linea (prompt-injection via
25
+ * `##`, `` ` ``, `*`, `_`, `<`, `>`) COMO bytes de control C0/DEL (ANSI
26
+ * escapes hacia una terminal real). `readDeclaredOrchestrators` solo valida
27
+ * que los campos sean strings no vacios; el saneo pertenece a esta frontera
28
+ * de render, no a la validacion de lectura.
29
+ *
30
+ * ESTA es la UNICA funcion que hace este saneo — es la que consume
31
+ * `renderDeclared` (el payload materializado en cada sesion de agente via
32
+ * `buildContext`) y la que consume `awm context orchestrators` (comando).
33
+ * Ningun consumidor necesita sanear de nuevo por su cuenta: si el comando
34
+ * aplicara su propio saneo, comando y payload podrian divergir en silencio
35
+ * — el modo de falla que R5.2 prohíbe para el modelo de proceso y que vale
36
+ * igual acá. Por la misma razon, cualquier comparacion contra un nombre
37
+ * declarado (p.ej. `--verify` en `commands/context/index.ts`) debe normalizar
38
+ * su lado tambien con `sanitizeDeclaredField`, no con una copia del regex.
29
39
  */
30
- function sanitizeForMarkdown(s) {
31
- return s.replace(/\r?\n/g, ' ').replace(/[`*_#<>]/g, '');
40
+ function composedOrchestrators(list) {
41
+ return list.map(o => ({
42
+ name: (0, text_1.sanitizeDeclaredField)(o.name),
43
+ appliesWhen: (0, text_1.sanitizeDeclaredField)(o.appliesWhen),
44
+ terminatesTo: (0, text_1.sanitizeDeclaredField)(o.terminatesTo),
45
+ }));
32
46
  }
33
47
  function renderDeclared(list) {
34
48
  if (list.length === 0)
35
49
  return '';
36
- const rows = list
37
- .map(o => {
38
- const name = sanitizeForMarkdown(o.name);
39
- const appliesWhen = sanitizeForMarkdown(o.appliesWhen);
40
- const terminatesTo = sanitizeForMarkdown(o.terminatesTo);
41
- return `- **${name}** — applies when: ${appliesWhen}. Terminates to: \`${terminatesTo}\`.`;
42
- })
50
+ const rows = composedOrchestrators(list)
51
+ .map(o => `- **${o.name}** — applies when: ${o.appliesWhen}. Terminates to: \`${o.terminatesTo}\`.`)
43
52
  .join('\n');
44
53
  return `## Declared orchestrators\n\nConsider these before the built-in pair:\n\n${rows}\n\n`;
45
54
  }
@@ -15,6 +15,7 @@ const types_1 = require("../evidence/types");
15
15
  const store_1 = require("../ledger/store");
16
16
  const paths_1 = require("../journal/paths");
17
17
  const store_2 = require("../journal/store");
18
+ const discover_1 = require("../process/discover");
18
19
  const sanitize_1 = require("./sanitize");
19
20
  const validate_1 = require("./validate");
20
21
  const plan_state_1 = require("./plan-state");
@@ -29,7 +30,7 @@ exports.REMEDIATION_BY_FINDING_ID = {
29
30
  };
30
31
  const BLOCKED_CYCLE_REMEDIATION = 'awm preflight';
31
32
  const EMPTY_ADAPTERS = {
32
- machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined,
33
+ machine: () => ({ findings: [] }), project: () => ({ findings: [] }), plans: () => [], execution: () => undefined, processes: () => [],
33
34
  };
34
35
  const SAFE_REMEDIATIONS = new Set(['awm init', 'awm update', 'awm sync', 'awm sensors status', 'awm preflight']);
35
36
  const HEALTHY_PROVIDER_STATES = new Set(['supported', 'healthy', 'shared', 'delivered']);
@@ -127,6 +128,7 @@ function productionDashboardAdapters(context) {
127
128
  retro: history.cycles.map((cycle) => cycleFinding('retro', 'Project context', cycle, cycle.plan.state !== 'executed')),
128
129
  };
129
130
  },
131
+ processes: () => (0, discover_1.discoverProcessModels)().models.map((m) => ({ name: m.name, status: m.status })),
130
132
  };
131
133
  }
132
134
  /** A journal has no durable plan reference. Use a generic fixed row so its
@@ -358,6 +360,29 @@ function collectDashboardSnapshot(options) {
358
360
  const retroItems = isolatedFindings(execution?.retro);
359
361
  const evidenceResult = optional(() => evidenceHistoryItems(root));
360
362
  const executionUnavailable = !executionResult.failed && execution === undefined;
363
+ const processesResult = optional(() => (0, sanitize_1.sanitizeDashboardSource)(adapters.processes({ root })));
364
+ // `id`/`label`/`detail` se construyen acá, en código de aplicación, DESPUÉS de
365
+ // la sanitización de source — igual que `planning` hace con `classifyPlanState`:
366
+ // el sanitizador descarta todo `detail` que venga del adapter (sanitize.ts), así
367
+ // que ponerlo antes sería escribirlo para que se borre en silencio. Y como estos
368
+ // tres campos nunca pasan por el recorrido recursivo de sanitizeDashboardSource
369
+ // de arriba, se los corre por las mismas ramas de sanitize() (sanitizeDashboardId/
370
+ // sanitizeDashboardLabel) para sostener la segunda barrera: un `p.name` que no es
371
+ // slug cae al id hasheado `item-*` en vez de colarse como `process.<bad-name>`.
372
+ // El estado `attention` es accionable (ver validate.ts): un modelo en draft no
373
+ // tiene un comando que lo "arregle" — el remediation apunta a inspeccionarlo, no
374
+ // a resolverlo automáticamente.
375
+ const processItems = (processesResult.value ?? []).map((p) => ({
376
+ id: (0, sanitize_1.sanitizeDashboardId)(`process.${p.name}`),
377
+ // El literal 'Process' ya está en CANONICAL_LABELS, así que el branch
378
+ // [redacted] de sanitizeDashboardLabel nunca se ejecuta desde este call
379
+ // site hoy — el wrapper queda por simetría con sanitizeDashboardId y como
380
+ // guardia ante un futuro label dinámico, no porque esta llamada pueda fallar.
381
+ label: (0, sanitize_1.sanitizeDashboardLabel)('Process'),
382
+ state: p.status === 'active' ? 'ok' : 'attention',
383
+ detail: p.status,
384
+ ...(p.status === 'active' ? {} : { remediation: 'awm process list' }),
385
+ }));
361
386
  const sections = [
362
387
  machineSection,
363
388
  section('project', projectResult.failed || projectItemsResult.failed ? 'unavailable' : 'available', projectResult.failed
@@ -371,10 +396,10 @@ function collectDashboardSnapshot(options) {
371
396
  section('docs', executionResult.failed || executionUnavailable || docsItems.failed ? 'unavailable' : 'available', docsItems.value),
372
397
  section('retro', executionResult.failed || executionUnavailable || retroItems.failed ? 'unavailable' : 'available', retroItems.value),
373
398
  section('history', evidenceResult.failed ? 'unavailable' : 'available', evidenceResult.failed ? [] : evidenceResult.value.items),
374
- // `processes` is a reserved section id with no adapter yet: a later
375
- // release (R1) populates it. `not_applicable` here means exactly
376
- // "this section doesn't apply here", not dead code.
377
- section('processes', 'not_applicable', []),
399
+ // R1a puebla `processes` desde su adapter. Sin modelos declarados la
400
+ // sección conserva `not_applicable` — el mismo valor que R0 dejó
401
+ // reservado — para que un proyecto sin procesos se vea igual que antes.
402
+ section('processes', processesResult.failed ? 'unavailable' : processItems.length === 0 ? 'not_applicable' : 'available', processesResult.failed ? [] : processItems),
378
403
  ];
379
404
  const degraded = sections.some((entry) => entry.availability === 'unavailable' || entry.items.some((item) => item.state !== 'ok' && item.state !== 'not_applicable'));
380
405
  return (0, validate_1.validateDashboardSnapshotV1)({ schema: 2, generatedAt: options.now, overall: degraded ? 'degraded' : 'healthy', project: { detected: true, label: projectSource?.label || 'Project detected' }, confidence: evidenceResult.failed ? 'none' : evidenceResult.value.confidence, sections });
@@ -1,12 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.sanitizeDashboardSource = sanitizeDashboardSource;
4
+ exports.sanitizeDashboardId = sanitizeDashboardId;
5
+ exports.sanitizeDashboardLabel = sanitizeDashboardLabel;
4
6
  const crypto_1 = require("crypto");
5
7
  const STATES = new Set(['ok', 'attention', 'missing', 'unavailable', 'not_applicable', 'active', 'blocked']);
6
- const ALLOWED_KEYS = new Set(['findings', 'label', 'id', 'state', 'detail', 'remediation', 'remediationVerified', 'execution', 'qa', 'docs', 'retro', 'history', 'lifecycle', 'journal', 'markers', 'tasks', 'total', 'completed', 'qaComplete', 'docsComplete', 'retroComplete']);
8
+ const ALLOWED_KEYS = new Set(['findings', 'label', 'id', 'state', 'detail', 'remediation', 'remediationVerified', 'execution', 'qa', 'docs', 'retro', 'history', 'lifecycle', 'journal', 'markers', 'tasks', 'total', 'completed', 'qaComplete', 'docsComplete', 'retroComplete', 'processes', 'name', 'status']);
7
9
  const CANONICAL_LABELS = new Set([
8
10
  'Preferences', 'Registries', 'Profile', 'Sensors', 'Optional source unavailable',
9
11
  'Extensions', 'Registry pins', 'Active bundles', 'Project context', 'Constitution', 'Static preflight', 'Documentation',
12
+ 'Process',
10
13
  ]);
11
14
  const CANONICAL_FINDING_IDS = new Set([
12
15
  'machine.preferences.missing', 'machine.registries.stale', 'project.profile.missing',
@@ -15,6 +18,10 @@ const CANONICAL_FINDING_IDS = new Set([
15
18
  const PROVIDER_FINDING_ID = /^machine\.provider\.(?:claude-code|codex|opencode|cursor|copilot|antigravity)\.(?:binary\.version|skills\.global|agents\.native|workflows\.global|context\.global|hook\.trust|guidance\.project|constitution\.delivery)$/;
16
19
  const PROJECT_FINDING_ID = /^project\.(?:profile\.present|extensions\.configured|registry-pins\.present|bundles\.coherent|context\.present|constitution\.present|sensors\.present|preflight\.not_collected)$/;
17
20
  const PROVIDER_LABEL = /^Provider (?:claude-code|codex|opencode|cursor|copilot|antigravity): (?:binary\.version|skills\.global|agents\.native|workflows\.global|context\.global|hook\.trust|guidance\.project|constitution\.delivery)$/;
21
+ // El slug ya lo validó PROCESS_NAME en core/process/model.ts. Repetir la forma
22
+ // acá es deliberado: este módulo es la frontera de render y no puede confiar en
23
+ // que su input pasó por aquel validador — un adapter nuevo podría no hacerlo.
24
+ const PROCESS_FINDING_ID = /^process\.[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
18
25
  const DANGEROUS = /(?:ghp_|sk-[A-Za-z]|<|>|\\\\[^\\\s]+\\[^\\\s]+|\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*|[A-Za-z]:\\|\b[A-Za-z_][A-Za-z0-9_]*=|token|secret|password)/iu;
19
26
  function sanitize(value, key) {
20
27
  if (value === null || typeof value === 'boolean')
@@ -33,7 +40,7 @@ function sanitize(value, key) {
33
40
  if (key === 'id') {
34
41
  if (value.trim() === '')
35
42
  throw new Error('Dashboard finding id is invalid');
36
- return CANONICAL_FINDING_IDS.has(value) || PROVIDER_FINDING_ID.test(value) || PROJECT_FINDING_ID.test(value)
43
+ return CANONICAL_FINDING_IDS.has(value) || PROVIDER_FINDING_ID.test(value) || PROJECT_FINDING_ID.test(value) || PROCESS_FINDING_ID.test(value)
37
44
  ? value : `item-${(0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, 16)}`;
38
45
  }
39
46
  if (key === 'label' && !CANONICAL_LABELS.has(value) && !PROVIDER_LABEL.test(value))
@@ -60,3 +67,18 @@ function sanitize(value, key) {
60
67
  function sanitizeDashboardSource(value) {
61
68
  return sanitize(value);
62
69
  }
70
+ // Sections that build an item's `id`/`label` in application code (rather than
71
+ // receiving them ready-made inside an adapter's `findings` array) never pass
72
+ // through `sanitizeDashboardSource`'s recursive walk — there is no `findings`
73
+ // array for it to walk. These wrappers let such call sites run the exact same
74
+ // `id`/`label` branches sanitize() applies to every other section, so a
75
+ // constructed value gets the same canonical-vocabulary / hashed-fallback
76
+ // treatment instead of skipping validation entirely.
77
+ /** Validates a dashboard item id against the canonical id vocabulary, falling back to a hashed id otherwise. */
78
+ function sanitizeDashboardId(value) {
79
+ return sanitize(value, 'id');
80
+ }
81
+ /** Validates a dashboard item label against the canonical label vocabulary, redacting it otherwise. */
82
+ function sanitizeDashboardLabel(value) {
83
+ return sanitize(value, 'label');
84
+ }
@@ -17,6 +17,7 @@ exports.collectAndWarn = collectAndWarn;
17
17
  const fs_1 = __importDefault(require("fs"));
18
18
  const path_1 = __importDefault(require("path"));
19
19
  const registries_1 = require("./registries");
20
+ const text_1 = require("./text");
20
21
  const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
21
22
  // These fields are semantically short (a short identity, a short trigger condition, a
22
23
  // short target name) — no legitimate declaration needs more than this. A registry is
@@ -109,21 +110,41 @@ function readDeclaredOrchestrators(root) {
109
110
  * de emitir ambas filas al markdown compuesto, gana la primera en el orden de
110
111
  * listRegistries() (= orden de registries.json, ver registries.ts) y la duplicada se
111
112
  * descarta con un diagnostico — misma degradacion tolerante (reportar, no lanzar) que el
112
- * resto de este modulo (R1.2, R5.1).
113
+ * resto de este modulo (R1.2, R5.1). `orch.name` es contenido no confiable del registry:
114
+ * se pasa por `stripControlChars` (core/text.ts) antes de interpolarlo en el diagnostico,
115
+ * que termina crudo en stderr real via `command-result.ts`'s `diagnosticsToStderr`/`emit`.
116
+ *
117
+ * Ademas del dedupe por nombre CRUDO, dos orquestadores con nombres crudos distintos
118
+ * (p.ej. `foo_bar` y `foo*bar`) pueden colisionar DESPUES del saneo que aplica
119
+ * `composedOrchestrators` (core/context/provider.ts) — ambos renderizarian como el mismo
120
+ * `foobar` visible, sin explicacion. Como son declaraciones genuinamente distintas, no se
121
+ * descarta ninguna (eso perderia una declaracion real) — se emite un diagnostico
122
+ * accionable en su lugar. `sanitizeDeclaredField` vive en core/text.ts (no en provider.ts)
123
+ * precisamente para que este modulo -- que debe seguir siendo hoja y no puede importar
124
+ * context/provider.ts, el cual ya importa `DeclaredOrchestrator` DESDE este archivo -- pueda
125
+ * calcular el mismo nombre saneado sin duplicar el regex.
113
126
  */
114
127
  function collectDeclaredOrchestrators() {
115
128
  const declared = [];
116
129
  const diagnostics = [];
117
130
  const seenNames = new Set();
131
+ const seenSanitizedNames = new Set();
118
132
  for (const reg of (0, registries_1.listRegistries)()) {
119
133
  const r = readDeclaredOrchestrators(reg.contentRoot);
120
134
  for (const orch of r.orchestrators) {
135
+ const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
121
136
  if (seenNames.has(orch.name)) {
122
- const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
123
- diagnostics.push(`${file}: orchestrator "${orch.name}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
137
+ diagnostics.push(`${file}: orchestrator "${(0, text_1.stripControlChars)(orch.name)}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
124
138
  continue;
125
139
  }
126
140
  seenNames.add(orch.name);
141
+ const sanitizedName = (0, text_1.sanitizeDeclaredField)(orch.name);
142
+ if (seenSanitizedNames.has(sanitizedName)) {
143
+ diagnostics.push(`${file}: orchestrator "${(0, text_1.stripControlChars)(orch.name)}" sanitizes to the same composed name "${sanitizedName}" as an already declared orchestrator — both will render as indistinguishable duplicates in the composed context`);
144
+ }
145
+ else {
146
+ seenSanitizedNames.add(sanitizedName);
147
+ }
127
148
  declared.push(orch);
128
149
  }
129
150
  diagnostics.push(...r.diagnostics);
@@ -0,0 +1,181 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseProcessBody = parseProcessBody;
4
+ const REQUIRED = ['Objetivo', 'Cuándo aplica', 'Estructura', 'Ruteo', 'Terminación', 'Sin verificar'];
5
+ const SG = /^-\s+(SG-\d+)\s+—\s+(.+)$/;
6
+ const OP = /^\s+-\s+(OP-(\d+)\.\d+)\s+—\s+(.+)$/;
7
+ // Mismo razonamiento que MAX_FIELD_LENGTH en orchestrators.ts: este texto
8
+ // llega al payload de contexto de un proveedor de IA, así que ningún campo
9
+ // puede ser ilimitado — un `## Objetivo` de 10MB en un registry no confiable
10
+ // debe rechazarse con diagnóstico, no aceptarse. Dos anchos, no uno, porque
11
+ // las secciones de prosa (Objetivo/Cuándo aplica/Terminación) son párrafos
12
+ // libres que legítimamente exceden una sola oración, mientras que los ítems
13
+ // estructurales (texto de SG-#/OP-#, celdas de la tabla Ruteo, entradas de
14
+ // "Sin verificar") son título-cortos por contrato — igual de acotados que los
15
+ // campos de orchestrators.ts.
16
+ const MAX_SECTION_LENGTH = 5000;
17
+ const MAX_ITEM_LENGTH = 500;
18
+ /** Corta el documento en secciones de nivel 2. Las de nivel 1 (`# Titulo`) y el
19
+ * contenido previo al primer `##` se descartan: el contrato vive en los `##`.
20
+ * Un heading repetido NO se fusiona con el anterior — eso corrompería en
21
+ * silencio los parsers posicionales (p.ej. `parseRouting` tomaría la fila de
22
+ * cabecera de la segunda sección como una fila de datos). En su lugar se
23
+ * registra como problema y el llamador debe abortar. */
24
+ function splitSections(source, problems) {
25
+ const out = new Map();
26
+ const seen = new Set();
27
+ let current = null;
28
+ for (const line of source.split(/\r?\n/)) {
29
+ // Greedy capture a fin-de-línea + trim en código, no en el regex: un
30
+ // `(.+?)` perezoso seguido de `\s*` sobre una clase que lo solapa
31
+ // (`.` incluye `\s`) es ReDoS cuadrático — ver AGENTS.md
32
+ // "regex-cuantificador-adyacente-a-clase-que-lo-solapa" y el mismo
33
+ // patrón ya corregido en cli/src/commands/evidence/index.ts marker().
34
+ const m = /^##\s+(.*)$/.exec(line);
35
+ // Un `##` seguido solo de espacio (grupo capturado vacío tras el trim)
36
+ // NO es un heading real: la regex vieja `(.+?)\s*$` exigía al menos un
37
+ // carácter no-blanco antes del `\s*$` final, así que una línea así
38
+ // nunca matcheaba como sección. `(.*)` sí puede capturar cero
39
+ // caracteres — sin este guard, una línea "## " perdida dentro de p.ej.
40
+ // `## Ruteo` abriría una sección fantasma "" y desviaría las filas de
41
+ // tabla reales hacia ella en silencio (ver finding-1 del reviewer).
42
+ if (m && m[1].trim() !== '') {
43
+ const heading = m[1].trim();
44
+ if (seen.has(heading)) {
45
+ problems.push(`duplicate section heading "## ${heading}"`);
46
+ current = null; // deja de recolectar: no fusionar con la primera aparición
47
+ continue;
48
+ }
49
+ seen.add(heading);
50
+ out.set(heading, []);
51
+ current = heading;
52
+ continue;
53
+ }
54
+ if (current !== null)
55
+ out.get(current).push(line);
56
+ }
57
+ return out;
58
+ }
59
+ function paragraph(lines) {
60
+ return lines.map((l) => l.trim()).filter((l) => l !== '').join(' ');
61
+ }
62
+ function parseStructure(lines, problems) {
63
+ const subgoals = [];
64
+ const seenSg = new Set();
65
+ for (const raw of lines) {
66
+ if (raw.trim() === '')
67
+ continue;
68
+ const sg = SG.exec(raw);
69
+ if (sg) {
70
+ // Un SG-# repetido produciría dos entradas con el mismo `id` en
71
+ // `structure` — inconsistente para cualquier consumidor que
72
+ // indexe por id. Mismo patrón `problems` que el resto del archivo.
73
+ if (seenSg.has(sg[1])) {
74
+ problems.push(`duplicate "Estructura" id ${sg[1]}`);
75
+ continue;
76
+ }
77
+ seenSg.add(sg[1]);
78
+ const text = sg[2].trim();
79
+ if (text.length > MAX_ITEM_LENGTH) {
80
+ problems.push(`"${sg[1]}" text must be at most ${MAX_ITEM_LENGTH} characters`);
81
+ continue;
82
+ }
83
+ subgoals.push({ id: sg[1], text, operations: [] });
84
+ continue;
85
+ }
86
+ const op = OP.exec(raw);
87
+ if (!op) {
88
+ problems.push(`"Estructura" line is neither an SG-# nor an OP-#: ${JSON.stringify(raw.trim().slice(0, 80))}`);
89
+ continue;
90
+ }
91
+ const owner = subgoals[subgoals.length - 1];
92
+ // El prefijo numérico de la operación DEBE coincidir con su subobjetivo:
93
+ // sin esto, OP-9.1 colgando de SG-2 pasaría y la jerarquía HTA sería una
94
+ // ilusión tipográfica.
95
+ if (!owner) {
96
+ problems.push(`${op[1]} appears before any SG-#`);
97
+ continue;
98
+ }
99
+ if (`SG-${op[2]}` !== owner.id) {
100
+ problems.push(`${op[1]} does not belong to ${owner.id}`);
101
+ continue;
102
+ }
103
+ const opText = op[3].trim();
104
+ if (opText.length > MAX_ITEM_LENGTH) {
105
+ problems.push(`"${op[1]}" text must be at most ${MAX_ITEM_LENGTH} characters`);
106
+ continue;
107
+ }
108
+ owner.operations.push({ id: op[1], text: opText });
109
+ }
110
+ if (subgoals.length === 0)
111
+ problems.push('"Estructura" declares no SG-#');
112
+ return subgoals;
113
+ }
114
+ function parseRouting(lines, problems) {
115
+ const rows = [];
116
+ const tableLines = lines.map((l) => l.trim()).filter((l) => l.startsWith('|'));
117
+ for (const [index, line] of tableLines.entries()) {
118
+ // `| a | b |` -> ['a','b']: se descartan los extremos vacíos que deja el
119
+ // split, no las celdas internas vacías — la columna "Estado requerido"
120
+ // vacía es WCP18 "sin hito", un valor con significado.
121
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
122
+ if (index === 0) {
123
+ if (cells.length !== 4)
124
+ problems.push('"Ruteo" header must have exactly 4 columns');
125
+ continue;
126
+ }
127
+ // Separador GFM: dashes puros (`|---|`) o con colons de alineación
128
+ // (`|:---|:---:|`). Se prueba celda por celda con un regex anclado y
129
+ // sin cuantificadores anidados (`^:?-+:?$`) — lineal, sin riesgo ReDoS
130
+ // — y se exige que cada celda tenga al menos un `-` real para no
131
+ // aceptar una fila de solo colons/vacíos como falso separador.
132
+ if (cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c)))
133
+ continue; // separador
134
+ if (cells.length !== 4) {
135
+ problems.push(`"Ruteo" row ${index} must have exactly 4 columns, found ${cells.length}`);
136
+ continue;
137
+ }
138
+ if (cells[0] === '' || cells[2] === '' || cells[3] === '') {
139
+ problems.push(`"Ruteo" row ${index} needs Cuándo, Va a and Termina en`);
140
+ continue;
141
+ }
142
+ if (cells.some((c) => c.length > MAX_ITEM_LENGTH)) {
143
+ problems.push(`"Ruteo" row ${index} has a cell longer than ${MAX_ITEM_LENGTH} characters`);
144
+ continue;
145
+ }
146
+ rows.push({ when: cells[0], requiredState: cells[1], goesTo: cells[2], endsAt: cells[3] });
147
+ }
148
+ if (rows.length === 0)
149
+ problems.push('"Ruteo" declares no transitions');
150
+ return rows;
151
+ }
152
+ function parseProcessBody(source, file) {
153
+ const problems = [];
154
+ const sections = splitSections(source, problems);
155
+ for (const heading of REQUIRED) {
156
+ if (!sections.has(heading))
157
+ problems.push(`missing required section "## ${heading}"`);
158
+ }
159
+ if (problems.length > 0)
160
+ return { diagnostics: [`${file}: invalid process model body — ${problems.join('; ')}`] };
161
+ const structure = parseStructure(sections.get('Estructura'), problems);
162
+ const routing = parseRouting(sections.get('Ruteo'), problems);
163
+ const objective = paragraph(sections.get('Objetivo'));
164
+ const appliesWhen = paragraph(sections.get('Cuándo aplica'));
165
+ const termination = paragraph(sections.get('Terminación'));
166
+ const unverified = sections.get('Sin verificar')
167
+ .map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => l.slice(2).trim());
168
+ for (const [label, value] of [['Objetivo', objective], ['Cuándo aplica', appliesWhen], ['Terminación', termination]]) {
169
+ if (value === '')
170
+ problems.push(`"${label}" is empty`);
171
+ else if (value.length > MAX_SECTION_LENGTH)
172
+ problems.push(`"${label}" must be at most ${MAX_SECTION_LENGTH} characters`);
173
+ }
174
+ for (const [index, entry] of unverified.entries()) {
175
+ if (entry.length > MAX_ITEM_LENGTH)
176
+ problems.push(`"Sin verificar" entry ${index} must be at most ${MAX_ITEM_LENGTH} characters`);
177
+ }
178
+ if (problems.length > 0)
179
+ return { diagnostics: [`${file}: invalid process model body — ${problems.join('; ')}`] };
180
+ return { model: { objective, appliesWhen, structure, routing, termination, unverified }, diagnostics: [] };
181
+ }