agentic-workflow-manager 3.12.0 → 3.13.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 (35) hide show
  1. package/dist/src/commands/hooks/shared.js +14 -1
  2. package/dist/src/commands/preflight/checks.js +63 -5
  3. package/dist/src/commands/preflight/index.js +6 -1
  4. package/dist/src/commands/registry/add.js +10 -1
  5. package/dist/src/commands/sensors/baseline.js +4 -3
  6. package/dist/src/core/atomic-file.js +24 -2
  7. package/dist/src/core/executor.js +40 -1
  8. package/dist/src/core/install-transaction.js +13 -1
  9. package/dist/src/core/journal/process.js +241 -9
  10. package/dist/tests/commands/hooks/install-symlink-fallback.test.js +25 -0
  11. package/dist/tests/commands/hooks/status.test.js +23 -3
  12. package/dist/tests/commands/job/exec-wrapper.test.js +15 -1
  13. package/dist/tests/commands/job/gate-reconcile.test.js +31 -5
  14. package/dist/tests/commands/preflight/preflight.test.js +99 -1
  15. package/dist/tests/commands/registry/add.test.js +27 -0
  16. package/dist/tests/commands/sensors/changed.test.js +13 -0
  17. package/dist/tests/commands/sensors/exec-windows.test.js +13 -0
  18. package/dist/tests/commands/sensors/exec.test.js +28 -6
  19. package/dist/tests/commands/sensors/formatters/ruff.test.js +22 -6
  20. package/dist/tests/commands/sensors/run-changed.test.js +33 -0
  21. package/dist/tests/commands/watch/e2e-crash.test.js +54 -18
  22. package/dist/tests/commands/watch/runner.test.js +32 -2
  23. package/dist/tests/commands/watch/supervisor-loop.test.js +24 -3
  24. package/dist/tests/core/artifact-state.test.js +11 -1
  25. package/dist/tests/core/atomic-file-durable.test.js +48 -1
  26. package/dist/tests/core/atomic-file.test.js +14 -3
  27. package/dist/tests/core/executor.test.js +58 -0
  28. package/dist/tests/core/install-transaction.test.js +59 -2
  29. package/dist/tests/core/journal/adapter.test.js +54 -0
  30. package/dist/tests/core/journal/fingerprint.test.js +10 -2
  31. package/dist/tests/core/journal/process.test.js +208 -9
  32. package/dist/tests/core/journal/store.test.js +8 -2
  33. package/dist/tests/core/no-color.test.js +23 -0
  34. package/dist/tests/core/registries-sync.test.js +32 -3
  35. package/package.json +1 -1
@@ -29,7 +29,20 @@ function syncExecutable(source, dest, method) {
29
29
  catch { /* not exists, fine */ }
30
30
  fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
31
31
  if (method === 'symlink') {
32
- fs_1.default.symlinkSync(source, dest);
32
+ try {
33
+ fs_1.default.symlinkSync(source, dest);
34
+ }
35
+ catch {
36
+ // best-effort: a FILE symlink needs SeCreateSymbolicLinkPrivilege on
37
+ // Windows, denied by default on unprivileged accounts (incl. GitHub
38
+ // Actions' windows-latest runner) — fall back to a plain copy, same
39
+ // as the bootstrap skill file's own fallback (hooks/claude.ts) and
40
+ // executor.ts's stageArtifact for file artifacts. 'awm update' will
41
+ // not auto-propagate for this file until re-synced, same tradeoff.
42
+ fs_1.default.copyFileSync(source, dest);
43
+ const srcMode = fs_1.default.statSync(source).mode;
44
+ fs_1.default.chmodSync(dest, srcMode);
45
+ }
33
46
  }
34
47
  else {
35
48
  fs_1.default.copyFileSync(source, dest);
@@ -9,6 +9,7 @@ const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const status_1 = require("../sensors/status");
11
11
  const init_1 = require("../sensors/init");
12
+ const baseline_1 = require("../sensors/baseline");
12
13
  const paths_1 = require("../../core/paths");
13
14
  const MANIFEST = path_1.default.join('.awm', 'sensors.json');
14
15
  /**
@@ -36,6 +37,18 @@ function readManifest(cwd) {
36
37
  return null;
37
38
  }
38
39
  }
40
+ /**
41
+ * Total sensor entries and how many are enabled (`enabled !== false`, so a missing
42
+ * `enabled` field defaults to counted-as-enabled). Shared by `checkManifest` and
43
+ * `checkSensorsBaseline` — they were previously two copies of the identical
44
+ * predicate, which post-implementation-qa flagged: a hardening applied to one (e.g.
45
+ * guarding a malformed sensor entry) would silently NOT apply to the other unless
46
+ * someone remembered to edit both. One function, two callers, one place to harden.
47
+ */
48
+ function countEnabledSensors(manifest) {
49
+ const entries = Object.values(manifest.sensors ?? {});
50
+ return { total: entries.length, enabled: entries.filter(s => s.enabled !== false).length };
51
+ }
39
52
  /**
40
53
  * A repo may legitimately have no sensors — but it has to SAY so, in a committed file.
41
54
  *
@@ -62,8 +75,7 @@ function checkManifest(cwd, manifest) {
62
75
  remedy: 'fix or regenerate it with `awm sensors init`',
63
76
  };
64
77
  }
65
- const total = Object.keys(manifest.sensors ?? {}).length;
66
- const enabled = Object.values(manifest.sensors ?? {}).filter(s => s.enabled !== false).length;
78
+ const { total, enabled } = countEnabledSensors(manifest);
67
79
  // total === 0 is NOT an opt-out: a deliberate opt-out lists every known sensor NAME
68
80
  // explicitly with `enabled: false` (total > 0, enabled === 0). Zero entries means
69
81
  // nothing was ever configured — most commonly because the registry had no pack.json
@@ -135,6 +147,51 @@ function checkPack(cwd, manifest) {
135
147
  }
136
148
  return { id: 'pack', ok: true, detail: `${manifest.pack} matches the detected stack` };
137
149
  }
150
+ /**
151
+ * Advisory only — `ok` is ALWAYS `true`, same contract as `checkHost` below. A team
152
+ * adopting AWM on a legacy repo starts with pre-existing sensor findings; the ratchet
153
+ * (`awm sensors baseline`, `.awm/sensors.baseline.json`) exists precisely to snapshot
154
+ * those as accepted debt so the gate only fails on genuinely NEW findings. But nothing
155
+ * today surfaces that the mechanism exists — the team discovers it only after hitting a
156
+ * wall of red findings and going looking. This nudges them toward it before that
157
+ * happens. It never blocks preflight: a repo can legitimately have zero debt to
158
+ * snapshot (sensors enabled from day one), and "no baseline yet" is not itself a
159
+ * failure — only the operator's lack of awareness that baselining is an option is the
160
+ * problem this addresses.
161
+ *
162
+ * Only called when a manifest exists (see the conditional spread in `preflight()`) —
163
+ * there is nothing to baseline without sensors configured in the first place, so this
164
+ * mirrors how `checkTools`/`checkPack` are skipped entirely rather than reported on a
165
+ * repo that was never set up.
166
+ *
167
+ * Also requires at least one ENABLED sensor, via the same `countEnabledSensors` helper
168
+ * `checkManifest` uses — a deliberate opt-out (every sensor `enabled: false`) or an
169
+ * unparseable/empty manifest has nothing to baseline either, and nudging "run `awm
170
+ * sensors baseline`" there would be actively misleading rather than merely unnecessary.
171
+ *
172
+ * Presence is checked via `readBaseline` (same function `partition()` uses at gate time
173
+ * to decide suppression), not a raw `fs.existsSync` — a baseline PATH that exists but
174
+ * isn't a readable JSON file (e.g. a stray directory at that path) is treated by the
175
+ * real gate as "no baseline, nothing suppressed"; `existsSync` alone would have reported
176
+ * "baseline present" for that same case, reassuring the operator that debt is being
177
+ * suppressed when it silently is not.
178
+ */
179
+ function checkSensorsBaseline(cwd, manifest) {
180
+ const enabled = manifest ? countEnabledSensors(manifest).enabled : 0;
181
+ if (enabled === 0) {
182
+ return { id: 'sensors-baseline', ok: true, detail: 'no enabled sensors — nothing to baseline' };
183
+ }
184
+ if ((0, baseline_1.readBaseline)(cwd) !== null) {
185
+ return { id: 'sensors-baseline', ok: true, detail: 'baseline present' };
186
+ }
187
+ return {
188
+ id: 'sensors-baseline',
189
+ ok: true,
190
+ detail: 'sensors configured, no baseline yet — awm sensors baseline',
191
+ remedy: 'run `awm sensors baseline` to snapshot pre-existing findings as accepted debt, '
192
+ + 'so the gate only chases new problems',
193
+ };
194
+ }
138
195
  /**
139
196
  * Extract just the hostname portion of a git remote URL — never match against the
140
197
  * full URL string. A bare substring check against the whole remote (`remote.includes
@@ -224,9 +281,10 @@ function preflight(cwd = process.cwd()) {
224
281
  const checks = [
225
282
  checkContext(cwd),
226
283
  checkManifest(cwd, manifest),
227
- // Skipped when there is no manifest: reporting "tools broken" on a repo that was
228
- // never set up buries the one thing the operator needs to read.
229
- ...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest)] : []),
284
+ // Skipped when there is no manifest: reporting "tools broken" (or nudging toward
285
+ // a baseline that has nothing to snapshot) on a repo that was never set up
286
+ // buries the one thing the operator needs to read.
287
+ ...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest), checkSensorsBaseline(cwd, manifest)] : []),
230
288
  // Runs unconditionally — orthogonal to sensor configuration entirely, this is
231
289
  // about PR/MR tooling, not sensors.
232
290
  checkHost(cwd),
@@ -20,7 +20,12 @@ function exitCodeFor(report) {
20
20
  return report.status === 'ready' ? 0 : 1;
21
21
  }
22
22
  function formatReport(report) {
23
- const lines = report.checks.map(c => ` ${c.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘')} ${c.id.padEnd(9)} ${c.detail}`
23
+ // Computed from the actual ids present, not hardcoded to the widest id THIS
24
+ // report happens to have — a fixed literal here silently misaligns the moment a
25
+ // longer `PreflightCheck['id']` is added (confirmed: 'sensors-baseline', at 16
26
+ // chars, broke a hardcoded 9-char pad).
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}`
24
29
  + (c.remedy ? `\n ${picocolors_1.default.dim('→ ' + c.remedy)}` : ''));
25
30
  if (report.status === 'ready') {
26
31
  return `${picocolors_1.default.green('✔')} Harness ready — this project can be gated.\n${lines.join('\n')}\n`;
@@ -13,7 +13,16 @@ const registries_1 = require("../../core/registries");
13
13
  const discovery_1 = require("../../core/discovery");
14
14
  const bundles_1 = require("../../core/bundles");
15
15
  function deriveRegistryName(remote) {
16
- const base = remote.replace(/\/+$/, '').split(/[/:]/).pop() ?? '';
16
+ // Split on '/', '\' and ':' — not just '/' and ':'. A git remote URL
17
+ // (https://…/repo.git, git@host:org/repo.git) only ever uses the first
18
+ // two, but `remote` here can also be a plain local filesystem path (e.g.
19
+ // a `awm registry add <local-repo>` clone source, or this repo's own
20
+ // tests), and on native Windows that path is backslash-separated
21
+ // (`C:\Users\...\src-alpha`). Splitting on `[/:]` alone leaves the whole
22
+ // backslash-joined tail as one segment (`\Users\...\src-alpha`), which
23
+ // then fails the caller's `/[/\\]/.test(name)` invalid-name check and
24
+ // makes every local-path `addRegistry` call fail on Windows.
25
+ const base = remote.replace(/[/\\]+$/, '').split(/[/\\:]/).pop() ?? '';
17
26
  return base.replace(/\.git$/, '');
18
27
  }
19
28
  async function addRegistry(remote, nameOverride) {
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.BASELINE_FILE = void 0;
6
7
  exports.fingerprint = fingerprint;
7
8
  exports.readBaseline = readBaseline;
8
9
  exports.writeBaseline = writeBaseline;
@@ -11,7 +12,7 @@ exports.buildBaseline = buildBaseline;
11
12
  const crypto_1 = __importDefault(require("crypto"));
12
13
  const fs_1 = __importDefault(require("fs"));
13
14
  const path_1 = __importDefault(require("path"));
14
- const BASELINE_FILE = path_1.default.join('.awm', 'sensors.baseline.json');
15
+ exports.BASELINE_FILE = path_1.default.join('.awm', 'sensors.baseline.json');
15
16
  /**
16
17
  * Mask runs of digits so location noise embedded in messages (e.g. the tsc
17
18
  * formatter writes "... line 199 ...") doesn't change the fingerprint when code
@@ -43,7 +44,7 @@ function fingerprint(sensor, e) {
43
44
  return crypto_1.default.createHash('sha1').update(basis).digest('hex');
44
45
  }
45
46
  function readBaseline(cwd) {
46
- const p = path_1.default.join(cwd, BASELINE_FILE);
47
+ const p = path_1.default.join(cwd, exports.BASELINE_FILE);
47
48
  if (!fs_1.default.existsSync(p))
48
49
  return null;
49
50
  try {
@@ -55,7 +56,7 @@ function readBaseline(cwd) {
55
56
  }
56
57
  function writeBaseline(cwd, baseline) {
57
58
  fs_1.default.mkdirSync(path_1.default.join(cwd, '.awm'), { recursive: true });
58
- fs_1.default.writeFileSync(path_1.default.join(cwd, BASELINE_FILE), JSON.stringify(baseline, null, 2), 'utf-8');
59
+ fs_1.default.writeFileSync(path_1.default.join(cwd, exports.BASELINE_FILE), JSON.stringify(baseline, null, 2), 'utf-8');
59
60
  }
60
61
  /**
61
62
  * Split a sensor's findings into new vs baseline-suppressed. With no accepted
@@ -75,12 +75,34 @@ function writeFileAtomic(file, content, mode = 0o644) {
75
75
  /** fsync del directorio contenedor: garantiza que una ENTRADA creada/renombrada
76
76
  * sobrevive un crash del OS. Falla LANZANDO — la durabilidad de la transición
77
77
  * es parte del contrato, nunca un best-effort silencioso (design R1.2,
78
- * bloqueador 4 de la review del plan). */
78
+ * bloqueador 4 de la review del plan) — EXCEPTO en Windows, donde fsync-ear un
79
+ * file descriptor de directorio no es una operacion que el SO soporte en
80
+ * absoluto (no es una falla de durabilidad real: no existe el mecanismo
81
+ * POSIX que esta funcion intenta invocar). Confirmado via R6 CI (2026-08-08,
82
+ * primera corrida real en windows-latest): `fs.openSync(dir, 'r')` tiene
83
+ * exito, pero el `fsyncSync` subsiguiente sobre ese fd siempre falla con
84
+ * EPERM — libuv mapea asi el resultado de `FlushFileBuffers` sobre un handle
85
+ * de directorio en Win32, que Windows rechaza categoricamente (no es un
86
+ * fallo intermitente de hardware/permisos, es ausencia de la capacidad).
87
+ * NTFS ya registra los renames/creates en su propio journal transaccional,
88
+ * asi que la garantia de durabilidad de la ENTRADA sigue sostenida por el
89
+ * filesystem — solo el mecanismo explicito para pedirla no existe ahi.
90
+ * Por eso, y solo para esta combinacion exacta (win32 + EPERM en el fsync,
91
+ * nunca en el open), esta funcion degrada a no-op en vez de lanzar; culquier
92
+ * otra plataforma, o cualquier otro código de error incluso en Windows
93
+ * (el open fallando, ENOENT, EACCES por permisos reales), sigue lanzando. */
79
94
  function fsyncDirSync(dir) {
80
95
  let dirFd;
81
96
  try {
82
97
  dirFd = fs_1.default.openSync(dir, 'r');
83
- fs_1.default.fsyncSync(dirFd);
98
+ try {
99
+ fs_1.default.fsyncSync(dirFd);
100
+ }
101
+ catch (error) {
102
+ if (process.platform === 'win32' && error.code === 'EPERM')
103
+ return;
104
+ throw error;
105
+ }
84
106
  }
85
107
  catch (error) {
86
108
  throw new Error(`fsync de directorio fallo para ${dir}: ${error.message}`);
@@ -10,6 +10,7 @@ exports.installArtifact = installArtifact;
10
10
  // src/core/executor.ts
11
11
  const fs_1 = __importDefault(require("fs"));
12
12
  const path_1 = __importDefault(require("path"));
13
+ const paths_1 = require("./paths");
13
14
  function removeArtifact(targetPath) {
14
15
  let exists = false;
15
16
  try {
@@ -39,7 +40,45 @@ function stageArtifact(sourcePath, targetPath, method) {
39
40
  const staged = path_1.default.join(parent, `.${path_1.default.basename(targetPath)}.${process.pid}.staged`);
40
41
  fs_1.default.rmSync(staged, { recursive: true, force: true });
41
42
  if (method === 'symlink') {
42
- fs_1.default.symlinkSync(sourcePath, staged, 'dir');
43
+ const sourceIsDirectory = fs_1.default.statSync(sourcePath).isDirectory();
44
+ if (sourceIsDirectory) {
45
+ // A directory *symlink* ('dir') needs SeCreateSymbolicLinkPrivilege on
46
+ // Windows — denied by default on unprivileged accounts, including
47
+ // GitHub Actions' windows-latest runner, so every install here would
48
+ // throw EPERM. A *junction* is a different NTFS reparse-point kind
49
+ // that Windows lets any account create, and Node/libuv report it the
50
+ // same way a symlink is reported (`lstat().isSymbolicLink()` is true,
51
+ // `readlinkSync()` resolves it) — so every downstream consumer
52
+ // (verify(), R19's provider-facts hashing, doctor's symlink checks)
53
+ // keeps working unmodified. Junctions require an absolute target,
54
+ // which `sourcePath` always is here (registry content roots are
55
+ // resolved under `awmHome()`). POSIX platforms are unaffected: 'dir'
56
+ // is a plain no-op hint there.
57
+ fs_1.default.symlinkSync(sourcePath, staged, (0, paths_1.isWindowsNative)() ? 'junction' : 'dir');
58
+ }
59
+ else {
60
+ // A junction is an NTFS *directory* reparse point — it has no
61
+ // equivalent for an individual FILE artifact (agent .md, workflow
62
+ // .md, ...), so a file source must never be passed to it (it was,
63
+ // before this branch existed, and that silently produced a
64
+ // reparse point that could not resolve back to the file — see
65
+ // tests/core/bundle-install.test.ts's "claude-code agents" case).
66
+ // A 'file'-type symlink is the correct primitive here, but it
67
+ // needs the same SeCreateSymbolicLinkPrivilege a directory symlink
68
+ // does, and Windows has no privilege-free substitute for files the
69
+ // way junctions are for directories. So: attempt the real symlink
70
+ // (works, and keeps `awm update` propagation, whenever the
71
+ // privilege IS available — e.g. Developer Mode) and fall back to a
72
+ // plain copy otherwise, mirroring the established fallback already
73
+ // used for the bootstrap skill file (hooks/claude.ts,
74
+ // tests/commands/hooks/install-symlink-fallback.test.ts).
75
+ try {
76
+ fs_1.default.symlinkSync(sourcePath, staged, 'file');
77
+ }
78
+ catch {
79
+ fs_1.default.copyFileSync(sourcePath, staged);
80
+ }
81
+ }
43
82
  }
44
83
  else {
45
84
  fs_1.default.cpSync(sourcePath, staged, { recursive: true });
@@ -339,7 +339,19 @@ function defaultTransactionDeps() {
339
339
  return;
340
340
  }
341
341
  if (op.method === 'symlink' && !stat.isSymbolicLink()) {
342
- throw new Error(`verification failed: ${op.targetPath} is not a symlink`);
342
+ // executor.ts's stageArtifact falls back to a plain copy for a
343
+ // FILE source when the real 'file'-type symlink throws (no
344
+ // privilege-free equivalent to a directory junction exists for
345
+ // individual files on Windows) — accept that fallback here
346
+ // rather than failing verification for an install that landed
347
+ // correctly, just not as a symlink. A directory source always
348
+ // gets a privilege-free junction and should never legitimately
349
+ // reach this branch, so this stays scoped to files only.
350
+ const sourceIsDirectory = fs_1.default.existsSync(op.sourcePath) && fs_1.default.statSync(op.sourcePath).isDirectory();
351
+ const acceptableFileFallback = !sourceIsDirectory && stat.isFile();
352
+ if (!acceptableFileFallback) {
353
+ throw new Error(`verification failed: ${op.targetPath} is not a symlink`);
354
+ }
343
355
  }
344
356
  if (op.method === 'copy' && stat.isSymbolicLink()) {
345
357
  throw new Error(`verification failed: ${op.targetPath} is unexpectedly a symlink`);