@produck/agent-toolkit 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -26,7 +26,7 @@ What it does (in order):
26
26
  6. Deploys root `.c8rc.json` and root `c8` devDependency
27
27
  **Note:** The `produck:coverage` script in subpackages is for local and AI development use only. It is NOT enforced by organization CI or `.c8rc.json`. Only the root workspace (monorepo root) is subject to org-level coverage enforcement and `.c8rc.json`.
28
28
  7. Deploys root `.gitattributes`
29
- 8. Deploys the pinned `produck:coverage` script and `c8` devDependency to each workspace package, and enforces `scripts.test` (generates a default `test` script when missing).
29
+ 8. Deploys the pinned `produck:coverage` script to each workspace package and enforces `scripts.test` (generates a default `test` script when missing). Workspace packages rely on root-level c8 devDependency (hoisted by npm workspaces).
30
30
  9. Deploys `.husky/pre-commit` and `.husky/commit-msg`
31
31
 
32
32
  After running, add the persistent enforcement entry to the repository
@@ -90,11 +90,14 @@ failure:
90
90
  (`produck:baseline`, `produck:commit:check`) plus shared pinned root
91
91
  devDependencies (`husky`, `lerna`, `@produck/agent-toolkit`)
92
92
  8. `sync-coverage` — deploy root `scripts.produck:coverage`, `.c8rc.json`, and
93
- root `c8` devDependency, then deploy pinned `produck:coverage` script and
94
- `c8` devDependency into each workspace package, and ensure each workspace
95
- package has `scripts.test` (auto-generate a default value when missing)
96
- 9. `sync-publish` create default `lerna.json` when missing and deploy root
97
- `scripts.produck:publish:check` plus `scripts.produck:publish`
93
+ root `c8` devDependency (root-only; does not touch workspace packages)
94
+ 9. `sync-workspace` deploy pinned `produck:coverage` script to each
95
+ workspace package and enforce `scripts.test` (auto-generate a default value
96
+ when missing). Workspace packages rely on root-level c8 devDependency
97
+ (hoisted by npm workspaces) and must not duplicate c8 in their own
98
+ `devDependencies`
99
+ 10. `sync-publish` — create default `lerna.json` when missing and deploy root
100
+ `scripts.produck:publish:check` plus `scripts.produck:publish`
98
101
 
99
102
  Add to downstream repository root `package.json` for one-command enforcement:
100
103
 
@@ -46,6 +46,10 @@ import {
46
46
  printSyncTypescriptHelp,
47
47
  runSyncTypescript,
48
48
  } from './command/sync-typescript/index.mjs';
49
+ import {
50
+ printSyncWorkspaceHelp,
51
+ runSyncWorkspace,
52
+ } from './command/sync-workspace/index.mjs';
49
53
  import { hasFlag, parseCommonArgs } from './command/shared/args.mjs';
50
54
  import {
51
55
  printValidateCommitMsgHelp,
@@ -109,6 +113,10 @@ const COMMANDS = {
109
113
  printHelp: printSyncTypescriptHelp,
110
114
  run: runSyncTypescript,
111
115
  },
116
+ 'sync-workspace': {
117
+ printHelp: printSyncWorkspaceHelp,
118
+ run: runSyncWorkspace,
119
+ },
112
120
  };
113
121
 
114
122
  const DEFAULT_COMMAND = 'enforce-node-baseline';
@@ -12,13 +12,14 @@ Behavior:
12
12
  5) sync-lint
13
13
  6) sync-install
14
14
  7) sync-git
15
- 8) sync-coverage
16
- 9) sync-publish
15
+ 8) sync-coverage (root-level only)
16
+ 9) sync-workspace (workspace packages, scripts only)
17
+ 10) sync-publish
17
18
  - Stops at first failed step and exits non-zero
18
19
  - Prints one combined JSON report for all executed steps
19
20
 
20
21
  Rules:
21
- - --check runs non-mutating validation mode for step 2, step 3, step 4, step 5, step 6, step 7, step 8, and step 9
22
- - --dry-run runs non-mutating preview mode for step 2, step 3, step 4, step 5, step 6, step 7, step 8, and step 9
22
+ - --check runs non-mutating validation mode for step 2 through step 10
23
+ - --dry-run runs non-mutating preview mode for step 2 through step 10
23
24
  - --check takes precedence over --dry-run
24
- - --workspace filters coverage sync targets in step 8
25
+ - --workspace filters workspace package targets in step 9
@@ -168,6 +168,16 @@ export function runEnforceNodeBaseline(options) {
168
168
  syncEslintConfigArgs.push('--dry-run');
169
169
  }
170
170
 
171
+ const syncWorkspaceArgs = ['sync-workspace', '--cwd', cwd];
172
+ for (const workspacePath of workspaces) {
173
+ syncWorkspaceArgs.push('--workspace', workspacePath);
174
+ }
175
+ if (check) {
176
+ syncWorkspaceArgs.push('--check');
177
+ } else if (dryRun) {
178
+ syncWorkspaceArgs.push('--dry-run');
179
+ }
180
+
171
181
  const plan = [
172
182
  { name: 'preflight', args: preflightArgs },
173
183
  { name: 'sync-instructions', args: syncInstructionsArgs },
@@ -177,6 +187,7 @@ export function runEnforceNodeBaseline(options) {
177
187
  { name: 'sync-install', args: syncInstallArgs },
178
188
  { name: 'sync-git', args: syncGitArgs },
179
189
  { name: 'sync-coverage', args: syncCoverageArgs },
190
+ { name: 'sync-workspace', args: syncWorkspaceArgs },
180
191
  { name: 'sync-publish', args: syncPublishArgs },
181
192
  ];
182
193
 
@@ -5,14 +5,15 @@ agent-toolkit commands:
5
5
  summarize-log
6
6
  sync-coverage
7
7
  sync-editorconfig
8
- sync-git
9
8
  sync-format
10
- sync-typescript
9
+ sync-git
11
10
  sync-install
11
+ sync-instructions
12
12
  sync-lint
13
13
  sync-publish
14
+ sync-typescript
15
+ sync-workspace
14
16
  validate-commit-msg
15
- sync-instructions
16
17
 
17
18
  Default:
18
19
  agent-toolkit
@@ -1,30 +1,15 @@
1
1
  Usage:
2
2
  agent-toolkit sync-coverage [--cwd <dir>]
3
- [--workspace <path>] ... [--check] [--dry-run] [--json <file>]
3
+ [--check] [--dry-run] [--json <file>]
4
4
 
5
5
  Behavior:
6
- - Adds or updates root scripts.produck:coverage, root .c8rc.json, and root devDependencies.c8.
7
- - Adds or updates scripts.produck:coverage and devDependencies.c8 in each workspace package.json.
8
- - Enforces scripts.test in each workspace package.json.
9
- - If scripts.test is missing, generates:
10
- node -e "console.log('No tests configured')"
11
- - The produck:coverage script in subpackages is for local and AI development use only.
12
- - Subpackage produck:coverage is NOT enforced by organization CI or .c8rc.json.
13
- - Only the root workspace (monorepo root) is subject to org-level coverage enforcement and .c8rc.json.
6
+ - Adds or updates root scripts.produck:coverage, root .c8rc.json,
7
+ and root devDependencies.c8.
8
+ - Only the root workspace (monorepo root) is subject to org-level
9
+ coverage enforcement and .c8rc.json.
14
10
  - Organization-reserved script key is scripts.produck:coverage
15
- - Target script is rendered from organization tooling baseline file
16
- (lookup order):
17
- 1) .github/distribution/produck/tooling-version-baseline.json
18
- 2) publish-assets/instructions/produck/tooling-version-baseline.json
19
- - Baseline template:
20
- c8 --reporter=lcov --reporter=html --reporter=text-summary npm test
11
+ - c8 version is resolved from organization tooling baseline file
21
12
 
22
13
  Rules:
23
- - When --workspace is omitted, root package.json workspaces are used
24
- - Root workspaces must be explicit paths (no glob tokens)
25
- - Workspace package.json files must include scripts.test
26
- - Workspace package.json files must pin devDependencies.c8 to baseline version
27
- - Subpackage produck:coverage is not a CI or org gate; it is for local/dev use only
28
- - Only the root workspace is enforced by org CI and .c8rc.json
29
14
  - --check validates without writing and exits non-zero on mismatch
30
15
  - --dry-run prints planned changes without writing
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
- import { getMulti, getSingle, hasFlag } from '../shared/args.mjs';
5
+ import { getSingle, hasFlag } from '../shared/args.mjs';
6
6
  import { printTextResource } from '../shared/text-resource.mjs';
7
7
 
8
8
  const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -19,7 +19,6 @@ const TOOLING_BASELINE_CANDIDATE_PATHS = [
19
19
  'publish-assets/instructions/produck/tooling-version-baseline.json',
20
20
  ),
21
21
  ];
22
- const GLOB_TOKEN_PATTERN = /[*?{}[\]]/;
23
22
  const REQUIRED_ROOT_COVERAGE_SCRIPT_KEY = 'produck:coverage';
24
23
  const REQUIRED_ROOT_COVERAGE_SCRIPT_VALUE = [
25
24
  'c8',
@@ -28,10 +27,6 @@ const REQUIRED_ROOT_COVERAGE_SCRIPT_VALUE = [
28
27
  '--workspaces',
29
28
  '--if-present',
30
29
  ].join(' ');
31
- const REQUIRED_COVERAGE_SCRIPT_KEY = 'produck:coverage';
32
- const REQUIRED_TEST_SCRIPT_KEY = 'test';
33
- const DEFAULT_TEST_SCRIPT_VALUE =
34
- 'node -e "console.log(\'No tests configured\')"';
35
30
  const REQUIRED_C8_CONFIG_FILE = '.c8rc.json';
36
31
  const REQUIRED_C8_CONFIG_TEMPLATE_FILE = path.resolve(
37
32
  COMMAND_DIR,
@@ -127,12 +122,6 @@ function loadToolingBaseline() {
127
122
  };
128
123
  }
129
124
 
130
- function buildRequiredCoverageScript(baseline) {
131
- const c8Version = String(baseline.tools.c8.version);
132
- const coverageTemplate = String(baseline.coverage.scriptTemplate);
133
- return coverageTemplate.replace(/\{c8\.version\}/g, c8Version);
134
- }
135
-
136
125
  function buildRequiredC8DevDependency(baseline) {
137
126
  return String(baseline.tools.c8.version);
138
127
  }
@@ -160,37 +149,6 @@ function loadRequiredC8ConfigContent() {
160
149
  return `${JSON.stringify(template, null, 2)}\n`;
161
150
  }
162
151
 
163
- function resolveWorkspacePaths(cwd, options) {
164
- const manual = getMulti(options, '--workspace');
165
- if (manual.length > 0) {
166
- return manual;
167
- }
168
-
169
- const rootPackageJsonPath = path.resolve(cwd, 'package.json');
170
- const rootPackageJson = parseJsonFile(
171
- rootPackageJsonPath,
172
- 'Root package.json',
173
- );
174
- if (!Array.isArray(rootPackageJson.workspaces)) {
175
- return [];
176
- }
177
-
178
- const workspaces = rootPackageJson.workspaces.map((entry) => String(entry));
179
- if (workspaces.length === 0) {
180
- return [];
181
- }
182
-
183
- const hasGlob = workspaces.some((entry) => GLOB_TOKEN_PATTERN.test(entry));
184
- if (hasGlob) {
185
- console.error(
186
- 'Root package.json `workspaces` must use explicit paths without glob tokens',
187
- );
188
- process.exit(2);
189
- }
190
-
191
- return workspaces;
192
- }
193
-
194
152
  function syncRootCoverage(
195
153
  cwd,
196
154
  mode,
@@ -274,130 +232,6 @@ function syncRootCoverage(
274
232
  };
275
233
  }
276
234
 
277
- function reconcileCoverageScript(
278
- cwd,
279
- workspacePath,
280
- mode,
281
- requiredCoverageScript,
282
- requiredC8Version,
283
- ) {
284
- const packageDir = path.resolve(cwd, workspacePath);
285
- const packageJsonPath = path.resolve(packageDir, 'package.json');
286
-
287
- const result = {
288
- workspacePath,
289
- packageDir,
290
- packageJsonPath,
291
- exists: false,
292
- validJson: false,
293
- previousCoverage: null,
294
- coverageScript: null,
295
- previousTestScript: null,
296
- testScript: null,
297
- previousC8DevDependency: null,
298
- c8DevDependency: null,
299
- matchesRequiredCoverageBefore: false,
300
- matchesRequiredCoverageAfter: false,
301
- hasRequiredTestScriptBefore: false,
302
- hasRequiredTestScriptAfter: false,
303
- matchesRequiredC8DevDependencyBefore: false,
304
- matchesRequiredC8DevDependencyAfter: false,
305
- updated: false,
306
- error: '',
307
- };
308
-
309
- if (!fs.existsSync(packageJsonPath)) {
310
- result.error = `Workspace package.json does not exist: ${workspacePath}`;
311
- return result;
312
- }
313
- result.exists = true;
314
-
315
- let pkg;
316
- try {
317
- pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
318
- result.validJson = true;
319
- } catch {
320
- result.error = `Workspace package.json is not valid JSON: ${workspacePath}`;
321
- return result;
322
- }
323
-
324
- const scripts =
325
- pkg.scripts &&
326
- typeof pkg.scripts === 'object' &&
327
- !Array.isArray(pkg.scripts)
328
- ? { ...pkg.scripts }
329
- : {};
330
- const devDependencies =
331
- pkg.devDependencies &&
332
- typeof pkg.devDependencies === 'object' &&
333
- !Array.isArray(pkg.devDependencies)
334
- ? { ...pkg.devDependencies }
335
- : {};
336
-
337
- const previousCoverage =
338
- typeof scripts[REQUIRED_COVERAGE_SCRIPT_KEY] === 'string'
339
- ? scripts[REQUIRED_COVERAGE_SCRIPT_KEY]
340
- : null;
341
- const previousTestScript =
342
- typeof scripts[REQUIRED_TEST_SCRIPT_KEY] === 'string' &&
343
- scripts[REQUIRED_TEST_SCRIPT_KEY].trim() !== ''
344
- ? scripts[REQUIRED_TEST_SCRIPT_KEY]
345
- : null;
346
- const previousC8DevDependency =
347
- typeof devDependencies.c8 === 'string' ? devDependencies.c8 : null;
348
- result.previousCoverage = previousCoverage;
349
- result.previousTestScript = previousTestScript;
350
- result.previousC8DevDependency = previousC8DevDependency;
351
- result.matchesRequiredCoverageBefore =
352
- previousCoverage === requiredCoverageScript;
353
- result.hasRequiredTestScriptBefore = previousTestScript !== null;
354
- result.matchesRequiredC8DevDependencyBefore =
355
- previousC8DevDependency === requiredC8Version;
356
-
357
- if (
358
- (!result.matchesRequiredCoverageBefore ||
359
- !result.hasRequiredTestScriptBefore ||
360
- !result.matchesRequiredC8DevDependencyBefore) &&
361
- mode === 'sync'
362
- ) {
363
- scripts[REQUIRED_COVERAGE_SCRIPT_KEY] = requiredCoverageScript;
364
- if (!result.hasRequiredTestScriptBefore) {
365
- scripts[REQUIRED_TEST_SCRIPT_KEY] = DEFAULT_TEST_SCRIPT_VALUE;
366
- }
367
- devDependencies.c8 = requiredC8Version;
368
- pkg.scripts = scripts;
369
- pkg.devDependencies = devDependencies;
370
- fs.writeFileSync(
371
- packageJsonPath,
372
- `${JSON.stringify(pkg, null, 2)}\n`,
373
- 'utf8',
374
- );
375
- result.updated = true;
376
- }
377
-
378
- result.coverageScript =
379
- mode === 'sync' && !result.matchesRequiredCoverageBefore
380
- ? requiredCoverageScript
381
- : previousCoverage;
382
- result.testScript =
383
- mode === 'sync' && !result.hasRequiredTestScriptBefore
384
- ? DEFAULT_TEST_SCRIPT_VALUE
385
- : previousTestScript;
386
- result.c8DevDependency =
387
- mode === 'sync' && !result.matchesRequiredC8DevDependencyBefore
388
- ? requiredC8Version
389
- : previousC8DevDependency;
390
-
391
- result.matchesRequiredCoverageAfter =
392
- result.updated || result.matchesRequiredCoverageBefore;
393
- result.hasRequiredTestScriptAfter =
394
- (mode === 'sync' && !result.hasRequiredTestScriptBefore) ||
395
- result.hasRequiredTestScriptBefore;
396
- result.matchesRequiredC8DevDependencyAfter =
397
- result.updated || result.matchesRequiredC8DevDependencyBefore;
398
- return result;
399
- }
400
-
401
235
  export function runSyncCoverage(options) {
402
236
  const cwd = path.resolve(getSingle(options, '--cwd', process.cwd()));
403
237
  const check = hasFlag(options, '--check');
@@ -405,7 +239,6 @@ export function runSyncCoverage(options) {
405
239
  const jsonFile = getSingle(options, '--json', '');
406
240
  const { baseline: toolingBaseline, toolingBaselinePath } =
407
241
  loadToolingBaseline();
408
- const requiredCoverageScript = buildRequiredCoverageScript(toolingBaseline);
409
242
  const requiredC8Version = buildRequiredC8DevDependency(toolingBaseline);
410
243
  const requiredC8ConfigContent = loadRequiredC8ConfigContent();
411
244
 
@@ -421,7 +254,6 @@ export function runSyncCoverage(options) {
421
254
  requiredC8Version,
422
255
  requiredC8ConfigContent,
423
256
  );
424
- const workspacePaths = resolveWorkspacePaths(cwd, options);
425
257
 
426
258
  const report = {
427
259
  cwd,
@@ -431,12 +263,8 @@ export function runSyncCoverage(options) {
431
263
  schemaVersion: toolingBaseline.schemaVersion,
432
264
  c8Version: toolingBaseline.tools.c8.version,
433
265
  },
434
- requiredCoverageScript,
435
- requiredTestScript: DEFAULT_TEST_SCRIPT_VALUE,
436
266
  requiredC8DevDependency: requiredC8Version,
437
267
  root,
438
- workspaces: workspacePaths,
439
- results: [],
440
268
  ok: true,
441
269
  };
442
270
 
@@ -449,32 +277,6 @@ export function runSyncCoverage(options) {
449
277
  report.ok = false;
450
278
  }
451
279
 
452
- for (const workspacePath of workspacePaths) {
453
- const effectiveMode = mode === 'sync' ? 'sync' : 'check';
454
- const item = reconcileCoverageScript(
455
- cwd,
456
- workspacePath,
457
- effectiveMode,
458
- requiredCoverageScript,
459
- requiredC8Version,
460
- );
461
- report.results.push(item);
462
-
463
- if (item.error) {
464
- report.ok = false;
465
- continue;
466
- }
467
-
468
- if (
469
- mode === 'check' &&
470
- (!item.matchesRequiredCoverageAfter ||
471
- !item.hasRequiredTestScriptAfter ||
472
- !item.matchesRequiredC8DevDependencyAfter)
473
- ) {
474
- report.ok = false;
475
- }
476
- }
477
-
478
280
  if (jsonFile) {
479
281
  const outPath = path.resolve(cwd, jsonFile);
480
282
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
@@ -10,7 +10,7 @@ Behavior:
10
10
  - Sync mode applies organization-required root publish scripts:
11
11
  - scripts.produck:publish:check = npm run produck:install && npm run produck:format && npm run produck:lint && npm run produck:coverage
12
12
  - scripts.produck:publish = npm run produck:publish:check && npm run publish --
13
- when scripts.publish exists; otherwise it falls back to lerna publish
13
+ - If scripts.publish is missing, defaults to "lerna publish"
14
14
  - Enforces lerna.json command.version.commitHooks = false so publish/version commits skip git commit hooks
15
15
 
16
16
  Rules:
@@ -26,6 +26,8 @@ const REQUIRED_PUBLISH_SCRIPT_VALUE = [
26
26
  'npm run produck:publish:check',
27
27
  'npm run publish --',
28
28
  ].join(' && ');
29
+ const REQUIRED_ROOT_PUBLISH_SCRIPT_KEY = 'publish';
30
+ const REQUIRED_ROOT_PUBLISH_SCRIPT_VALUE = 'lerna publish';
29
31
  const REQUIRED_LERNA_VERSION_COMMIT_HOOKS = false;
30
32
 
31
33
  export function printSyncPublishHelp() {
@@ -175,31 +177,49 @@ export function runSyncPublish(options) {
175
177
  typeof scripts[REQUIRED_PUBLISH_SCRIPT_KEY] === 'string'
176
178
  ? scripts[REQUIRED_PUBLISH_SCRIPT_KEY]
177
179
  : null;
180
+ const previousRootPublish =
181
+ typeof scripts[REQUIRED_ROOT_PUBLISH_SCRIPT_KEY] === 'string' &&
182
+ scripts[REQUIRED_ROOT_PUBLISH_SCRIPT_KEY].trim() !== ''
183
+ ? scripts[REQUIRED_ROOT_PUBLISH_SCRIPT_KEY]
184
+ : null;
178
185
 
179
186
  const matchesRequiredPublishCheck =
180
187
  previousPublishCheck === REQUIRED_PUBLISH_CHECK_SCRIPT_VALUE;
181
188
  const matchesRequiredPublish =
182
189
  previousPublish === REQUIRED_PUBLISH_SCRIPT_VALUE;
190
+ const hasRequiredRootPublishBefore = previousRootPublish !== null;
183
191
  const lernaRequiresCreation = !lernaExistedBefore && !lernaDefaultCreated;
184
192
  const requiresUpdate =
185
193
  !matchesRequiredPublishCheck ||
186
194
  !matchesRequiredPublish ||
187
195
  lernaRequiresCreation ||
188
- !matchesRequiredLernaCommitHooks;
189
-
190
- if (
191
- mode === 'sync' &&
192
- (!matchesRequiredPublishCheck || !matchesRequiredPublish)
193
- ) {
194
- scripts[REQUIRED_PUBLISH_CHECK_SCRIPT_KEY] =
195
- REQUIRED_PUBLISH_CHECK_SCRIPT_VALUE;
196
- scripts[REQUIRED_PUBLISH_SCRIPT_KEY] = REQUIRED_PUBLISH_SCRIPT_VALUE;
197
- pkg.scripts = scripts;
198
- fs.writeFileSync(
199
- rootPackageJsonPath,
200
- `${JSON.stringify(pkg, null, 2)}\n`,
201
- 'utf8',
202
- );
196
+ !matchesRequiredLernaCommitHooks ||
197
+ !hasRequiredRootPublishBefore;
198
+
199
+ if (mode === 'sync') {
200
+ let needsWrite = false;
201
+
202
+ if (!matchesRequiredPublishCheck || !matchesRequiredPublish) {
203
+ scripts[REQUIRED_PUBLISH_CHECK_SCRIPT_KEY] =
204
+ REQUIRED_PUBLISH_CHECK_SCRIPT_VALUE;
205
+ scripts[REQUIRED_PUBLISH_SCRIPT_KEY] = REQUIRED_PUBLISH_SCRIPT_VALUE;
206
+ needsWrite = true;
207
+ }
208
+
209
+ if (!hasRequiredRootPublishBefore) {
210
+ scripts[REQUIRED_ROOT_PUBLISH_SCRIPT_KEY] =
211
+ REQUIRED_ROOT_PUBLISH_SCRIPT_VALUE;
212
+ needsWrite = true;
213
+ }
214
+
215
+ if (needsWrite) {
216
+ pkg.scripts = scripts;
217
+ fs.writeFileSync(
218
+ rootPackageJsonPath,
219
+ `${JSON.stringify(pkg, null, 2)}\n`,
220
+ 'utf8',
221
+ );
222
+ }
203
223
  }
204
224
 
205
225
  const report = {
@@ -232,6 +252,9 @@ export function runSyncPublish(options) {
232
252
  !matchesRequiredPublish && mode === 'sync'
233
253
  ? true
234
254
  : matchesRequiredPublish,
255
+ hasRequiredRootPublishBefore,
256
+ hasRequiredRootPublishAfter:
257
+ mode === 'sync' ? true : hasRequiredRootPublishBefore,
235
258
  updated: requiresUpdate && mode === 'sync',
236
259
  },
237
260
  };
@@ -0,0 +1,22 @@
1
+ Usage:
2
+ agent-toolkit sync-workspace [--cwd <dir>]
3
+ [--workspace <path>] ... [--check] [--dry-run] [--json <file>]
4
+
5
+ Behavior:
6
+ - Adds or updates scripts.produck:coverage in each workspace
7
+ package.json.
8
+ - Enforces scripts.test in each workspace package.json.
9
+ - If scripts.test is missing, generates:
10
+ node -e "console.log('No tests configured')"
11
+ - The produck:coverage script in workspaces relies on root-level
12
+ c8 devDependency (hoisted by npm workspaces).
13
+ - Organization-reserved script key is scripts.produck:coverage
14
+
15
+ Rules:
16
+ - When --workspace is omitted, root package.json workspaces are used
17
+ - Single-level glob patterns (e.g. packages/*) are expanded automatically
18
+ - Recursive glob patterns (e.g. packages/**) are rejected
19
+ - Workspace packages must not duplicate root devDependencies;
20
+ c8 belongs in root package.json only
21
+ - --check validates without writing and exits non-zero on mismatch
22
+ - --dry-run prints planned changes without writing
@@ -0,0 +1,340 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { getMulti, getSingle, hasFlag } from '../shared/args.mjs';
6
+ import { printTextResource } from '../shared/text-resource.mjs';
7
+
8
+ const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
9
+ const HELP_FILE = path.resolve(COMMAND_DIR, 'help.txt');
10
+ const PACKAGE_ROOT = path.resolve(COMMAND_DIR, '../../..');
11
+ const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..');
12
+ const TOOLING_BASELINE_CANDIDATE_PATHS = [
13
+ path.resolve(
14
+ REPO_ROOT,
15
+ '.github/distribution/produck/tooling-version-baseline.json',
16
+ ),
17
+ path.resolve(
18
+ PACKAGE_ROOT,
19
+ 'publish-assets/instructions/produck/tooling-version-baseline.json',
20
+ ),
21
+ ];
22
+ const GLOB_TOKEN_PATTERN = /[*?{}[\]]/;
23
+ const REQUIRED_COVERAGE_SCRIPT_KEY = 'produck:coverage';
24
+ const REQUIRED_TEST_SCRIPT_KEY = 'test';
25
+ const DEFAULT_TEST_SCRIPT_VALUE =
26
+ 'node -e "console.log(\'No tests configured\')"';
27
+
28
+ export function printSyncWorkspaceHelp() {
29
+ printTextResource(HELP_FILE);
30
+ }
31
+
32
+ function parseJsonFile(filePath, label) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
35
+ } catch {
36
+ console.error(`${label} is not valid JSON: ${filePath}`);
37
+ process.exit(2);
38
+ }
39
+ }
40
+
41
+ function resolveSemverExact(text) {
42
+ return text.replace(/^[\^~>=<]+\s*/, '').trim();
43
+ }
44
+
45
+ function resolveToolVersionFromDevDeps(baseline, toolName) {
46
+ const baselineVersion = String(
47
+ baseline?.tools?.[toolName]?.version || '',
48
+ ).trim();
49
+ if (baselineVersion && baselineVersion !== 'auto') {
50
+ return baselineVersion;
51
+ }
52
+
53
+ const repoRoot = path.resolve(PACKAGE_ROOT, '../..');
54
+ const pkgJsonPath = path.resolve(repoRoot, 'package.json');
55
+ if (fs.existsSync(pkgJsonPath)) {
56
+ const pkg = parseJsonFile(pkgJsonPath, 'root package.json');
57
+ const dep = pkg?.devDependencies?.[toolName];
58
+ if (typeof dep === 'string' && dep.trim()) {
59
+ return resolveSemverExact(dep);
60
+ }
61
+ }
62
+
63
+ return '';
64
+ }
65
+
66
+ function loadToolingBaseline() {
67
+ const toolingBaselinePath = TOOLING_BASELINE_CANDIDATE_PATHS.find(
68
+ (candidatePath) => fs.existsSync(candidatePath),
69
+ );
70
+
71
+ if (!toolingBaselinePath) {
72
+ console.error(
73
+ 'Tooling baseline file does not exist in expected locations:',
74
+ );
75
+ for (const candidatePath of TOOLING_BASELINE_CANDIDATE_PATHS) {
76
+ console.error(`- ${candidatePath}`);
77
+ }
78
+ process.exit(2);
79
+ }
80
+
81
+ const baseline = parseJsonFile(toolingBaselinePath, 'Tooling baseline file');
82
+ if (typeof baseline.schemaVersion !== 'number') {
83
+ console.error(
84
+ `Tooling baseline schemaVersion must be a number: ${toolingBaselinePath}`,
85
+ );
86
+ process.exit(2);
87
+ }
88
+
89
+ const c8Version = resolveToolVersionFromDevDeps(baseline, 'c8');
90
+ if (typeof c8Version !== 'string' || c8Version.trim() === '') {
91
+ console.error(
92
+ `Tooling baseline tools.c8.version must be a non-empty string: ${toolingBaselinePath}`,
93
+ );
94
+ process.exit(2);
95
+ }
96
+
97
+ const coverageTemplate = baseline?.coverage?.scriptTemplate;
98
+ if (typeof coverageTemplate !== 'string' || coverageTemplate.trim() === '') {
99
+ console.error(
100
+ `Tooling baseline coverage.scriptTemplate must be a non-empty string: ${toolingBaselinePath}`,
101
+ );
102
+ process.exit(2);
103
+ }
104
+
105
+ return { baseline, toolingBaselinePath };
106
+ }
107
+
108
+ function buildRequiredCoverageScript(baseline) {
109
+ const c8Version = String(baseline.tools.c8.version);
110
+ const coverageTemplate = String(baseline.coverage.scriptTemplate);
111
+ return coverageTemplate.replace(/\{c8\.version\}/g, c8Version);
112
+ }
113
+
114
+ function expandGlobPatterns(cwd, entries) {
115
+ const result = [];
116
+ for (const entry of entries) {
117
+ // Reject recursive glob patterns (**) as too dangerous to auto-expand
118
+ if (entry.includes('**')) {
119
+ console.error(
120
+ `Recursive glob pattern '${entry}' is not allowed in workspaces. ` +
121
+ 'Use explicit paths or single-level globs (e.g. packages/*).',
122
+ );
123
+ process.exit(2);
124
+ }
125
+
126
+ const starIndex = entry.indexOf('*');
127
+ if (starIndex === -1) {
128
+ result.push(entry);
129
+ continue;
130
+ }
131
+
132
+ // entry is like "packages/*" → split at first *
133
+ const baseDir = entry.slice(0, starIndex);
134
+ const basePath = path.resolve(cwd, baseDir);
135
+
136
+ if (!fs.existsSync(basePath)) {
137
+ continue;
138
+ }
139
+
140
+ const dirEntries = fs.readdirSync(basePath, { withFileTypes: true });
141
+ const remainder = entry.slice(starIndex + 1);
142
+
143
+ for (const dirent of dirEntries) {
144
+ if (dirent.isDirectory()) {
145
+ const subpath = `${baseDir}${dirent.name}${remainder}`;
146
+ result.push(subpath);
147
+ }
148
+ }
149
+ }
150
+ return result.sort();
151
+ }
152
+
153
+ function resolveWorkspacePaths(cwd, options) {
154
+ const manual = getMulti(options, '--workspace');
155
+ if (manual.length > 0) {
156
+ return manual;
157
+ }
158
+
159
+ const rootPackageJsonPath = path.resolve(cwd, 'package.json');
160
+ const rootPackageJson = parseJsonFile(
161
+ rootPackageJsonPath,
162
+ 'Root package.json',
163
+ );
164
+ if (!Array.isArray(rootPackageJson.workspaces)) {
165
+ return [];
166
+ }
167
+
168
+ const raw = rootPackageJson.workspaces.map((entry) => String(entry));
169
+ if (raw.length === 0) {
170
+ return [];
171
+ }
172
+
173
+ const hasGlob = raw.some((entry) => GLOB_TOKEN_PATTERN.test(entry));
174
+ if (hasGlob) {
175
+ return expandGlobPatterns(cwd, raw);
176
+ }
177
+
178
+ return raw;
179
+ }
180
+
181
+ function reconcileWorkspace(cwd, workspacePath, mode, requiredCoverageScript) {
182
+ const packageDir = path.resolve(cwd, workspacePath);
183
+ const packageJsonPath = path.resolve(packageDir, 'package.json');
184
+
185
+ const result = {
186
+ workspacePath,
187
+ packageDir,
188
+ packageJsonPath,
189
+ exists: false,
190
+ validJson: false,
191
+ previousCoverage: null,
192
+ coverageScript: null,
193
+ previousTestScript: null,
194
+ testScript: null,
195
+ matchesRequiredCoverageBefore: false,
196
+ matchesRequiredCoverageAfter: false,
197
+ hasRequiredTestScriptBefore: false,
198
+ hasRequiredTestScriptAfter: false,
199
+ updated: false,
200
+ error: '',
201
+ };
202
+
203
+ if (!fs.existsSync(packageJsonPath)) {
204
+ result.error = `Workspace package.json does not exist: ${workspacePath}`;
205
+ return result;
206
+ }
207
+ result.exists = true;
208
+
209
+ let pkg;
210
+ try {
211
+ pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
212
+ result.validJson = true;
213
+ } catch {
214
+ result.error = `Workspace package.json is not valid JSON: ${workspacePath}`;
215
+ return result;
216
+ }
217
+
218
+ const scripts =
219
+ pkg.scripts &&
220
+ typeof pkg.scripts === 'object' &&
221
+ !Array.isArray(pkg.scripts)
222
+ ? { ...pkg.scripts }
223
+ : {};
224
+
225
+ const previousCoverage =
226
+ typeof scripts[REQUIRED_COVERAGE_SCRIPT_KEY] === 'string'
227
+ ? scripts[REQUIRED_COVERAGE_SCRIPT_KEY]
228
+ : null;
229
+ const previousTestScript =
230
+ typeof scripts[REQUIRED_TEST_SCRIPT_KEY] === 'string' &&
231
+ scripts[REQUIRED_TEST_SCRIPT_KEY].trim() !== ''
232
+ ? scripts[REQUIRED_TEST_SCRIPT_KEY]
233
+ : null;
234
+ result.previousCoverage = previousCoverage;
235
+ result.previousTestScript = previousTestScript;
236
+ result.matchesRequiredCoverageBefore =
237
+ previousCoverage === requiredCoverageScript;
238
+ result.hasRequiredTestScriptBefore = previousTestScript !== null;
239
+
240
+ if (
241
+ (!result.matchesRequiredCoverageBefore ||
242
+ !result.hasRequiredTestScriptBefore) &&
243
+ mode === 'sync'
244
+ ) {
245
+ scripts[REQUIRED_COVERAGE_SCRIPT_KEY] = requiredCoverageScript;
246
+ if (!result.hasRequiredTestScriptBefore) {
247
+ scripts[REQUIRED_TEST_SCRIPT_KEY] = DEFAULT_TEST_SCRIPT_VALUE;
248
+ }
249
+ pkg.scripts = scripts;
250
+ fs.writeFileSync(
251
+ packageJsonPath,
252
+ `${JSON.stringify(pkg, null, 2)}\n`,
253
+ 'utf8',
254
+ );
255
+ result.updated = true;
256
+ }
257
+
258
+ result.coverageScript =
259
+ mode === 'sync' && !result.matchesRequiredCoverageBefore
260
+ ? requiredCoverageScript
261
+ : previousCoverage;
262
+ result.testScript =
263
+ mode === 'sync' && !result.hasRequiredTestScriptBefore
264
+ ? DEFAULT_TEST_SCRIPT_VALUE
265
+ : previousTestScript;
266
+
267
+ result.matchesRequiredCoverageAfter =
268
+ result.updated || result.matchesRequiredCoverageBefore;
269
+ result.hasRequiredTestScriptAfter =
270
+ (mode === 'sync' && !result.hasRequiredTestScriptBefore) ||
271
+ result.hasRequiredTestScriptBefore;
272
+ return result;
273
+ }
274
+
275
+ export function runSyncWorkspace(options) {
276
+ const cwd = path.resolve(getSingle(options, '--cwd', process.cwd()));
277
+ const check = hasFlag(options, '--check');
278
+ const dryRun = hasFlag(options, '--dry-run');
279
+ const jsonFile = getSingle(options, '--json', '');
280
+ const { baseline: toolingBaseline, toolingBaselinePath } =
281
+ loadToolingBaseline();
282
+ const requiredCoverageScript = buildRequiredCoverageScript(toolingBaseline);
283
+
284
+ if (!fs.existsSync(cwd)) {
285
+ console.error(`CWD does not exist: ${cwd}`);
286
+ process.exit(2);
287
+ }
288
+
289
+ const mode = dryRun ? 'dry-run' : check ? 'check' : 'sync';
290
+ const workspacePaths = resolveWorkspacePaths(cwd, options);
291
+
292
+ const report = {
293
+ cwd,
294
+ mode,
295
+ toolingBaselinePath,
296
+ toolingBaseline: {
297
+ schemaVersion: toolingBaseline.schemaVersion,
298
+ c8Version: toolingBaseline.tools.c8.version,
299
+ },
300
+ requiredCoverageScript,
301
+ requiredTestScript: DEFAULT_TEST_SCRIPT_VALUE,
302
+ workspaces: workspacePaths,
303
+ results: [],
304
+ ok: true,
305
+ };
306
+
307
+ for (const workspacePath of workspacePaths) {
308
+ const effectiveMode = mode === 'sync' ? 'sync' : 'check';
309
+ const item = reconcileWorkspace(
310
+ cwd,
311
+ workspacePath,
312
+ effectiveMode,
313
+ requiredCoverageScript,
314
+ );
315
+ report.results.push(item);
316
+
317
+ if (item.error) {
318
+ report.ok = false;
319
+ continue;
320
+ }
321
+
322
+ if (
323
+ mode === 'check' &&
324
+ (!item.matchesRequiredCoverageAfter || !item.hasRequiredTestScriptAfter)
325
+ ) {
326
+ report.ok = false;
327
+ }
328
+ }
329
+
330
+ if (jsonFile) {
331
+ const outPath = path.resolve(cwd, jsonFile);
332
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
333
+ fs.writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
334
+ }
335
+
336
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
337
+ if (!report.ok) {
338
+ process.exit(2);
339
+ }
340
+ }
package/package.json CHANGED
@@ -31,6 +31,6 @@
31
31
  "test": "node test/index.mjs"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.10.0",
35
- "gitHead": "d0d432838dddbbc90f340b525bdd41153f8e6502"
34
+ "version": "0.11.1",
35
+ "gitHead": "6ed9aad7d47f4483bd865c15543ed0ca4d712d26"
36
36
  }
@@ -138,3 +138,6 @@ dist
138
138
 
139
139
  # @produck/agent-toolkit
140
140
  publish-assets/
141
+
142
+ # Ensure .github directory is not ignored by any existing rules
143
+ !.github/
@@ -51,18 +51,28 @@ The Produck monorepo provides unified configuration across all packages for cons
51
51
 
52
52
  **Decision rule:**
53
53
 
54
- - If the workspace has no TypeScript source files and no package-level need for
55
- shared TypeScript options, do not create/deploy root `tsconfig.json`.
56
- - If any package uses TypeScript source files or needs centralized strict/type
57
- options, create root `tsconfig.json` and let TypeScript packages extend it.
54
+ Root-level `tsconfig.json` is **not** recommended for downstream repositories.
55
+ Prefer per-package `tsconfig.json` that stands alone.
56
+
57
+ - Do **not** create/deploy root `tsconfig.json` unless there is a clear,
58
+ unavoidable need for centralized TypeScript configuration (e.g., a shared
59
+ path alias map or compound project references).
60
+ - Each TypeScript package should manage its own `tsconfig.json` independently.
61
+ - Only introduce root `tsconfig.json` when per-package duplication becomes a
62
+ proven maintenance burden — not as a proactive measure.
58
63
 
59
64
  **Governance (enforced by `agent-toolkit sync-typescript`):**
60
65
 
61
- Run `agent-toolkit sync-typescript --package-root packages/<name> --cwd .`
66
+ When root `tsconfig.json` exists, run
67
+ `agent-toolkit sync-typescript --package-root packages/<name> --cwd .`
62
68
  to ensure a sub-package has a `tsconfig.json` that extends the root with the
63
69
  correct relative path and uses standard compiler options. If the file already
64
70
  exists, it is skipped without modification.
65
71
 
72
+ If no root `tsconfig.json` exists (the recommended default), each TypeScript
73
+ package maintains its own standalone `tsconfig.json` with its own compiler
74
+ options — no `extends` chain is needed.
75
+
66
76
  **Enforced settings:**
67
77
 
68
78
  - Target: ES2022
@@ -95,7 +105,7 @@ exists, it is skipped without modification.
95
105
 
96
106
  **Rules:**
97
107
 
98
- - Print width: 100 characters
108
+ - Print width: 80 characters
99
109
  - Tab width: 2 spaces
100
110
  - Single quotes: true
101
111
  - Trailing commas: es5
@@ -185,6 +195,33 @@ For TypeScript configuration, packages may extend root `tsconfig.json` (see
185
195
  [TypeScript Configuration](#2-typescript-configuration-tsconfigjson-conditional)
186
196
  above).
187
197
 
198
+ ### Dependency Management in Downstream Repositories
199
+
200
+ When a monorepo policy is distributed to a downstream repository, sub-package
201
+ `devDependencies` must **not** duplicate workspace-level (root) devDependencies.
202
+
203
+ **Rationale:**
204
+
205
+ - Avoids version conflicts between root and sub-package declarations
206
+ - Eliminates ambiguity about which declaration is the source of truth
207
+ - Keeps sub-packages version-stable — root-level upgrades apply uniformly,
208
+ preventing drift across packages
209
+ - Reduces `npm install` deduplication overhead and `lockfile` churn
210
+
211
+ **Rule:**
212
+
213
+ - Shared tooling (ESLint, Prettier, TypeScript, c8, test runners, build tools)
214
+ belongs in root `devDependencies` only.
215
+ - Sub-packages may list only **package-specific** devDependencies that are not
216
+ already declared at root level.
217
+ - In the downstream repository, sub-packages that extend root configs (e.g.,
218
+ `extends` in `tsconfig.json` or `eslint.config.mjs`) inherit their tooling
219
+ from root and must **not** redeclare those tools in their own
220
+ `devDependencies`.
221
+ - When a downstream sub-package needs a different version of a root-level tool,
222
+ use root-level overrides (e.g., `overrides` or `resolutions` in root
223
+ `package.json`) rather than redeclaring in the sub-package.
224
+
188
225
  ## .editorconfig
189
226
 
190
227
  **Location:** Root `.editorconfig`
@@ -27,7 +27,7 @@
27
27
  "@eslint/json": {
28
28
  "allowLatest": false,
29
29
  "policy": "pinned",
30
- "version": "1.2.0"
30
+ "version": "2.0.0"
31
31
  },
32
32
  "@eslint/markdown": {
33
33
  "allowLatest": false,
@@ -37,7 +37,7 @@
37
37
  "@types/node": {
38
38
  "allowLatest": false,
39
39
  "policy": "pinned",
40
- "version": "22.19.19"
40
+ "version": "24.13.2"
41
41
  },
42
42
  "c8": {
43
43
  "allowLatest": false,
@@ -47,12 +47,12 @@
47
47
  "eslint": {
48
48
  "allowLatest": false,
49
49
  "policy": "pinned",
50
- "version": "10.4.0"
50
+ "version": "10.6.0"
51
51
  },
52
52
  "globals": {
53
53
  "allowLatest": false,
54
54
  "policy": "pinned",
55
- "version": "17.6.0"
55
+ "version": "17.7.0"
56
56
  },
57
57
  "husky": {
58
58
  "allowLatest": false,
@@ -67,12 +67,12 @@
67
67
  "prettier": {
68
68
  "allowLatest": false,
69
69
  "policy": "pinned",
70
- "version": "3.8.3"
70
+ "version": "3.9.1"
71
71
  },
72
72
  "typescript-eslint": {
73
73
  "allowLatest": false,
74
74
  "policy": "pinned",
75
- "version": "8.60.0"
75
+ "version": "8.62.0"
76
76
  },
77
77
  "@produck/eslint-rules": {
78
78
  "version": "0.4.1",