agentic-workflow-manager 9.21.0 → 9.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/src/commands/change/index.js +16 -5
  2. package/dist/src/commands/init.js +8 -4
  3. package/dist/src/commands/preflight/checks.js +22 -0
  4. package/dist/src/commands/registry/add.js +9 -3
  5. package/dist/src/commands/sensors/compatibility/contract.js +56 -3
  6. package/dist/src/commands/sensors/compatibility/manifest.js +16 -2
  7. package/dist/src/commands/sensors/index.js +10 -1
  8. package/dist/src/commands/sensors/inputs-digest.js +315 -0
  9. package/dist/src/commands/sensors/prepare.js +10 -0
  10. package/dist/src/commands/sensors/result-cache.js +278 -0
  11. package/dist/src/commands/sensors/result.js +31 -12
  12. package/dist/src/commands/sensors/run.js +265 -8
  13. package/dist/src/commands/sensors/status.js +66 -7
  14. package/dist/src/commands/sync.js +11 -5
  15. package/dist/src/commands/update.js +23 -12
  16. package/dist/src/core/changes/derive.js +6 -3
  17. package/dist/src/core/changes/git.js +1 -2
  18. package/dist/src/core/changes/receipt.js +219 -9
  19. package/dist/src/core/changes/worktree.js +15 -3
  20. package/dist/src/core/init/steps.js +3 -8
  21. package/dist/src/core/registries.js +20 -15
  22. package/dist/src/core/registry-git-errors.js +107 -0
  23. package/dist/src/core/registry-git.js +93 -0
  24. package/dist/src/core/registry-sync-report.js +74 -0
  25. package/dist/src/core/versioning.js +3 -6
  26. package/dist/src/index.js +5 -8
  27. package/dist/tests/commands/change/new.test.js +104 -58
  28. package/dist/tests/commands/change/validate-unmeasured.test.js +246 -0
  29. package/dist/tests/commands/change/validate.test.js +164 -108
  30. package/dist/tests/commands/changes/list.test.js +41 -22
  31. package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
  32. package/dist/tests/commands/preflight/sensors-inputs.test.js +110 -0
  33. package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
  34. package/dist/tests/commands/sensors/compatibility/contract.test.js +60 -0
  35. package/dist/tests/commands/sensors/compatibility/manifest.test.js +94 -0
  36. package/dist/tests/commands/sensors/exec.test.js +3 -1
  37. package/dist/tests/commands/sensors/inputs-digest.test.js +373 -0
  38. package/dist/tests/commands/sensors/result-cache.test.js +352 -0
  39. package/dist/tests/commands/sensors/run-cache.test.js +434 -0
  40. package/dist/tests/commands/sensors/run-slice.test.js +171 -0
  41. package/dist/tests/commands/sensors/status-inputs.test.js +271 -0
  42. package/dist/tests/commands/update.test.js +8 -8
  43. package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
  44. package/dist/tests/core/changes/helpers.js +15 -3
  45. package/dist/tests/core/changes/list-paging.test.js +131 -110
  46. package/dist/tests/core/changes/list.test.js +133 -83
  47. package/dist/tests/core/changes/receipt.test.js +279 -8
  48. package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
  49. package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
  50. package/dist/tests/core/registry-git.test.js +153 -0
  51. package/dist/tests/helpers/commit-seeded-registry.js +18 -0
  52. package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
  53. package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
  54. package/dist/tests/structural/active-documentation.test.js +0 -41
  55. package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
  56. package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
  57. package/dist/tests/structural/native-release-artifacts.test.js +116 -107
  58. package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
  59. package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
  60. package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
  61. package/package.json +3 -2
  62. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  63. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  64. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  65. package/prebuilds/win32-x64/secure_fs.node +0 -0
  66. package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
  67. package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
  68. package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
  69. package/dist/tests/structural/process-model-description-contract.test.js +0 -34
  70. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
  71. package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
@@ -9,6 +9,8 @@ exports.detectPackDrift = detectPackDrift;
9
9
  exports.shouldRun = shouldRun;
10
10
  exports.resolveConcurrency = resolveConcurrency;
11
11
  exports.runSensors = runSensors;
12
+ const crypto_1 = __importDefault(require("crypto"));
13
+ const fs_1 = __importDefault(require("fs"));
12
14
  const os_1 = __importDefault(require("os"));
13
15
  const path_1 = __importDefault(require("path"));
14
16
  const baseline_1 = require("./baseline");
@@ -28,11 +30,205 @@ const prepare_1 = require("./prepare");
28
30
  const verdict_1 = require("./verdict");
29
31
  const project_1 = require("./project");
30
32
  const read_only_observation_1 = require("./read-only-observation");
33
+ const inputs_digest_1 = require("./inputs-digest");
34
+ const result_cache_1 = require("./result-cache");
35
+ const cli_version_1 = require("../../core/cli-version");
36
+ const worktree_1 = require("../../core/changes/worktree");
37
+ const git_1 = require("../../core/changes/git");
31
38
  // Sensor JSON output can be several MB on large repos (e.g. `eslint --format json`
32
39
  // with thousands of findings). A 1MB cap killed the child with SIGTERM when
33
40
  // exceeded — which previously surfaced as a false "timeout".
34
41
  /** Hard ceiling on parallel sensors: past this, they only contend for the same cores. */
35
42
  const MAX_CONCURRENCY = 4;
43
+ /** Relative POSIX under gitRoot; realpath both sides (Windows-safe). Empty when same path. */
44
+ function posixJoinRel(root, abs) {
45
+ return (0, inputs_digest_1.toSafeRepoRel)(root, abs);
46
+ }
47
+ function effectiveClosureOnly(projectOrPack, liveSensor) {
48
+ if (projectOrPack && projectOrPack.closureOnly !== undefined)
49
+ return projectOrPack.closureOnly;
50
+ if (liveSensor && liveSensor.closureOnly !== undefined)
51
+ return liveSensor.closureOnly;
52
+ return false;
53
+ }
54
+ function effectiveInputs(manifestInputs, variant) {
55
+ if (manifestInputs && manifestInputs.length > 0)
56
+ return manifestInputs;
57
+ if (variant?.inputs && variant.inputs.length > 0)
58
+ return variant.inputs;
59
+ return undefined;
60
+ }
61
+ function includePackageJsonForVariant(variant) {
62
+ if (!variant)
63
+ return false;
64
+ if (variant.command.packageManager)
65
+ return true;
66
+ if (variant.requirements.packageJsonFields && variant.requirements.packageJsonFields.length > 0)
67
+ return true;
68
+ if (variant.probe.kind === 'package-script-present')
69
+ return true;
70
+ return false;
71
+ }
72
+ function sliceSkipExecution(name, requestedScope) {
73
+ return {
74
+ name,
75
+ timeoutMs: 1,
76
+ timeoutSource: 'fallback',
77
+ requestedScope,
78
+ effectiveScope: 'full',
79
+ syntheticStatus: 'skipped',
80
+ syntheticReason: 'closureOnly: skipped by --slice',
81
+ };
82
+ }
83
+ function buildCachePlan(args) {
84
+ const toolVersion = args.liveState?.toolVersion;
85
+ const runtimeVersion = args.liveState?.runtimeVersion;
86
+ const command = args.variant?.command ?? args.command ?? null;
87
+ const fingerprintParts = {
88
+ variantId: args.liveState?.variantId ?? args.variant?.id ?? null,
89
+ command,
90
+ changedCommand: args.variant?.changedCommand,
91
+ cliVersion: (0, cli_version_1.cliVersion)(),
92
+ inputs: args.inputGlobs,
93
+ toolVersion,
94
+ runtimeVersion,
95
+ };
96
+ // Cache requires live versions (RF-2.10). Receipt measurements still need a fingerprint
97
+ // when versions are absent so project-declared sensors with inputs do not demote RF-4.6.
98
+ const cacheFingerprint = (0, result_cache_1.computeSensorFingerprint)(fingerprintParts);
99
+ const fingerprint = cacheFingerprint
100
+ ?? (0, result_cache_1.computeSensorFingerprint)(fingerprintParts, { allowMissingLiveVersions: true });
101
+ if (fingerprint === null)
102
+ return null;
103
+ const packageRootRel = posixJoinRel(args.gitRoot, args.packageRoot);
104
+ const sensorsJsonRel = posixJoinRel(args.gitRoot, args.manifestPath);
105
+ const includePackageJson = args.variant
106
+ ? includePackageJsonForVariant(args.variant)
107
+ : Boolean(args.command?.packageManager);
108
+ const alwaysMeasuredRepoRels = (0, inputs_digest_1.buildAlwaysMeasuredPaths)({
109
+ sensorsJsonRel,
110
+ assetRels: args.assets ?? args.variant?.assets ?? [],
111
+ configFileRels: args.variant?.requirements.configFiles ?? [],
112
+ includePackageJson,
113
+ packageRootRel: packageRootRel === '' ? '' : packageRootRel,
114
+ });
115
+ const digest = (0, inputs_digest_1.computeCacheInputDigest)({
116
+ gitRoot: args.gitRoot,
117
+ packageRoot: args.packageRoot,
118
+ inputGlobs: args.inputGlobs,
119
+ alwaysMeasuredRepoRels,
120
+ });
121
+ if (!digest.ok)
122
+ return null;
123
+ const projectKey = `sha256:${crypto_1.default.createHash('sha256').update(`${args.manifestPath}\0${args.packageRoot}`).digest('hex')}`;
124
+ // Baseline is excluded from the cache digest but required for validate (RF-2.12 / RF-4.5).
125
+ // Derive from the already-safe packageRootRel — do NOT posixJoinRel an absolute path to a
126
+ // often-missing baseline file (realpath falls back → Windows path.relative goes absolute).
127
+ const baselineForValidate = packageRootRel === ''
128
+ ? inputs_digest_1.CACHE_DIGEST_BASELINE_REL
129
+ : (0, inputs_digest_1.joinRepoRel)(packageRootRel, inputs_digest_1.CACHE_DIGEST_BASELINE_REL);
130
+ const validateAlwaysMeasuredRepoRels = [...new Set([
131
+ ...alwaysMeasuredRepoRels,
132
+ baselineForValidate,
133
+ ])].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
134
+ const rootRel = packageRootRel === '' ? '' : packageRootRel;
135
+ return {
136
+ gitRoot: args.gitRoot,
137
+ projectKey,
138
+ sensorFingerprint: fingerprint,
139
+ inputDigest: digest.digest,
140
+ inputGlobs: args.inputGlobs,
141
+ packageRootRel: rootRel,
142
+ alwaysMeasuredRepoRels,
143
+ measuredPaths: digest.matchedPaths,
144
+ validateAlwaysMeasuredRepoRels,
145
+ cacheable: cacheFingerprint !== null,
146
+ };
147
+ }
148
+ function measurementFromPlan(plan) {
149
+ return {
150
+ inputs: [...plan.inputGlobs],
151
+ packageRootRel: plan.packageRootRel,
152
+ inputDigest: plan.inputDigest,
153
+ sensorFingerprint: plan.sensorFingerprint,
154
+ measuredPaths: [...plan.measuredPaths],
155
+ alwaysMeasuredPaths: [...plan.validateAlwaysMeasuredRepoRels],
156
+ };
157
+ }
158
+ async function runPreparedSlot(slot, projectCwd, baseline, cacheEnabled, writeCache) {
159
+ const { execution, cachePlan } = slot;
160
+ // RF-2.2/2.6/6.1: synthetic skip/disabled/inconclusive prepare results must not
161
+ // be overridden by a prior cache hit.
162
+ const synthetic = execution.syntheticStatus !== undefined;
163
+ // RF-2.10: cacheable is false without live versions — still attach measurements below.
164
+ if (cacheEnabled && cachePlan?.cacheable && !synthetic) {
165
+ const cached = (0, result_cache_1.readResultCache)(cachePlan.gitRoot, {
166
+ sensorId: execution.name,
167
+ projectKey: cachePlan.projectKey,
168
+ sensorFingerprint: cachePlan.sensorFingerprint,
169
+ inputDigest: cachePlan.inputDigest,
170
+ });
171
+ if (cached) {
172
+ const raw = {
173
+ name: cached.raw.name,
174
+ status: cached.raw.status,
175
+ errors: cached.raw.errors,
176
+ ...(cached.raw.failureKind ? { failureKind: cached.raw.failureKind } : {}),
177
+ ...(cached.raw.certification ? { certification: cached.raw.certification } : {}),
178
+ ...(cached.raw.skipReason ? { skipReason: cached.raw.skipReason } : {}),
179
+ cached: true,
180
+ cacheProvenance: {
181
+ ...(cached.originCommit ? { originCommit: cached.originCommit } : {}),
182
+ originDirty: cached.originDirty,
183
+ inputDigest: cached.inputDigest,
184
+ sensorFingerprint: cached.sensorFingerprint,
185
+ },
186
+ measurement: measurementFromPlan(cachePlan),
187
+ execution: {
188
+ timeoutMs: execution.timeoutMs,
189
+ timeoutSource: execution.timeoutSource,
190
+ elapsedMs: 0,
191
+ requestedScope: execution.requestedScope,
192
+ effectiveScope: execution.effectiveScope,
193
+ },
194
+ };
195
+ return baseline ? (0, result_1.applyBaseline)(raw, baseline[execution.name]) : raw;
196
+ }
197
+ }
198
+ const raw = await (0, result_1.executePrepared)(execution, projectCwd);
199
+ if (cacheEnabled && writeCache && cachePlan?.cacheable && !synthetic && (0, result_cache_1.canCacheRawResult)(raw) && execution.requestedScope === 'full') {
200
+ const after = (0, inputs_digest_1.computeCacheInputDigest)({
201
+ gitRoot: cachePlan.gitRoot,
202
+ packageRoot: projectCwd,
203
+ inputGlobs: cachePlan.inputGlobs,
204
+ alwaysMeasuredRepoRels: cachePlan.alwaysMeasuredRepoRels,
205
+ });
206
+ if (after.ok && after.digest === cachePlan.inputDigest) {
207
+ const state = (0, worktree_1.gitState)(cachePlan.gitRoot, true);
208
+ const originCommit = state.kind === 'ok' ? state.commit : null;
209
+ const originDirty = state.kind === 'ok' ? state.dirty : true;
210
+ try {
211
+ (0, result_cache_1.writeResultCache)(cachePlan.gitRoot, {
212
+ schema: 1,
213
+ sensorId: execution.name,
214
+ projectKey: cachePlan.projectKey,
215
+ sensorFingerprint: cachePlan.sensorFingerprint,
216
+ inputDigest: cachePlan.inputDigest,
217
+ originCommit,
218
+ originDirty,
219
+ raw: (0, result_cache_1.toCacheRaw)(raw),
220
+ });
221
+ }
222
+ catch {
223
+ // Cache write is best-effort beside the verdict; never fail the run.
224
+ }
225
+ }
226
+ }
227
+ const withMeasurement = cachePlan
228
+ ? { ...raw, measurement: measurementFromPlan(cachePlan) }
229
+ : raw;
230
+ return baseline ? (0, result_1.applyBaseline)(withMeasurement, baseline[execution.name]) : withMeasurement;
231
+ }
36
232
  async function resolveLiveFromSource(cwd, source, packSelection) {
37
233
  try {
38
234
  const parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
@@ -215,6 +411,22 @@ async function runSensors(opts = {}) {
215
411
  const drift = parsed.kind === 'legacy' ? detectPackDrift(manifestDir, parsed.pack) : undefined;
216
412
  const requestedScope = opts.changed ? 'changed' : 'full';
217
413
  const prepared = [];
414
+ const cacheEnabled = opts.useCache === true && !opts.ignoreBaseline && !opts.readOnly;
415
+ const writeCache = cacheEnabled && requestedScope === 'full';
416
+ // Realpath so Windows git toplevel / Node path forms agree before cache digests.
417
+ const gitRootRaw = (0, git_1.repoRoot)(manifestDir) ?? manifestDir;
418
+ let gitRoot = gitRootRaw;
419
+ try {
420
+ gitRoot = fs_1.default.realpathSync.native(path_1.default.resolve(gitRootRaw));
421
+ }
422
+ catch {
423
+ try {
424
+ gitRoot = fs_1.default.realpathSync(path_1.default.resolve(gitRootRaw));
425
+ }
426
+ catch {
427
+ gitRoot = path_1.default.resolve(gitRootRaw);
428
+ }
429
+ }
218
430
  const activeManifest = parsed.pack;
219
431
  const sensorsMap = activeManifest.sensors ?? {};
220
432
  const packSelected = projectSensorsManifest !== null && hasSelectedPack(projectSensorsManifest);
@@ -240,6 +452,10 @@ async function runSensors(opts = {}) {
240
452
  const fast = projectEntry.fast ?? false;
241
453
  if (!shouldRun(fast, opts))
242
454
  continue;
455
+ if (opts.slice && effectiveClosureOnly(projectEntry, undefined)) {
456
+ prepared.push({ execution: sliceSkipExecution(name, requestedScope), cachePlan: null });
457
+ continue;
458
+ }
243
459
  const markers = [
244
460
  ...(projectEntry.applicability?.allFiles ?? []),
245
461
  ...(projectEntry.applicability?.anyFiles ?? []),
@@ -255,9 +471,27 @@ async function runSensors(opts = {}) {
255
471
  projectTimeout: projectEntry.timeout,
256
472
  applicabilityPaths,
257
473
  });
258
- prepared.push(projectEntry.enabled === false
474
+ const disabled = projectEntry.enabled === false
259
475
  ? { ...execution, command: undefined, syntheticStatus: 'skipped', syntheticReason: 'disabled' }
260
- : execution);
476
+ : execution;
477
+ // Project-declared sensors have no live tool/runtime versions → never cache (RF-2.10),
478
+ // but still attach digests/measurements when authored inputs exist (RF-4.6).
479
+ let projectPlan = null;
480
+ const projectInputs = effectiveInputs(projectEntry.inputs, undefined);
481
+ if (projectInputs) {
482
+ projectPlan = buildCachePlan({
483
+ gitRoot,
484
+ packageRoot: projectCwd,
485
+ manifestPath: configured.manifestPath,
486
+ sensorId: name,
487
+ inputGlobs: projectInputs,
488
+ variant: undefined,
489
+ liveState: undefined,
490
+ assets: projectEntry.assets,
491
+ command: projectEntry.command,
492
+ });
493
+ }
494
+ prepared.push({ execution: disabled, cachePlan: projectPlan });
261
495
  continue;
262
496
  }
263
497
  const liveSensor = parsed.kind !== 'legacy' ? live?.pack.sensors[name] : undefined;
@@ -294,6 +528,10 @@ async function runSensors(opts = {}) {
294
528
  : entry.fast ?? false;
295
529
  if (!shouldRun(fast, opts))
296
530
  continue;
531
+ if (opts.slice && effectiveClosureOnly(packBoundSensor, liveSensor)) {
532
+ prepared.push({ execution: sliceSkipExecution(name, requestedScope), cachePlan: null });
533
+ continue;
534
+ }
297
535
  const execution = parsed.kind !== 'legacy'
298
536
  ? (0, prepare_1.prepareV2Sensor)({
299
537
  name,
@@ -309,14 +547,33 @@ async function runSensors(opts = {}) {
309
547
  requestedScope,
310
548
  changed: changed ?? undefined,
311
549
  });
312
- prepared.push(packBoundSensor.enabled === false
550
+ const ready = packBoundSensor.enabled === false
313
551
  ? { ...execution, command: undefined, syntheticStatus: 'skipped', syntheticReason: 'disabled' }
314
- : execution);
552
+ : execution;
553
+ // Measurement (and optional cache) when the sensor has authored inputs — even under
554
+ // `--no-cache`, so schema-2 receipts still carry digests (RF-4.3 / RF-2.9).
555
+ let cachePlan = null;
556
+ if (parsed.kind !== 'legacy') {
557
+ const variantId = packBoundSensor.variantId;
558
+ const variant = liveSensor?.variants.find((candidate) => candidate.id === variantId)
559
+ ?? liveSensor?.variants.find((candidate) => candidate.id === liveState?.variantId);
560
+ const inputs = effectiveInputs(packBoundSensor.inputs, variant);
561
+ if (inputs) {
562
+ cachePlan = buildCachePlan({
563
+ gitRoot,
564
+ packageRoot: projectCwd,
565
+ manifestPath: configured.manifestPath,
566
+ sensorId: name,
567
+ inputGlobs: inputs,
568
+ variant,
569
+ liveState,
570
+ assets: packBoundSensor.assets ?? variant?.assets,
571
+ });
572
+ }
573
+ }
574
+ prepared.push({ execution: ready, cachePlan });
315
575
  }
316
- const results = await pooled(prepared.map(entry => async () => {
317
- const result = await (0, result_1.executePrepared)(entry, projectCwd);
318
- return baseline ? (0, result_1.applyBaseline)(result, baseline[entry.name]) : result;
319
- }), resolveConcurrency(activeManifest, prepared.length));
576
+ const results = await pooled(prepared.map(slot => async () => runPreparedSlot(slot, projectCwd, baseline, cacheEnabled, writeCache)), resolveConcurrency(activeManifest, prepared.length));
320
577
  let overall = (0, verdict_1.reduceVerdict)(results);
321
578
  // Legacy commands retain operational compatibility but their shell-backed
322
579
  // contract cannot certify a green run.
@@ -15,6 +15,61 @@ const project_1 = require("./project");
15
15
  const source_1 = require("./compatibility/source");
16
16
  const registries_1 = require("../../core/registries");
17
17
  const manifest_1 = require("./compatibility/manifest");
18
+ const UNCACHEABLE_DETAIL = 'not cacheable: no effective inputs';
19
+ function hasAuthoredInputs(inputs) {
20
+ return Array.isArray(inputs) && inputs.length > 0;
21
+ }
22
+ /** RF-1 effective inputs: project override ?? pack-variant inputs. */
23
+ function hasEffectiveInputs(manifestInputs, variantInputs) {
24
+ return hasAuthoredInputs(manifestInputs) || hasAuthoredInputs(variantInputs);
25
+ }
26
+ function uncacheableWarning(sensor) {
27
+ return { sensor, detail: UNCACHEABLE_DETAIL };
28
+ }
29
+ function withWarnings(result, warnings) {
30
+ return warnings.length === 0 ? result : { ...result, warnings };
31
+ }
32
+ function variantInputsFromPack(pack, sensorName, variantId) {
33
+ if (!pack || !variantId)
34
+ return undefined;
35
+ const sensor = pack.sensors[sensorName];
36
+ const variant = sensor?.variants.find(candidate => candidate.id === variantId);
37
+ return variant?.inputs;
38
+ }
39
+ function parsePackForInputs(source) {
40
+ try {
41
+ const parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
42
+ return parsed.kind === 'v2' ? parsed.pack : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Enabled v2/v3 sensors without effective inputs → RF-3.4 warnings (never blocks overall). */
49
+ function collectConfiguredInputsWarnings(sensors, pack = null) {
50
+ const warnings = [];
51
+ for (const [name, raw] of Object.entries(sensors)) {
52
+ if (raw.enabled === false)
53
+ continue;
54
+ if ((0, manifest_1.isProjectDeclaredSensor)(raw)) {
55
+ if (!hasAuthoredInputs(raw.inputs)) {
56
+ warnings.push(uncacheableWarning(name));
57
+ }
58
+ continue;
59
+ }
60
+ const packBound = raw;
61
+ if (!hasEffectiveInputs(packBound.inputs, variantInputsFromPack(pack, name, packBound.variantId))) {
62
+ warnings.push(uncacheableWarning(name));
63
+ }
64
+ }
65
+ return warnings;
66
+ }
67
+ /** Legacy v1 sensors never author `inputs` — every enabled entry is uncacheable. */
68
+ function collectLegacyInputsWarnings(sensors) {
69
+ return Object.entries(sensors)
70
+ .filter(([, config]) => config?.enabled !== false)
71
+ .map(([name]) => uncacheableWarning(name));
72
+ }
18
73
  /** First non-flag token after `npx` — the tool the command actually runs. */
19
74
  function npxTool(parts) {
20
75
  for (let i = 1; i < parts.length; i++) {
@@ -356,12 +411,12 @@ async function computeSensorStatus(cwd = process.cwd()) {
356
411
  checks[name] = { ok: false, detail: 'pack-bound sensor requires a nonempty pack' };
357
412
  }
358
413
  }
359
- return {
414
+ return withWarnings({
360
415
  overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
361
416
  pack: null,
362
417
  checks,
363
418
  ...projectMetadata(project, 'project-sensors', 'project-declared-only'),
364
- };
419
+ }, collectConfiguredInputsWarnings(parsed.pack.sensors));
365
420
  }
366
421
  if (parsed.kind === 'v2' || parsed.kind === 'v3') {
367
422
  const parsedPack = parsed.pack;
@@ -378,6 +433,7 @@ async function computeSensorStatus(cwd = process.cwd()) {
378
433
  return sourceFailure(project, packId ?? 'unknown', resolution);
379
434
  const sourceMeta = resolvedSourceMetadata(resolution);
380
435
  const liveIds = livePackSensorIds(resolution.source);
436
+ const packForInputs = parsePackForInputs(resolution.source);
381
437
  const packManifest = packBoundOnly(parsedPack);
382
438
  const checks = {};
383
439
  let compatibility;
@@ -396,7 +452,10 @@ async function computeSensorStatus(cwd = process.cwd()) {
396
452
  : { ok: false, detail };
397
453
  }
398
454
  }
399
- return { overall: 'DEGRADED', pack: packId, checks, ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }) };
455
+ return withWarnings({
456
+ overall: 'DEGRADED', pack: packId, checks,
457
+ ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }),
458
+ }, collectConfiguredInputsWarnings(parsedPack.sensors, packForInputs));
400
459
  }
401
460
  for (const [name, sensor] of Object.entries(parsedPack.sensors)) {
402
461
  if ((0, manifest_1.isProjectDeclaredSensor)(sensor)) {
@@ -408,12 +467,12 @@ async function computeSensorStatus(cwd = process.cwd()) {
408
467
  : staticCompatibilityCheck(packSensor, compatibility[name]) ?? checkStructuredCommand(packSensor.command, packageRoot, packSensor.assets);
409
468
  }
410
469
  const uninitialized = uninitializedSensors(packManifest, compatibility);
411
- return {
470
+ return withWarnings({
412
471
  overall: Object.keys(checks).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
413
472
  pack: packId, checks,
414
473
  ...(uninitialized ? { uninitialized } : {}),
415
474
  ...projectMetadata(project, 'project-sensors', sourceMeta.reason, { ...sourceMeta }),
416
- };
475
+ }, collectConfiguredInputsWarnings(parsedPack.sensors, packForInputs));
417
476
  }
418
477
  const manifest = parsed.pack;
419
478
  const checks = {};
@@ -428,8 +487,8 @@ async function computeSensorStatus(cwd = process.cwd()) {
428
487
  }
429
488
  checks[name] = checkCmd(config.cmd, packageRoot);
430
489
  }
431
- return {
490
+ return withWarnings({
432
491
  overall: Object.keys(manifest.sensors ?? {}).length > 0 && Object.values(checks).every(check => check.ok) ? 'READY' : 'DEGRADED',
433
492
  pack: manifest.pack, checks, ...projectMetadata(project, 'project-sensors', 'legacy-v1'),
434
- };
493
+ }, collectLegacyInputsWarnings(manifest.sensors ?? {}));
435
494
  }
@@ -19,6 +19,7 @@ const picocolors_1 = __importDefault(require("picocolors"));
19
19
  const paths_1 = require("../core/paths");
20
20
  const profile_1 = require("../core/profile");
21
21
  const registries_1 = require("../core/registries");
22
+ const registry_sync_report_1 = require("../core/registry-sync-report");
22
23
  const bundles_1 = require("../core/bundles");
23
24
  const bundle_install_1 = require("../core/bundle-install");
24
25
  const profile_pins_1 = require("../core/profile-pins");
@@ -74,9 +75,14 @@ async function runSyncCore(options, deps = {}) {
74
75
  }
75
76
  const selectedAgents = resolved.targets;
76
77
  const syncResults = await d.syncRegistries();
77
- for (const r of syncResults) {
78
- if (r.action === 'error')
79
- console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
78
+ // SG-7: any sync/clone failure → non-zero exit. Keep later profile sync / link
79
+ // repair when usable content remains (audit A1) — aborting here left healthy
80
+ // registries advanced on disk without reconciling the project tree.
81
+ const syncHadErrors = (0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(syncResults);
82
+ if (syncHadErrors) {
83
+ for (const e of (0, registries_1.registrySyncErrors)(syncResults)) {
84
+ console.error(picocolors_1.default.red(` ✖ registry ${e.name}: ${e.error}`));
85
+ }
80
86
  }
81
87
  try {
82
88
  (0, registries_1.assertSyncedRegistriesUsable)(syncResults);
@@ -126,7 +132,7 @@ async function runSyncCore(options, deps = {}) {
126
132
  reportProjectLinkRepair(d.reconcileProjectSkillLinks(projectRoot, selectedAgents, (0, registries_2.contentRoots)()));
127
133
  if (profile.extensions.length === 0) {
128
134
  console.log(picocolors_1.default.yellow('No extensions in .awm/profile.json — nothing to sync. Use `awm add <bundle>` first.'));
129
- return { code: 0, selectedAgents };
135
+ return { code: syncHadErrors ? 1 : 0, selectedAgents };
130
136
  }
131
137
  const method = options.method === 'copy' ? 'copy' : 'symlink';
132
138
  let result;
@@ -150,5 +156,5 @@ async function runSyncCore(options, deps = {}) {
150
156
  for (const id of result.transactionIds) {
151
157
  console.log(picocolors_1.default.dim(` transaction ${id} — undo with \`awm backup restore ${id}\``));
152
158
  }
153
- return { code: 0, selectedAgents, result };
159
+ return { code: syncHadErrors ? 1 : 0, selectedAgents, result };
154
160
  }
@@ -26,6 +26,7 @@ const paths_1 = require("../core/paths");
26
26
  const config_1 = require("../utils/config");
27
27
  const agent_targets_1 = require("../core/agent-targets");
28
28
  const registries_1 = require("../core/registries");
29
+ const registry_sync_report_1 = require("../core/registry-sync-report");
29
30
  const paths_2 = require("../core/paths");
30
31
  const regenerate_1 = require("../core/context/regenerate");
31
32
  const reconciliation_1 = require("../core/reconciliation");
@@ -59,11 +60,14 @@ function updateOutro(result) {
59
60
  const { registries: reg } = result;
60
61
  if (reg.configured === 0)
61
62
  return 'Nothing updated — no registries configured on this machine.';
62
- if (result.code !== 0)
63
+ // SG-7: sync errors always yield code ≠ 0. When healthy registries still advanced,
64
+ // later stages ran — say so instead of the unreachable pre-SG-7 "stale content / exit 0".
65
+ if (result.code !== 0) {
66
+ if (reg.failed.length > 0 && reg.synced > 0) {
67
+ return (`⚠ Finished with registry sync errors — ${reg.synced}/${reg.configured} registries synced `
68
+ + `(failed: ${reg.failed.join(', ')}); see errors above.`);
69
+ }
63
70
  return 'Update failed — see errors above.';
64
- if (reg.failed.length > 0) {
65
- return `⚠ Updated with stale content — ${reg.synced}/${reg.configured} registries synced, `
66
- + `kept on-disk content for: ${reg.failed.join(', ')}.`;
67
71
  }
68
72
  return `✅ ${reg.synced} ${reg.synced === 1 ? 'registry' : 'registries'}, skills and hooks updated.`;
69
73
  }
@@ -90,8 +94,10 @@ async function runUpdateCore(options = {}, deps = {}) {
90
94
  const selectedAgents = resolved.targets;
91
95
  const registryResults = await d.syncRegistries();
92
96
  for (const r of registryResults) {
93
- if (r.action === 'error')
94
- console.warn(picocolors_1.default.yellow(` ⚠ registry ${r.name}: ${r.error}`));
97
+ if (r.action === 'error') {
98
+ // Printed below as a hard SG-7 failure — skip the stale warn path.
99
+ continue;
100
+ }
95
101
  else if (r.disposition === 'regressed') {
96
102
  const latest = r.availableVersion && r.availableVersion !== r.version
97
103
  ? ` Latest available: ${r.availableVersion}.`
@@ -131,11 +137,16 @@ async function runUpdateCore(options = {}, deps = {}) {
131
137
  console.error(picocolors_1.default.dim(" This machine was never initialized: run 'awm init' first."));
132
138
  return { code: 1, selectedAgents, registries };
133
139
  }
134
- // Falla CERRADO solo cuando el registry quedó sin contenido en disco: ahí lo que sigue
135
- // leería un árbol inexistente y el fallo reaparecería más tarde, disfrazado de otra
136
- // etapa. Un registry que falló pero conserva su contenido queda stale, no roto — se
137
- // sigue (doctrina de `unusableSyncedRegistries`: un registry secundario flaky nunca
138
- // aborta la corrida) y el cierre lo nombra en vez de declarar éxito parejo.
140
+ // SG-7: any sync/clone failure → non-zero exit with classified message.
141
+ // Do NOT abort the later stages early when healthy registries already advanced
142
+ // on disk (audit A1): reconcile / hooks / self-update still run against usable
143
+ // content, then the command exits ≠ 0 naming the failed registry.
144
+ const syncHadErrors = (0, registry_sync_report_1.hardFailOnAnyRegistrySyncError)(registryResults);
145
+ if (syncHadErrors) {
146
+ for (const e of (0, registries_1.registrySyncErrors)(registryResults)) {
147
+ console.error(picocolors_1.default.red(` ✖ registry ${e.name}: ${e.error}`));
148
+ }
149
+ }
139
150
  try {
140
151
  (0, registries_1.assertSyncedRegistriesUsable)(registryResults);
141
152
  }
@@ -188,5 +199,5 @@ async function runUpdateCore(options = {}, deps = {}) {
188
199
  // pasa `undefined` a propósito, para que la decisión la tome `defaultSelfUpdateMode()`
189
200
  // según haya o no un humano en stdin — no este llamador.
190
201
  await d.offerSelfUpdate(options.yes === true ? 'assume-yes' : undefined);
191
- return { code: 0, selectedAgents, registries };
202
+ return { code: syncHadErrors ? 1 : 0, selectedAgents, registries };
192
203
  }
@@ -96,13 +96,16 @@ const fail = (code, message) => ({ ok: false, diagnostic: { code, message: (0, t
96
96
  * conventional commit does), while `semver` and `breaking` cover every commit.
97
97
  * `mergeBaseSha` must be a full commit id (the output of `mergeBase`); anything else is `invalid-input`.
98
98
  */
99
- function deriveFields(root, mergeBaseSha) {
99
+ function deriveFields(root, mergeBaseSha, pinnedHead) {
100
100
  if (typeof root !== 'string' || root.length === 0)
101
101
  return fail('invalid-input', 'repository root must be a non-empty string');
102
102
  if (typeof mergeBaseSha !== 'string' || !types_1.COMMIT_ID.test(mergeBaseSha))
103
103
  return fail('invalid-input', `merge-base must be a full commit id, got "${(0, text_safety_1.esc)(mergeBaseSha)}"`);
104
- // HEAD is read once: the log and the diff below both use this sha, so a HEAD that moves meanwhile cannot mix snapshots.
105
- const head = (0, git_1.headSha)(root);
104
+ if (pinnedHead !== undefined && (typeof pinnedHead !== 'string' || !types_1.COMMIT_ID.test(pinnedHead)))
105
+ return fail('invalid-input', `pinned HEAD must be a full commit id, got "${(0, text_safety_1.esc)(pinnedHead)}"`);
106
+ // HEAD is read once: the log and the diff below both use this sha, so a HEAD that moves meanwhile cannot mix snapshots. A
107
+ // command that already pinned HEAD passes it (and checks `headMovedFailure` afterwards), saving one more `rev-parse`.
108
+ const head = pinnedHead ?? (0, git_1.headSha)(root);
106
109
  if (head === null)
107
110
  return fail('git-failed', 'git could not read HEAD');
108
111
  const range = `${mergeBaseSha}..${head}`;
@@ -171,8 +171,7 @@ function isCommittedClean(root, rel) {
171
171
  * Unknown state, or an unsafe exclusion, counts as dirty: an unreadable repository must never look clean.
172
172
  */
173
173
  function isDirtyOutside(root, extraExcludes) {
174
- const state = (0, worktree_1.gitState)(root, true, extraExcludes);
175
- return state.kind !== 'ok' || state.dirty;
174
+ return (0, worktree_1.treeDirty)(root, true, extraExcludes) !== false;
176
175
  }
177
176
  /** Repository root with native separators; null outside a repository. Only `rev-parse`: never a (slow, refreshing) `status`. */
178
177
  function repoRoot(cwd) {