agentic-workflow-manager 9.20.0 → 9.20.1

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.
@@ -1,4 +1,7 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.PROCESS_NAME = void 0;
4
7
  exports.parseProcessFrontmatter = parseProcessFrontmatter;
@@ -7,40 +10,155 @@ exports.parseProcessFrontmatter = parseProcessFrontmatter;
7
10
  // Disciplina heredada literal de core/orchestrators.ts: NUNCA lanza. Un modelo
8
11
  // malformado se rechaza con diagnóstico y no invalida al registry que lo
9
12
  // contiene ni a los demás (R7.1).
13
+ const js_yaml_1 = __importDefault(require("js-yaml"));
10
14
  const frontmatter_1 = require("../frontmatter");
11
15
  const types_1 = require("./types");
12
16
  /** El discriminador es literal: ningún documento se reconoce como modelo por su
13
17
  * cuerpo, sus headings ni su nombre de archivo (R1.2). */
14
18
  const DISCRIMINATOR = 'process-model';
15
- const ALLOWED_FIELDS = ['awm', 'schema', 'name', 'status', 'entry_point', 'terminates_to', 'created', 'updated'];
19
+ /** `description` es el único campo opcional: runtimes como Codex se niegan a
20
+ * cargar un `SKILL.md` sin él, y el modelo *es* el `SKILL.md` del skill. */
21
+ const ALLOWED_FIELDS = ['awm', 'schema', 'name', 'status', 'entry_point', 'terminates_to', 'created', 'updated', 'description'];
22
+ /** Límite de Agent Skills que imponen los runtimes que consumen el skill.
23
+ * Se cuenta en code points, no en unidades UTF-16. */
24
+ const DESCRIPTION_MAX = 1024;
25
+ /** Tope del bloque de frontmatter antes de entregarlo al parser YAML: acota el
26
+ * costo de parsear contenido no confiable de un registry. Por encima no se
27
+ * parsea (para `description`, se rechaza con el mensaje del límite de 1024). */
28
+ const FRONTMATTER_MAX = 65536;
29
+ /** Lo que un runtime mostraría mal o usaría para engañar: controles C0 (salvo
30
+ * tab), DEL, C1, separadores U+2028/U+2029, marcas LRM/RLM/ALM, overrides e
31
+ * isolates bidi, y surrogates sueltos (regex en vez de `isWellFormed`: el lib
32
+ * de TS es ES2022). */
33
+ const FORBIDDEN_CHAR = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
34
+ /** Discriminador indentado: un modelo cuyas claves quedaron con sangría se
35
+ * perdería en silencio como "no es un modelo". */
36
+ const INDENTED_DISCRIMINATOR = /^awm[ \t]*:[ \t]*process-model$/;
16
37
  const STATUSES = ['draft', 'active'];
17
38
  /** El slug es lo único del modelo que puede viajar dentro de un id del Dashboard
18
39
  * (ver sanitize.ts). Por eso se valida acá y no en la frontera de render: si
19
40
  * admitiera rutas, markup o `=`, el id dejaría de ser seguro por construcción. */
20
41
  exports.PROCESS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
21
42
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
43
+ /** Caracteres de ancho cero: un valor hecho solo de ellos se ve vacío. */
44
+ const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF]/g;
22
45
  /** Lector de pares `clave: valor` de un frontmatter plano. El contrato no admite
23
- * anidamiento ni block scalars, así que no se arrastra un parser YAML: cualquier
24
- * línea que no sea `clave: valor` es un rechazo, no una interpretación. */
46
+ * anidamiento ni block scalars: cualquier línea que no sea `clave: valor` es un
47
+ * rechazo, no una interpretación. (El VALOR de `description` sí pasa por el
48
+ * parser YAML, más abajo.) */
25
49
  function readPairs(block) {
26
50
  const pairs = new Map();
27
51
  const problems = [];
28
- for (const raw of block.split(/\r?\n/)) {
29
- const line = raw.trim();
52
+ const continuations = [];
53
+ // Mismo criterio que YAML, no que JS: se parte en TODO salto de línea YAML
54
+ // (un CR suelto también; si no, `a\rclave: x` sería un par para acá y dos
55
+ // claves para un runtime) y solo se recorta espacio y tab (`\s`/`trim()`
56
+ // comerían NBSP o U+3000, que para YAML son contenido).
57
+ const lines = block.split(/\r\n|\r|\n/);
58
+ for (const [index, raw] of lines.entries()) {
59
+ const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
60
+ // NEL/LS/PS son saltos de línea para YAML 1.1 (PyYAML) y no para js-yaml:
61
+ // un comentario podría esconder una clave que solo un runtime ve. Se
62
+ // reporta en CUALQUIER línea, comentarios incluidos, y solo por posición.
63
+ if (/[\u0085\u2028\u2029]/.test(raw))
64
+ problems.push(`frontmatter line ${index + 1} contains a YAML 1.1 line break (NEL, LS or PS)`);
30
65
  if (line === '' || line.startsWith('#'))
31
66
  continue;
32
- const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$/.exec(line);
67
+ // El contrato es un mapa PLANO: una línea que empieza con espacio o tab
68
+ // es continuación de un valor multilínea, diga lo que diga. Si se
69
+ // recortara antes de mirar, ` KEY: x` pasaría por campo (y su nombre
70
+ // terminaría en un diagnóstico).
71
+ const indented = /^[ \t]/.test(raw);
72
+ if (indented)
73
+ continuations.push({ line: index + 1, text: line });
74
+ // `[\s\S]` y no `.`: un U+2028/U+2029 dentro del valor no lo vuelve
75
+ // "no-par" (la línea ya viene partida); lo rechaza el chequeo de controles.
76
+ const m = indented ? null : /^([A-Za-z_][A-Za-z0-9_]*)[ \t]*:[ \t]*([\s\S]*)$/.exec(line);
77
+ // Se reporta la POSICIÓN (1-based dentro del bloque), nunca el
78
+ // contenido: una línea suelta suele ser la continuación de un valor
79
+ // multilínea, y los diagnósticos no transportan valores de campos.
33
80
  if (!m) {
34
- problems.push(`line is not a "key: value" pair: ${JSON.stringify(line.slice(0, 80))}`);
81
+ problems.push(`frontmatter line ${index + 1} is not a "key: value" pair (multi-line values are not supported)`);
35
82
  continue;
36
83
  }
37
84
  if (pairs.has(m[1])) {
38
85
  problems.push(`duplicate field ${JSON.stringify(m[1])}`);
39
86
  continue;
40
87
  }
41
- pairs.set(m[1], m[2].trim());
88
+ pairs.set(m[1], m[2]);
42
89
  }
43
- return { pairs, problems };
90
+ return { pairs, problems, continuations };
91
+ }
92
+ /** Carga el bloque de frontmatter COMPLETO con js-yaml (schema por defecto),
93
+ * exactamente como lo lee un runtime. Por encima del tope no se parsea. NUNCA
94
+ * lanza: un error de js-yaml es `invalid`. */
95
+ function loadBlock(block) {
96
+ if (block.length > FRONTMATTER_MAX)
97
+ return { kind: 'too-long' };
98
+ try {
99
+ return { kind: 'loaded', doc: js_yaml_1.default.load(block) };
100
+ }
101
+ catch {
102
+ return { kind: 'invalid' };
103
+ }
104
+ }
105
+ function isPlainObject(doc) {
106
+ return doc !== null && typeof doc === 'object' && Object.getPrototypeOf(doc) === Object.prototype;
107
+ }
108
+ /** Valida `description` leyéndola del bloque entero con el parser YAML real, no
109
+ * de la salida de readPairs (ya recortada y partida en `:`, que difiere de lo
110
+ * que lee un runtime). Ni el valor ni el texto del error de js-yaml (que puede
111
+ * citar contenido) llegan al problema. */
112
+ /** ¿El valor crudo de la línea, cargado SOLO, da el mismo string que el bloque
113
+ * entero? Si no, YAML lo plegó con líneas siguientes (p. ej. una comilla que
114
+ * sigue abierta a través de una línea `# …`). NUNCA lanza. */
115
+ function readsSameAlone(raw, value) {
116
+ let alone;
117
+ try {
118
+ alone = js_yaml_1.default.load(`description: ${raw}`);
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ return isPlainObject(alone) && alone.description === value;
124
+ }
125
+ function checkDescription(raw, block, keys) {
126
+ const singleLine = { problem: '"description" must be a single-line string (block scalars are not supported in a process model)' };
127
+ // El prefijo alcanza: todo header de block scalar (bien o mal formado)
128
+ // empieza con `>` o `|`.
129
+ if (/^[>|]/.test(raw))
130
+ return singleLine;
131
+ const tooLong = { problem: `"description" must be at most ${DESCRIPTION_MAX} characters` };
132
+ const loaded = loadBlock(block);
133
+ if (loaded.kind === 'too-long')
134
+ return tooLong;
135
+ if (loaded.kind === 'invalid' || !isPlainObject(loaded.doc))
136
+ return { problem: '"description" cannot be read: the frontmatter is not valid YAML' };
137
+ const doc = loaded.doc;
138
+ // Si YAML lee otras claves de nivel superior que readPairs (un CR suelto,
139
+ // una comilla que se traga la línea siguiente), lo validado acá no es lo
140
+ // que verá un runtime. Ni claves ni valores van al problema.
141
+ const yamlKeys = Object.keys(doc);
142
+ if (yamlKeys.length !== keys.size || yamlKeys.some((k) => !keys.has(k))) {
143
+ return { problem: '"description" cannot be read: the frontmatter does not read the same as YAML (check quotes and line breaks)' };
144
+ }
145
+ const value = doc.description;
146
+ const empty = { problem: '"description" must not be empty' };
147
+ if (value === null || value === undefined)
148
+ return empty;
149
+ if (typeof value !== 'string') {
150
+ return { problem: '"description" must be a string (quote values YAML reads as a boolean, a number, a date or a collection)' };
151
+ }
152
+ if (value.replace(ZERO_WIDTH, '').trim() === '')
153
+ return empty;
154
+ if ([...value].length > DESCRIPTION_MAX)
155
+ return tooLong;
156
+ if (FORBIDDEN_CHAR.test(value))
157
+ return { problem: '"description" must not contain control, line-break or bidirectional-override characters' };
158
+ if (!readsSameAlone(raw, value))
159
+ return singleLine;
160
+ // Se guarda tal cual lo devuelve YAML: sin trim, con NBSP/BOM intactos.
161
+ return { value };
44
162
  }
45
163
  function parseProcessFrontmatter(source, file) {
46
164
  let block;
@@ -52,7 +170,22 @@ function parseProcessFrontmatter(source, file) {
52
170
  }
53
171
  if (block === null)
54
172
  return { diagnostics: [] };
55
- const { pairs, problems } = readPairs(block);
173
+ const { pairs, problems, continuations } = readPairs(block);
174
+ // Un modelo con las claves indentadas no tiene `awm` de nivel superior y se
175
+ // perdería en silencio como "otro documento". Se nombra solo la línea.
176
+ // Dispara con el prefiltro barato y, además, si el bloque NO carga como YAML
177
+ // (sangría parcial o con tab; por encima del tope cuenta como no cargable)
178
+ // o carga con `awm: process-model` en su nivel superior. Si carga sin `awm`
179
+ // arriba (mención bajo `metadata:` o dentro de un block scalar), silencio.
180
+ if (!pairs.has('awm')) {
181
+ const indentedAwm = continuations.find((c) => INDENTED_DISCRIMINATOR.test(c.text));
182
+ const loaded = indentedAwm ? loadBlock(block) : undefined;
183
+ const fires = loaded !== undefined
184
+ && (loaded.kind !== 'loaded' || (isPlainObject(loaded.doc) && loaded.doc.awm === DISCRIMINATOR));
185
+ if (indentedAwm && fires) {
186
+ return { diagnostics: [`${file}: invalid process model — frontmatter keys must not be indented (frontmatter line ${indentedAwm.line})`] };
187
+ }
188
+ }
56
189
  // Sin discriminador NO es un modelo de proceso: no es un error, es otro
57
190
  // documento. Devolver diagnóstico acá inundaría de ruido a todo registry
58
191
  // (cada SKILL.md normal caería en esta rama).
@@ -96,6 +229,15 @@ function parseProcessFrontmatter(source, file) {
96
229
  if (value !== '' && !DATE.test(value))
97
230
  problems.push(`"${field}" must be YYYY-MM-DD`);
98
231
  }
232
+ let description;
233
+ const rawDescription = pairs.get('description');
234
+ if (rawDescription !== undefined) {
235
+ const checked = checkDescription(rawDescription, block, new Set(pairs.keys()));
236
+ if ('problem' in checked)
237
+ problems.push(checked.problem);
238
+ else
239
+ description = checked.value;
240
+ }
99
241
  if (problems.length > 0) {
100
242
  return { diagnostics: [`${file}: invalid process model — ${problems.join('; ')}`] };
101
243
  }
@@ -103,6 +245,7 @@ function parseProcessFrontmatter(source, file) {
103
245
  model: {
104
246
  schema, name, status: status, entryPoint: entryPointRaw === 'true',
105
247
  terminatesTo, created: pairs.get('created'), updated: pairs.get('updated'),
248
+ ...(description !== undefined ? { description } : {}),
106
249
  },
107
250
  diagnostics: [],
108
251
  };
@@ -42,6 +42,10 @@ describe('awm process show --json', () => {
42
42
  expect(parsed).toEqual(expect.objectContaining({ name: 'mi-proceso', schema: 1, status: 'draft' }));
43
43
  expect(parsed.body.routing).toEqual([{ when: 'Al empezar', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' }]);
44
44
  });
45
+ it('emite description cuando el modelo la trae', () => {
46
+ const parsed = JSON.parse((0, process_1.runProcessShow)({ models: [model({ description: 'Use when tracing' })], diagnostics: [] }, 'mi-proceso', true).stdout);
47
+ expect(parsed.description).toBe('Use when tracing');
48
+ });
45
49
  it('el JSON no filtra el path del filesystem del registry', () => {
46
50
  const parsed = JSON.parse((0, process_1.runProcessShow)({ models: [model()], diagnostics: [] }, 'mi-proceso', true).stdout);
47
51
  expect(JSON.stringify(parsed)).not.toContain('/r/skills');
@@ -135,8 +135,8 @@ function gitAtLeast(major, minor) {
135
135
  const v = gitVersion();
136
136
  return v !== null && (v[0] > major || (v[0] === major && v[1] >= minor));
137
137
  }
138
- /** `test` where symlinks and POSIX modes exist; reported as skipped (not as passed) on Windows. */
139
- exports.posixOnly = process.platform === 'win32' ? test.skip : test;
138
+ var platform_1 = require("../../helpers/platform");
139
+ Object.defineProperty(exports, "posixOnly", { enumerable: true, get: function () { return platform_1.posixOnly; } });
140
140
  /**
141
141
  * A copy of `template` in its own directory (same throwaway home, same git isolation): a few milliseconds instead of the
142
142
  * several git processes `makeRepo` spawns. For suites that need a fresh repository per test. `cleanup` removes only the copy;
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const write_html_1 = require("../../../src/core/dashboard/write-html");
10
+ const platform_1 = require("../../helpers/platform");
10
11
  describe('writeHtmlAtomically', () => {
11
12
  let root;
12
13
  beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-html-')); });
@@ -85,9 +86,7 @@ describe('writeHtmlAtomically', () => {
85
86
  open.mockRestore();
86
87
  }
87
88
  });
88
- it('uses mode 0600 for a POSIX adjacent temporary file', () => {
89
- if (process.platform === 'win32')
90
- return;
89
+ (0, platform_1.posixOnly)('uses mode 0600 for a POSIX adjacent temporary file', () => {
91
90
  const target = path_1.default.join(root, 'posix.html');
92
91
  const open = jest.spyOn(fs_1.default, 'openSync');
93
92
  try {
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const evidence_fixtures_1 = require("../../helpers/evidence-fixtures");
10
10
  const store_1 = require("../../../src/core/evidence/store");
11
+ const platform_1 = require("../../helpers/platform");
11
12
  describe('cycle evidence store', () => {
12
13
  let root;
13
14
  beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-evidence-')); });
@@ -20,9 +21,7 @@ describe('cycle evidence store', () => {
20
21
  expect(fs_1.default.readdirSync(dir)).toEqual([`${evidence.cycleId}.json`]);
21
22
  expect(JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, `${evidence.cycleId}.json`), 'utf8')).qa.fixes).toBe(0);
22
23
  });
23
- test('rejects symlinked evidence ancestors without writing outside root', () => {
24
- if (process.platform === 'win32')
25
- return;
24
+ (0, platform_1.posixOnly)('rejects symlinked evidence ancestors without writing outside root', () => {
26
25
  const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-evidence-outside-'));
27
26
  fs_1.default.symlinkSync(outside, path_1.default.join(root, '.awm'));
28
27
  expect(() => (0, store_1.writeCycleEvidence)(root, (0, evidence_fixtures_1.cycleEvidenceFixture)())).toThrow(/symlink/);
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const store_1 = require("../../../src/core/ledger/store");
10
10
  const types_1 = require("../../../src/core/ledger/types");
11
+ const platform_1 = require("../../helpers/platform");
11
12
  function mkTmp() {
12
13
  return fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ledger-'));
13
14
  }
@@ -191,18 +192,11 @@ describe('ledger store — archive', () => {
191
192
  expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
192
193
  expect(fs_1.default.readFileSync(archivePath, 'utf-8')).toContain('archived-evidence');
193
194
  });
194
- test('refuses a dangling symlink archive destination and preserves the active ledger', () => {
195
+ (0, platform_1.symlinkTest)('refuses a dangling symlink archive destination and preserves the active ledger', () => {
195
196
  (0, store_1.addEntry)(cwd, entry({ signature: 'active-evidence' }));
196
197
  const archivePath = path_1.default.join(cwd, '.awm', 'ledger', 'archive', 'feat-x-20260606T000000.jsonl');
197
198
  fs_1.default.mkdirSync(path_1.default.dirname(archivePath), { recursive: true });
198
- try {
199
- fs_1.default.symlinkSync(path_1.default.join(cwd, 'missing-target.jsonl'), archivePath);
200
- }
201
- catch (error) {
202
- if (process.platform === 'win32')
203
- return;
204
- throw error;
205
- }
199
+ fs_1.default.symlinkSync(path_1.default.join(cwd, 'missing-target.jsonl'), archivePath);
206
200
  expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toThrow(/archive.*already exists/i);
207
201
  expect(fs_1.default.lstatSync(archivePath).isSymbolicLink()).toBe(true);
208
202
  expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
@@ -66,6 +66,27 @@ describe('discoverProcessModels', () => {
66
66
  expect(r.models.map((m) => m.name)).toEqual(['mi-proceso']);
67
67
  expect(r.diagnostics).toEqual([]);
68
68
  });
69
+ it('description sobrevive parse → discover, tal cual la lee YAML', () => {
70
+ const saved = { HOME: process.env.HOME, AWM_HOME: process.env.AWM_HOME };
71
+ process.env.HOME = path_1.default.join(tmp, 'home');
72
+ process.env.AWM_HOME = path_1.default.join(tmp, 'home', '.awm');
73
+ try {
74
+ const root = registry(path_1.default.join(tmp, 'rd'), {
75
+ 'con-desc': FIXTURE.replace('NAME', 'con-desc').replace('created:', 'description: "Use when: tracing\\u00A0"\ncreated:'),
76
+ });
77
+ const r = (0, discover_1.discoverProcessModels)([root]);
78
+ expect(r.diagnostics).toEqual([]);
79
+ expect(r.models.map((m) => m.description)).toEqual(['Use when: tracing ']);
80
+ }
81
+ finally {
82
+ for (const [k, v] of Object.entries(saved)) {
83
+ if (v === undefined)
84
+ delete process.env[k];
85
+ else
86
+ process.env[k] = v;
87
+ }
88
+ }
89
+ });
69
90
  it('un modelo roto no impide descubrir los sanos', () => {
70
91
  const root = registry(path_1.default.join(tmp, 'r2'), {
71
92
  'sano': FIXTURE.replace('NAME', 'sano'),
@@ -1,5 +1,9 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ const js_yaml_1 = __importDefault(require("js-yaml"));
3
7
  const model_1 = require("../../../src/core/process/model");
4
8
  const valid = `---
5
9
  awm: process-model
@@ -80,4 +84,338 @@ describe('parseProcessFrontmatter', () => {
80
84
  expect(r.model).toBeUndefined();
81
85
  expect(r.diagnostics.join(' ')).toMatch(new RegExp(`"${field}" is required`));
82
86
  });
87
+ describe('description (optional)', () => {
88
+ const withDescription = (line) => valid.replace('created:', `${line}\ncreated:`);
89
+ it('is optional: a model without it parses unchanged and has no description key', () => {
90
+ const r = (0, model_1.parseProcessFrontmatter)(valid, 'p');
91
+ expect(r.diagnostics).toEqual([]);
92
+ expect(r.model).toBeDefined();
93
+ expect(Object.prototype.hasOwnProperty.call(r.model, 'description')).toBe(false);
94
+ });
95
+ it.each([
96
+ ['plain', 'description: Use when tracing a work session', 'Use when tracing a work session'],
97
+ ['double-quoted', 'description: "Use when: tracing \\"sessions\\""', 'Use when: tracing "sessions"'],
98
+ ['single-quoted', "description: 'It''s the entry point'", "It's the entry point"],
99
+ ])('accepts a %s single-line value and exposes it resolved', (_kind, line, expected) => {
100
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p');
101
+ expect(r.diagnostics).toEqual([]);
102
+ expect(r.model?.description).toBe(expected);
103
+ });
104
+ it('accepts exactly 1024 characters and rejects 1025', () => {
105
+ expect((0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'a'.repeat(1024)}`), 'p').model?.description).toHaveLength(1024);
106
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'a'.repeat(1025)}`), 'p');
107
+ expect(r.model).toBeUndefined();
108
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
109
+ });
110
+ it.each(['description:', 'description: ""', "description: ''", 'description: " "'])('rejects an empty value: %s', (line) => {
111
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p');
112
+ expect(r.model).toBeUndefined();
113
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
114
+ });
115
+ it.each(['description: >', 'description: >-', 'description: |', 'description: |2+', 'description: >-basura'])('rejects a block scalar header: %s', (line) => {
116
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`${line}\n folded text`), 'p');
117
+ expect(r.model).toBeUndefined();
118
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must be a single-line string \(block scalars are not supported in a process model\)/);
119
+ });
120
+ it('still rejects every other unknown field next to a valid description', () => {
121
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: ok\nsummary: nope'), 'p');
122
+ expect(r.model).toBeUndefined();
123
+ expect(r.diagnostics.join(' ')).toMatch(/unknown field "summary"/);
124
+ expect(r.diagnostics.join(' ')).not.toMatch(/unknown field "description"/);
125
+ });
126
+ it('rejects a duplicated description', () => {
127
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: a\ndescription: b'), 'p');
128
+ expect(r.model).toBeUndefined();
129
+ expect(r.diagnostics.join(' ')).toMatch(/duplicate field "description"/);
130
+ });
131
+ it.each([
132
+ ['a block scalar', 'description: >-\n SECRETVALUE here'],
133
+ ['a plain multi-line value', 'description: first\n SECRETVALUE second'],
134
+ ['a quote closed on a later line', 'description: "hola\n SECRETVALUE mundo"'],
135
+ ])('reports %s by line position and never echoes its content', (_kind, lines) => {
136
+ // Bloque del fixture: awm(1) … terminates_to(6); la línea description
137
+ // queda en 7 y su continuación en 8.
138
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
139
+ expect(r.model).toBeUndefined();
140
+ for (const d of r.diagnostics)
141
+ expect(d).not.toContain('SECRETVALUE');
142
+ expect(r.diagnostics.join(' ')).toContain('frontmatter line 8 is not a "key: value" pair (multi-line values are not supported)');
143
+ });
144
+ it.each([
145
+ ['a plain value', 'description: Use for\n SECRETKEY: when X', false],
146
+ ['a block scalar', 'description: >-\n SECRETKEY: x', true],
147
+ ])('treats an indented "KEY: x" after %s as a continuation, never as a field', (_kind, lines, isBlock) => {
148
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
149
+ expect(r.model).toBeUndefined();
150
+ for (const d of r.diagnostics)
151
+ expect(d).not.toContain('SECRETKEY');
152
+ expect(r.diagnostics.join(' ')).toContain('frontmatter line 8 is not a "key: value" pair (multi-line values are not supported)');
153
+ if (isBlock) {
154
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must be a single-line string \(block scalars are not supported in a process model\)/);
155
+ }
156
+ });
157
+ const PARSE_ERROR = /"description" cannot be read: the frontmatter is not valid YAML/;
158
+ const NOT_A_STRING = /"description" must be a string \(quote values YAML reads as a boolean, a number, a date or a collection\)/;
159
+ it.each([
160
+ '- foo', '? foo', 'a:\tb', 'Use when: tracing', '"hola', "'hola", "'hola # x", '"hola # x', '"a" junk',
161
+ 'ends with colon:', '@x', '\x1b',
162
+ ])('rejects a value YAML cannot parse as a single-line string: %j', (value) => {
163
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
164
+ expect(r.model).toBeUndefined();
165
+ expect(r.diagnostics.join(' ')).toMatch(PARSE_ERROR);
166
+ });
167
+ it.each(['true', '123', '2026-01-01', '[a, b]'])('rejects a value YAML reads as a non-string: %s', (value) => {
168
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
169
+ expect(r.model).toBeUndefined();
170
+ expect(r.diagnostics.join(' ')).toMatch(NOT_A_STRING);
171
+ });
172
+ it.each(['null', '~', '# x'])('rejects a value YAML reads as null as empty: %s', (value) => {
173
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
174
+ expect(r.model).toBeUndefined();
175
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
176
+ });
177
+ it('never echoes the value or the YAML error text into a diagnostic', () => {
178
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: SECRETVALUE: x'), 'p');
179
+ expect(r.diagnostics.join(' ')).toMatch(PARSE_ERROR);
180
+ for (const d of r.diagnostics)
181
+ expect(d).not.toContain('SECRETVALUE');
182
+ });
183
+ it('resolves YAML double-quoted escapes', () => {
184
+ expect((0, model_1.parseProcessFrontmatter)(withDescription('description: "\\x41"'), 'p').model?.description).toBe('A');
185
+ });
186
+ it('stores the YAML string untrimmed', () => {
187
+ expect((0, model_1.parseProcessFrontmatter)(withDescription('description: " padded "'), 'p').model?.description).toBe(' padded ');
188
+ });
189
+ it.each([
190
+ ['description: "Use when: tracing"', 'Use when: tracing'],
191
+ ["description: 'It''s'", "It's"],
192
+ ])('accepts the quoted form %s', (line, expected) => {
193
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p');
194
+ expect(r.diagnostics).toEqual([]);
195
+ expect(r.model?.description).toBe(expected);
196
+ });
197
+ const blockOf = (source) => source.slice(4, source.indexOf('\n---', 4));
198
+ /** Modelo válido con description `ok` cuyo bloque de frontmatter mide exactamente `target`. */
199
+ const withBlockLength = (target) => {
200
+ const base = withDescription('description: ok');
201
+ const pad = target - blockOf(base).length - 3;
202
+ const source = base.replace('\ncreated:', `\n# ${'x'.repeat(pad)}\ncreated:`);
203
+ expect(blockOf(source)).toHaveLength(target);
204
+ return source;
205
+ };
206
+ it('parses a frontmatter block of exactly 65536 characters', () => {
207
+ const r = (0, model_1.parseProcessFrontmatter)(withBlockLength(65536), 'p');
208
+ expect(r.diagnostics).toEqual([]);
209
+ expect(r.model?.description).toBe('ok');
210
+ });
211
+ it('rejects a frontmatter block over 65536 characters with the 1024 message without parsing it', () => {
212
+ const source = withBlockLength(65537);
213
+ const load = jest.spyOn(js_yaml_1.default, 'load');
214
+ try {
215
+ const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
216
+ expect(r.model).toBeUndefined();
217
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
218
+ expect(load).not.toHaveBeenCalled();
219
+ }
220
+ finally {
221
+ load.mockRestore();
222
+ }
223
+ });
224
+ it('accepts a short value followed by a 20000-character comment', () => {
225
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ok # ${'x'.repeat(20000)}`), 'p');
226
+ expect(r.diagnostics).toEqual([]);
227
+ expect(r.model?.description).toBe('ok');
228
+ });
229
+ it('accepts an escape-heavy value that resolves to 1024 code points', () => {
230
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: "${'\\x41'.repeat(1024)}"`), 'p');
231
+ expect(r.diagnostics).toEqual([]);
232
+ expect(r.model?.description).toBe('A'.repeat(1024));
233
+ });
234
+ it('preserves NBSP, U+3000 and BOM exactly as YAML reads them', () => {
235
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: \u00A0hello\u3000world\uFEFF'), 'p');
236
+ expect(r.diagnostics).toEqual([]);
237
+ expect(r.model?.description).toBe('\u00A0hello\u3000world\uFEFF');
238
+ });
239
+ it('rejects description:value with no space after the colon', () => {
240
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description:value'), 'p');
241
+ expect(r.model).toBeUndefined();
242
+ expect(r.diagnostics.join(' ')).toMatch(PARSE_ERROR);
243
+ });
244
+ const CONTROL = /"description" must not contain control, line-break or bidirectional-override characters/;
245
+ it.each([
246
+ '"a\\nb"', '"a\\rb"', '"\\0"', '"\\e[31m"', '"\\x7f"', '"\\x85"', '"\\x9f"',
247
+ '"a\\u2028b"', '"a\\u2029b"', '"\\u202A"', '"\\u202E"', '"\\u2066"', '"\\u2069"',
248
+ '"\\uD800"', '"\\uDC00"', 'a\u2028b', 'a\u2029b', 'a\u202Eb', 'a\u0085b',
249
+ ])('rejects control, line-break, bidi or lone-surrogate content: %j', (value) => {
250
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
251
+ expect(r.model).toBeUndefined();
252
+ expect(r.diagnostics.join(' ')).toMatch(CONTROL);
253
+ });
254
+ it.each([
255
+ ['an escaped tab', '"a\\tb"', 'a\tb'],
256
+ ['a surrogate pair', '"\\uD83D\\uDE00"', '\u{1F600}'],
257
+ ])('accepts %s', (_kind, value, expected) => {
258
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
259
+ expect(r.diagnostics).toEqual([]);
260
+ expect(r.model?.description).toBe(expected);
261
+ });
262
+ it.each(['"\\u200B"', '"\\u200B\\u200C\\u200D"', '"\\u2060\\uFEFF "'])('rejects a value made only of zero-width characters as empty: %j', (value) => {
263
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
264
+ expect(r.model).toBeUndefined();
265
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
266
+ });
267
+ it.each(['"a\\bb"', '"a\\x1fb"', '"a\\u200Eb"', '"a\\u200Fb"', '"a\\u061Cb"'])('rejects backspace, US and LRM/RLM/ALM: %j', (value) => {
268
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
269
+ expect(r.model).toBeUndefined();
270
+ expect(r.diagnostics.join(' ')).toMatch(CONTROL);
271
+ });
272
+ const DIVERGES = /"description" cannot be read: the frontmatter does not read the same as YAML \(check quotes and line breaks\)/;
273
+ it('rejects a bare CR that smuggles a second top-level key', () => {
274
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: hello\rallowed-tools: x'), 'p');
275
+ expect(r.model).toBeUndefined();
276
+ expect(r.diagnostics.join(' ')).toMatch(DIVERGES);
277
+ for (const d of r.diagnostics)
278
+ expect(d).not.toContain('allowed-tools');
279
+ });
280
+ it('rejects an unclosed quote that YAML folds into the following name line', () => {
281
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'description: "hola\nname: ejemplo-proceso"'), 'p');
282
+ expect(r.model).toBeUndefined();
283
+ expect(r.diagnostics.join(' ')).toMatch(DIVERGES);
284
+ });
285
+ it('validates a description that follows a comment on a bare CR', () => {
286
+ const bad = (0, model_1.parseProcessFrontmatter)(withDescription('#\rdescription: 123'), 'p');
287
+ expect(bad.model).toBeUndefined();
288
+ expect(bad.diagnostics.join(' ')).toMatch(NOT_A_STRING);
289
+ const ok = (0, model_1.parseProcessFrontmatter)(withDescription('#\rdescription: ok'), 'p');
290
+ expect(ok.diagnostics).toEqual([]);
291
+ expect(ok.model?.description).toBe('ok');
292
+ });
293
+ it('reads NBSP followed by > as a string, not as a block-scalar header', () => {
294
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: \u00A0>'), 'p');
295
+ expect(r.diagnostics).toEqual([]);
296
+ expect(r.model?.description).toBe('\u00A0>');
297
+ });
298
+ it.each([['NEL', '\u0085'], ['LS', '\u2028'], ['PS', '\u2029']])('rejects a %s inside a comment line by position', (_kind, ch) => {
299
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(`# SECRET${ch}name: smuggled`), 'p');
300
+ expect(r.model).toBeUndefined();
301
+ expect(r.diagnostics.join(' ')).toContain('frontmatter line 7 contains a YAML 1.1 line break (NEL, LS or PS)');
302
+ for (const d of r.diagnostics)
303
+ expect(d).not.toContain('SECRET');
304
+ });
305
+ it.each([['NEL', '\u0085'], ['LS', '\u2028'], ['PS', '\u2029']])('rejects a %s inside a value line of any field by position', (_kind, ch) => {
306
+ const inDescription = (0, model_1.parseProcessFrontmatter)(withDescription(`description: a${ch}b`), 'p');
307
+ expect(inDescription.model).toBeUndefined();
308
+ expect(inDescription.diagnostics.join(' ')).toContain('frontmatter line 7 contains a YAML 1.1 line break (NEL, LS or PS)');
309
+ const inName = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ejemplo${ch}proceso`), 'p');
310
+ expect(inName.model).toBeUndefined();
311
+ expect(inName.diagnostics.join(' ')).toContain('frontmatter line 3 contains a YAML 1.1 line break (NEL, LS or PS)');
312
+ });
313
+ it.each(['description: "foo\n# bar"', "description: 'foo\n# bar'"])('rejects a quoted value that YAML folds across a following comment line: %j', (lines) => {
314
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
315
+ expect(r.model).toBeUndefined();
316
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must be a single-line string \(block scalars are not supported in a process model\)/);
317
+ for (const d of r.diagnostics)
318
+ expect(d).not.toContain('bar');
319
+ });
320
+ it.each([
321
+ ['description: "Use when: tracing a work session"', 'Use when: tracing a work session'],
322
+ ['description: Use when tracing a work session # nota', 'Use when tracing a work session'],
323
+ ["description: 'It''s the entry point' # nota", "It's the entry point"],
324
+ ['description: "Traza la sesión \\u2014 sin perder pasos"\n# comentario aparte', 'Traza la sesión \u2014 sin perder pasos'],
325
+ ])('keeps accepting a realistic description: %j', (lines, expected) => {
326
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
327
+ expect(r.diagnostics).toEqual([]);
328
+ expect(r.model?.description).toBe(expected);
329
+ });
330
+ it('lists description in the unknown-field message', () => {
331
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('summary: nope'), 'p');
332
+ expect(r.diagnostics.join(' ')).toMatch(/the contract admits only [^;]*\bdescription\b/);
333
+ });
334
+ it('accepts a closed quote followed by a comment', () => {
335
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: "a" # note'), 'p');
336
+ expect(r.diagnostics).toEqual([]);
337
+ expect(r.model?.description).toBe('a');
338
+ });
339
+ it('rejects a comment-only value as empty and never echoes it', () => {
340
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: # x'), 'p');
341
+ expect(r.model).toBeUndefined();
342
+ expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
343
+ for (const d of r.diagnostics)
344
+ expect(d).not.toContain('# x');
345
+ });
346
+ it('counts the 1024 limit in code points, not UTF-16 units', () => {
347
+ const ok = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'😀'.repeat(1024)}`), 'p');
348
+ expect(ok.diagnostics).toEqual([]);
349
+ expect([...(ok.model?.description ?? '')]).toHaveLength(1024);
350
+ const tooLong = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'😀'.repeat(1025)}`), 'p');
351
+ expect(tooLong.model).toBeUndefined();
352
+ expect(tooLong.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
353
+ });
354
+ it('keeps skipping an indented comment line after a valid description', () => {
355
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: ok\n # comentario'), 'p');
356
+ expect(r.diagnostics).toEqual([]);
357
+ expect(r.model?.description).toBe('ok');
358
+ });
359
+ it('accepts a plain value followed by a comment, without the comment', () => {
360
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: plain text # note'), 'p');
361
+ expect(r.diagnostics).toEqual([]);
362
+ expect(r.model?.description).toBe('plain text');
363
+ });
364
+ it('accepts escaped quotes inside a closed double-quoted value', () => {
365
+ const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: "a \\"b\\""'), 'p');
366
+ expect(r.diagnostics).toEqual([]);
367
+ expect(r.model?.description).toBe('a "b"');
368
+ });
369
+ it('never throws on hostile description values', () => {
370
+ for (const line of ['description: "unclosed', "description: '''", 'description: \x00', `description: ${'"'.repeat(5000)}`]) {
371
+ expect(() => (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p')).not.toThrow();
372
+ }
373
+ });
374
+ });
375
+ describe('indented frontmatter keys', () => {
376
+ const indentAll = (source) => source.replace(/^---\n([\s\S]*?)\n---/, (_m, block) => `---\n${block.split('\n').map((l) => ` ${l}`).join('\n')}\n---`);
377
+ it('reports a model whose keys are all indented instead of ignoring it', () => {
378
+ const r = (0, model_1.parseProcessFrontmatter)(indentAll(valid), 'p');
379
+ expect(r.model).toBeUndefined();
380
+ expect(r.diagnostics).toEqual(['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)']);
381
+ });
382
+ it('allows whitespace around the colon and counts comment lines', () => {
383
+ const r = (0, model_1.parseProcessFrontmatter)('---\n# nota\n awm : process-model\n name: x\n---\n', 'p');
384
+ expect(r.diagnostics).toEqual(['p: invalid process model — frontmatter keys must not be indented (frontmatter line 2)']);
385
+ });
386
+ it.each([
387
+ ['another indented value', '---\nname: x\nmetadata:\n awm: other\n---\n'],
388
+ ['a mention nested under metadata', '---\nname: x\nmetadata:\n awm: process-model\n---\n'],
389
+ ['a mention inside a block-scalar description', '---\nname: x\ndescription: >\n awm: process-model\n---\n'],
390
+ ['a near-miss discriminator', '---\n awm: process-model-x\n name: x\n---\n'],
391
+ ])('stays silent for a non-model with %s', (_kind, source) => {
392
+ expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({ diagnostics: [] });
393
+ });
394
+ it.each([
395
+ ['only the awm line indented', valid.replace('awm: process-model', ' awm: process-model')],
396
+ ['a tab-indented awm line', valid.replace('awm: process-model', '\tawm: process-model')],
397
+ ])('reports %s even though YAML cannot load the block', (_kind, source) => {
398
+ expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({
399
+ diagnostics: ['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)'],
400
+ });
401
+ });
402
+ it('gives no indented diagnostic to a top-level-awm model with a nested metadata.awm', () => {
403
+ const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', 'metadata:\n awm: process-model\ncreated:'), 'p');
404
+ expect(r.model).toBeUndefined();
405
+ expect(r.diagnostics.join(' ')).not.toMatch(/must not be indented/);
406
+ });
407
+ it('reports an indented discriminator in a block over 65536 characters without loading it', () => {
408
+ const source = indentAll(valid.replace('\ncreated:', `\n# ${'x'.repeat(65536)}\ncreated:`));
409
+ const load = jest.spyOn(js_yaml_1.default, 'load');
410
+ try {
411
+ expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({
412
+ diagnostics: ['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)'],
413
+ });
414
+ expect(load).not.toHaveBeenCalled();
415
+ }
416
+ finally {
417
+ load.mockRestore();
418
+ }
419
+ });
420
+ });
83
421
  });
@@ -0,0 +1,27 @@
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.symlinkTest = exports.canCreateSymlinks = exports.posixOnly = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
10
+ /** `test` where symlinks and POSIX modes exist; reported as skipped (not as passed) on Windows. */
11
+ exports.posixOnly = process.platform === 'win32' ? test.skip : test;
12
+ /** True when this process can create a symlink (Windows needs the privilege or developer mode). */
13
+ exports.canCreateSymlinks = (() => {
14
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-symlink-probe-'));
15
+ try {
16
+ fs_1.default.symlinkSync(path_1.default.join(dir, 'missing-target'), path_1.default.join(dir, 'link'));
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ finally {
23
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ })();
26
+ /** `test` that needs to create symlinks; reported as skipped (not as passed) where it cannot. */
27
+ exports.symlinkTest = exports.canCreateSymlinks ? test : test.skip;
@@ -8,7 +8,8 @@
8
8
  // stdin, bounds time and output and sets GIT_NO_LAZY_FETCH. A bare `execFileSync('git', ...)`
9
9
  // in these directories silently loses all of that, and reviewers found such leaks four times.
10
10
  // 2. A test that returns early on Windows is reported as PASSED by the 3-platform release gate.
11
- // These suites use `posixOnly` (test.skip with a visible reason) instead.
11
+ // Every suite under cli/tests uses a visible skip instead: `posixOnly`, or `symlinkTest` when the
12
+ // test only needs symlinks (cli/tests/helpers/platform.ts).
12
13
  //
13
14
  // The detectors are exercised on inline samples below, so each guard demonstrably fires.
14
15
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -66,10 +67,10 @@ describe('change-record conventions (structural)', () => {
66
67
  .map(file => path_1.default.relative(CLI_ROOT, file));
67
68
  expect(offenders).toEqual([]);
68
69
  });
69
- test('change-record tests skip with posixOnly instead of returning early on Windows', () => {
70
- const dirs = ['tests/core/changes', 'tests/commands/change', 'tests/commands/changes'].map(d => path_1.default.join(CLI_ROOT, d));
71
- const offenders = dirs
72
- .flatMap(tsFiles)
70
+ test('no test under cli/tests returns early on Windows instead of skipping visibly', () => {
71
+ // This file is excluded: its detector samples are offending shapes on purpose.
72
+ const offenders = tsFiles(path_1.default.join(CLI_ROOT, 'tests'))
73
+ .filter(file => file !== __filename)
73
74
  .filter(file => silentWin32Returns(fs_1.default.readFileSync(file, 'utf8')))
74
75
  .map(file => path_1.default.relative(CLI_ROOT, file));
75
76
  expect(offenders).toEqual([]);
@@ -0,0 +1,34 @@
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
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const DESIGN = path_1.default.resolve(__dirname, '../../../docs/plans/2026-08-23-process-lifecycle-design.md');
9
+ function artifactSection() {
10
+ const text = fs_1.default.readFileSync(DESIGN, 'utf8');
11
+ const start = text.indexOf('### El artefacto');
12
+ expect(start).toBeGreaterThan(-1);
13
+ const end = text.indexOf('\n### ', start + 1);
14
+ return text.slice(start, end === -1 ? undefined : end);
15
+ }
16
+ describe('process-model contract documents the optional description (PD-4)', () => {
17
+ it('lists description in the frontmatter block as optional', () => {
18
+ const section = artifactSection();
19
+ expect(section).toMatch(/^description: <texto de una línea> {2}# opcional$/m);
20
+ });
21
+ it('states the rules the parser enforces and why the field exists', () => {
22
+ /** Whitespace-normalized so re-wrapping the prose never breaks a pinned phrase. */
23
+ const section = artifactSection().replace(/\s+/g, ' ');
24
+ expect(section).toContain('#### Enmienda 2026-10-18 — `description` opcional');
25
+ expect(section).toContain('una sola línea');
26
+ expect(section).toContain('1024 caracteres');
27
+ expect(section).toContain('Codex');
28
+ expect(section).toContain('único campo opcional');
29
+ expect(section).toContain('parser YAML');
30
+ expect(section).not.toContain('no reemplaza a un parser YAML');
31
+ expect(section).toContain('code points');
32
+ expect(section).toContain('caracteres de control');
33
+ });
34
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.20.0",
3
+ "version": "9.20.1",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file