agentic-workflow-manager 9.2.0 → 9.3.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.
@@ -13,6 +13,8 @@ const init_1 = require("../sensors/init");
13
13
  const baseline_1 = require("../sensors/baseline");
14
14
  const run_1 = require("../sensors/run");
15
15
  const paths_1 = require("../../core/paths");
16
+ const registries_1 = require("../../core/registries");
17
+ const inspect_1 = require("../../core/context-kernel/inspect");
16
18
  const MANIFEST = path_1.default.join('.awm', 'sensors.json');
17
19
  /**
18
20
  * The agent needs project context delivered every session. A repo with neither file
@@ -31,6 +33,44 @@ function checkContext(cwd) {
31
33
  }
32
34
  return { id: 'context', ok: true, detail: present.join(', ') };
33
35
  }
36
+ function checkContextKernel(cwd) {
37
+ const resolution = (0, registries_1.resolveProjectContextSchema)();
38
+ if (resolution.diagnostics.length > 0) {
39
+ return {
40
+ id: 'context-kernel',
41
+ ok: false,
42
+ advisory: false,
43
+ detail: `Context Kernel registry declaration is invalid: ${resolution.diagnostics.join('; ')}`,
44
+ remedy: 'repair the registry manifest or use the safe full-context project files until the registry is valid',
45
+ };
46
+ }
47
+ if (!resolution.declaration)
48
+ return null;
49
+ const inspection = (0, inspect_1.inspectContextKernel)(cwd);
50
+ if (inspection.state === 'legacy') {
51
+ return {
52
+ id: 'context-kernel',
53
+ ok: false,
54
+ advisory: true,
55
+ detail: 'legacy full context — Context Kernel v1 migration available',
56
+ remedy: 'run project-context-init and review the generated rule trace; awm update never rewrites project files',
57
+ };
58
+ }
59
+ if (inspection.state === 'valid') {
60
+ return {
61
+ id: 'context-kernel',
62
+ ok: true,
63
+ detail: `Context Kernel v${inspection.schema} valid (${inspection.fixedBytes} fixed bytes)`,
64
+ };
65
+ }
66
+ return {
67
+ id: 'context-kernel',
68
+ ok: false,
69
+ advisory: false,
70
+ detail: inspection.detail,
71
+ remedy: inspection.remedy,
72
+ };
73
+ }
34
74
  function readManifest(cwd) {
35
75
  try {
36
76
  return JSON.parse(fs_1.default.readFileSync(path_1.default.join(cwd, MANIFEST), 'utf-8'));
@@ -345,8 +385,10 @@ async function preflight(cwd = process.cwd(), opts = {}) {
345
385
  }
346
386
  const manifest = readManifest(cwd);
347
387
  const manifestExists = fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST));
388
+ const contextKernel = checkContextKernel(cwd);
348
389
  const checks = [
349
390
  checkContext(cwd),
391
+ ...(contextKernel ? [contextKernel] : []),
350
392
  checkManifest(cwd, manifest),
351
393
  // Skipped when there is no manifest: reporting "tools broken" (or nudging toward
352
394
  // a baseline that has nothing to snapshot) on a repo that was never set up
@@ -359,8 +401,11 @@ async function preflight(cwd = process.cwd(), opts = {}) {
359
401
  // about PR/MR tooling, not sensors.
360
402
  checkHost(cwd),
361
403
  ];
362
- const status = !manifestExists ? 'not_configured'
363
- : checks.every(c => c.ok) ? 'ready'
364
- : 'degraded';
404
+ const blockingFailure = (check) => !check.ok && check.advisory !== true;
405
+ const invalidContextKernel = contextKernel !== null && blockingFailure(contextKernel);
406
+ const status = invalidContextKernel ? 'degraded'
407
+ : !manifestExists ? 'not_configured'
408
+ : checks.some(blockingFailure) ? 'degraded'
409
+ : 'ready';
365
410
  return { status, checks };
366
411
  }
@@ -25,19 +25,18 @@ function formatReport(report) {
25
25
  // longer `PreflightCheck['id']` is added (confirmed: 'sensors-baseline', at 16
26
26
  // chars, broke a hardcoded 9-char pad).
27
27
  const idWidth = Math.max(0, ...report.checks.map(c => c.id.length));
28
- const lines = report.checks.map(c => ` ${c.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘')} ${c.id.padEnd(idWidth)} ${c.detail}`
28
+ const lines = report.checks.map(c => ` ${c.advisory === true ? picocolors_1.default.yellow('⚠') : c.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘')} ${c.id.padEnd(idWidth)} ${c.detail}`
29
29
  + (c.remedy ? `\n ${picocolors_1.default.dim('→ ' + c.remedy)}` : ''));
30
30
  if (report.status === 'ready') {
31
31
  return `${picocolors_1.default.green('✔')} Harness ready — this project can be gated.\n${lines.join('\n')}\n`;
32
32
  }
33
33
  const headline = report.status === 'not_configured'
34
34
  ? `${picocolors_1.default.red('✘')} AWM is not configured in this project.`
35
- : `${picocolors_1.default.red('✘')} Harness degraded — it declares sensors it cannot run.`;
35
+ : `${picocolors_1.default.red('✘')} Harness degraded — it has blocking preflight failures.`;
36
36
  return `${headline}\n${lines.join('\n')}\n\n`
37
37
  + ` ${picocolors_1.default.bold('Do not hand this off to an unattended run.')} Every quality phase downstream\n`
38
- + ` (implementer, reviewers, post-qa) consumes \`awm sensors run\`. With the harness in\n`
39
- + ` this state the gate reports on checks that never ran, and nobody finds out until a\n`
40
- + ` bad change is already merged.\n`;
38
+ + ` (implementer, reviewers, post-qa) consumes these gates. Resolve every blocking\n`
39
+ + ` check before handoff, so a bad change is not merged.\n`;
41
40
  }
42
41
  function registerPreflightCommand(program) {
43
42
  program
@@ -21,6 +21,7 @@ exports.runUpdateCore = runUpdateCore;
21
21
  // instead (which already imports clack at the top and is never `require()`d
22
22
  // by tests).
23
23
  const picocolors_1 = __importDefault(require("picocolors"));
24
+ const versioning_1 = require("../core/versioning");
24
25
  const paths_1 = require("../core/paths");
25
26
  const config_1 = require("../utils/config");
26
27
  const agent_targets_1 = require("../core/agent-targets");
@@ -89,8 +90,29 @@ async function runUpdateCore(options = {}, deps = {}) {
89
90
  for (const r of registryResults) {
90
91
  if (r.action === 'error')
91
92
  console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
92
- else
93
- console.log(picocolors_1.default.green(` ✓ Registry ${r.name} ${r.action === 'pulled' ? 'updated' : 're-cloned'} @ ${r.version}`));
93
+ else if (r.disposition === 'regressed') {
94
+ const latest = r.availableVersion && r.availableVersion !== r.version
95
+ ? ` Latest available: ${r.availableVersion}.`
96
+ : '';
97
+ console.warn(picocolors_1.default.yellow(` ⚠ Registry ${r.name} regressed ${r.previousVersion ?? '(previous version)'} → ${r.version} `
98
+ + `because it is pinned to v${(0, versioning_1.normalizePin)(r.pin ?? '')}.${latest} Run \`awm unpin ${r.name}\` to move forward.`));
99
+ }
100
+ else if (r.pin && r.availableVersion !== r.version) {
101
+ console.log(picocolors_1.default.yellow(` ✓ Registry ${r.name} resolved @ ${r.version} (pinned to v${(0, versioning_1.normalizePin)(r.pin)}; `
102
+ + `latest available: ${r.availableVersion ?? 'unknown'}). Run \`awm unpin ${r.name}\` to move forward.`));
103
+ }
104
+ else if (r.disposition === 'unchanged') {
105
+ console.log(picocolors_1.default.dim(` ✓ Registry ${r.name} already at ${r.version}`));
106
+ }
107
+ else if (r.disposition === 'advanced') {
108
+ console.log(picocolors_1.default.green(` ✓ Registry ${r.name} updated @ ${r.version}`));
109
+ }
110
+ else if (r.disposition === 'installed') {
111
+ console.log(picocolors_1.default.green(` ✓ Registry ${r.name} installed @ ${r.version}`));
112
+ }
113
+ else {
114
+ console.log(picocolors_1.default.green(` ✓ Registry ${r.name} resolved @ ${r.version}`));
115
+ }
94
116
  }
95
117
  const registries = {
96
118
  configured: registryResults.length,
@@ -0,0 +1,221 @@
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.inspectContextKernel = inspectContextKernel;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const INDEX_PATH = ['.awm', 'context', 'index.json'];
10
+ const ROOT_CONTEXT_FILES = ['AGENTS.md', 'CONSTITUTION.md', 'CLAUDE.md'];
11
+ const START_MARKER = '<!-- AWM:CONTEXT-KERNEL:START v1 -->';
12
+ const END_MARKER = '<!-- AWM:CONTEXT-KERNEL:END v1 -->';
13
+ const ANCHOR_MARKER_PREFIX = '<!-- awm-context:';
14
+ const TOP_LEVEL_FIELDS = ['schema', 'kernelFiles', 'maxFixedBytes', 'entries'];
15
+ const ENTRY_FIELDS = ['id', 'tier', 'path', 'anchor', 'when'];
16
+ const REMEDY = 'run project-context-init and review the Context Kernel v1 artifacts';
17
+ function invalid(detail) {
18
+ return { state: 'invalid', detail, remedy: REMEDY };
19
+ }
20
+ function within(root, candidate) {
21
+ const relative = path_1.default.relative(root, candidate);
22
+ return relative === '' || (!relative.startsWith(`..${path_1.default.sep}`) && relative !== '..' && !path_1.default.isAbsolute(relative));
23
+ }
24
+ function count(text, needle) {
25
+ return text.split(needle).length - 1;
26
+ }
27
+ function exactFields(raw, fields) {
28
+ const keys = Object.keys(raw);
29
+ return keys.length === fields.length && fields.every((field) => Object.prototype.hasOwnProperty.call(raw, field));
30
+ }
31
+ function isNormalizedRelative(value) {
32
+ if (typeof value !== 'string' || value.length === 0 || path_1.default.isAbsolute(value) || path_1.default.win32.isAbsolute(value))
33
+ return false;
34
+ if (value.includes('\\') || value.split('/').some((part) => part === '.' || part === '..' || part === ''))
35
+ return false;
36
+ return path_1.default.posix.normalize(value) === value;
37
+ }
38
+ function resolveRegularInside(root, relative) {
39
+ if (!isNormalizedRelative(relative))
40
+ return { detail: `invalid repository-relative path ${JSON.stringify(relative)}` };
41
+ const candidate = path_1.default.join(root, relative);
42
+ try {
43
+ const stat = fs_1.default.statSync(candidate);
44
+ if (!stat.isFile())
45
+ return { detail: `${relative} must resolve to a regular file` };
46
+ const real = fs_1.default.realpathSync(candidate);
47
+ if (!within(root, real))
48
+ return { detail: `${relative} resolves outside the project root` };
49
+ return { file: real };
50
+ }
51
+ catch (error) {
52
+ return { detail: `${relative} cannot be read: ${error instanceof Error ? error.message : String(error)}` };
53
+ }
54
+ }
55
+ function parseEntry(raw) {
56
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw) || !exactFields(raw, ENTRY_FIELDS)) {
57
+ return { detail: 'each index entry must contain exactly id, tier, path, anchor, when' };
58
+ }
59
+ const value = raw;
60
+ if (typeof value.id !== 'string' || !/^CTX-[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(value.id) || Buffer.byteLength(value.id, 'utf8') > 80) {
61
+ return { detail: 'entry id must match CTX-[A-Z0-9-]+ and be at most 80 bytes' };
62
+ }
63
+ if (value.tier !== 'kernel' && value.tier !== 'selective')
64
+ return { detail: 'entry tier must be kernel or selective' };
65
+ if (!isNormalizedRelative(value.path))
66
+ return { detail: `invalid repository-relative path ${JSON.stringify(value.path)}` };
67
+ for (const field of ['anchor', 'when']) {
68
+ if (typeof value[field] !== 'string' || value[field].length === 0 || Buffer.byteLength(value[field], 'utf8') > 500) {
69
+ return { detail: `entry ${field} must be a non-empty string of at most 500 bytes` };
70
+ }
71
+ }
72
+ return { entry: { id: value.id, tier: value.tier, path: value.path, anchor: value.anchor, when: value.when } };
73
+ }
74
+ function parseIndex(contents) {
75
+ let raw;
76
+ try {
77
+ raw = JSON.parse(contents);
78
+ }
79
+ catch (error) {
80
+ return { detail: `index is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
81
+ }
82
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw) || !exactFields(raw, TOP_LEVEL_FIELDS)) {
83
+ return { detail: 'index must contain exactly schema, kernelFiles, maxFixedBytes, entries' };
84
+ }
85
+ const value = raw;
86
+ if (value.schema !== 1 || !Number.isSafeInteger(value.schema))
87
+ return { detail: 'index schema must be exactly 1' };
88
+ if (!Array.isArray(value.kernelFiles) || value.kernelFiles.length === 0 || value.kernelFiles.some((file) => !isNormalizedRelative(file)) || new Set(value.kernelFiles).size !== value.kernelFiles.length) {
89
+ return { detail: 'kernelFiles must be a non-empty unique normalized path array' };
90
+ }
91
+ if (value.kernelFiles.some((file) => file.includes('/')))
92
+ return { detail: 'kernelFiles must name root context files' };
93
+ if (!Number.isSafeInteger(value.maxFixedBytes) || value.maxFixedBytes < 1)
94
+ return { detail: 'maxFixedBytes must be a positive integer' };
95
+ if (!Array.isArray(value.entries) || value.entries.length === 0)
96
+ return { detail: 'entries must be a non-empty array' };
97
+ const entries = [];
98
+ for (const rawEntry of value.entries) {
99
+ const parsed = parseEntry(rawEntry);
100
+ if (!parsed.entry)
101
+ return { detail: parsed.detail };
102
+ entries.push(parsed.entry);
103
+ }
104
+ if (new Set(entries.map((entry) => entry.id)).size !== entries.length)
105
+ return { detail: 'entry ids must be unique' };
106
+ if (new Set(entries.map((entry) => entry.anchor)).size !== entries.length)
107
+ return { detail: 'entry anchors must be unique' };
108
+ return { index: { schema: 1, kernelFiles: value.kernelFiles, maxFixedBytes: value.maxFixedBytes, entries } };
109
+ }
110
+ function markerBounds(contents) {
111
+ if (count(contents, START_MARKER) !== 1 || count(contents, END_MARKER) !== 1)
112
+ return null;
113
+ const start = contents.indexOf(START_MARKER) + START_MARKER.length;
114
+ const end = contents.indexOf(END_MARKER);
115
+ return start < end ? { start, end } : null;
116
+ }
117
+ /** Inspects project-owned Context Kernel v1 artifacts. Invalid project artifacts are
118
+ * classified explicitly; only invalid public input throws. */
119
+ function inspectContextKernel(cwd) {
120
+ if (typeof cwd !== 'string' || cwd.length === 0)
121
+ throw new Error('cwd must be a non-empty directory path');
122
+ let root;
123
+ try {
124
+ root = fs_1.default.realpathSync(cwd);
125
+ if (!fs_1.default.statSync(root).isDirectory())
126
+ throw new Error('not a directory');
127
+ }
128
+ catch (error) {
129
+ throw new Error(`cwd must resolve to an existing directory: ${error instanceof Error ? error.message : String(error)}`);
130
+ }
131
+ const indexFile = path_1.default.join(root, ...INDEX_PATH);
132
+ let indexExists;
133
+ try {
134
+ fs_1.default.lstatSync(indexFile);
135
+ indexExists = true;
136
+ }
137
+ catch (error) {
138
+ if (error.code !== 'ENOENT')
139
+ return invalid(`${indexFile} cannot be inspected`);
140
+ indexExists = false;
141
+ }
142
+ if (!indexExists) {
143
+ for (const name of ROOT_CONTEXT_FILES) {
144
+ const candidate = path_1.default.join(root, name);
145
+ try {
146
+ if (fs_1.default.existsSync(candidate) && fs_1.default.statSync(candidate).isFile()) {
147
+ const contents = fs_1.default.readFileSync(candidate, 'utf8');
148
+ if (!contents.includes('AWM:CONTEXT-KERNEL:') && !contents.includes(ANCHOR_MARKER_PREFIX))
149
+ continue;
150
+ return invalid(`${name} contains Context Kernel markers but ${INDEX_PATH.join('/')} is missing`);
151
+ }
152
+ }
153
+ catch (error) {
154
+ return invalid(`${name} cannot be inspected: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ }
157
+ return { state: 'legacy' };
158
+ }
159
+ const indexTarget = resolveRegularInside(root, INDEX_PATH.join('/'));
160
+ if (!indexTarget.file)
161
+ return invalid(`${INDEX_PATH.join('/')}: ${indexTarget.detail}`);
162
+ let parsed;
163
+ try {
164
+ parsed = parseIndex(fs_1.default.readFileSync(indexTarget.file, 'utf8'));
165
+ }
166
+ catch (error) {
167
+ return invalid(`${INDEX_PATH.join('/')}: cannot be read: ${error instanceof Error ? error.message : String(error)}`);
168
+ }
169
+ if (!parsed.index)
170
+ return invalid(`${INDEX_PATH.join('/')}: ${parsed.detail}`);
171
+ const index = parsed.index;
172
+ const files = new Map();
173
+ for (const kernelFile of index.kernelFiles) {
174
+ const target = resolveRegularInside(root, kernelFile);
175
+ if (!target.file)
176
+ return invalid(`${kernelFile}: ${target.detail}`);
177
+ const contents = fs_1.default.readFileSync(target.file, 'utf8');
178
+ if (!markerBounds(contents))
179
+ return invalid(`${kernelFile}: requires exactly one ordered Context Kernel v1 marker pair`);
180
+ files.set(kernelFile, contents);
181
+ }
182
+ for (const entry of index.entries) {
183
+ const target = resolveRegularInside(root, entry.path);
184
+ if (!target.file)
185
+ return invalid(`${entry.path}: ${target.detail}`);
186
+ let contents;
187
+ try {
188
+ contents = files.get(entry.path) ?? fs_1.default.readFileSync(target.file, 'utf8');
189
+ }
190
+ catch (error) {
191
+ return invalid(`${entry.path}: cannot be read: ${error instanceof Error ? error.message : String(error)}`);
192
+ }
193
+ const marker = `<!-- ${entry.anchor} -->`;
194
+ if (count(contents, marker) !== 1)
195
+ return invalid(`${entry.path}: anchor ${entry.anchor} must appear exactly once`);
196
+ const anchor = contents.indexOf(marker);
197
+ const bounds = files.has(entry.path) ? markerBounds(contents) : null;
198
+ if (entry.tier === 'kernel') {
199
+ if (!files.has(entry.path))
200
+ return invalid(`${entry.path}: kernel entry must point to a kernel file`);
201
+ if (!bounds || anchor < bounds.start || anchor >= bounds.end)
202
+ return invalid(`${entry.path}: kernel anchor ${entry.anchor} must be inside the protected region`);
203
+ }
204
+ else if (bounds && anchor >= bounds.start && anchor < bounds.end) {
205
+ return invalid(`${entry.path}: selective anchor ${entry.anchor} must be outside the protected region`);
206
+ }
207
+ }
208
+ let fixedBytes = 0;
209
+ for (const name of ROOT_CONTEXT_FILES) {
210
+ const candidate = path_1.default.join(root, name);
211
+ if (!fs_1.default.existsSync(candidate))
212
+ continue;
213
+ const target = resolveRegularInside(root, name);
214
+ if (!target.file)
215
+ return invalid(`${name}: ${target.detail}`);
216
+ fixedBytes += fs_1.default.statSync(target.file).size;
217
+ }
218
+ if (fixedBytes > index.maxFixedBytes)
219
+ return invalid(`fixed context bytes ${fixedBytes} exceed maxFixedBytes ${index.maxFixedBytes}`);
220
+ return { state: 'valid', schema: 1, index, fixedBytes };
221
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -23,6 +23,7 @@ exports.describeRegistrySyncErrors = describeRegistrySyncErrors;
23
23
  exports.unusableSyncedRegistries = unusableSyncedRegistries;
24
24
  exports.assertSyncedRegistriesUsable = assertSyncedRegistriesUsable;
25
25
  exports.readRegistryManifest = readRegistryManifest;
26
+ exports.resolveProjectContextSchema = resolveProjectContextSchema;
26
27
  exports.registryNameForPath = registryNameForPath;
27
28
  exports.verifyMinCliVersions = verifyMinCliVersions;
28
29
  exports.assertRegistryGates = assertRegistryGates;
@@ -251,9 +252,11 @@ async function syncRegistries() {
251
252
  await (0, simple_git_1.default)(reg.contentRoot).reset(['--hard']);
252
253
  }
253
254
  const git = (0, simple_git_1.default)(reg.contentRoot);
255
+ const previousVersion = freshClone ? null : await (0, versioning_1.currentVersion)(reg.contentRoot);
256
+ const versionOpts = (0, versioning_1.machineVersionOpts)(reg.name);
254
257
  let resolved;
255
258
  try {
256
- resolved = await (0, versioning_1.resolveTargetRef)(reg.contentRoot, (0, versioning_1.machineVersionOpts)(reg.name));
259
+ resolved = await (0, versioning_1.resolveTargetRef)(reg.contentRoot, versionOpts);
257
260
  await git.checkout(resolved.ref);
258
261
  if (resolved.kind !== 'tag')
259
262
  await git.pull('origin', resolved.ref);
@@ -266,10 +269,24 @@ async function syncRegistries() {
266
269
  fs_1.default.rmSync(reg.contentRoot, { recursive: true, force: true });
267
270
  throw e;
268
271
  }
272
+ const version = resolved.kind === 'tag' ? `v${resolved.version}` : 'HEAD';
273
+ let disposition;
274
+ if (freshClone)
275
+ disposition = 'installed';
276
+ else if (resolved.kind !== 'tag' || previousVersion === null)
277
+ disposition = 'resolved';
278
+ else {
279
+ const direction = (0, versioning_1.compareSemver)(resolved.version, previousVersion);
280
+ disposition = direction > 0 ? 'advanced' : direction < 0 ? 'regressed' : 'unchanged';
281
+ }
269
282
  results.push({
270
283
  name: reg.name,
271
284
  action: freshClone ? 'recloned' : 'pulled',
272
- version: resolved.kind === 'tag' ? `v${resolved.version}` : 'HEAD',
285
+ version,
286
+ disposition,
287
+ ...(versionOpts.pin ? { pin: (0, versioning_1.normalizePin)(versionOpts.pin) } : {}),
288
+ ...(previousVersion ? { previousVersion: `v${previousVersion}` } : {}),
289
+ ...(resolved.kind === 'tag' ? { availableVersion: `v${resolved.latestVersion}` } : {}),
273
290
  });
274
291
  }
275
292
  catch (e) {
@@ -347,7 +364,35 @@ function readRegistryManifest(root) {
347
364
  }
348
365
  minCliVersion = rawMin.replace(/^v/, '');
349
366
  }
350
- return { overrides: new Set(overrides), minCliVersion };
367
+ const rawSchema = raw?.projectContextSchema;
368
+ let projectContextSchema;
369
+ if (rawSchema !== undefined) {
370
+ if (rawSchema !== 1) {
371
+ throw new Error(`Invalid registry manifest at ${file}: "projectContextSchema" must be exactly 1, got ${JSON.stringify(rawSchema)}`);
372
+ }
373
+ projectContextSchema = 1;
374
+ }
375
+ return { overrides: new Set(overrides), minCliVersion, projectContextSchema };
376
+ }
377
+ /** Finds the first usable registry that declares Context Kernel v1. Every active
378
+ * registry is inspected even after a declaration is found, so a malformed manifest
379
+ * cannot be masked by a later valid declaration. */
380
+ function resolveProjectContextSchema() {
381
+ const diagnostics = [];
382
+ let declaration;
383
+ for (const registry of listRegistries()) {
384
+ if (!validateRegistryLayout(registry.contentRoot))
385
+ continue;
386
+ try {
387
+ if (!declaration && readRegistryManifest(registry.contentRoot).projectContextSchema === 1) {
388
+ declaration = { registry, schema: 1 };
389
+ }
390
+ }
391
+ catch (error) {
392
+ diagnostics.push(`Registry "${registry.name}" manifest is invalid: ${error instanceof Error ? error.message : String(error)}`);
393
+ }
394
+ }
395
+ return declaration ? { declaration, diagnostics } : { diagnostics };
351
396
  }
352
397
  /** Nombre del registry dueño de un path: el nombre del clone bajo registriesDir(),
353
398
  * o null si no pertenece a ninguno. */
@@ -69,7 +69,7 @@ async function resolveTargetRef(repoDir, opts) {
69
69
  : 'the registry has no version tags';
70
70
  throw new Error(`Pinned version ${want} not found in ${repoDir} — ${available}`);
71
71
  }
72
- return { kind: 'tag', ref: want, version: normalizePin(opts.pin) };
72
+ return { kind: 'tag', ref: want, version: normalizePin(opts.pin), latestVersion: tags[tags.length - 1].slice(1) };
73
73
  }
74
74
  const branch = await defaultBranch(git);
75
75
  if (opts.channel === 'dev')
@@ -77,7 +77,7 @@ async function resolveTargetRef(repoDir, opts) {
77
77
  if (tags.length === 0)
78
78
  return { kind: 'head-fallback', ref: branch };
79
79
  const latest = tags[tags.length - 1];
80
- return { kind: 'tag', ref: latest, version: latest.slice(1) };
80
+ return { kind: 'tag', ref: latest, version: latest.slice(1), latestVersion: latest.slice(1) };
81
81
  }
82
82
  /** Versión checkouteada actual ("X.Y.Z") si HEAD coincide exactamente con un tag semver; null si sigue un branch. */
83
83
  async function currentVersion(repoDir) {
@@ -51,9 +51,154 @@ function project(opts = {}) {
51
51
  const dirs = [];
52
52
  const make = (o) => { const d = project(o); dirs.push(d); return d; };
53
53
  afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
54
+ let previousAwmHome;
55
+ const awmHomes = [];
56
+ beforeEach(() => {
57
+ previousAwmHome = process.env.AWM_HOME;
58
+ const awmHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-home-'));
59
+ awmHomes.push(awmHome);
60
+ process.env.AWM_HOME = awmHome;
61
+ });
62
+ afterEach(() => {
63
+ if (previousAwmHome === undefined)
64
+ delete process.env.AWM_HOME;
65
+ else
66
+ process.env.AWM_HOME = previousAwmHome;
67
+ });
68
+ afterAll(() => awmHomes.forEach(dir => fs_1.default.rmSync(dir, { recursive: true, force: true })));
69
+ function installRegistry(manifest, name = 'test-registry') {
70
+ const awmHome = process.env.AWM_HOME;
71
+ const root = path_1.default.join(awmHome, 'registries', name);
72
+ fs_1.default.mkdirSync(path_1.default.join(root, 'skills'), { recursive: true });
73
+ const config = fs_1.default.existsSync(path_1.default.join(awmHome, 'registries.json'))
74
+ ? JSON.parse(fs_1.default.readFileSync(path_1.default.join(awmHome, 'registries.json'), 'utf8'))
75
+ : [];
76
+ config.push({ name, remote: `https://example.invalid/${name}.git` });
77
+ fs_1.default.writeFileSync(path_1.default.join(awmHome, 'registries.json'), JSON.stringify(config));
78
+ fs_1.default.writeFileSync(path_1.default.join(root, 'awm-registry.json'), JSON.stringify(manifest));
79
+ }
80
+ function writeValidKernel(dir) {
81
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), '<!-- AWM:CONTEXT-KERNEL:START v1 -->\nagent rules\n<!-- AWM:CONTEXT-KERNEL:END v1 -->\n');
82
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'CONSTITUTION.md'), '<!-- AWM:CONTEXT-KERNEL:START v1 -->\n<!-- awm-context:CTX-PROCESS-001 -->\nprocess rules\n<!-- AWM:CONTEXT-KERNEL:END v1 -->\n');
83
+ const card = path_1.default.join(dir, 'docs', 'awm', 'context', 'releases.md');
84
+ fs_1.default.mkdirSync(path_1.default.dirname(card), { recursive: true });
85
+ fs_1.default.writeFileSync(card, '<!-- awm-context:CTX-RELEASE-001 -->\nrelease rules\n');
86
+ fs_1.default.mkdirSync(path_1.default.join(dir, '.awm', 'context'), { recursive: true });
87
+ fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'context', 'index.json'), JSON.stringify({
88
+ schema: 1,
89
+ kernelFiles: ['AGENTS.md', 'CONSTITUTION.md'],
90
+ maxFixedBytes: 33_740,
91
+ entries: [
92
+ { id: 'CTX-PROCESS-001', tier: 'kernel', path: 'CONSTITUTION.md', anchor: 'awm-context:CTX-PROCESS-001', when: 'always' },
93
+ { id: 'CTX-RELEASE-001', tier: 'selective', path: 'docs/awm/context/releases.md', anchor: 'awm-context:CTX-RELEASE-001', when: 'release automation' },
94
+ ],
95
+ }));
96
+ }
97
+ function writePartialKernel(dir) {
98
+ fs_1.default.writeFileSync(path_1.default.join(dir, 'AGENTS.md'), '<!-- AWM:CONTEXT-KERNEL:START v1 -->\nagent rules\n');
99
+ }
54
100
  const check = (r, id) => r.checks.find(c => c.id === id);
55
101
  describe('preflight', () => {
56
102
  afterEach(() => mockRunSensors.mockReset());
103
+ it('does not add a row when no registry declares the schema', async () => {
104
+ installRegistry({ minCliVersion: '9.2.1' });
105
+ const report = await (0, checks_1.preflight)(make({
106
+ manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
107
+ }));
108
+ expect(report.checks.map(c => c.id)).not.toContain('context-kernel');
109
+ });
110
+ it('blocks preflight when an active registry declares an invalid context kernel schema', async () => {
111
+ installRegistry({ projectContextSchema: 2 });
112
+ const report = await (0, checks_1.preflight)(make({
113
+ manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
114
+ }));
115
+ expect(report.status).toBe('degraded');
116
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(1);
117
+ expect(check(report, 'context-kernel')).toMatchObject({
118
+ ok: false,
119
+ advisory: false,
120
+ detail: expect.stringMatching(/projectContextSchema.*exactly 1.*2/),
121
+ });
122
+ });
123
+ it('fails closed when an earlier active registry manifest is malformed before a valid declaration', async () => {
124
+ installRegistry({ projectContextSchema: 2 }, 'malformed');
125
+ installRegistry({ projectContextSchema: 1 }, 'valid');
126
+ const report = await (0, checks_1.preflight)(make({
127
+ manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
128
+ }));
129
+ expect(report.status).toBe('degraded');
130
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(1);
131
+ expect(check(report, 'context-kernel')).toMatchObject({
132
+ ok: false,
133
+ advisory: false,
134
+ detail: expect.stringMatching(/malformed.*awm-registry\.json.*projectContextSchema.*exactly 1/i),
135
+ });
136
+ });
137
+ it('keeps legacy ready but renders a persistent warning and remedy', async () => {
138
+ installRegistry({ projectContextSchema: 1 });
139
+ const report = await (0, checks_1.preflight)(make({
140
+ manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
141
+ }));
142
+ expect(report.status).toBe('ready');
143
+ expect(report.checks).toContainEqual(expect.objectContaining({
144
+ id: 'context-kernel', ok: false, advisory: true,
145
+ detail: expect.stringMatching(/legacy full context/),
146
+ remedy: expect.stringMatching(/project-context-init/),
147
+ }));
148
+ expect((0, preflight_1.formatReport)(report)).toMatch(/⚠.*context-kernel.*legacy full context/s);
149
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(0);
150
+ expect(JSON.parse(JSON.stringify(report))).toEqual(expect.objectContaining({
151
+ status: 'ready',
152
+ checks: expect.arrayContaining([expect.objectContaining({
153
+ id: 'context-kernel', ok: false, advisory: true,
154
+ detail: expect.any(String), remedy: expect.any(String),
155
+ })]),
156
+ }));
157
+ });
158
+ it('keeps an unconfigured legacy project not_configured while the context kernel remains advisory', async () => {
159
+ installRegistry({ projectContextSchema: 1 });
160
+ const report = await (0, checks_1.preflight)(make());
161
+ expect(report.status).toBe('not_configured');
162
+ expect(check(report, 'manifest')).toMatchObject({ ok: false, detail: 'no .awm/sensors.json' });
163
+ expect(check(report, 'context-kernel')).toMatchObject({
164
+ ok: false,
165
+ advisory: true,
166
+ detail: expect.stringMatching(/legacy full context/),
167
+ });
168
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(1);
169
+ });
170
+ it('passes a valid kernel', async () => {
171
+ installRegistry({ projectContextSchema: 1 });
172
+ const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
173
+ writeValidKernel(dir);
174
+ const report = await (0, checks_1.preflight)(dir);
175
+ expect(report).toEqual(expect.objectContaining({ status: 'ready' }));
176
+ expect(check(report, 'context-kernel')).toMatchObject({ ok: true });
177
+ expect(check(report, 'context-kernel')).not.toHaveProperty('advisory');
178
+ });
179
+ it('degrades partial or invalid migration', async () => {
180
+ installRegistry({ projectContextSchema: 1 });
181
+ const dir = make({ manifest: { pack: 'generic', sensors: { security: { enabled: false } } } });
182
+ writePartialKernel(dir);
183
+ const report = await (0, checks_1.preflight)(dir);
184
+ expect(report.status).toBe('degraded');
185
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(1);
186
+ expect(report.checks).toContainEqual(expect.objectContaining({
187
+ id: 'context-kernel', advisory: false, ok: false,
188
+ }));
189
+ expect((0, preflight_1.formatReport)(report)).toMatch(/Harness degraded — it has blocking preflight failures\./);
190
+ expect((0, preflight_1.formatReport)(report)).not.toMatch(/declares sensors it cannot run/);
191
+ });
192
+ it('degrades an invalid kernel even when no sensor manifest exists', async () => {
193
+ installRegistry({ projectContextSchema: 1 });
194
+ const dir = make();
195
+ writePartialKernel(dir);
196
+ const report = await (0, checks_1.preflight)(dir);
197
+ expect(report.status).toBe('degraded');
198
+ expect(check(report, 'manifest')).toMatchObject({ ok: false, detail: 'no .awm/sensors.json' });
199
+ expect(check(report, 'context-kernel')).toMatchObject({ ok: false, advisory: false });
200
+ expect((0, preflight_1.exitCodeFor)(report)).toBe(1);
201
+ });
57
202
  it('keeps default preflight static and does not dispatch sensors', async () => {
58
203
  const dir = make({
59
204
  manifest: { pack: 'generic', sensors: { security: { enabled: false } } },
@@ -125,6 +125,37 @@ describe('awm update — honest outcome', () => {
125
125
  expect(result.registries).toEqual({ configured: 2, synced: 2, failed: [] });
126
126
  expect(updateOutro(result)).toBe('✅ 2 registries, skills and hooks updated.');
127
127
  });
128
+ it('una regresión causada por pin se advierte y explica cómo volver a avanzar', async () => {
129
+ const { runUpdateCore } = require('../../src/commands/update');
130
+ await runUpdateCore({}, deps({
131
+ syncRegistries: async () => [{
132
+ name: 'baseline',
133
+ action: 'pulled',
134
+ version: 'v1.0.0',
135
+ disposition: 'regressed',
136
+ pin: '1.0.0',
137
+ availableVersion: 'v1.1.0',
138
+ }],
139
+ }));
140
+ const output = [...logSpy.mock.calls, ...warnSpy.mock.calls].flat().join('\n');
141
+ expect(output).toMatch(/regress/i);
142
+ expect(output).toMatch(/v1\.0\.0/);
143
+ expect(output).toMatch(/v1\.1\.0/);
144
+ expect(output).toMatch(/awm unpin baseline/);
145
+ expect(output).not.toMatch(/Registry baseline updated/i);
146
+ });
147
+ it('un pin que ya coincide con el último release no recomienda unpin', async () => {
148
+ const { runUpdateCore } = require('../../src/commands/update');
149
+ await runUpdateCore({}, deps({
150
+ syncRegistries: async () => [{
151
+ name: 'baseline', action: 'pulled', version: 'v1.1.0', disposition: 'unchanged',
152
+ pin: 'v1.1.0', availableVersion: 'v1.1.0',
153
+ }],
154
+ }));
155
+ const output = [...logSpy.mock.calls, ...warnSpy.mock.calls].flat().join('\n');
156
+ expect(output).toMatch(/already at v1\.1\.0/i);
157
+ expect(output).not.toMatch(/awm unpin baseline/);
158
+ });
128
159
  it('un registry que falló y NO dejó contenido en disco falla cerrado', async () => {
129
160
  const { runUpdateCore } = require('../../src/commands/update');
130
161
  const result = await runUpdateCore({}, deps({
@@ -0,0 +1,144 @@
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 os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ describe('Context Kernel v1 inspection', () => {
10
+ let root;
11
+ let previousAwmHome;
12
+ beforeEach(() => {
13
+ jest.resetModules();
14
+ previousAwmHome = process.env.AWM_HOME;
15
+ root = fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-context-kernel-')));
16
+ process.env.AWM_HOME = path_1.default.join(root, 'awm-home');
17
+ fs_1.default.mkdirSync(process.env.AWM_HOME, { recursive: true });
18
+ });
19
+ afterEach(() => {
20
+ if (previousAwmHome === undefined)
21
+ delete process.env.AWM_HOME;
22
+ else
23
+ process.env.AWM_HOME = previousAwmHome;
24
+ fs_1.default.rmSync(root, { recursive: true, force: true });
25
+ });
26
+ function inspect() {
27
+ return require('../../../src/core/context-kernel/inspect').inspectContextKernel;
28
+ }
29
+ function indexPath() {
30
+ return path_1.default.join(root, '.awm', 'context', 'index.json');
31
+ }
32
+ function writeIndex(index) {
33
+ fs_1.default.mkdirSync(path_1.default.dirname(indexPath()), { recursive: true });
34
+ fs_1.default.writeFileSync(indexPath(), JSON.stringify(index, null, 2));
35
+ }
36
+ function writeValidKernel() {
37
+ const index = {
38
+ schema: 1,
39
+ kernelFiles: ['AGENTS.md', 'CONSTITUTION.md'],
40
+ maxFixedBytes: 33_740,
41
+ entries: [
42
+ { id: 'CTX-PROCESS-001', tier: 'kernel', path: 'CONSTITUTION.md', anchor: 'awm-context:CTX-PROCESS-001', when: 'always' },
43
+ { id: 'CTX-RELEASE-001', tier: 'selective', path: 'docs/awm/context/releases.md', anchor: 'awm-context:CTX-RELEASE-001', when: 'release automation' },
44
+ ],
45
+ };
46
+ fs_1.default.writeFileSync(path_1.default.join(root, 'AGENTS.md'), '<!-- AWM:CONTEXT-KERNEL:START v1 -->\nagent rules\n<!-- AWM:CONTEXT-KERNEL:END v1 -->\n');
47
+ fs_1.default.writeFileSync(path_1.default.join(root, 'CONSTITUTION.md'), '<!-- AWM:CONTEXT-KERNEL:START v1 -->\n<!-- awm-context:CTX-PROCESS-001 -->\nprocess rules\n<!-- AWM:CONTEXT-KERNEL:END v1 -->\n');
48
+ const card = path_1.default.join(root, 'docs', 'awm', 'context', 'releases.md');
49
+ fs_1.default.mkdirSync(path_1.default.dirname(card), { recursive: true });
50
+ fs_1.default.writeFileSync(card, '<!-- awm-context:CTX-RELEASE-001 -->\nrelease rules\n');
51
+ writeIndex(index);
52
+ return index;
53
+ }
54
+ function mutateFixture(mutation) {
55
+ const constitution = path_1.default.join(root, 'CONSTITUTION.md');
56
+ if (mutation === 'start marker missing') {
57
+ fs_1.default.writeFileSync(constitution, fs_1.default.readFileSync(constitution, 'utf8').replace('<!-- AWM:CONTEXT-KERNEL:START v1 -->\n', ''));
58
+ }
59
+ else if (mutation === 'end marker duplicated') {
60
+ fs_1.default.appendFileSync(constitution, '<!-- AWM:CONTEXT-KERNEL:END v1 -->\n');
61
+ }
62
+ else if (mutation === 'anchor outside region') {
63
+ fs_1.default.writeFileSync(constitution, '<!-- awm-context:CTX-PROCESS-001 -->\n' + fs_1.default.readFileSync(constitution, 'utf8'));
64
+ }
65
+ else {
66
+ fs_1.default.writeFileSync(constitution, fs_1.default.readFileSync(constitution, 'utf8').replace('process rules', '<!-- awm-context:CTX-PROCESS-001 -->\nprocess rules'));
67
+ }
68
+ }
69
+ it('classifies no R3 artifacts as legacy', () => {
70
+ expect(inspect()(root)).toEqual({ state: 'legacy' });
71
+ });
72
+ it('rejects a root context marker when the Context Kernel index is missing', () => {
73
+ fs_1.default.writeFileSync(path_1.default.join(root, 'AGENTS.md'), '<!-- AWM:CONTEXT-KERNEL:START v1 -->\nagent rules\n');
74
+ expect(inspect()(root)).toEqual(expect.objectContaining({
75
+ state: 'invalid',
76
+ detail: expect.stringContaining('.awm/context/index.json is missing'),
77
+ }));
78
+ });
79
+ it('rejects an orphaned context anchor when the Context Kernel index is missing', () => {
80
+ fs_1.default.writeFileSync(path_1.default.join(root, 'AGENTS.md'), '<!-- awm-context:CTX-PROCESS-001 -->\nagent rules\n');
81
+ expect(inspect()(root)).toEqual(expect.objectContaining({
82
+ state: 'invalid',
83
+ detail: expect.stringContaining('.awm/context/index.json is missing'),
84
+ }));
85
+ });
86
+ it('accepts the canonical fixture', () => {
87
+ writeValidKernel();
88
+ expect(inspect()(root)).toEqual(expect.objectContaining({ state: 'valid', schema: 1 }));
89
+ });
90
+ it.each([
91
+ ['unknown top-level field', (x) => { x.extra = true; }],
92
+ ['future schema', (x) => { x.schema = 2; }],
93
+ ['duplicate id', (x) => { x.entries[1].id = x.entries[0].id; }],
94
+ ['duplicate anchor', (x) => { x.entries[1].anchor = x.entries[0].anchor; }],
95
+ ['absolute path', (x) => { x.entries[1].path = '/tmp/outside.md'; }],
96
+ ['traversal', (x) => { x.entries[1].path = '../outside.md'; }],
97
+ ['empty when', (x) => { x.entries[1].when = ''; }],
98
+ ['fixed byte cap', (x) => { x.maxFixedBytes = 1; }],
99
+ ])('rejects %s', (_name, mutate) => {
100
+ const index = writeValidKernel();
101
+ mutate(index);
102
+ writeIndex(index);
103
+ expect(inspect()(root)).toEqual(expect.objectContaining({ state: 'invalid' }));
104
+ });
105
+ it('rejects a project path that resolves through a symlink to an external regular file', () => {
106
+ const index = writeValidKernel();
107
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-outside-'));
108
+ try {
109
+ const card = path_1.default.join(root, index.entries[1].path);
110
+ const externalCard = path_1.default.join(outside, 'card.md');
111
+ fs_1.default.writeFileSync(externalCard, '<!-- awm-context:CTX-RELEASE-001 -->\nexternal release rules\n');
112
+ fs_1.default.rmSync(card);
113
+ fs_1.default.symlinkSync(externalCard, card);
114
+ expect(fs_1.default.statSync(card).isFile()).toBe(true);
115
+ expect(fs_1.default.realpathSync(card)).toBe(externalCard);
116
+ expect(inspect()(root)).toEqual(expect.objectContaining({
117
+ state: 'invalid',
118
+ detail: expect.stringContaining('resolves outside the project root'),
119
+ }));
120
+ }
121
+ finally {
122
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
123
+ }
124
+ });
125
+ it('rejects a dangling index symlink instead of treating it as legacy', () => {
126
+ const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-outside-'));
127
+ try {
128
+ fs_1.default.mkdirSync(path_1.default.dirname(indexPath()), { recursive: true });
129
+ fs_1.default.symlinkSync(path_1.default.join(outside, 'index.json'), indexPath());
130
+ expect(inspect()(root)).toEqual(expect.objectContaining({ state: 'invalid' }));
131
+ }
132
+ finally {
133
+ fs_1.default.rmSync(outside, { recursive: true, force: true });
134
+ }
135
+ });
136
+ it.each(['start marker missing', 'end marker duplicated', 'anchor outside region', 'anchor duplicated'])('rejects %s', (mutation) => {
137
+ writeValidKernel();
138
+ mutateFixture(mutation);
139
+ expect(inspect()(root)).toEqual(expect.objectContaining({ state: 'invalid' }));
140
+ });
141
+ it('throws for an invalid public cwd', () => {
142
+ expect(() => inspect()(path_1.default.join(root, 'missing'))).toThrow(/cwd.*directory/);
143
+ });
144
+ });
@@ -79,7 +79,7 @@ describe('syncRegistries (git fixtures locales)', () => {
79
79
  const source = makeSourceRepo(tmpWork, 'alpha');
80
80
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
81
81
  const results = await m.syncRegistries();
82
- expect(results).toEqual([{ name: 'personal', action: 'recloned', version: 'HEAD' }]);
82
+ expect(results).toEqual([{ name: 'personal', action: 'recloned', version: 'HEAD', disposition: 'installed' }]);
83
83
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'))).toBe(true);
84
84
  });
85
85
  it('pulls an existing clone and reports non-fatal errors per registry', async () => {
@@ -95,7 +95,7 @@ describe('syncRegistries (git fixtures locales)', () => {
95
95
  GIT(source, 'add -A');
96
96
  GIT(source, 'commit -qm v2');
97
97
  const results = await m.syncRegistries();
98
- expect(results[0]).toEqual({ name: 'personal', action: 'pulled', version: 'HEAD' });
98
+ expect(results[0]).toEqual({ name: 'personal', action: 'pulled', version: 'HEAD', disposition: 'resolved' });
99
99
  expect(results[1].name).toBe('broken');
100
100
  expect(results[1].action).toBe('error');
101
101
  const synced = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'), 'utf-8');
@@ -127,7 +127,7 @@ describe('syncRegistries (git fixtures locales)', () => {
127
127
  GIT(source, 'commit -qm unreleased');
128
128
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
129
129
  const results = await m.syncRegistries();
130
- expect(results).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.0.0' }]);
130
+ expect(results).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.0.0', disposition: 'installed', availableVersion: 'v1.0.0' }]);
131
131
  const synced = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'), 'utf-8');
132
132
  expect(synced).toContain('test skill'); // contenido del tag, no del HEAD
133
133
  });
@@ -157,7 +157,9 @@ describe('syncRegistries (git fixtures locales)', () => {
157
157
  fs_1.default.mkdirSync(awmDir, { recursive: true });
158
158
  fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '1.0.0' } }));
159
159
  const results = await m.syncRegistries();
160
- expect(results).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.0.0' }]);
160
+ expect(results).toEqual([{
161
+ name: 'personal', action: 'recloned', version: 'v1.0.0', disposition: 'installed', pin: '1.0.0', availableVersion: 'v1.1.0',
162
+ }]);
161
163
  });
162
164
  it('baseline sembrado se sincroniza por el mismo loop que cualquier registry', async () => {
163
165
  const m = require('../../src/core/registries');
@@ -167,7 +169,7 @@ describe('syncRegistries (git fixtures locales)', () => {
167
169
  m.seedBaselineRegistry();
168
170
  delete process.env.AWM_BASE_REMOTE;
169
171
  const results = await m.syncRegistries();
170
- expect(results).toEqual([{ name: 'baseline', action: 'recloned', version: 'v1.0.0' }]);
172
+ expect(results).toEqual([{ name: 'baseline', action: 'recloned', version: 'v1.0.0', disposition: 'installed', availableVersion: 'v1.0.0' }]);
171
173
  expect(fs_1.default.existsSync(path_1.default.join(tmpHome, '.awm/registries/baseline/skills/alpha/SKILL.md'))).toBe(true);
172
174
  });
173
175
  it('verifyMinCliVersions reporta registries que exigen CLI más nuevo', async () => {
@@ -193,7 +195,7 @@ describe('syncRegistries (git fixtures locales)', () => {
193
195
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
194
196
  // primera sync: queda en v1.0.0
195
197
  const r1 = await m.syncRegistries();
196
- expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.0.0' }]);
198
+ expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.0.0', disposition: 'installed', availableVersion: 'v1.0.0' }]);
197
199
  // nueva release en el remote
198
200
  fs_1.default.writeFileSync(path_1.default.join(source, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: v2\n---\n');
199
201
  GIT(source, 'add -A');
@@ -201,9 +203,15 @@ describe('syncRegistries (git fixtures locales)', () => {
201
203
  GIT(source, 'tag v1.1.0');
202
204
  // segunda sync: avanza al nuevo tag
203
205
  const r2 = await m.syncRegistries();
204
- expect(r2).toEqual([{ name: 'personal', action: 'pulled', version: 'v1.1.0' }]);
206
+ expect(r2).toEqual([{
207
+ name: 'personal', action: 'pulled', version: 'v1.1.0', disposition: 'advanced', previousVersion: 'v1.0.0', availableVersion: 'v1.1.0',
208
+ }]);
205
209
  const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'), 'utf-8');
206
210
  expect(content).toContain('v2');
211
+ const r3 = await m.syncRegistries();
212
+ expect(r3).toEqual([{
213
+ name: 'personal', action: 'pulled', version: 'v1.1.0', disposition: 'unchanged', previousVersion: 'v1.1.0', availableVersion: 'v1.1.0',
214
+ }]);
207
215
  });
208
216
  it('rollback tag→tag: clone en v1.1.0 retrocede a v1.0.0 al establecer pin', async () => {
209
217
  const m = require('../../src/core/registries');
@@ -216,13 +224,21 @@ describe('syncRegistries (git fixtures locales)', () => {
216
224
  m.writeRegistriesConfig([{ name: 'personal', remote: source }]);
217
225
  // primera sync sin pin: queda en v1.1.0
218
226
  const r1 = await m.syncRegistries();
219
- expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.1.0' }]);
227
+ expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'v1.1.0', disposition: 'installed', availableVersion: 'v1.1.0' }]);
220
228
  // establece pin a la versión anterior
221
229
  const awmDir = path_1.default.join(tmpHome, '.awm');
222
230
  fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', pins: { personal: '1.0.0' } }));
223
231
  // segunda sync con pin: retrocede a v1.0.0
224
232
  const r2 = await m.syncRegistries();
225
- expect(r2).toEqual([{ name: 'personal', action: 'pulled', version: 'v1.0.0' }]);
233
+ expect(r2).toEqual([{
234
+ name: 'personal',
235
+ action: 'pulled',
236
+ version: 'v1.0.0',
237
+ disposition: 'regressed',
238
+ pin: '1.0.0',
239
+ previousVersion: 'v1.1.0',
240
+ availableVersion: 'v1.1.0',
241
+ }]);
226
242
  const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'), 'utf-8');
227
243
  expect(content).toContain('test skill'); // contenido original del tag v1.0.0
228
244
  });
@@ -236,14 +252,16 @@ describe('syncRegistries (git fixtures locales)', () => {
236
252
  fs_1.default.writeFileSync(path_1.default.join(awmDir, 'preferences.json'), JSON.stringify({ defaultAgent: 'claude-code', installMethod: 'symlink', defaultScope: 'local', channel: 'dev' }));
237
253
  // primera sync en canal dev: queda en HEAD (no en el tag)
238
254
  const r1 = await m.syncRegistries();
239
- expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'HEAD' }]);
255
+ expect(r1).toEqual([{ name: 'personal', action: 'recloned', version: 'HEAD', disposition: 'installed' }]);
240
256
  // nuevo commit en el remote (post-tag, no release)
241
257
  fs_1.default.writeFileSync(path_1.default.join(source, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\ndescription: head-2\n---\n');
242
258
  GIT(source, 'add -A');
243
259
  GIT(source, 'commit -qm head-2');
244
260
  // segunda sync: recibe el nuevo commit
245
261
  const r2 = await m.syncRegistries();
246
- expect(r2).toEqual([{ name: 'personal', action: 'pulled', version: 'HEAD' }]);
262
+ expect(r2).toEqual([{
263
+ name: 'personal', action: 'pulled', version: 'HEAD', disposition: 'resolved', previousVersion: 'v1.0.0',
264
+ }]);
247
265
  const content = fs_1.default.readFileSync(path_1.default.join(tmpHome, '.awm/registries/personal/skills/alpha/SKILL.md'), 'utf-8');
248
266
  expect(content).toContain('head-2');
249
267
  });
@@ -99,4 +99,32 @@ describe('registry manifest (awm-registry.json)', () => {
99
99
  const { readRegistryManifest } = load();
100
100
  expect(() => readRegistryManifest(tmpWork)).toThrow(/minCliVersion/);
101
101
  });
102
+ it('reads projectContextSchema 1 without changing legacy manifests', () => {
103
+ writeManifest({ minCliVersion: '9.2.1', projectContextSchema: 1 });
104
+ const { readRegistryManifest } = load();
105
+ expect(readRegistryManifest(tmpWork)).toEqual({
106
+ overrides: new Set(), minCliVersion: '9.2.1', projectContextSchema: 1,
107
+ });
108
+ writeManifest({ minCliVersion: '9.2.1' });
109
+ expect(readRegistryManifest(tmpWork).projectContextSchema).toBeUndefined();
110
+ });
111
+ it.each([0, 2, -1, 1.5, '1', null])('rejects unsupported project context schema %p', (value) => {
112
+ writeManifest({ projectContextSchema: value });
113
+ const { readRegistryManifest } = load();
114
+ expect(() => readRegistryManifest(tmpWork)).toThrow(/projectContextSchema.*exactly 1/);
115
+ });
116
+ it('resolves the first usable Context Kernel declaration and retains malformed manifest diagnostics', () => {
117
+ const { registriesDir, writeRegistriesConfig, resolveProjectContextSchema } = load();
118
+ writeRegistriesConfig([
119
+ { name: 'broken', remote: 'https://example.invalid/broken.git' },
120
+ { name: 'active', remote: 'https://example.invalid/active.git' },
121
+ ]);
122
+ for (const name of ['broken', 'active'])
123
+ fs_1.default.mkdirSync(path_1.default.join(registriesDir(), name, 'skills'), { recursive: true });
124
+ fs_1.default.writeFileSync(path_1.default.join(registriesDir(), 'broken', 'awm-registry.json'), JSON.stringify({ projectContextSchema: 2 }));
125
+ fs_1.default.writeFileSync(path_1.default.join(registriesDir(), 'active', 'awm-registry.json'), JSON.stringify({ projectContextSchema: 1 }));
126
+ const result = resolveProjectContextSchema();
127
+ expect(result.declaration).toEqual(expect.objectContaining({ registry: expect.objectContaining({ name: 'active' }), schema: 1 }));
128
+ expect(result.diagnostics).toEqual([expect.stringMatching(/broken.*projectContextSchema.*exactly 1/)]);
129
+ });
102
130
  });
@@ -62,16 +62,16 @@ describe('versioning core', () => {
62
62
  const clone = cloneOf(source, tmpWork, 'clone');
63
63
  const { resolveTargetRef } = require('../../src/core/versioning');
64
64
  const r = await resolveTargetRef(clone, { channel: 'stable' });
65
- expect(r).toEqual({ kind: 'tag', ref: 'v1.10.0', version: '1.10.0' });
65
+ expect(r).toEqual({ kind: 'tag', ref: 'v1.10.0', version: '1.10.0', latestVersion: '1.10.0' });
66
66
  });
67
67
  it('pin exacto gana, con y sin prefijo v', async () => {
68
68
  const source = makeTaggedRepo(tmpWork, 'src', ['1.0.0', '1.1.0']);
69
69
  const clone = cloneOf(source, tmpWork, 'clone');
70
70
  const { resolveTargetRef } = require('../../src/core/versioning');
71
71
  expect(await resolveTargetRef(clone, { pin: '1.0.0', channel: 'stable' }))
72
- .toEqual({ kind: 'tag', ref: 'v1.0.0', version: '1.0.0' });
72
+ .toEqual({ kind: 'tag', ref: 'v1.0.0', version: '1.0.0', latestVersion: '1.1.0' });
73
73
  expect(await resolveTargetRef(clone, { pin: 'v1.0.0', channel: 'stable' }))
74
- .toEqual({ kind: 'tag', ref: 'v1.0.0', version: '1.0.0' });
74
+ .toEqual({ kind: 'tag', ref: 'v1.0.0', version: '1.0.0', latestVersion: '1.1.0' });
75
75
  });
76
76
  it('pin inexistente → error que lista las versiones disponibles', async () => {
77
77
  const source = makeTaggedRepo(tmpWork, 'src', ['1.0.0']);
@@ -102,7 +102,7 @@ describe('versioning core', () => {
102
102
  const clone = cloneOf(source, tmpWork, 'clone');
103
103
  const { resolveTargetRef } = require('../../src/core/versioning');
104
104
  const r = await resolveTargetRef(clone, { channel: 'stable' });
105
- expect(r).toEqual({ kind: 'tag', ref: 'v1.0.0', version: '1.0.0' });
105
+ expect(r).toEqual({ kind: 'tag', ref: 'v1.0.0', version: '1.0.0', latestVersion: '1.0.0' });
106
106
  });
107
107
  it('hace fetch: ve tags creados en el remote después del clone', async () => {
108
108
  const source = makeTaggedRepo(tmpWork, 'src', ['1.0.0']);
@@ -114,7 +114,7 @@ describe('versioning core', () => {
114
114
  GIT(source, 'tag v1.1.0');
115
115
  const { resolveTargetRef } = require('../../src/core/versioning');
116
116
  const r = await resolveTargetRef(clone, { channel: 'stable' });
117
- expect(r).toEqual({ kind: 'tag', ref: 'v1.1.0', version: '1.1.0' });
117
+ expect(r).toEqual({ kind: 'tag', ref: 'v1.1.0', version: '1.1.0', latestVersion: '1.1.0' });
118
118
  });
119
119
  });
120
120
  describe('currentVersion', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.2.0",
3
+ "version": "9.3.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
@@ -55,7 +55,7 @@
55
55
  "@types/js-yaml": "4.0.9",
56
56
  "@types/node": "^25.3.0",
57
57
  "@types/semver": "^7.7.1",
58
- "dependency-cruiser": "^17.4.3",
58
+ "dependency-cruiser": "16.10.4",
59
59
  "eslint": "10.8.1",
60
60
  "jest": "^30.2.0",
61
61
  "js-yaml": "4.1.0",