@wipcomputer/wip-repos 1.9.68 → 1.9.70

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 1.9.69 (2026-04-24)
4
+
5
+ # wip-repos v1.9.69
6
+
7
+ ## Classify lifecycle paths and make sync apply explicit
8
+
9
+ - Classifies discovered repos as active, worktree, trash, sort, sunsetted, archived, or third-party.
10
+ - Ignores worktrees and lifecycle folders by default in `wip-repos check`, with `--all` and `--class` for inspection.
11
+ - Makes `wip-repos sync` dry-run by default; repo moves require `--apply`.
12
+ - Refuses sync moves for dirty repos, linked worktrees, and target collisions.
13
+ - Adds a regression test for default check filtering, lifecycle class output, sync planning, and dirty-repo refusal.
package/README.md CHANGED
@@ -21,12 +21,18 @@ Move folders around all day. On sync, everything snaps back to where the manifes
21
21
  ```bash
22
22
  # Check for drift between filesystem and manifest
23
23
  wip-repos check
24
+ wip-repos check --all
25
+ wip-repos check --class worktree
26
+
27
+ # Report release-pipeline enrollment status
28
+ wip-repos release-enrollment
29
+ wip-repos release-enrollment --strict
24
30
 
25
31
  # See what sync would do
26
- wip-repos sync --dry-run
32
+ wip-repos sync
27
33
 
28
34
  # Actually move folders to match manifest
29
- wip-repos sync
35
+ wip-repos sync --apply
30
36
 
31
37
  # Add a new repo
32
38
  wip-repos add ldm-os/utilities/my-tool --remote wipcomputer/my-tool
@@ -44,21 +50,49 @@ wip-repos tree
44
50
  --manifest Path to repos-manifest.json (default: ./repos-manifest.json)
45
51
  --root Path to repos root directory (default: directory containing manifest)
46
52
  --dry-run Show what would happen without making changes
53
+ --strict Treat missing release enrollment decisions as failures
47
54
  --json Output as JSON
48
55
  ```
49
56
 
50
57
  ## How It Works
51
58
 
52
- 1. **check** walks the filesystem, finds all git repos, compares against the manifest. Reports what's on disk but not in manifest, and what's in manifest but not on disk. Exit code 1 if drift detected.
59
+ 1. **check** walks the filesystem, classifies repos, and compares active repos against the manifest. Worktrees, trash, sort, sunsetted, and archived paths are ignored by default and summarized separately. Use `--all` or `--class <class>` to inspect them.
53
60
 
54
- 2. **sync** matches repos by their git remote URL. If a repo's remote matches a manifest entry but it's at the wrong path, sync moves it to the manifest path.
61
+ 2. **sync** matches active repos by their git remote URL. If a repo's remote matches a manifest entry but it's at the wrong path, sync prints the move plan by default. It only moves with `--apply`, and refuses dirty repos, linked worktrees, and target collisions.
55
62
 
56
63
  3. **add/move** update the manifest file. The actual folder moves happen on the next `sync`.
57
64
 
65
+ 4. **release-enrollment** reports which active manifest repos are enrolled in the release pipeline, explicitly excluded, still need a release-profile decision, missing on disk, or active on disk but unmanifested. By default, missing repos and unmanifested active repos are blockers. With `--strict`, missing release decisions are blockers too.
66
+
67
+ Release enrollment metadata lives in each manifest entry:
68
+
69
+ ```json
70
+ {
71
+ "ldm-os/devops/my-tool-private": {
72
+ "remote": "wipcomputer/my-tool-private",
73
+ "release": {
74
+ "enabled": true,
75
+ "profile": "node-package",
76
+ "smokeProfile": "ldm-tool",
77
+ "publicMirror": "wipcomputer/my-tool",
78
+ "requiredSecrets": ["NPM_TOKEN"]
79
+ }
80
+ },
81
+ "ldm-os/docs/archive": {
82
+ "remote": "wipcomputer/docs-archive",
83
+ "release": {
84
+ "enabled": false,
85
+ "reason": "archived repo"
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
58
91
  ## Integration
59
92
 
60
93
  - `deploy-public` and `wip-release` can call `wip-repos check` before running. Stale manifest blocks deploys.
61
94
  - CI: run `wip-repos check` as a PR check. Drift = blocked merge.
95
+ - CI/release planning: run `wip-repos release-enrollment --strict --json` to make release-owned repo enrollment machine-readable.
62
96
  - README generation: `wip-repos tree` outputs a directory tree from the manifest.
63
97
 
64
98
  ## Source
package/SKILL.md CHANGED
@@ -39,6 +39,7 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
39
39
  **Use wip-repos for:**
40
40
  - Checking if the filesystem matches the manifest (`check`)
41
41
  - Moving repos to match the manifest (`sync`)
42
+ - Reporting release-pipeline enrollment state (`release-enrollment`)
42
43
  - Adding a new repo to the manifest (`add`)
43
44
  - Moving a repo in the manifest (`move`)
44
45
  - Generating a directory tree from the manifest (`tree`)
@@ -59,8 +60,12 @@ Repo manifest reconciler. Like prettier for folder structure. Move folders aroun
59
60
 
60
61
  ```bash
61
62
  wip-repos check # diff filesystem vs manifest
62
- wip-repos sync --dry-run # preview moves
63
- wip-repos sync # execute moves
63
+ wip-repos check --all # include worktrees/trash/archive paths
64
+ wip-repos check --class worktree # inspect one lifecycle class
65
+ wip-repos release-enrollment # report release enrollment decisions
66
+ wip-repos release-enrollment --strict --json # CI-ready enrollment gate
67
+ wip-repos sync # preview moves
68
+ wip-repos sync --apply # execute safe moves
64
69
  wip-repos add ldm-os/utilities/new-tool --remote wipcomputer/new-tool
65
70
  wip-repos move ldm-os/utilities/tool --to ldm-os/devops/tool
66
71
  wip-repos tree # generate directory tree
@@ -69,12 +74,15 @@ wip-repos tree # generate directory tree
69
74
  ### Module
70
75
 
71
76
  ```javascript
72
- import { check, planSync, addRepo, moveRepo, generateReadmeTree } from '@wipcomputer/wip-repos';
77
+ import { check, releaseEnrollment, planSync, addRepo, moveRepo, generateReadmeTree } from '@wipcomputer/wip-repos';
73
78
 
74
79
  const result = check('/path/to/manifest.json', '/path/to/repos/');
80
+ const enrollment = releaseEnrollment('/path/to/manifest.json', '/path/to/repos/', { strict: true });
75
81
  const moves = planSync('/path/to/manifest.json', '/path/to/repos/');
76
82
  ```
77
83
 
84
+ `sync` is dry-run by default. It refuses dirty repos, linked worktrees, and target collisions when `--apply` is used.
85
+
78
86
  ### MCP
79
87
 
80
- Tools: `repos_check`, `repos_sync_plan`, `repos_add`, `repos_move`, `repos_tree`
88
+ Tools: `repos_check`, `repos_release_enrollment`, `repos_sync_plan`, `repos_add`, `repos_move`, `repos_tree`
package/cli.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  * tree - Generate directory tree from manifest
12
12
  */
13
13
 
14
- import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest } from './core.mjs';
14
+ import { check, planSync, executeSync, addRepo, moveRepo, generateReadmeTree, loadManifest, checkCompliance, fixCompliance, findUnmanifested, releaseEnrollment } from './core.mjs';
15
15
  import { runClaude } from './claude.mjs';
16
16
  import { resolve, dirname, join } from 'node:path';
17
17
  import { readFileSync } from 'node:fs';
@@ -31,16 +31,34 @@ function usage() {
31
31
  console.log(`wip-repos ... repo manifest reconciler
32
32
 
33
33
  Usage:
34
- wip-repos check [--manifest path] [--root path]
35
- wip-repos sync [--manifest path] [--root path] [--dry-run]
34
+ wip-repos check [--manifest path] [--root path] [--all] [--class class]
35
+ wip-repos sync [--manifest path] [--root path] [--apply]
36
+ wip-repos compliance [--manifest path] [--root path] [--fix]
37
+ wip-repos watchdog [--manifest path] [--root path]
38
+ wip-repos release-enrollment [--manifest path] [--root path] [--strict]
36
39
  wip-repos add <path> --remote <org/repo> [--category cat] [--description desc]
37
40
  wip-repos move <path> --to <new-path>
38
- wip-repos tree [--manifest path]
41
+ wip-repos tree [--manifest path]
42
+
43
+ Commands:
44
+ check Diff filesystem against manifest, flag drift
45
+ sync Move local folders to match the manifest
46
+ compliance Check all repos for .license-guard.json, LICENSE, CLA.md, .npmignore
47
+ watchdog Find repos on disk that are not in the manifest
48
+ release-enrollment
49
+ Report release-pipeline enrollment decisions for active repos
50
+ add Add a repo to the manifest
51
+ move Move a repo to a different category in the manifest
52
+ tree Generate directory tree from manifest
39
53
 
40
54
  Options:
41
55
  --manifest Path to repos-manifest.json (default: ./repos-manifest.json)
42
56
  --root Path to repos root directory (default: directory containing manifest)
43
- --dry-run Show what would happen without making changes
57
+ --all Include worktrees, trash, archived, and other ignored lifecycle paths
58
+ --class Show one lifecycle class only (active, worktree, trash, sort, sunsetted, archived, third-party)
59
+ --strict Treat missing release enrollment decisions as failures
60
+ --apply Apply sync moves. Without this, sync is a dry run
61
+ --fix Create missing compliance files (with compliance command)
44
62
  --json Output as JSON`);
45
63
  }
46
64
 
@@ -56,13 +74,17 @@ function hasFlag(flag) {
56
74
 
57
75
  const manifestPath = resolve(getFlag('--manifest') || 'repos-manifest.json');
58
76
  const reposRoot = resolve(getFlag('--root') || dirname(manifestPath));
59
- const dryRun = hasFlag('--dry-run');
77
+ const apply = hasFlag('--apply');
78
+ const dryRun = hasFlag('--dry-run') || !apply;
60
79
  const jsonOutput = hasFlag('--json');
61
80
 
62
81
  try {
63
82
  switch (command) {
64
83
  case 'check': {
65
- const result = check(manifestPath, reposRoot);
84
+ const result = check(manifestPath, reposRoot, {
85
+ all: hasFlag('--all'),
86
+ classFilter: getFlag('--class') || null,
87
+ });
66
88
 
67
89
  if (jsonOutput) {
68
90
  console.log(JSON.stringify(result, null, 2));
@@ -72,12 +94,18 @@ try {
72
94
  console.log(`Manifest: ${result.total.manifest} repos`);
73
95
  console.log(`On disk: ${result.total.disk} repos`);
74
96
  console.log(`Matched: ${result.total.matched}`);
97
+ if (Object.keys(result.ignored).length > 0 && !hasFlag('--all') && !getFlag('--class')) {
98
+ const ignored = Object.entries(result.ignored).map(([k, v]) => `${k}=${v}`).join(', ');
99
+ console.log(`Ignored lifecycle paths: ${ignored}`);
100
+ }
75
101
  console.log();
76
102
 
77
103
  if (result.onDiskOnly.length > 0) {
78
104
  console.log(`On disk but NOT in manifest (${result.onDiskOnly.length}):`);
79
- for (const p of result.onDiskOnly) {
80
- console.log(` + ${p}`);
105
+ for (const p of result.onDiskOnly) {
106
+ const detail = result.onDiskOnlyDetails.find(d => d.path === p);
107
+ const suffix = detail ? ` [${detail.class}: ${detail.reason}]` : '';
108
+ console.log(` + ${p}${suffix}`);
81
109
  }
82
110
  console.log();
83
111
  }
@@ -122,7 +150,7 @@ try {
122
150
  }
123
151
 
124
152
  if (dryRun) {
125
- console.log('Dry run. No changes made.');
153
+ console.log('Dry run. No changes made. Re-run with --apply to move repos.');
126
154
  break;
127
155
  }
128
156
 
@@ -174,6 +202,147 @@ try {
174
202
  break;
175
203
  }
176
204
 
205
+ case 'compliance': {
206
+ const results = checkCompliance(manifestPath, reposRoot);
207
+ const failing = results.filter(r => !r.clean);
208
+ const passing = results.filter(r => r.clean);
209
+
210
+ if (jsonOutput) {
211
+ console.log(JSON.stringify(results, null, 2));
212
+ break;
213
+ }
214
+
215
+ console.log(`Compliance check: ${results.length} repos scanned\n`);
216
+
217
+ if (failing.length > 0) {
218
+ console.log(`FAILING (${failing.length}):`);
219
+ for (const r of failing) {
220
+ console.log(` ✗ ${r.repoPath}`);
221
+ for (const issue of r.issues) {
222
+ console.log(` - ${issue}`);
223
+ }
224
+ }
225
+ console.log();
226
+ }
227
+
228
+ if (passing.length > 0) {
229
+ console.log(`PASSING (${passing.length}):`);
230
+ for (const r of passing) {
231
+ console.log(` ✓ ${r.repoPath}`);
232
+ }
233
+ console.log();
234
+ }
235
+
236
+ if (failing.length > 0 && hasFlag('--fix')) {
237
+ console.log('Fixing...\n');
238
+ for (const r of failing) {
239
+ const created = fixCompliance(r.fullPath);
240
+ if (created.length > 0) {
241
+ console.log(` ${r.repoPath}: created ${created.join(', ')}`);
242
+ }
243
+ }
244
+ console.log('\nFiles created. You still need to commit and push each repo.');
245
+ } else if (failing.length > 0) {
246
+ console.log('Run with --fix to create missing files.');
247
+ }
248
+
249
+ if (failing.length > 0) process.exit(1);
250
+ break;
251
+ }
252
+
253
+ case 'watchdog': {
254
+ const unmanifested = findUnmanifested(manifestPath, reposRoot);
255
+ if (jsonOutput) {
256
+ console.log(JSON.stringify(unmanifested, null, 2));
257
+ break;
258
+ }
259
+ if (unmanifested.length === 0) {
260
+ console.log('All repos on disk are in the manifest.');
261
+ } else {
262
+ console.log(`Repos on disk but NOT in manifest (${unmanifested.length}):`);
263
+ for (const p of unmanifested) {
264
+ console.log(` ! ${p}`);
265
+ }
266
+ console.log('\nAdd them with: wip-repos add <path> --remote <org/repo>');
267
+ process.exit(1);
268
+ }
269
+ break;
270
+ }
271
+
272
+ case 'release-enrollment': {
273
+ const result = releaseEnrollment(manifestPath, reposRoot, {
274
+ strict: hasFlag('--strict'),
275
+ });
276
+
277
+ if (jsonOutput) {
278
+ console.log(JSON.stringify(result, null, 2));
279
+ if (!result.clean) process.exit(1);
280
+ break;
281
+ }
282
+
283
+ console.log('Release enrollment inventory');
284
+ console.log(`Active manifest repos: ${result.summary.manifestActive}`);
285
+ console.log(`Enrolled: ${result.summary.enrolled}`);
286
+ console.log(`Excluded: ${result.summary.excluded}`);
287
+ console.log(`Needs decision: ${result.summary.needsDecision}`);
288
+ console.log(`Missing on disk: ${result.summary.missingOnDisk}`);
289
+ console.log(`Unmanifested active: ${result.summary.unmanifestedActive}`);
290
+ console.log(`Blockers: ${result.summary.blockers}`);
291
+ console.log();
292
+
293
+ if (result.enrolled.length > 0) {
294
+ console.log(`ENROLLED (${result.enrolled.length}):`);
295
+ for (const entry of result.enrolled) {
296
+ const profile = entry.profile ? ` profile=${entry.profile}` : '';
297
+ const pkg = entry.packageName ? ` package=${entry.packageName}` : '';
298
+ const issues = entry.issues?.length ? ` issues=${entry.issues.join('; ')}` : '';
299
+ console.log(` ✓ ${entry.path}${profile}${pkg}${issues}`);
300
+ }
301
+ console.log();
302
+ }
303
+
304
+ if (result.excluded.length > 0) {
305
+ console.log(`EXCLUDED (${result.excluded.length}):`);
306
+ for (const entry of result.excluded) {
307
+ console.log(` - ${entry.path}: ${entry.reason}`);
308
+ }
309
+ console.log();
310
+ }
311
+
312
+ if (result.needsDecision.length > 0) {
313
+ const mode = result.strict ? 'BLOCKING because --strict is set' : 'NON-BLOCKING without --strict';
314
+ console.log(`NEEDS DECISION (${result.needsDecision.length}, ${mode}):`);
315
+ for (const entry of result.needsDecision) {
316
+ console.log(` ? ${entry.path}: ${entry.issue}`);
317
+ }
318
+ console.log();
319
+ }
320
+
321
+ if (result.missingOnDisk.length > 0) {
322
+ console.log(`MISSING ON DISK (${result.missingOnDisk.length}):`);
323
+ for (const entry of result.missingOnDisk) {
324
+ console.log(` ! ${entry.path}: ${entry.issue}`);
325
+ }
326
+ console.log();
327
+ }
328
+
329
+ if (result.unmanifestedActive.length > 0) {
330
+ console.log(`UNMANIFESTED ACTIVE (${result.unmanifestedActive.length}):`);
331
+ for (const entry of result.unmanifestedActive) {
332
+ console.log(` + ${entry.path}: ${entry.reason}`);
333
+ }
334
+ console.log();
335
+ }
336
+
337
+ if (result.clean) {
338
+ console.log('Release enrollment inventory is actionable.');
339
+ } else {
340
+ console.log('Release enrollment inventory has blockers.');
341
+ process.exit(1);
342
+ }
343
+ break;
344
+ }
345
+
177
346
  case 'claude': {
178
347
  runClaude(manifestPath, args.slice(1));
179
348
  break;
package/core.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  * Like prettier for folder structure.
6
6
  */
7
7
 
8
+ import { execSync } from 'node:child_process';
8
9
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, renameSync } from 'node:fs';
9
10
  import { join, dirname, relative, resolve } from 'node:path';
10
11
 
@@ -58,45 +59,91 @@ export function saveManifest(manifestPath, meta, repos) {
58
59
  writeFileSync(manifestPath, JSON.stringify(out, null, 2) + '\n', 'utf8');
59
60
  }
60
61
 
62
+ export function classifyRepoPath(relPath, fullPath) {
63
+ const parts = relPath.split('/');
64
+ if (parts.includes('.worktrees')) return { class: 'worktree', reason: 'inside .worktrees' };
65
+ if (parts.includes('_trash')) return { class: 'trash', reason: 'inside _trash' };
66
+ if (parts.includes('_sort')) return { class: 'sort', reason: 'inside _sort' };
67
+ if (parts.includes('_sunsetted')) return { class: 'sunsetted', reason: 'inside _sunsetted' };
68
+ if (parts.includes('_archive') || parts.includes('archive') || parts.includes('archived')) {
69
+ return { class: 'archived', reason: 'inside archive path' };
70
+ }
71
+ if (parts.includes('third-party') || parts.includes('third-party-repos') || parts.includes('external')) {
72
+ return { class: 'third-party', reason: 'inside third-party path' };
73
+ }
74
+ try {
75
+ const gitStat = statSync(join(fullPath, '.git'));
76
+ if (gitStat.isFile()) return { class: 'worktree', reason: '.git is a worktree file' };
77
+ } catch {}
78
+ return { class: 'active', reason: 'regular repo path' };
79
+ }
80
+
81
+ function shouldSkipDirName(name, includeIgnored) {
82
+ if (name === '.git' || name === 'node_modules' || name === '.DS_Store') return true;
83
+ if (includeIgnored) return false;
84
+ return ['.worktrees', '_trash', '_sort', '_sunsetted', '_archive', 'archive', 'archived'].includes(name);
85
+ }
86
+
61
87
  /**
62
- * Walk a directory tree and find all git repos (directories containing .git/).
63
- * Returns array of relative paths from the root.
88
+ * Walk a directory tree and find all git repos (directories containing .git).
89
+ * Returns classified entries: { path, fullPath, class, reason }.
64
90
  */
65
- export function walkRepos(rootDir, prefix = '') {
91
+ export function walkRepoEntries(rootDir, prefix = '', opts = {}) {
66
92
  const results = [];
67
93
  if (!existsSync(rootDir)) return results;
94
+ const includeIgnored = opts.includeIgnored || false;
95
+ const classFilter = opts.classFilter || null;
68
96
 
69
97
  const entries = readdirSync(rootDir, { withFileTypes: true });
70
98
  for (const entry of entries) {
71
99
  if (!entry.isDirectory()) continue;
72
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
73
- if (entry.name === '.DS_Store') continue;
100
+ if (shouldSkipDirName(entry.name, includeIgnored)) continue;
74
101
 
75
102
  const fullPath = join(rootDir, entry.name);
76
103
  const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
77
104
 
78
105
  // If this directory has a .git, it's a repo
79
106
  if (existsSync(join(fullPath, '.git'))) {
80
- results.push(relPath);
107
+ const cls = classifyRepoPath(relPath, fullPath);
108
+ if ((!classFilter || cls.class === classFilter) && (includeIgnored || cls.class === 'active')) {
109
+ results.push({ path: relPath, fullPath, ...cls });
110
+ }
81
111
  } else {
82
112
  // Recurse into non-repo directories (organizational folders)
83
- results.push(...walkRepos(fullPath, relPath));
113
+ results.push(...walkRepoEntries(fullPath, relPath, opts));
84
114
  }
85
115
  }
86
116
  return results;
87
117
  }
88
118
 
119
+ export function walkRepos(rootDir, prefix = '', opts = {}) {
120
+ return walkRepoEntries(rootDir, prefix, opts).map(r => r.path);
121
+ }
122
+
89
123
  /**
90
124
  * Check: diff the filesystem against the manifest.
91
125
  * Returns { inManifestOnly, onDiskOnly, matched }
92
126
  */
93
- export function check(manifestPath, reposRoot) {
127
+ export function check(manifestPath, reposRoot, opts = {}) {
94
128
  const manifest = loadManifest(manifestPath);
95
- const manifestPaths = new Set(Object.keys(manifest.repos));
96
- const diskPaths = new Set(walkRepos(reposRoot));
129
+ const includeIgnored = opts.all || opts.includeIgnored || Boolean(opts.classFilter);
130
+ const manifestEntries = Object.keys(manifest.repos)
131
+ .map(path => ({ path, ...classifyRepoPath(path, join(reposRoot, path)) }))
132
+ .filter(entry => {
133
+ if (opts.classFilter) return entry.class === opts.classFilter;
134
+ return includeIgnored || entry.class === 'active';
135
+ });
136
+ const manifestPaths = new Set(manifestEntries.map(e => e.path));
137
+ const allDiskEntries = walkRepoEntries(reposRoot, '', { includeIgnored: true });
138
+ const diskEntries = walkRepoEntries(reposRoot, '', {
139
+ includeIgnored,
140
+ classFilter: opts.classFilter || null,
141
+ });
142
+ const diskPaths = new Set(diskEntries.map(e => e.path));
97
143
 
98
144
  const inManifestOnly = [];
99
145
  const onDiskOnly = [];
146
+ const onDiskOnlyDetails = [];
100
147
  const matched = [];
101
148
 
102
149
  for (const p of manifestPaths) {
@@ -110,16 +157,28 @@ export function check(manifestPath, reposRoot) {
110
157
  for (const p of diskPaths) {
111
158
  if (!manifestPaths.has(p)) {
112
159
  onDiskOnly.push(p);
160
+ const detail = diskEntries.find(e => e.path === p);
161
+ if (detail) onDiskOnlyDetails.push(detail);
113
162
  }
114
163
  }
115
164
 
165
+ const ignored = {};
166
+ for (const entry of allDiskEntries) {
167
+ if (entry.class === 'active') continue;
168
+ ignored[entry.class] = (ignored[entry.class] || 0) + 1;
169
+ }
170
+
116
171
  return {
117
172
  inManifestOnly: inManifestOnly.sort(),
118
173
  onDiskOnly: onDiskOnly.sort(),
174
+ onDiskOnlyDetails: onDiskOnlyDetails.sort((a, b) => a.path.localeCompare(b.path)),
119
175
  matched: matched.sort(),
176
+ disk: diskEntries.sort((a, b) => a.path.localeCompare(b.path)),
177
+ ignored,
120
178
  total: {
121
179
  manifest: manifestPaths.size,
122
180
  disk: diskPaths.size,
181
+ diskAll: allDiskEntries.length,
123
182
  matched: matched.length,
124
183
  },
125
184
  };
@@ -187,6 +246,14 @@ export function executeSync(moves, reposRoot) {
187
246
  results.push({ ...move, status: 'skipped', reason: 'target exists' });
188
247
  continue;
189
248
  }
249
+ if (isWorktree(move.fromFull)) {
250
+ results.push({ ...move, status: 'skipped', reason: 'source is a linked worktree' });
251
+ continue;
252
+ }
253
+ if (isDirtyRepo(move.fromFull)) {
254
+ results.push({ ...move, status: 'skipped', reason: 'source repo has uncommitted changes' });
255
+ continue;
256
+ }
190
257
  try {
191
258
  renameSync(move.fromFull, move.toFull);
192
259
  results.push({ ...move, status: 'moved' });
@@ -197,6 +264,26 @@ export function executeSync(moves, reposRoot) {
197
264
  return results;
198
265
  }
199
266
 
267
+ export function isWorktree(repoPath) {
268
+ try {
269
+ return statSync(join(repoPath, '.git')).isFile();
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ export function isDirtyRepo(repoPath) {
276
+ try {
277
+ return execSync('git status --porcelain 2>/dev/null', {
278
+ cwd: repoPath,
279
+ encoding: 'utf8',
280
+ timeout: 3000,
281
+ }).trim().length > 0;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
200
287
  /**
201
288
  * Add a repo to the manifest.
202
289
  */
@@ -288,3 +375,261 @@ export function generateReadmeTree(manifestPath) {
288
375
  render(tree, '', false);
289
376
  return lines.join('\n');
290
377
  }
378
+
379
+ // ── Compliance checking (Wave 3.3) ──────────────────────────────────
380
+
381
+ const DEFAULT_LICENSE_GUARD = {
382
+ copyright: 'WIP Computer, Inc.',
383
+ license: 'MIT+AGPL',
384
+ spdx: 'MIT AND AGPL-3.0-or-later',
385
+ };
386
+
387
+ /**
388
+ * Check compliance of all repos in the manifest.
389
+ * Returns array of { repoPath, fullPath, issues[], hasPackageJson }
390
+ */
391
+ export function checkCompliance(manifestPath, reposRoot) {
392
+ const manifest = loadManifest(manifestPath);
393
+ const results = [];
394
+
395
+ for (const [repoPath, info] of Object.entries(manifest.repos)) {
396
+ const fullPath = join(reposRoot, repoPath);
397
+ if (!existsSync(fullPath)) continue;
398
+ if (!existsSync(join(fullPath, 'package.json'))) continue;
399
+
400
+ // Skip _to-privatize, _sort, _sunsetted, _trash
401
+ if (repoPath.includes('_to-privatize') || repoPath.includes('_sort') ||
402
+ repoPath.includes('_sunsetted') || repoPath.includes('_trash')) continue;
403
+
404
+ const issues = [];
405
+
406
+ if (!existsSync(join(fullPath, '.license-guard.json'))) {
407
+ issues.push('missing .license-guard.json');
408
+ }
409
+ if (!existsSync(join(fullPath, 'LICENSE'))) {
410
+ issues.push('missing LICENSE');
411
+ } else {
412
+ const lic = readFileSync(join(fullPath, 'LICENSE'), 'utf8');
413
+ if (!lic.includes('WIP Computer')) issues.push('LICENSE missing copyright');
414
+ if (!lic.includes('AGPL') && !lic.includes('GNU Affero')) issues.push('LICENSE missing AGPL');
415
+ }
416
+ if (!existsSync(join(fullPath, 'CLA.md'))) {
417
+ issues.push('missing CLA.md');
418
+ }
419
+ if (existsSync(join(fullPath, 'ai')) && !existsSync(join(fullPath, '.npmignore'))) {
420
+ issues.push('missing .npmignore (has ai/ directory)');
421
+ } else if (existsSync(join(fullPath, 'ai')) && existsSync(join(fullPath, '.npmignore'))) {
422
+ const npmignore = readFileSync(join(fullPath, '.npmignore'), 'utf8');
423
+ if (!npmignore.includes('ai/')) issues.push('.npmignore does not exclude ai/');
424
+ }
425
+
426
+ results.push({ repoPath, fullPath, issues, clean: issues.length === 0 });
427
+ }
428
+
429
+ return results;
430
+ }
431
+
432
+ /**
433
+ * Fix compliance issues for a single repo.
434
+ * Creates missing files with WIP Computer defaults.
435
+ * Returns array of files created.
436
+ */
437
+ export function fixCompliance(fullPath) {
438
+ const created = [];
439
+
440
+ // .license-guard.json
441
+ if (!existsSync(join(fullPath, '.license-guard.json'))) {
442
+ writeFileSync(
443
+ join(fullPath, '.license-guard.json'),
444
+ JSON.stringify(DEFAULT_LICENSE_GUARD, null, 2) + '\n',
445
+ );
446
+ created.push('.license-guard.json');
447
+ }
448
+
449
+ // CLA.md
450
+ if (!existsSync(join(fullPath, 'CLA.md'))) {
451
+ writeFileSync(join(fullPath, 'CLA.md'), [
452
+ '# Contributor License Agreement',
453
+ '',
454
+ 'By submitting a pull request to this repository, you agree that:',
455
+ '',
456
+ '1. Your contribution is original work or you have the right to submit it.',
457
+ '2. You grant WIP Computer, Inc. a perpetual, worldwide, non-exclusive,',
458
+ ' royalty-free license to use, modify, and distribute your contribution',
459
+ ' under the terms of this project\'s license.',
460
+ '3. You understand your contribution will be publicly available.',
461
+ '',
462
+ 'This CLA is required for all contributions. By opening a PR, you accept these terms.',
463
+ '',
464
+ ].join('\n'));
465
+ created.push('CLA.md');
466
+ }
467
+
468
+ // .npmignore (if ai/ exists)
469
+ if (existsSync(join(fullPath, 'ai'))) {
470
+ const npmignorePath = join(fullPath, '.npmignore');
471
+ if (!existsSync(npmignorePath)) {
472
+ writeFileSync(npmignorePath, 'ai/\n.claude/\n.worktrees/\n.DS_Store\nCLAUDE.md\n');
473
+ created.push('.npmignore');
474
+ } else {
475
+ const content = readFileSync(npmignorePath, 'utf8');
476
+ if (!content.includes('ai/')) {
477
+ writeFileSync(npmignorePath, content.trimEnd() + '\nai/\n');
478
+ created.push('.npmignore (updated)');
479
+ }
480
+ }
481
+ }
482
+
483
+ return created;
484
+ }
485
+
486
+ /**
487
+ * Find repos on disk that are NOT in the manifest.
488
+ * This is the watchdog function for the daily audit.
489
+ */
490
+ export function findUnmanifested(manifestPath, reposRoot) {
491
+ const result = check(manifestPath, reposRoot);
492
+ return result.onDiskOnly;
493
+ }
494
+
495
+ function readPackageInfo(fullPath) {
496
+ const packagePath = join(fullPath, 'package.json');
497
+ if (!existsSync(packagePath)) return null;
498
+ try {
499
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
500
+ return {
501
+ name: pkg.name || null,
502
+ version: pkg.version || null,
503
+ private: Boolean(pkg.private),
504
+ scripts: pkg.scripts || {},
505
+ };
506
+ } catch (err) {
507
+ return { error: err.message };
508
+ }
509
+ }
510
+
511
+ function getReleaseConfig(info) {
512
+ if (!info || typeof info !== 'object') return {};
513
+ if (info.release && typeof info.release === 'object') return info.release;
514
+ if (info.ci && typeof info.ci === 'object' && info.ci.release && typeof info.ci.release === 'object') {
515
+ return info.ci.release;
516
+ }
517
+ return {};
518
+ }
519
+
520
+ function isReleaseExcluded(releaseConfig) {
521
+ return releaseConfig.enabled === false || releaseConfig.enrolled === false;
522
+ }
523
+
524
+ function isReleaseEnrolled(releaseConfig) {
525
+ return releaseConfig.enabled === true || releaseConfig.enrolled === true || Boolean(releaseConfig.profile);
526
+ }
527
+
528
+ /**
529
+ * Report release-pipeline enrollment status for active manifest repos.
530
+ *
531
+ * This intentionally reuses the manifest and filesystem classifier from check().
532
+ * It answers the Phase 0 release-pipeline question: which repos are enrolled,
533
+ * which are explicitly excluded, and which still need a release-profile decision.
534
+ */
535
+ export function releaseEnrollment(manifestPath, reposRoot, opts = {}) {
536
+ const manifest = loadManifest(manifestPath);
537
+ const inventory = check(manifestPath, reposRoot);
538
+ const strict = Boolean(opts.strict);
539
+
540
+ const enrolled = [];
541
+ const excluded = [];
542
+ const needsDecision = [];
543
+ const missingOnDisk = [];
544
+
545
+ for (const [repoPath, info] of Object.entries(manifest.repos)) {
546
+ const classification = classifyRepoPath(repoPath, join(reposRoot, repoPath));
547
+ if (classification.class !== 'active') continue;
548
+
549
+ const fullPath = join(reposRoot, repoPath);
550
+ const exists = existsSync(fullPath);
551
+ const releaseConfig = getReleaseConfig(info);
552
+ const packageInfo = exists ? readPackageInfo(fullPath) : null;
553
+ const entry = {
554
+ path: repoPath,
555
+ remote: info.remote || null,
556
+ packageName: packageInfo?.name || releaseConfig.packageName || releaseConfig.npmPackage || null,
557
+ packageVersion: packageInfo?.version || null,
558
+ profile: releaseConfig.profile || null,
559
+ smokeProfile: releaseConfig.smokeProfile || null,
560
+ publicMirror: releaseConfig.publicMirror || info.public || null,
561
+ requiredSecrets: Array.isArray(releaseConfig.requiredSecrets) ? releaseConfig.requiredSecrets : [],
562
+ scripts: packageInfo?.scripts ? Object.keys(packageInfo.scripts).sort() : [],
563
+ };
564
+
565
+ if (!exists) {
566
+ missingOnDisk.push({
567
+ ...entry,
568
+ issue: 'manifest entry is active but repo is not on disk',
569
+ });
570
+ continue;
571
+ }
572
+
573
+ if (packageInfo?.error) {
574
+ needsDecision.push({
575
+ ...entry,
576
+ issue: `package.json is invalid: ${packageInfo.error}`,
577
+ });
578
+ continue;
579
+ }
580
+
581
+ if (isReleaseExcluded(releaseConfig)) {
582
+ excluded.push({
583
+ ...entry,
584
+ reason: releaseConfig.reason || releaseConfig.excludeReason || 'release enrollment explicitly disabled',
585
+ });
586
+ continue;
587
+ }
588
+
589
+ if (isReleaseEnrolled(releaseConfig)) {
590
+ const issues = [];
591
+ if (!entry.profile) issues.push('missing release.profile');
592
+ if (!entry.packageName && packageInfo) issues.push('missing package name');
593
+ enrolled.push({ ...entry, issues });
594
+ continue;
595
+ }
596
+
597
+ needsDecision.push({
598
+ ...entry,
599
+ issue: 'missing release enrollment decision',
600
+ });
601
+ }
602
+
603
+ const unmanifestedActive = inventory.onDiskOnlyDetails
604
+ .filter(entry => entry.class === 'active')
605
+ .map(entry => ({
606
+ path: entry.path,
607
+ reason: entry.reason,
608
+ }));
609
+
610
+ const blockers = [
611
+ ...missingOnDisk.map(entry => ({ type: 'missing-on-disk', path: entry.path, issue: entry.issue })),
612
+ ...unmanifestedActive.map(entry => ({ type: 'unmanifested-active', path: entry.path, issue: entry.reason })),
613
+ ...(strict ? needsDecision.map(entry => ({ type: 'needs-decision', path: entry.path, issue: entry.issue })) : []),
614
+ ].sort((a, b) => a.path.localeCompare(b.path));
615
+
616
+ return {
617
+ strict,
618
+ clean: blockers.length === 0,
619
+ summary: {
620
+ manifestActive: enrolled.length + excluded.length + needsDecision.length + missingOnDisk.length,
621
+ enrolled: enrolled.length,
622
+ excluded: excluded.length,
623
+ needsDecision: needsDecision.length,
624
+ missingOnDisk: missingOnDisk.length,
625
+ unmanifestedActive: unmanifestedActive.length,
626
+ blockers: blockers.length,
627
+ },
628
+ enrolled: enrolled.sort((a, b) => a.path.localeCompare(b.path)),
629
+ excluded: excluded.sort((a, b) => a.path.localeCompare(b.path)),
630
+ needsDecision: needsDecision.sort((a, b) => a.path.localeCompare(b.path)),
631
+ missingOnDisk: missingOnDisk.sort((a, b) => a.path.localeCompare(b.path)),
632
+ unmanifestedActive,
633
+ blockers,
634
+ };
635
+ }
package/mcp-server.mjs CHANGED
@@ -7,7 +7,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  import {
10
- check, planSync, addRepo, moveRepo, generateReadmeTree,
10
+ check, planSync, addRepo, moveRepo, generateReadmeTree, releaseEnrollment,
11
11
  } from './core.mjs';
12
12
 
13
13
  const server = new Server(
@@ -43,6 +43,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
43
43
  required: ['manifestPath', 'reposRoot'],
44
44
  },
45
45
  },
46
+ {
47
+ name: 'repos_release_enrollment',
48
+ description: 'Report release-pipeline enrollment status for active manifest repos: enrolled, excluded, needs decision, missing on disk, and unmanifested active repos.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ manifestPath: { type: 'string', description: 'Path to repos-manifest.json' },
53
+ reposRoot: { type: 'string', description: 'Root directory containing repos' },
54
+ strict: { type: 'boolean', description: 'Treat repos missing release enrollment decisions as blockers' },
55
+ },
56
+ required: ['manifestPath', 'reposRoot'],
57
+ },
58
+ },
46
59
  {
47
60
  name: 'repos_add',
48
61
  description: 'Add a repo to the manifest.',
@@ -112,6 +125,19 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
112
125
  };
113
126
  }
114
127
 
128
+ if (name === 'repos_release_enrollment') {
129
+ const result = releaseEnrollment(args.manifestPath, args.reposRoot, {
130
+ strict: Boolean(args.strict),
131
+ });
132
+ return {
133
+ content: [{
134
+ type: 'text',
135
+ text: JSON.stringify(result, null, 2),
136
+ }],
137
+ isError: !result.clean,
138
+ };
139
+ }
140
+
115
141
  if (name === 'repos_add') {
116
142
  const entry = addRepo(args.manifestPath, args.repoPath, args.remote, {
117
143
  description: args.description,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-repos",
3
- "version": "1.9.68",
3
+ "version": "1.9.70",
4
4
  "type": "module",
5
5
  "description": "Repo manifest reconciler. Single source of truth for repo organization. Like prettier for folder structure.",
6
6
  "main": "core.mjs",
@@ -11,7 +11,7 @@
11
11
  ".": "./core.mjs"
12
12
  },
13
13
  "scripts": {
14
- "test": "node cli.mjs --help"
14
+ "test": "node cli.mjs --help && node test.mjs"
15
15
  },
16
16
  "keywords": [
17
17
  "repo-manifest",
package/test.mjs ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { check, executeSync, planSync, releaseEnrollment } from './core.mjs';
7
+
8
+ function run(cmd, cwd) {
9
+ execSync(cmd, { cwd, stdio: 'ignore' });
10
+ }
11
+
12
+ function initRepo(path, remote) {
13
+ mkdirSync(path, { recursive: true });
14
+ run('git init -q -b main', path);
15
+ run(`git remote add origin git@github.com:${remote}.git`, path);
16
+ writeFileSync(join(path, 'README.md'), '# repo\n');
17
+ run('git add README.md', path);
18
+ run('git -c user.email=t@t -c user.name=t commit -q -m init', path);
19
+ }
20
+
21
+ const root = mkdtempSync(join(tmpdir(), 'wip-repos-test-'));
22
+ const manifestPath = join(root, 'repos-manifest.json');
23
+
24
+ initRepo(join(root, 'active/repo-a'), 'wipcomputer/repo-a');
25
+ initRepo(join(root, 'active/repo-b'), 'wipcomputer/repo-b');
26
+ initRepo(join(root, 'active/no-decision'), 'wipcomputer/no-decision');
27
+ initRepo(join(root, '_trash/old-repo'), 'wipcomputer/old-repo');
28
+ initRepo(join(root, 'active/misplaced'), 'wipcomputer/right-place');
29
+ mkdirSync(join(root, '.worktrees'), { recursive: true });
30
+ run(`git worktree add -q -b feat ${join(root, '.worktrees/repo-a--feat')}`, join(root, 'active/repo-a'));
31
+
32
+ writeFileSync(manifestPath, JSON.stringify({
33
+ _format: 'v2',
34
+ 'active/repo-a': {
35
+ remote: 'wipcomputer/repo-a',
36
+ release: { enabled: true, profile: 'node-package', smokeProfile: 'none' },
37
+ },
38
+ 'active/repo-b': {
39
+ remote: 'wipcomputer/repo-b',
40
+ release: { enabled: false, reason: 'docs-only fixture' },
41
+ },
42
+ 'active/no-decision': { remote: 'wipcomputer/no-decision' },
43
+ 'active/right-place': {
44
+ remote: 'wipcomputer/right-place',
45
+ release: { enabled: true, profile: 'node-package' },
46
+ },
47
+ }, null, 2) + '\n');
48
+
49
+ const defaultCheck = check(manifestPath, root);
50
+ if (defaultCheck.onDiskOnly.includes('_trash/old-repo')) {
51
+ throw new Error('default check should ignore _trash repos');
52
+ }
53
+ if (defaultCheck.disk.some(r => r.class !== 'active')) {
54
+ throw new Error('default check should only include active repos');
55
+ }
56
+
57
+ const allCheck = check(manifestPath, root, { all: true });
58
+ if (!allCheck.disk.some(r => r.class === 'trash')) {
59
+ throw new Error('--all check should include trash class');
60
+ }
61
+ if (!allCheck.disk.some(r => r.class === 'worktree')) {
62
+ throw new Error('--all check should include worktree class');
63
+ }
64
+
65
+ const worktreeCheck = check(manifestPath, root, { classFilter: 'worktree' });
66
+ if (worktreeCheck.disk.length !== 1 || worktreeCheck.disk[0].class !== 'worktree') {
67
+ throw new Error('--class worktree should return only worktrees');
68
+ }
69
+
70
+ const enrollment = releaseEnrollment(manifestPath, root);
71
+ if (enrollment.summary.enrolled !== 1 || enrollment.summary.excluded !== 1) {
72
+ throw new Error('releaseEnrollment should count enrolled and excluded repos');
73
+ }
74
+ if (enrollment.summary.needsDecision !== 1) {
75
+ throw new Error('releaseEnrollment should count repos without release decisions');
76
+ }
77
+ if (enrollment.summary.blockers !== 2) {
78
+ throw new Error('releaseEnrollment should block on missing and unmanifested active repos by default');
79
+ }
80
+ if (!enrollment.missingOnDisk.some(r => r.path === 'active/right-place')) {
81
+ throw new Error('releaseEnrollment should report active manifest repos missing on disk');
82
+ }
83
+ if (!enrollment.unmanifestedActive.some(r => r.path === 'active/misplaced')) {
84
+ throw new Error('releaseEnrollment should report active repos on disk but not in manifest');
85
+ }
86
+
87
+ const strictEnrollment = releaseEnrollment(manifestPath, root, { strict: true });
88
+ if (strictEnrollment.summary.blockers !== 3) {
89
+ throw new Error('releaseEnrollment --strict should make missing decisions blocking');
90
+ }
91
+
92
+ const moves = planSync(manifestPath, root);
93
+ if (moves.length !== 1 || moves[0].to !== 'active/right-place') {
94
+ throw new Error('planSync should plan the misplaced active repo only');
95
+ }
96
+
97
+ writeFileSync(join(root, 'active/misplaced/dirty.txt'), 'dirty\n');
98
+ const results = executeSync(moves, root);
99
+ if (results[0].status !== 'skipped' || !results[0].reason.includes('uncommitted')) {
100
+ throw new Error('executeSync should refuse dirty repo moves');
101
+ }
102
+
103
+ console.log('wip-repos regression passed');