agentic-workflow-manager 9.6.0 → 9.7.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 (34) hide show
  1. package/README.md +2 -0
  2. package/dist/src/commands/context/index.js +3 -1
  3. package/dist/src/commands/track/index.js +3 -7
  4. package/dist/src/commands/watch/apply.js +15 -0
  5. package/dist/src/commands/watch/tracks.js +56 -7
  6. package/dist/src/core/discovery.js +79 -4
  7. package/dist/src/core/journal/types.js +1 -0
  8. package/dist/src/core/orchestrators.js +34 -10
  9. package/dist/src/core/text.js +7 -0
  10. package/dist/src/core/tracks/git.js +15 -3
  11. package/dist/src/core/tracks/protocol.js +26 -6
  12. package/dist/tests/commands/context.test.js +9 -1
  13. package/dist/tests/commands/hooks/install.test.js +4 -0
  14. package/dist/tests/commands/hooks/resync.test.js +2 -0
  15. package/dist/tests/commands/track/verbs.test.js +6 -0
  16. package/dist/tests/commands/watch/apply.test.js +46 -9
  17. package/dist/tests/commands/watch/track-remove-request.test.js +165 -0
  18. package/dist/tests/commands/watch/track-teardown-crash.test.js +27 -0
  19. package/dist/tests/core/context/orchestrator.test.js +2 -0
  20. package/dist/tests/core/discovery-multiroot.test.js +5 -0
  21. package/dist/tests/core/discovery-parent-race.test.js +95 -0
  22. package/dist/tests/core/discovery-safe-read.test.js +115 -0
  23. package/dist/tests/core/discovery.test.js +28 -1
  24. package/dist/tests/core/journal/types.test.js +11 -0
  25. package/dist/tests/core/orchestrators.test.js +171 -14
  26. package/dist/tests/core/text.test.js +10 -0
  27. package/dist/tests/core/tracks/git.test.js +7 -0
  28. package/dist/tests/core/tracks/protocol.test.js +103 -0
  29. package/dist/tests/integration/context-orchestrators-e2e.test.js +57 -7
  30. package/package.json +1 -1
  31. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  32. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  33. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  34. package/prebuilds/win32-x64/secure_fs.node +0 -0
package/README.md CHANGED
@@ -69,6 +69,8 @@ and one JSON argv integration command. Start or resume with `awm watch`; inspect
69
69
  `awm track status`. Track workers commit only their assigned files and request
70
70
  `awm track join`. The plan supervisor freezes and merges tracks, runs global QA once on the
71
71
  final HEAD, executes the canonical integration job once, then applies the interlock.
72
+ `awm track remove` requests a safe cancellation of the complete cohort; only demonstrably
73
+ owned resources are removed before returning to serial execution.
72
74
 
73
75
  Any missing declaration, overlap, global file class, unavailable worktree, or failed
74
76
  preparation degrades to serial with an event naming the cause. `BLOCKED` means ownership or
@@ -15,7 +15,9 @@ function runContextOrchestrators(collected, opts) {
15
15
  // un usuario que tipee el nombre declarado exacto (con un caracter que el saneo
16
16
  // quita, p.ej. `_` o un byte de control) se lleva un falso "not composed" (R3.5/R3.6).
17
17
  const verifyNormalized = (0, text_1.sanitizeDeclaredField)(opts.verify);
18
- const found = composed.some((o) => o.name === verifyNormalized);
18
+ // A dropped raw identity must not borrow a retained declaration's sanitized name.
19
+ const found = !collected.droppedNames.includes(opts.verify)
20
+ && composed.some((o) => o.name === verifyNormalized);
19
21
  if (!found) {
20
22
  const available = composed.map((o) => o.name).join(', ') || '(none)';
21
23
  return {
@@ -104,14 +104,10 @@ function registerTrackCommand(program) {
104
104
  }
105
105
  process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey, qaHeadSha: r.qaHeadSha }, null, 2) + '\n');
106
106
  });
107
- // NO IMPLEMENTADO del lado del supervisor: no hay handler para `track-teardown-request`,
108
- // así que la request se emite y el supervisor la RECHAZA (`request-rejected-invalid`).
109
- // La descripción lo dice en vez de prometer un desmantelamiento que no ocurre — hasta
110
- // este release afirmaba que el supervisor desmantelaba worktree/rama, y no lo hacía.
111
- // El teardown que sí funciona es el automático de la degradación a serial
112
- // (`begin-teardown` desde `FALLBACK_PENDING`), que no pasa por este verbo.
107
+ // Request-only: el supervisor persiste la intención, cancela la cohorte completa y
108
+ // reutiliza el teardown fail-closed antes de volver a ejecución serial.
113
109
  track.command('remove')
114
- .description('NO IMPLEMENTADO: emite track-teardown-request, que el supervisor todavía no sabe aplicar y rechaza')
110
+ .description('emite track-teardown-request — cancela la cohorte y solicita teardown seguro antes del fallback serial')
115
111
  .requiredOption('--generation <token>', 'token de la generacion vigente')
116
112
  .argument('<trackId>')
117
113
  .action((trackId, opts) => {
@@ -250,6 +250,21 @@ function applyRequestToState(s, env, digest, repoRoot) {
250
250
  (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
251
251
  return;
252
252
  }
253
+ if (env.kind === 'track-teardown-request') {
254
+ const p = env.payload;
255
+ if (typeof p.trackId !== 'string' || p.trackId.length === 0)
256
+ throw new Error('track-teardown-request requiere trackId');
257
+ const trackId = p.trackId;
258
+ const ref = s.tracks?.find((t) => t.trackId === trackId);
259
+ if (ref === undefined)
260
+ throw new Error(`track-teardown-request: track desconocido: ${trackId}`);
261
+ if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
262
+ throw new Error('track-teardown-request requiere una cohorte válida de al menos dos tracks');
263
+ }
264
+ ref.teardownRequested = true;
265
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
266
+ return;
267
+ }
253
268
  if (env.kind === 'track-freeze-request') {
254
269
  // R5.2/R6.3 (Task 10): request administrativa CROSS-JOURNAL — el
255
270
  // supervisor del PLAN la emite directamente al `requestsDir` de ESTE
@@ -135,13 +135,22 @@ function applyProtocolToState(state, protocol) {
135
135
  // `expectedPlanHeadSha` incorrecto.
136
136
  if (protocol.planHeadSha !== undefined)
137
137
  next.cohortPlanHeadSha = protocol.planHeadSha;
138
- // R7/C3 (Task 12): mismo criterio que `cohortPlanHeadSha` arriba — valores
139
- // que solo avanzan (nunca retroceden a `undefined` una vez fijados por
140
- // `reconcileProtocol`).
141
- if (protocol.globalQaHeadSha !== undefined)
142
- next.globalQaHeadSha = protocol.globalQaHeadSha;
143
- if (protocol.finalIntegrationJobId !== undefined)
144
- next.finalIntegrationJobId = protocol.finalIntegrationJobId;
138
+ // R7/C3 (Task 12): los valores finales normalmente solo avanzan, salvo
139
+ // que fallback abandone explícitamente esa ruta. En ese caso el reducer
140
+ // ya invalidó su evidencia y el mirror durable DEBE borrarla también;
141
+ // conservarla permitiría que un restart reconstruyera un protocolo que
142
+ // contradice los tracks en teardown.
143
+ if (protocol.cohortPhase === 'FALLBACK_PENDING') {
144
+ next.globalQaHeadSha = undefined;
145
+ next.finalIntegrationJobId = undefined;
146
+ next.qaFinalizeRequested = undefined;
147
+ }
148
+ else {
149
+ if (protocol.globalQaHeadSha !== undefined)
150
+ next.globalQaHeadSha = protocol.globalQaHeadSha;
151
+ if (protocol.finalIntegrationJobId !== undefined)
152
+ next.finalIntegrationJobId = protocol.finalIntegrationJobId;
153
+ }
145
154
  if (protocol.fallbackReason !== undefined)
146
155
  next.cohortFallbackReason = protocol.fallbackReason;
147
156
  next.tracks = (next.tracks ?? []).map((ref) => {
@@ -818,6 +827,46 @@ async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
818
827
  if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
819
828
  return { state: s, effectExecuted: null };
820
829
  }
830
+ // La request de teardown se consume en dos pasos durables, igual que
831
+ // join: `apply.ts` solo deja el marcador y este reconciler traduce la
832
+ // intención al reducer. BLOCKED, SERIAL y COMPLETE no admiten nueva
833
+ // transición: limpiar únicamente el marcador preserva el estado seguro.
834
+ const pendingTeardown = (s.tracks ?? []).find((track) => track.teardownRequested === true);
835
+ if (pendingTeardown !== undefined) {
836
+ const moot = ['BLOCKED', 'SERIAL', 'COMPLETE'].includes(s.cohortPhase);
837
+ if (moot) {
838
+ const next = structuredClone(s);
839
+ for (const track of next.tracks ?? [])
840
+ if (track.trackId === pendingTeardown.trackId)
841
+ track.teardownRequested = undefined;
842
+ s = persist(planRoot, branch, next);
843
+ (0, store_1.appendEvent)(planRoot, branch, {
844
+ kind: 'track-teardown-moot', trackId: pendingTeardown.trackId, phase: s.cohortPhase,
845
+ });
846
+ return { state: s, effectExecuted: null };
847
+ }
848
+ const protocolBefore = toProtocol(s, maxParallel);
849
+ const observed = (0, protocol_1.reconcileProtocol)(protocolBefore, { kind: 'teardown-requested', trackId: pendingTeardown.trackId });
850
+ const next = applyProtocolToState(s, observed);
851
+ for (const track of next.tracks ?? [])
852
+ if (track.trackId === pendingTeardown.trackId)
853
+ track.teardownRequested = undefined;
854
+ s = persist(planRoot, branch, next);
855
+ // El lock pertenece al proceso vivo que alcanzó la integración.
856
+ // Al abandonar FINAL_INTERLOCK debe soltarse en este mismo proceso;
857
+ // tras un crash el lease muerto se reclama por identidad al iniciar.
858
+ if (protocolBefore.cohortPhase === 'FINAL_INTERLOCK' && s.cohortPhase === 'FALLBACK_PENDING') {
859
+ runtime.releaseIntegrationLockIfHeld();
860
+ }
861
+ (0, store_1.appendEvent)(planRoot, branch, {
862
+ kind: 'track-teardown-observed', trackId: pendingTeardown.trackId,
863
+ phase: s.tracks?.find((t) => t.trackId === pendingTeardown.trackId)?.phase,
864
+ });
865
+ // El marcador es una frontera durable por sí mismo. No se consume
866
+ // otro marcador ni se entra a begin-teardown hasta la siguiente
867
+ // llamada, para que un crash conserve el resto de las requests.
868
+ return { state: s, effectExecuted: null };
869
+ }
821
870
  // El pedido de join del controller (`joinRequested`, marcado declarativamente al
822
871
  // consumir `track-join-request`) se convierte ACÁ en la observación que el reducer
823
872
  // entiende. La regla de si eso mueve la fase vive en `reconcileProtocol` (solo
@@ -34,6 +34,73 @@ function readArtifactDescription(filePath) {
34
34
  return '';
35
35
  }
36
36
  }
37
+ function inspectSkillDirectory(parent, expected) {
38
+ const stat = fs_1.default.lstatSync(parent, { bigint: true });
39
+ if (stat.isSymbolicLink())
40
+ throw new Error(`${parent}: must not be a symbolic link`);
41
+ if (!stat.isDirectory())
42
+ throw new Error(`${parent}: must be a directory`);
43
+ if (typeof stat.dev !== 'bigint' || typeof stat.ino !== 'bigint' || stat.dev < 0n || stat.ino <= 0n) {
44
+ throw new Error(`${parent}: directory identity is unobservable`);
45
+ }
46
+ if (expected && (stat.dev !== expected.dev || stat.ino !== expected.ino)) {
47
+ throw new Error(`${parent}: directory identity changed after inspection`);
48
+ }
49
+ return stat;
50
+ }
51
+ /** CTX-CONSTITUTION-052: bind the inspected leaf to its observable parent chain. */
52
+ function readSkillDescription(file) {
53
+ const absolute = path_1.default.resolve(file);
54
+ const parents = [];
55
+ for (let parent = path_1.default.dirname(absolute);; parent = path_1.default.dirname(parent)) {
56
+ parents.unshift(parent);
57
+ if (parent === path_1.default.dirname(parent))
58
+ break;
59
+ }
60
+ const inspectedParents = parents.map(parent => ({ path: parent, stat: inspectSkillDirectory(parent) }));
61
+ const validateParents = () => {
62
+ for (const parent of inspectedParents)
63
+ inspectSkillDirectory(parent.path, parent.stat);
64
+ };
65
+ let inspected;
66
+ try {
67
+ inspected = fs_1.default.lstatSync(file, { bigint: true });
68
+ }
69
+ catch (error) {
70
+ if (error?.code === 'ENOENT')
71
+ return null;
72
+ throw error;
73
+ }
74
+ // A parent swapped before leaf lstat must not establish a new trusted identity.
75
+ validateParents();
76
+ if (inspected.isSymbolicLink())
77
+ throw new Error('must not be a symbolic link');
78
+ if (!inspected.isFile())
79
+ throw new Error('must be a regular file');
80
+ if (typeof inspected.dev !== 'bigint' || typeof inspected.ino !== 'bigint'
81
+ || typeof inspected.size !== 'bigint' || inspected.dev < 0n
82
+ || inspected.ino <= 0n || inspected.size < 0n) {
83
+ throw new Error('file identity or size is unobservable');
84
+ }
85
+ const flags = fs_1.default.constants.O_RDONLY | (fs_1.default.constants.O_NOFOLLOW ?? 0) | (fs_1.default.constants.O_NONBLOCK ?? 0);
86
+ const fd = fs_1.default.openSync(file, flags);
87
+ try {
88
+ const opened = fs_1.default.fstatSync(fd, { bigint: true });
89
+ if (!opened.isFile())
90
+ throw new Error('must be a regular file');
91
+ if (opened.dev !== inspected.dev || opened.ino !== inspected.ino || opened.size !== inspected.size) {
92
+ throw new Error('file identity or size changed after inspection');
93
+ }
94
+ // Reject substitutions observable after open, while retaining descriptor authority.
95
+ validateParents();
96
+ const raw = fs_1.default.readFileSync(fd, 'utf-8');
97
+ const frontmatter = (0, frontmatter_2.matchFrontmatterBlock)(raw);
98
+ return frontmatter === null ? '' : (0, frontmatter_2.readFrontmatterDescription)(frontmatter);
99
+ }
100
+ finally {
101
+ fs_1.default.closeSync(fd);
102
+ }
103
+ }
37
104
  function collisionError(kind, name, first, second) {
38
105
  return new Error(`Artifact name collision: ${kind} "${name}" exists in both ${first} and ${second}. ` +
39
106
  `Remove or rename one of them, or declare "${name}" in "overrides" of the later registry's awm-registry.json.`);
@@ -54,7 +121,7 @@ function mergeEntry(kind, byName, entry, rootOverrides) {
54
121
  }
55
122
  /**
56
123
  * Scans skills directories across all provided content roots and returns all valid skills.
57
- * A valid skill is a directory that contains a SKILL.md file.
124
+ * A valid skill is a directory that contains a regular, non-symlink SKILL.md file.
58
125
  * Throws on name collision across roots unless the later root declares the name in its awm-registry.json overrides.
59
126
  */
60
127
  function discoverSkills(roots = (0, registries_1.contentRoots)()) {
@@ -68,12 +135,20 @@ function discoverSkills(roots = (0, registries_1.contentRoots)()) {
68
135
  if (!entry.isDirectory())
69
136
  continue;
70
137
  const skillPath = path_1.default.join(dir, entry.name);
71
- if (!fs_1.default.existsSync(path_1.default.join(skillPath, 'SKILL.md')))
72
- continue;
138
+ const skillFile = path_1.default.join(skillPath, 'SKILL.md');
139
+ let description;
140
+ try {
141
+ description = readSkillDescription(skillFile);
142
+ if (description === null)
143
+ continue;
144
+ }
145
+ catch (e) {
146
+ throw new Error(`${skillFile}: cannot read (${e instanceof Error ? e.message : String(e)})`);
147
+ }
73
148
  mergeEntry('skill', byName, {
74
149
  name: entry.name,
75
150
  path: skillPath,
76
- description: readArtifactDescription(path_1.default.join(skillPath, 'SKILL.md')),
151
+ description,
77
152
  }, overrides);
78
153
  }
79
154
  }
@@ -246,6 +246,7 @@ function isWellFormedTrackRef(x) {
246
246
  && (x.supervisorProcessRef === undefined || isWellFormedProcessRef(x.supervisorProcessRef))
247
247
  && (x.joinIntent === undefined || isWellFormedJoinIntent(x.joinIntent))
248
248
  && (x.teardownIntent === undefined || isWellFormedTeardownIntent(x.teardownIntent))
249
+ && (x.teardownRequested === undefined || typeof x.teardownRequested === 'boolean')
249
250
  && (x.joinedCommitSha === undefined || typeof x.joinedCommitSha === 'string')
250
251
  && (x.blockedReason === undefined || typeof x.blockedReason === 'string');
251
252
  }
@@ -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 discovery_1 = require("./discovery");
20
21
  const text_1 = require("./text");
21
22
  const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
22
23
  // These fields are semantically short (a short identity, a short trigger condition, a
@@ -24,6 +25,24 @@ const ALLOWED_FIELDS = ['name', 'appliesWhen', 'terminatesTo'];
24
25
  // untrusted input whose fields flow straight into the AI-provider context payload, so an
25
26
  // unbounded string here would let a crafted registry bloat/DoS that context.
26
27
  const MAX_FIELD_LENGTH = 500;
28
+ function discoverDeclaredSkillNames() {
29
+ let names = new Set();
30
+ const diagnostics = [];
31
+ const acceptedRoots = [];
32
+ for (const root of (0, registries_1.contentRoots)()) {
33
+ try {
34
+ // Admit the entire root only after global collision/override resolution succeeds.
35
+ const skills = (0, discovery_1.discoverSkills)([...acceptedRoots, root]);
36
+ names = new Set(skills.map((skill) => skill.name));
37
+ acceptedRoots.push(root);
38
+ }
39
+ catch (e) {
40
+ const message = e instanceof Error ? e.message : String(e);
41
+ diagnostics.push(`${(0, text_1.sanitizeDiagnosticText)(root)}: orchestrator skill discovery unavailable (${(0, text_1.sanitizeDiagnosticText)(message)})`);
42
+ }
43
+ }
44
+ return { names, diagnostics };
45
+ }
27
46
  function readDeclaredOrchestrators(root) {
28
47
  const file = path_1.default.join(root, registries_1.REGISTRY_MANIFEST_NAME);
29
48
  // Shares the same trust boundary as readRegistryManifest (registries.ts): a manifest
@@ -35,7 +54,7 @@ function readDeclaredOrchestrators(root) {
35
54
  exists = (0, registries_1.assertRegularRegistryFile)(file);
36
55
  }
37
56
  catch (e) {
38
- return { orchestrators: [], diagnostics: [`${file}: ${e instanceof Error ? e.message : String(e)}`] };
57
+ return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: ${e instanceof Error ? e.message : String(e)}`)] };
39
58
  }
40
59
  if (!exists)
41
60
  return { orchestrators: [], diagnostics: [] };
@@ -44,28 +63,27 @@ function readDeclaredOrchestrators(root) {
44
63
  contents = fs_1.default.readFileSync(file, 'utf-8');
45
64
  }
46
65
  catch (e) {
47
- return { orchestrators: [], diagnostics: [`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`] };
66
+ return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: cannot read manifest (${e instanceof Error ? e.message : String(e)})`)] };
48
67
  }
49
68
  let raw;
50
69
  try {
51
70
  raw = JSON.parse(contents);
52
71
  }
53
72
  catch (e) {
54
- return { orchestrators: [], diagnostics: [`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`] };
73
+ return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: manifest is not valid JSON (${e instanceof Error ? e.message : String(e)})`)] };
55
74
  }
56
75
  const decl = raw?.orchestrator;
57
76
  if (decl === undefined)
58
77
  return { orchestrators: [], diagnostics: [] };
59
78
  if (typeof decl !== 'object' || decl === null || Array.isArray(decl)) {
60
- return { orchestrators: [], diagnostics: [`${file}: "orchestrator" must be an object`] };
79
+ return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: "orchestrator" must be an object`)] };
61
80
  }
62
81
  const problems = [];
63
82
  const entries = decl;
64
83
  for (const key of Object.keys(entries)) {
65
84
  if (!ALLOWED_FIELDS.includes(key)) {
66
- // key comes straight from an untrusted registry's JSON — JSON.stringify keeps the
67
- // diagnostic single-line and unambiguous even if the key contains newlines or other
68
- // control characters, which would otherwise let a crafted key forge extra log lines.
85
+ // Quote keys unambiguously; sanitize the complete diagnostic below because
86
+ // JSON.stringify preserves Unicode line separators and C1 controls.
69
87
  problems.push(`unknown field ${JSON.stringify(key)} — the contract admits only ${ALLOWED_FIELDS.join(', ')}`);
70
88
  }
71
89
  }
@@ -79,7 +97,7 @@ function readDeclaredOrchestrators(root) {
79
97
  }
80
98
  }
81
99
  if (problems.length > 0) {
82
- return { orchestrators: [], diagnostics: [`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`] };
100
+ return { orchestrators: [], diagnostics: [(0, text_1.sanitizeDiagnosticText)(`${file}: invalid "orchestrator" declaration — ${problems.join('; ')}`)] };
83
101
  }
84
102
  return {
85
103
  orchestrators: [{
@@ -126,13 +144,19 @@ function readDeclaredOrchestrators(root) {
126
144
  */
127
145
  function collectDeclaredOrchestrators() {
128
146
  const declared = [];
129
- const diagnostics = [];
147
+ const droppedNames = [];
148
+ const { names: availableSkillNames, diagnostics } = discoverDeclaredSkillNames();
130
149
  const seenNames = new Set();
131
150
  const seenSanitizedNames = new Set();
132
151
  for (const reg of (0, registries_1.listRegistries)()) {
133
152
  const r = readDeclaredOrchestrators(reg.contentRoot);
134
153
  for (const orch of r.orchestrators) {
135
154
  const file = path_1.default.join(reg.contentRoot, registries_1.REGISTRY_MANIFEST_NAME);
155
+ if (!availableSkillNames.has(orch.name)) {
156
+ droppedNames.push(orch.name);
157
+ diagnostics.push(`${(0, text_1.sanitizeDiagnosticText)(file)}: orchestrator declaration dropped because skill "${(0, text_1.sanitizeDiagnosticText)(orch.name)}" is not discoverable in configured safe registries`);
158
+ continue;
159
+ }
136
160
  if (seenNames.has(orch.name)) {
137
161
  diagnostics.push(`${file}: orchestrator "${(0, text_1.stripControlChars)(orch.name)}" duplicates one already declared by an earlier registry — shadowed duplicate dropped`);
138
162
  continue;
@@ -149,7 +173,7 @@ function collectDeclaredOrchestrators() {
149
173
  }
150
174
  diagnostics.push(...r.diagnostics);
151
175
  }
152
- return { declared, diagnostics };
176
+ return { declared, diagnostics, droppedNames };
153
177
  }
154
178
  /** Recolecta declarados y emite sus diagnosticos como warnings. Punto unico usado por
155
179
  * `InjectionOrchestrator.inputFor`/`statusInputFor` y por `commands/hooks/claude.ts`
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.stripControlChars = stripControlChars;
4
+ exports.sanitizeDiagnosticText = sanitizeDiagnosticText;
4
5
  exports.sanitizeDeclaredField = sanitizeDeclaredField;
5
6
  // cli/src/core/text.ts
6
7
  /** Neutraliza bytes de control C0 (incluyendo ESC `\x1b`) y DEL de texto que
@@ -16,6 +17,12 @@ function stripControlChars(text) {
16
17
  // eslint-disable-next-line no-control-regex -- necesitamos matchear C0 deliberadamente
17
18
  return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
18
19
  }
20
+ /** Keeps an untrusted diagnostic field on one terminal line, without changing
21
+ * the whitespace policy for multiline public text or composed markdown. */
22
+ function sanitizeDiagnosticText(text) {
23
+ // eslint-disable-next-line no-control-regex -- remove all C0/C1 controls, including terminal escape bytes
24
+ return text.replace(/\r\n|[\r\n\t\u2028\u2029]/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
25
+ }
19
26
  /**
20
27
  * Neutraliza marcado markdown (colapsa `\r?\n` a un espacio y quita
21
28
  * `` ` * _ # < > ``) de contenido no confiable antes de interpolarlo en un
@@ -136,13 +136,25 @@ function ownedWorktreeExists(repo, worktreePath, branch) {
136
136
  * worktree (contenido de filesystem que SÍ podría ser ajeno), una branch es
137
137
  * solo un ref; nada más crea una con ese nombre exacto. */
138
138
  function branchExists(repo, branch) {
139
+ // `show-ref --quiet` puede devolver 1 tanto para una ref ausente como
140
+ // para un repositorio que ni siquiera pudo abrirse. Primero probamos que
141
+ // git puede identificar el repositorio; solo después status 1 significa
142
+ // honestamente "la branch no existe".
143
+ try {
144
+ git(repo, ['rev-parse', '--git-dir']);
145
+ }
146
+ catch (error) {
147
+ throw new Error(`branchExists: repo de ${branch} indemostrable: ${error.message}`);
148
+ }
139
149
  try {
140
150
  git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
141
- return true;
142
151
  }
143
- catch {
144
- return false;
152
+ catch (error) {
153
+ if (error.status === 1)
154
+ return false;
155
+ throw new Error(`branchExists: existencia de ${branch} indemostrable: ${error.message}`);
145
156
  }
157
+ return true;
146
158
  }
147
159
  /** Task 9 (paso BRANCH_REMOVED, Task 13 endurece el guard — R4.10/C9): borra
148
160
  * la branch local determinista de un track SOLO después de que su worktree
@@ -269,7 +269,25 @@ function nextProtocolEffect(s) {
269
269
  }
270
270
  function reconcileProtocol(s, observation) {
271
271
  const out = structuredClone(s);
272
- if (observation.kind === 'prepare-failed') {
272
+ if (observation.kind === 'teardown-requested') {
273
+ const requested = out.tracks[observation.trackId];
274
+ if (requested === undefined)
275
+ throw new Error(`track desconocido: ${observation.trackId}`);
276
+ if (out.cohortPhase !== 'BLOCKED' && out.cohortPhase !== 'SERIAL' && out.cohortPhase !== 'COMPLETE'
277
+ && out.cohortPhase !== 'FALLBACK_PENDING') {
278
+ out.cohortPhase = 'FALLBACK_PENDING';
279
+ out.fallbackReason = `controller-requested:${observation.trackId}`;
280
+ // La evidencia de QA/integración describe la ruta de finalización
281
+ // que acabamos de abandonar. Debe desaparecer ANTES de mover los
282
+ // tracks a teardown: de otro modo el invariante de integración
283
+ // final ve evidence viva junto a tracks no mergeados y rechaza la
284
+ // transición segura hacia fallback.
285
+ out.globalQaHeadSha = undefined;
286
+ out.finalIntegrationJobId = undefined;
287
+ markTeardownRequested(out.tracks);
288
+ }
289
+ }
290
+ else if (observation.kind === 'prepare-failed') {
273
291
  out.cohortPhase = 'FALLBACK_PENDING';
274
292
  out.fallbackReason = `prepare-failed:${observation.trackId}`;
275
293
  markTeardownRequested(out.tracks);
@@ -295,10 +313,11 @@ function reconcileProtocol(s, observation) {
295
313
  // `decideTeardown` documenta para BLOCKED: solo el operador lo
296
314
  // saca de acá.
297
315
  const t = out.tracks[observation.trackId];
298
- if (t !== undefined) {
299
- t.phase = 'BLOCKED';
300
- t.blockedReason = observation.detail;
301
- }
316
+ if (t === undefined)
317
+ throw new Error(`track desconocido: ${observation.trackId}`);
318
+ t.phase = 'BLOCKED';
319
+ t.blockedReason = observation.detail;
320
+ out.cohortPhase = 'BLOCKED';
302
321
  }
303
322
  // effect-failed fuera de PREPARING/begin-teardown: sin transición definida todavía (no ejercitado por T1).
304
323
  }
@@ -351,6 +370,7 @@ function reconcileProtocol(s, observation) {
351
370
  if (decision === 'block-foreign') {
352
371
  t.phase = 'BLOCKED';
353
372
  t.blockedReason = observation.foreignSupervisor ? 'identidad de supervisor ajena' : 'worktree preexistente ajeno';
373
+ out.cohortPhase = 'BLOCKED';
354
374
  }
355
375
  else if (decision === 'accept-supervisor-stopped') {
356
376
  t.phase = 'SUPERVISOR_STOPPED';
@@ -432,7 +452,7 @@ function reconcileProtocol(s, observation) {
432
452
  }
433
453
  function observeProtocolEffect(s, effect, observation) {
434
454
  if (observation.kind === 'effect-failed' || observation.kind === 'prepare-failed' || observation.kind === 'join-observation'
435
- || observation.kind === 'freeze-observation' || observation.kind === 'join-requested'
455
+ || observation.kind === 'freeze-observation' || observation.kind === 'join-requested' || observation.kind === 'teardown-requested'
436
456
  || observation.kind === 'supervisor-observed' || observation.kind === 'worktree-observed'
437
457
  || observation.kind === 'teardown-observation'
438
458
  || observation.kind === 'global-qa-pass'
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const context_1 = require("../../src/commands/context");
4
- const collected = (declared, diagnostics = []) => ({ declared, diagnostics });
4
+ const collected = (declared, diagnostics = []) => ({ declared, diagnostics, droppedNames: [] });
5
5
  const uno = { name: 'mi-proceso', appliesWhen: 'cuando hay una tarea', terminatesTo: 'development-process' };
6
6
  describe('awm context orchestrators', () => {
7
7
  it('lista los orquestadores compuestos', () => {
@@ -55,6 +55,14 @@ describe('awm context orchestrators', () => {
55
55
  const r = (0, context_1.runContextOrchestrators)(collected([uno]), { json: false, verify: 'de-verdad-no-existe' });
56
56
  expect(r.code).toBe(2);
57
57
  });
58
+ it('--verify rejects a dropped raw identity that sanitizes to a retained name', () => {
59
+ const input = {
60
+ ...collected([{ name: 'foobar', appliesWhen: 'x', terminatesTo: 'y' }]),
61
+ droppedNames: ['foo_bar'],
62
+ };
63
+ expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foo_bar' }).code).toBe(2);
64
+ expect((0, context_1.runContextOrchestrators)(input, { json: false, verify: 'foobar' }).code).toBe(0);
65
+ });
58
66
  it('sanea bytes de control antes de escribir a la terminal', () => {
59
67
  const hostil = { name: 'x\x1b[31m', appliesWhen: 'w\x07', terminatesTo: 't\x00' };
60
68
  const r = (0, context_1.runContextOrchestrators)(collected([hostil]), { json: false });
@@ -186,6 +186,8 @@ describe('installHook (happy path + merge)', () => {
186
186
  fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
187
187
  fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
188
188
  fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
189
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
190
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
189
191
  fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
190
192
  installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
191
193
  const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm', 'hooks', 'using-awm.md'), 'utf-8');
@@ -246,6 +248,8 @@ describe('installHook (happy path + merge)', () => {
246
248
  fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
247
249
  fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
248
250
  fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
251
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido'), { recursive: true });
252
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'proceso-valido', 'SKILL.md'), '---\nname: proceso-valido\n---\n');
249
253
  fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'proceso-valido', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
250
254
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
251
255
  let result;
@@ -101,6 +101,8 @@ describe('resyncInstalledHooks', () => {
101
101
  fs_1.default.writeFileSync(path_1.default.join(regHooks, 'session-start'), '#!/usr/bin/env bash\necho "{}"', { mode: 0o755 });
102
102
  fs_1.default.writeFileSync(path_1.default.join(regHooks, 'run-hook.cmd'), '#!/usr/bin/env bash\nexec bash "$1"', { mode: 0o755 });
103
103
  fs_1.default.writeFileSync(path_1.default.join(regSkill, 'SKILL.md'), '---\nname: using-awm\n---\nMUST invoke skills.');
104
+ fs_1.default.mkdirSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso'), { recursive: true });
105
+ fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'skills', 'mi-proceso', 'SKILL.md'), '---\nname: mi-proceso\n---\n');
104
106
  fs_1.default.writeFileSync(path_1.default.join(registryRoot, 'awm-registry.json'), JSON.stringify({ orchestrator: { name: 'mi-proceso', appliesWhen: 'al arrancar', terminatesTo: 'development-process' } }));
105
107
  const { installHook } = require('../../../src/commands/hooks/install');
106
108
  installHook({ agent: 'claude-code', registryRoot, installMethod: 'symlink' });
@@ -103,6 +103,12 @@ describe('awm track — verbos mutantes son request-only (R6.1)', () => {
103
103
  expect(git(repo, ['rev-parse', 'HEAD'])).toBe(beforeHead);
104
104
  expect(fs_1.default.readFileSync((0, paths_1.statePath)(repo, branch), 'utf8')).toBe(beforeState);
105
105
  });
106
+ test('remove se anuncia como cancelación request-only soportada (TR-REQ-10)', async () => {
107
+ const out = await runCli(repo, ['track', 'remove', '--help']);
108
+ expect(out.exitCode).toBe(0);
109
+ expect(out.out).toContain('cancela la cohorte');
110
+ expect(out.out).not.toContain('NO IMPLEMENTADO');
111
+ });
106
112
  // `finalize` es PLAN-scoped y sin <trackId>, así que no entra en las matrices de
107
113
  // add/join/remove — pero comparte con ellas la propiedad que define a la superficie:
108
114
  // emite una request y no toca Git ni state.json.
@@ -126,7 +126,7 @@ describe('aplicacion transaccional de requests', () => {
126
126
  expect(Object.keys((0, store_1.readJournal)(repo, 'rama').state.jobs)).toHaveLength(1); // sin duplicados
127
127
  });
128
128
  test('register-entity CREA task con VerificationPlan y ReviewObligations; cycle-plan y dispatch (R1.4/R1.4b)', () => {
129
- (0, requests_1.emitRequest)(repo, 'rama', {
129
+ const request = (0, requests_1.emitRequest)(repo, 'rama', {
130
130
  kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
131
131
  payload: {
132
132
  entity: 'task', taskId: 'T1', title: 'implementar',
@@ -513,20 +513,57 @@ describe('aplicacion transaccional de requests', () => {
513
513
  expect(out.rejectedInvalid).toBe(1);
514
514
  expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
515
515
  });
516
- test('un kind SIN handler falla cerrado: rechazo visible, no un `applied` que borra el archivo', () => {
517
- // `track-teardown-request` es un kind real, emitido por `awm track remove`, que hoy
518
- // no tiene handler en el supervisor. Antes de este cambio se contaba como aplicado y
519
- // desaparecía sin dejar rastro; ahora queda registrado como problema y el archivo se
520
- // conserva con sufijo `.rejected`, que es lo que permite darse cuenta.
521
- const r = (0, requests_1.emitRequest)(repo, 'rama', {
522
- kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-1',
516
+ test('track-teardown-request persiste intención sin mover fase ni cohorte', () => {
517
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
518
+ s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }];
519
+ s0.cohortPhase = 'JOINING';
520
+ (0, store_1.writeJournal)(repo, 'rama', s0);
521
+ const request = (0, requests_1.emitRequest)(repo, 'rama', {
522
+ kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-alpha',
523
523
  payload: { trackId: 'alpha' },
524
524
  });
525
+ expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
526
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
527
+ expect(state.tracks[0].teardownRequested).toBe(true);
528
+ expect(state.tracks[0].phase).toBe('ACTIVE');
529
+ expect(state.cohortPhase).toBe('JOINING');
530
+ expect(state.appliedRequests[request.requestId]).toMatchObject({ outcome: 'applied', resultRef: 'alpha' });
531
+ });
532
+ test.each([
533
+ ['ausente', {}],
534
+ ['vacio', { trackId: '' }],
535
+ ['malformado', { trackId: 42 }],
536
+ ['desconocido', { trackId: 'no-existe' }],
537
+ ])('track-teardown-request %s se rechaza visiblemente sin mutar el track', (_case, payload) => {
538
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
539
+ s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }];
540
+ s0.cohortPhase = 'JOINING';
541
+ (0, store_1.writeJournal)(repo, 'rama', s0);
542
+ const r = (0, requests_1.emitRequest)(repo, 'rama', {
543
+ kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: `teardown-${_case}`,
544
+ payload,
545
+ });
525
546
  const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
526
547
  expect(out.applied).toBe(0);
527
548
  expect(out.rejectedInvalid).toBe(1);
528
549
  expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
529
- expect((0, store_1.readJournal)(repo, 'rama').state.requestProblems.some((p) => p.kind === 'rejected')).toBe(true);
550
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
551
+ expect(state.tracks).toEqual([{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }]);
552
+ expect(state.cohortPhase).toBe('JOINING');
553
+ });
554
+ test('track-teardown-request se rechaza si la cohorte no puede reconciliarse', () => {
555
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
556
+ s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }];
557
+ s0.cohortPhase = 'JOINING';
558
+ (0, store_1.writeJournal)(repo, 'rama', s0);
559
+ const request = (0, requests_1.emitRequest)(repo, 'rama', {
560
+ kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-invalid-cohort',
561
+ payload: { trackId: 'alpha' },
562
+ });
563
+ const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
564
+ expect(out.rejectedInvalid).toBe(1);
565
+ expect(fs_1.default.existsSync(`${request.file}.rejected`)).toBe(true);
566
+ expect((0, store_1.readJournal)(repo, 'rama').state.tracks[0].teardownRequested).toBeUndefined();
530
567
  });
531
568
  test('track-finalize-request persiste el autoreporte del controller (qaFinalizeRequested) — puramente declarativo (R7.2)', () => {
532
569
  (0, requests_1.emitRequest)(repo, 'rama', {