@dalzoubi/dev-agents-sync 2.2.6 → 2.2.8

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.
package/README.md CHANGED
@@ -140,6 +140,8 @@ A blocking hook runs on `PreToolUse`, where exiting 2 denies the tool call and t
140
140
 
141
141
  **A blocker denies only on a positive match, and fails open on everything else.** An unreadable payload, an unextractable path, or JSON it cannot parse with confidence produces at most one stderr note (for example `warning: handoff-state-guard stood down — <reason>`) and exit 0. A hook is never the reason a session breaks.
142
142
 
143
+ **Failing open is never silent.** There is a difference between "the payload said nothing worth denying" and "this gate could not run at all" — a missing `awk`, a payload a hook cannot decode unambiguously. Both exit 0, but the second is a blocker that has quietly stopped blocking, so it always prints one stderr line naming what did not happen and what to do about it. If a blocking hook says nothing, it checked and found nothing wrong.
144
+
143
145
  #### Bypassing a blocking hook
144
146
 
145
147
  Set `DEV_AGENTS_HOOKS_BYPASS` to a comma-separated list of **exact** hook names to stand those blockers down for the run:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.2.6",
3
+ "version": "2.2.8",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
@@ -0,0 +1,433 @@
1
+ /**
2
+ * tests/e2e/deliver-status-sync.test.mjs
3
+ *
4
+ * Slice S5 — Docs + consumer surface.
5
+ * Spec: docs/specs/260813-deliver-progress-clarity/ (T019, claims AC-20)
6
+ *
7
+ * WHAT THIS PROVES (and what it deliberately does not)
8
+ *
9
+ * AC-20 has two halves. The content repo's unit suite
10
+ * (tests/deliver-status-skill.test.mjs) proves the EMISSION half: that
11
+ * `.agents/skills/deliver-status.md` builds into all four targets. This file
12
+ * proves the SYNC half: that a consumer running the CLI against that release
13
+ * actually receives the skill, at each target's own discovery path, with the
14
+ * managed marker intact and with drift-free `check` afterwards. A skill that
15
+ * emits but never reaches a consumer ships as nothing.
16
+ *
17
+ * DERIVED, NOT PINNED
18
+ *
19
+ * The release file map is produced by running the real `scripts/build.mjs`
20
+ * into a throwaway dist dir and keying every emitted file exactly the way
21
+ * `fetcher.mjs` keys a release tarball (strip the leading `dist/`). So the
22
+ * fixture IS the release: if the build ever stops emitting deliver-status to a
23
+ * target, the map lacks it and this file goes red at the consumer boundary
24
+ * rather than passing vacuously over a hand-written stand-in. Likewise the
25
+ * content sentinels are extracted from the canonical skill by regex, and the
26
+ * Codex bundle is located by scanning every emitted `SKILL.md` — which agent
27
+ * bundles deliver-status is an implementation decision this test refuses to
28
+ * pin (the unit suite makes the same refusal, for the same reason).
29
+ *
30
+ * The ONE intentional pin is TARGET_DIR below: the release-prefix → consumer-
31
+ * directory mapping is the CLI's published contract, restated here rather than
32
+ * imported from writer.mjs so that a silent change to `TARGET_PREFIX` fails
33
+ * this test instead of being rubber-stamped by it.
34
+ *
35
+ * Runs from the monorepo: it reads the content repo at the repo root. If those
36
+ * sources are absent the test FAILS with a pointer — it never skips, because a
37
+ * skipped assertion is an assertion that cannot fail.
38
+ *
39
+ * Test runner: `npm run test:e2e` (node --test tests/e2e/*.test.mjs)
40
+ * i18n/accessibility: N/A — CLI-only, no translated strings, no UI surface.
41
+ */
42
+
43
+ import { describe, it, before, beforeEach, afterEach } from 'node:test';
44
+ import assert from 'node:assert/strict';
45
+ import {
46
+ mkdtempSync,
47
+ readFileSync,
48
+ existsSync,
49
+ rmSync,
50
+ readdirSync,
51
+ statSync,
52
+ } from 'node:fs';
53
+ import { spawnSync } from 'node:child_process';
54
+ import { tmpdir } from 'node:os';
55
+ import path from 'node:path';
56
+ import { fileURLToPath } from 'node:url';
57
+
58
+ import { runInit } from '../../src/commands/init.mjs';
59
+ import { runCheck } from '../../src/commands/check.mjs';
60
+ import { hasMarker } from '../../src/marker.mjs';
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Paths
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
67
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
68
+ const BUILD_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build.mjs');
69
+ const CANONICAL_SKILL = path.join(REPO_ROOT, '.agents', 'skills', 'deliver-status.md');
70
+
71
+ const AVAILABLE_TAGS = ['v1.0.0', 'v1.1.0', 'v1.2.0', 'v2.0.0'];
72
+
73
+ /**
74
+ * Release-key prefix → directory inside the consumer repo. The CLI's published
75
+ * contract (see writer.mjs TARGET_PREFIX and the README target table); `codex`
76
+ * keys already carry a repo-root-relative path, so they resolve against ''.
77
+ */
78
+ const TARGET_DIR = {
79
+ claude: '.claude',
80
+ cursor: '.cursor',
81
+ copilot: '.github',
82
+ codex: '',
83
+ };
84
+
85
+ const SYNC_TARGETS = Object.keys(TARGET_DIR);
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Release map — built from the real content build, keyed like a release tarball
89
+ // ---------------------------------------------------------------------------
90
+
91
+ let RELEASE_MAP = null;
92
+ let SENTINELS = null;
93
+ let DELIVER_STATUS_KEYS = null;
94
+
95
+ function collectInto(map, baseDir, currentDir) {
96
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
97
+ const full = path.join(currentDir, entry.name);
98
+ if (entry.isDirectory()) {
99
+ collectInto(map, baseDir, full);
100
+ continue;
101
+ }
102
+ const rel = path.relative(baseDir, full).split(path.sep).join('/');
103
+ map[rel] = readFileSync(full, 'utf8');
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Runs scripts/build.mjs into a throwaway dist dir and returns the FileMap the
109
+ * fetcher would hand the CLI for that release. `--no-mirror-root` keeps the
110
+ * build from touching this repo's worktree.
111
+ *
112
+ * Keys outside the four sync targets (`root/`, `steering/`) are kept out of the
113
+ * map: production drops or re-seeds them elsewhere, and their presence here
114
+ * would only add stderr noise to an assertion about deliver-status.
115
+ */
116
+ function buildReleaseMap() {
117
+ assert.ok(
118
+ existsSync(BUILD_SCRIPT),
119
+ `expected the content build at ${BUILD_SCRIPT}.\n` +
120
+ 'This e2e reads the monorepo content sources; run it from a full checkout.',
121
+ );
122
+
123
+ const distDir = mkdtempSync(path.join(tmpdir(), 'das-e2e-deliver-status-dist-'));
124
+ try {
125
+ const res = spawnSync(
126
+ process.execPath,
127
+ [BUILD_SCRIPT, '--dist-dir', distDir, '--no-mirror-root'],
128
+ { cwd: REPO_ROOT, encoding: 'utf8' },
129
+ );
130
+ assert.equal(
131
+ res.status,
132
+ 0,
133
+ 'scripts/build.mjs must succeed to produce the release this sync test consumes ' +
134
+ '(run `npm ci` at the repo root if dependencies are missing).\n' +
135
+ `stderr:\n${res.stderr ?? ''}`,
136
+ );
137
+
138
+ const all = {};
139
+ collectInto(all, distDir, distDir);
140
+
141
+ const map = {};
142
+ for (const [key, content] of Object.entries(all)) {
143
+ if (SYNC_TARGETS.includes(key.split('/')[0])) map[key] = content;
144
+ }
145
+ return map;
146
+ } finally {
147
+ rmSync(distDir, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Content sentinels extracted from the canonical skill: the progress-marker
153
+ * template, the boundary header's remaining-work line, and the artifact path.
154
+ * Extracted rather than transcribed so a reworded skill updates them for free
155
+ * and a gutted skill cannot satisfy them.
156
+ */
157
+ function deriveSentinels() {
158
+ assert.ok(
159
+ existsSync(CANONICAL_SKILL),
160
+ `expected the canonical skill at ${CANONICAL_SKILL}`,
161
+ );
162
+ const raw = readFileSync(CANONICAL_SKILL, 'utf8');
163
+
164
+ const markerTemplate = raw.match(/^▸ \[<slice-index>.*$/m)?.[0];
165
+ const remainingLine = raw.match(/^\s*Remaining after this:.*$/m)?.[0]?.trim();
166
+ const artifactPath = raw.match(/docs\/specs\/<YYMMDD-slug>\/delivery-status\.md/)?.[0];
167
+
168
+ assert.ok(markerTemplate, 'canonical skill must define the "▸ [<slice-index>…" marker template');
169
+ assert.ok(remainingLine, 'canonical skill must define the "Remaining after this:" header line');
170
+ assert.ok(artifactPath, 'canonical skill must name docs/specs/<YYMMDD-slug>/delivery-status.md');
171
+
172
+ return [markerTemplate, remainingLine, artifactPath];
173
+ }
174
+
175
+ /**
176
+ * Locates the deliver-status carrier in the release, per target.
177
+ *
178
+ * claude/cursor/copilot each emit a standalone `deliver-status.*` file; codex
179
+ * inlines the skill into some agent's `SKILL.md` bundle. Both are found by
180
+ * inspection, so neither the file extension nor the bundling agent is pinned.
181
+ */
182
+ function findDeliverStatusKeys(releaseMap, sentinels) {
183
+ const byTarget = {};
184
+ for (const target of SYNC_TARGETS) {
185
+ const keys = Object.keys(releaseMap)
186
+ .filter((k) => k.split('/')[0] === target)
187
+ .filter((k) =>
188
+ target === 'codex'
189
+ ? k.endsWith('/SKILL.md') && sentinels.every((s) => releaseMap[k].includes(s))
190
+ : path.basename(k).startsWith('deliver-status.'),
191
+ )
192
+ .sort();
193
+ byTarget[target] = keys;
194
+ }
195
+ return byTarget;
196
+ }
197
+
198
+ /**
199
+ * The release keys carrying deliver-status for `target`, asserted non-empty.
200
+ *
201
+ * Every loop below goes through this: an empty list would make its enclosing
202
+ * `for` body vacuous, and a loop that runs zero times is an assertion that
203
+ * cannot fail. D1 owns the same claim per target; this keeps D2/D3 from
204
+ * quietly passing on D1's failure.
205
+ */
206
+ function keysFor(target) {
207
+ const keys = DELIVER_STATUS_KEYS?.[target] ?? [];
208
+ assert.ok(
209
+ keys.length >= 1,
210
+ `no deliver-status carrier in the built release for target "${target}" — see D1`,
211
+ );
212
+ return keys;
213
+ }
214
+
215
+ /** Maps a release key to its absolute path inside a consumer repo. */
216
+ function consumerPathFor(consumerDir, releaseKey) {
217
+ const [target, ...rest] = releaseKey.split('/');
218
+ return path.join(consumerDir, TARGET_DIR[target], ...rest);
219
+ }
220
+
221
+ function makeFetcher() {
222
+ return async () => ({ ...RELEASE_MAP });
223
+ }
224
+
225
+ function makeTmpDir() {
226
+ return mkdtempSync(path.join(tmpdir(), 'das-e2e-deliver-status-'));
227
+ }
228
+
229
+ before(() => {
230
+ RELEASE_MAP = buildReleaseMap();
231
+ SENTINELS = deriveSentinels();
232
+ DELIVER_STATUS_KEYS = findDeliverStatusKeys(RELEASE_MAP, SENTINELS);
233
+ });
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // D1 — the release the consumer syncs carries deliver-status for every target
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe('S5 D1 — deliver-status is present in the built release for all four targets', () => {
240
+ for (const target of SYNC_TARGETS) {
241
+ it(`D1 (${target}): the release carries a deliver-status carrier for ${target}`, () => {
242
+ const keys = DELIVER_STATUS_KEYS[target];
243
+ assert.ok(
244
+ keys.length >= 1,
245
+ `the built release must carry deliver-status for the "${target}" target (AC-20).\n` +
246
+ `No ${target} release key matched. Keys for this target:\n` +
247
+ Object.keys(RELEASE_MAP)
248
+ .filter((k) => k.split('/')[0] === target)
249
+ .sort()
250
+ .join('\n'),
251
+ );
252
+ });
253
+ }
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // D2 — a consumer syncing that release receives it, intact, at each path
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe('S5 D2 — init delivers deliver-status into a synced consumer repo', () => {
261
+ let consumerDir;
262
+
263
+ beforeEach(() => {
264
+ consumerDir = makeTmpDir();
265
+ });
266
+
267
+ afterEach(() => {
268
+ rmSync(consumerDir, { recursive: true, force: true });
269
+ });
270
+
271
+ it('D2a: every target\'s deliver-status carrier lands at its consumer discovery path', async () => {
272
+ await runInit(consumerDir, {
273
+ targets: [...SYNC_TARGETS],
274
+ fetcher: makeFetcher(),
275
+ availableTags: AVAILABLE_TAGS,
276
+ });
277
+
278
+ for (const target of SYNC_TARGETS) {
279
+ for (const key of keysFor(target)) {
280
+ const dest = consumerPathFor(consumerDir, key);
281
+ assert.ok(
282
+ existsSync(dest),
283
+ `init must write the ${target} deliver-status carrier "${key}" to ${dest}`,
284
+ );
285
+ assert.ok(
286
+ statSync(dest).isFile(),
287
+ `${dest} must be a file`,
288
+ );
289
+ }
290
+ }
291
+ });
292
+
293
+ it('D2b: the delivered bytes are identical to the release entry (no truncation in transit)', async () => {
294
+ await runInit(consumerDir, {
295
+ targets: [...SYNC_TARGETS],
296
+ fetcher: makeFetcher(),
297
+ availableTags: AVAILABLE_TAGS,
298
+ });
299
+
300
+ for (const target of SYNC_TARGETS) {
301
+ for (const key of keysFor(target)) {
302
+ const dest = consumerPathFor(consumerDir, key);
303
+ assert.equal(
304
+ readFileSync(dest, 'utf8'),
305
+ RELEASE_MAP[key],
306
+ `${dest} must be byte-identical to release entry "${key}"`,
307
+ );
308
+ }
309
+ }
310
+ });
311
+
312
+ it('D2c: the delivered file carries the managed-by marker (so update manages it, not orphans it)', async () => {
313
+ await runInit(consumerDir, {
314
+ targets: [...SYNC_TARGETS],
315
+ fetcher: makeFetcher(),
316
+ availableTags: AVAILABLE_TAGS,
317
+ });
318
+
319
+ for (const target of SYNC_TARGETS) {
320
+ for (const key of keysFor(target)) {
321
+ const dest = consumerPathFor(consumerDir, key);
322
+ assert.ok(
323
+ hasMarker(readFileSync(dest, 'utf8')),
324
+ `${dest} must carry the managed-by marker — an unmarked file is consumer-owned ` +
325
+ 'and would never be updated by a later sync',
326
+ );
327
+ }
328
+ }
329
+ });
330
+
331
+ it('D2d: the skill\'s actual content arrives — marker template, boundary header, artifact path', async () => {
332
+ await runInit(consumerDir, {
333
+ targets: [...SYNC_TARGETS],
334
+ fetcher: makeFetcher(),
335
+ availableTags: AVAILABLE_TAGS,
336
+ });
337
+
338
+ for (const target of SYNC_TARGETS) {
339
+ for (const key of keysFor(target)) {
340
+ const delivered = readFileSync(consumerPathFor(consumerDir, key), 'utf8');
341
+ for (const sentinel of SENTINELS) {
342
+ assert.ok(
343
+ delivered.includes(sentinel),
344
+ `the ${target} consumer copy of "${key}" must contain the canonical skill's ` +
345
+ `content, missing: ${JSON.stringify(sentinel)}`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+ });
351
+
352
+ it('D2e: init reports each deliver-status key among the files it wrote', async () => {
353
+ const result = await runInit(consumerDir, {
354
+ targets: [...SYNC_TARGETS],
355
+ fetcher: makeFetcher(),
356
+ availableTags: AVAILABLE_TAGS,
357
+ });
358
+
359
+ const written = new Set(result.files ?? []);
360
+ for (const target of SYNC_TARGETS) {
361
+ for (const key of keysFor(target)) {
362
+ assert.ok(
363
+ written.has(key),
364
+ `init's reported file list must include "${key}" (reported ${written.size} files)`,
365
+ );
366
+ }
367
+ }
368
+ });
369
+
370
+ it('D2f: check exits 0 on the synced tree (the delivered skill is drift-free)', async () => {
371
+ await runInit(consumerDir, {
372
+ targets: [...SYNC_TARGETS],
373
+ fetcher: makeFetcher(),
374
+ availableTags: AVAILABLE_TAGS,
375
+ });
376
+
377
+ let exitCode = 0;
378
+ try {
379
+ await runCheck(consumerDir, {
380
+ fetcher: makeFetcher(),
381
+ availableTags: ['v2.0.0'],
382
+ });
383
+ } catch (err) {
384
+ exitCode = err.exitCode ?? 1;
385
+ }
386
+
387
+ assert.equal(
388
+ exitCode,
389
+ 0,
390
+ 'check must exit 0 immediately after init — a freshly synced deliver-status must not read as drift',
391
+ );
392
+ });
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // D3 — target scoping: a single-target consumer gets its copy and only its copy
397
+ // ---------------------------------------------------------------------------
398
+
399
+ describe('S5 D3 — deliver-status respects target scoping', () => {
400
+ let consumerDir;
401
+
402
+ beforeEach(() => {
403
+ consumerDir = makeTmpDir();
404
+ });
405
+
406
+ afterEach(() => {
407
+ rmSync(consumerDir, { recursive: true, force: true });
408
+ });
409
+
410
+ it('D3a: --targets claude delivers the claude copy and none of the other targets\' copies', async () => {
411
+ await runInit(consumerDir, {
412
+ targets: ['claude'],
413
+ fetcher: makeFetcher(),
414
+ availableTags: AVAILABLE_TAGS,
415
+ });
416
+
417
+ for (const key of keysFor('claude')) {
418
+ assert.ok(
419
+ existsSync(consumerPathFor(consumerDir, key)),
420
+ `a claude-only consumer must receive "${key}"`,
421
+ );
422
+ }
423
+
424
+ for (const target of SYNC_TARGETS.filter((t) => t !== 'claude')) {
425
+ for (const key of keysFor(target)) {
426
+ assert.ok(
427
+ !existsSync(consumerPathFor(consumerDir, key)),
428
+ `a claude-only consumer must NOT receive the ${target} copy "${key}"`,
429
+ );
430
+ }
431
+ }
432
+ });
433
+ });