@eltonssouza/development-utility-kit 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.claude/agents/README.md +24 -0
  2. package/.claude/agents/analyst.md +198 -0
  3. package/.claude/agents/backend-developer.md +126 -0
  4. package/.claude/agents/brain-keeper.md +229 -0
  5. package/.claude/agents/code-reviewer.md +181 -0
  6. package/.claude/agents/database-engineer.md +94 -0
  7. package/.claude/agents/devops-engineer.md +141 -0
  8. package/.claude/agents/frontend-developer.md +97 -0
  9. package/.claude/agents/gate-keeper.md +118 -0
  10. package/.claude/agents/migrator.md +291 -0
  11. package/.claude/agents/mobile-developer.md +80 -0
  12. package/.claude/agents/n8n-specialist.md +94 -0
  13. package/.claude/agents/product-owner.md +115 -0
  14. package/.claude/agents/qa-engineer.md +232 -0
  15. package/.claude/agents/release-engineer.md +204 -0
  16. package/.claude/agents/scaffold.md +87 -0
  17. package/.claude/agents/security-engineer.md +199 -0
  18. package/.claude/agents/sprint-runner.md +46 -0
  19. package/.claude/agents/stack-resolver.md +104 -0
  20. package/.claude/agents/tech-lead.md +182 -0
  21. package/.claude/agents/update-template.md +54 -0
  22. package/.claude/agents/ux-designer.md +118 -0
  23. package/.claude/hooks/flow-guard.js +261 -0
  24. package/.claude/hooks/flow-state.js +197 -0
  25. package/.claude/local/CLAUDE.md +71 -0
  26. package/.claude/settings.json +55 -0
  27. package/.claude/skills/README.md +331 -0
  28. package/.claude/skills/active-project/SKILL.md +131 -0
  29. package/.claude/skills/api-integration-test/SKILL.md +84 -0
  30. package/.claude/skills/auto-test-guard/SKILL.md +239 -0
  31. package/.claude/skills/auto-test-guard/resources/backend-tests.md +20 -0
  32. package/.claude/skills/auto-test-guard/resources/e2e-tests.md +24 -0
  33. package/.claude/skills/auto-test-guard/resources/execution-report.md +49 -0
  34. package/.claude/skills/auto-test-guard/resources/frontend-tests.md +18 -0
  35. package/.claude/skills/auto-test-guard/resources/initial-setup.md +108 -0
  36. package/.claude/skills/auto-test-guard/resources/run-suite.md +48 -0
  37. package/.claude/skills/auto-test-guard/resources/senior-gate.md +19 -0
  38. package/.claude/skills/brain-keeper/SKILL.md +62 -0
  39. package/.claude/skills/brain-keeper/obsidian/app.json +9 -0
  40. package/.claude/skills/brain-keeper/obsidian/appearance.json +4 -0
  41. package/.claude/skills/brain-keeper/obsidian/core-plugins.json +20 -0
  42. package/.claude/skills/brain-keeper/obsidian/daily-notes.json +5 -0
  43. package/.claude/skills/brain-keeper/obsidian/graph.json +32 -0
  44. package/.claude/skills/brain-keeper/obsidian/snippets/folder-colors.css +90 -0
  45. package/.claude/skills/brain-keeper/obsidian/templates.json +5 -0
  46. package/.claude/skills/brain-keeper/templates/README.md +51 -0
  47. package/.claude/skills/brain-keeper/templates/adr.md +40 -0
  48. package/.claude/skills/brain-keeper/templates/bug.md +35 -0
  49. package/.claude/skills/brain-keeper/templates/daily.md +38 -0
  50. package/.claude/skills/brain-keeper/templates/feature.md +62 -0
  51. package/.claude/skills/brain-keeper/templates/meeting.md +34 -0
  52. package/.claude/skills/brain-keeper/templates/tech-debt.md +21 -0
  53. package/.claude/skills/caveman/SKILL.md +189 -0
  54. package/.claude/skills/create-stack-pack/SKILL.md +281 -0
  55. package/.claude/skills/grill-me/SKILL.md +80 -0
  56. package/.claude/skills/pair-debug/SKILL.md +288 -0
  57. package/.claude/skills/prd-ready-check/SKILL.md +86 -0
  58. package/.claude/skills/project-manager/SKILL.md +334 -0
  59. package/.claude/skills/quality-standards/SKILL.md +203 -0
  60. package/.claude/skills/quick-feature/SKILL.md +266 -0
  61. package/.claude/skills/run-sprint/SKILL.md +41 -0
  62. package/.claude/skills/scaffold/SKILL.md +60 -0
  63. package/.claude/skills/stack-discovery/SKILL.md +161 -0
  64. package/.claude/skills/test-coverage-auditor/SKILL.md +87 -0
  65. package/.claude/skills/to-issues/SKILL.md +163 -0
  66. package/.claude/skills/to-prd/SKILL.md +130 -0
  67. package/.claude/skills/update-template/SKILL.md +256 -0
  68. package/.claude/stacks/CODEOWNERS +30 -0
  69. package/.claude/stacks/README.md +97 -0
  70. package/.claude/stacks/_template.md +116 -0
  71. package/.claude/stacks/dotnet/aspire-9.md +528 -0
  72. package/.claude/stacks/go/gin-1.10.md +570 -0
  73. package/.claude/stacks/java/spring-boot-3.md +376 -0
  74. package/.claude/stacks/java/spring-boot-4.md +438 -0
  75. package/.claude/stacks/node/express-5.md +538 -0
  76. package/.claude/stacks/python/django-5.md +483 -0
  77. package/.claude/stacks/python/fastapi-0.115.md +522 -0
  78. package/.claude/stacks/typescript/angular-18.md +420 -0
  79. package/.claude/stacks/typescript/angular-19.md +397 -0
  80. package/.claude/stacks/typescript/angular-21.md +494 -0
  81. package/CLAUDE.md +472 -0
  82. package/README.md +412 -0
  83. package/bin/cli.js +848 -0
  84. package/bin/lib/adr.js +146 -0
  85. package/bin/lib/backup.js +62 -0
  86. package/bin/lib/detect-stack.js +476 -0
  87. package/bin/lib/doctor.js +527 -0
  88. package/bin/lib/help.js +328 -0
  89. package/bin/lib/identity.js +108 -0
  90. package/bin/lib/lint-allowlist.json +15 -0
  91. package/bin/lib/lint.js +798 -0
  92. package/bin/lib/local-dir.js +68 -0
  93. package/bin/lib/manifest.js +236 -0
  94. package/bin/lib/sync-all.js +394 -0
  95. package/bin/lib/version-check.js +398 -0
  96. package/dashboard/db.js +321 -0
  97. package/dashboard/package.json +22 -0
  98. package/dashboard/public/app.js +853 -0
  99. package/dashboard/public/content/docs/agents-reference.en.md +911 -0
  100. package/dashboard/public/content/docs/architecture-overview.en.md +252 -0
  101. package/dashboard/public/content/docs/autonomy-matrix.en.md +186 -0
  102. package/dashboard/public/content/docs/cli-reference.en.md +538 -0
  103. package/dashboard/public/content/docs/git-flow.en.md +525 -0
  104. package/dashboard/public/content/docs/honcho-memory.en.md +394 -0
  105. package/dashboard/public/content/docs/hooks-reference.en.md +404 -0
  106. package/dashboard/public/content/docs/pipeline.en.md +414 -0
  107. package/dashboard/public/content/docs/plugins.en.md +289 -0
  108. package/dashboard/public/content/docs/quality-gate.en.md +315 -0
  109. package/dashboard/public/content/docs/skills-reference.en.md +484 -0
  110. package/dashboard/public/content/docs/stack-rules.en.md +362 -0
  111. package/dashboard/public/content/docs/troubleshooting.en.md +565 -0
  112. package/dashboard/public/content/manifest.json +114 -0
  113. package/dashboard/public/content/manual/backend.en.md +1053 -0
  114. package/dashboard/public/content/manual/existing-project.en.md +848 -0
  115. package/dashboard/public/content/manual/frontend.en.md +1008 -0
  116. package/dashboard/public/content/manual/fullstack.en.md +1459 -0
  117. package/dashboard/public/content/manual/mobile.en.md +837 -0
  118. package/dashboard/public/content/manual/quickstart.en.md +169 -0
  119. package/dashboard/public/index.html +217 -0
  120. package/dashboard/public/style.css +857 -0
  121. package/dashboard/public/vendor/marked.min.js +69 -0
  122. package/dashboard/rtk.js +143 -0
  123. package/dashboard/server-app.js +421 -0
  124. package/dashboard/server.js +104 -0
  125. package/dashboard/test/sprint1.test.js +406 -0
  126. package/dashboard/test/sprint2.test.js +571 -0
  127. package/dashboard/test/sprint3.test.js +560 -0
  128. package/package.json +33 -0
  129. package/scripts/hooks/subagent-telemetry.sh +14 -0
  130. package/scripts/hooks/telemetry-writer.js +250 -0
  131. package/scripts/latest-versions.json +56 -0
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Provision `.claude/local/` with a README explaining the override contract
8
+ * (per ADR-032). Idempotent — never overwrites an existing README.
9
+ */
10
+
11
+ const LOCAL_README = `# .claude/local/ — project-only overrides
12
+
13
+ This directory is **never touched** by \`duk install\`. Anything you put
14
+ here survives every harness update.
15
+
16
+ Use it for customisations that apply ONLY to this project:
17
+
18
+ local/agents/ project-specific agent overrides
19
+ local/skills/ project-specific skills (non-canonical)
20
+ local/stacks/ proprietary or internal stack packs
21
+
22
+ Loader priority (per ADR-032 §3):
23
+ .claude/local/<path> wins over
24
+ .claude/<path>
25
+
26
+ When to use \`.claude/local/\`:
27
+ - Stack proprietária da empresa (ex: \`local/stacks/internal/cobol.md\`)
28
+ - Regra de projeto que NÃO se aplica a outros
29
+ - Skill experimental que pode virar canônica via PR no harness
30
+
31
+ When NOT to use:
32
+ - Algo que beneficia todos → PR no harness repo
33
+
34
+ Drift detection: files under \`local/\` are excluded from \`.MANIFEST\`,
35
+ so editing them never triggers a drift abort during \`duk install\`.
36
+ `;
37
+
38
+ /**
39
+ * Ensure `.claude/local/` exists with a README. Subdirs (agents/, skills/,
40
+ * stacks/) are NOT pre-created — they are lazy, born when the dev adds an
41
+ * override.
42
+ *
43
+ * @param {string} claudeDir absolute path to .claude/
44
+ * @param {boolean} dryRun
45
+ */
46
+ function ensureLocalDir(claudeDir, dryRun) {
47
+ const localDir = path.join(claudeDir, 'local');
48
+ const readmePath = path.join(localDir, 'README.md');
49
+
50
+ if (dryRun) {
51
+ if (!fs.existsSync(localDir)) {
52
+ process.stdout.write(' [dry-run] would create .claude/local/ with README.md\n');
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (!fs.existsSync(localDir)) {
58
+ fs.mkdirSync(localDir, { recursive: true });
59
+ }
60
+ if (!fs.existsSync(readmePath)) {
61
+ fs.writeFileSync(readmePath, LOCAL_README, 'utf8');
62
+ }
63
+ }
64
+
65
+ module.exports = {
66
+ ensureLocalDir,
67
+ LOCAL_README,
68
+ };
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ /**
8
+ * `.claude/.MANIFEST` — hash-based drift detection per ADR-032.
9
+ *
10
+ * generateManifest(claudeDir) walk .claude/ and return Map<relPath, sha256>
11
+ * writeManifest(claudeDir, harnessVersion)
12
+ * loadManifest(claudeDir) parse .claude/.MANIFEST or return null
13
+ * detectDrift(claudeDir) compare current files vs stored manifest
14
+ *
15
+ * Paths excluded from the manifest (managed independently):
16
+ * - local/ (per-project overrides — never touched by install)
17
+ * - .MANIFEST (the manifest file itself)
18
+ * - HARNESS_VERSION
19
+ * - Anything under .claude.bak / .claude.backup-* (lives outside .claude/)
20
+ */
21
+
22
+ const MANIFEST_FILENAME = '.MANIFEST';
23
+ const HARNESS_VERSION_FILENAME = 'HARNESS_VERSION';
24
+ const LOCAL_DIR = 'local';
25
+
26
+ /**
27
+ * Walk dir recursively, returning relative POSIX-style paths of files,
28
+ * skipping any directory or filename that matches `shouldSkip(name, relPath)`.
29
+ * @param {string} root
30
+ * @param {(name: string, relPath: string) => boolean} shouldSkip
31
+ * @returns {string[]} sorted relative paths
32
+ */
33
+ function walkFiles(root, shouldSkip) {
34
+ const out = [];
35
+
36
+ function recur(absDir, relDir) {
37
+ let entries;
38
+ try {
39
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
40
+ } catch {
41
+ return;
42
+ }
43
+ for (const entry of entries) {
44
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
45
+ if (shouldSkip(entry.name, relPath)) continue;
46
+ const absPath = path.join(absDir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ recur(absPath, relPath);
49
+ } else if (entry.isFile()) {
50
+ out.push(relPath);
51
+ }
52
+ }
53
+ }
54
+
55
+ recur(root, '');
56
+ out.sort();
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Return sha256 hex of a file's bytes.
62
+ * @param {string} absPath
63
+ * @returns {string}
64
+ */
65
+ function sha256OfFile(absPath) {
66
+ const buf = fs.readFileSync(absPath);
67
+ return crypto.createHash('sha256').update(buf).digest('hex');
68
+ }
69
+
70
+ /**
71
+ * Default skip rule for manifest walks.
72
+ * @param {string} name
73
+ * @param {string} relPath
74
+ * @returns {boolean}
75
+ */
76
+ function defaultSkip(name, relPath) {
77
+ // Skip top-level local/, MANIFEST, HARNESS_VERSION
78
+ if (relPath === LOCAL_DIR) return true;
79
+ if (relPath.startsWith(`${LOCAL_DIR}/`)) return true;
80
+ if (relPath === MANIFEST_FILENAME) return true;
81
+ if (relPath === HARNESS_VERSION_FILENAME) return true;
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Build a Map<relPath, sha256> for every file under .claude/ except the
87
+ * exclusions listed above.
88
+ * @param {string} claudeDir absolute path to the .claude/ directory
89
+ * @returns {Map<string, string>}
90
+ */
91
+ function generateManifest(claudeDir) {
92
+ const result = new Map();
93
+ if (!fs.existsSync(claudeDir)) return result;
94
+ const files = walkFiles(claudeDir, defaultSkip);
95
+ for (const rel of files) {
96
+ const abs = path.join(claudeDir, rel);
97
+ result.set(rel, sha256OfFile(abs));
98
+ }
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Render a manifest Map to the on-disk format and write it to
104
+ * `<claudeDir>/.MANIFEST`.
105
+ * @param {string} claudeDir
106
+ * @param {string} harnessVersion
107
+ * @param {Map<string, string>} [manifest] - pre-computed; if omitted, regenerates
108
+ */
109
+ function writeManifest(claudeDir, harnessVersion, manifest) {
110
+ const map = manifest || generateManifest(claudeDir);
111
+ const ts = new Date().toISOString();
112
+ const header = [
113
+ '# .claude/.MANIFEST',
114
+ `# Auto-generated by duk install at ${ts}.`,
115
+ `# Harness version: ${harnessVersion}`,
116
+ '# Format: <relative-path> <sha256>',
117
+ '# Do not edit by hand.',
118
+ ];
119
+ const lines = [...map.entries()]
120
+ .sort((a, b) => a[0].localeCompare(b[0]))
121
+ .map(([rel, sha]) => `${rel} ${sha}`);
122
+ const body = header.concat(lines).join('\n') + '\n';
123
+ const target = path.join(claudeDir, MANIFEST_FILENAME);
124
+ fs.writeFileSync(target, body, 'utf8');
125
+ }
126
+
127
+ /**
128
+ * Read and parse `.claude/.MANIFEST`. Returns null if missing or unreadable.
129
+ * @param {string} claudeDir
130
+ * @returns {Map<string, string> | null}
131
+ */
132
+ function loadManifest(claudeDir) {
133
+ const target = path.join(claudeDir, MANIFEST_FILENAME);
134
+ if (!fs.existsSync(target)) return null;
135
+
136
+ let content;
137
+ try {
138
+ content = fs.readFileSync(target, 'utf8');
139
+ } catch {
140
+ return null;
141
+ }
142
+
143
+ const map = new Map();
144
+ for (const line of content.split(/\r?\n/)) {
145
+ if (!line || line.startsWith('#')) continue;
146
+ // last whitespace-separated chunk is the sha; rest is path
147
+ const idx = line.lastIndexOf(' ');
148
+ if (idx === -1) continue;
149
+ const rel = line.slice(0, idx).trim();
150
+ const sha = line.slice(idx + 1).trim();
151
+ if (rel && /^[a-f0-9]{64}$/i.test(sha)) {
152
+ map.set(rel, sha);
153
+ }
154
+ }
155
+ return map;
156
+ }
157
+
158
+ /**
159
+ * Compare current files in .claude/ against the stored manifest. Returns
160
+ * { hasManifest, drifted, added, removed }
161
+ * where each list contains relative paths.
162
+ *
163
+ * - hasManifest=false → no baseline (first install ever) → not blocking.
164
+ * - drifted → file present in both but sha differs.
165
+ * - added → file present on disk but not in manifest.
166
+ * - removed → file in manifest but missing on disk.
167
+ *
168
+ * @param {string} claudeDir
169
+ * @returns {{ hasManifest: boolean, drifted: string[], added: string[], removed: string[] }}
170
+ */
171
+ function detectDrift(claudeDir) {
172
+ const stored = loadManifest(claudeDir);
173
+ if (stored === null) {
174
+ return { hasManifest: false, drifted: [], added: [], removed: [] };
175
+ }
176
+ const current = generateManifest(claudeDir);
177
+
178
+ const drifted = [];
179
+ const added = [];
180
+ const removed = [];
181
+
182
+ for (const [rel, sha] of current.entries()) {
183
+ if (!stored.has(rel)) {
184
+ added.push(rel);
185
+ } else if (stored.get(rel) !== sha) {
186
+ drifted.push(rel);
187
+ }
188
+ }
189
+ for (const rel of stored.keys()) {
190
+ if (!current.has(rel)) {
191
+ removed.push(rel);
192
+ }
193
+ }
194
+
195
+ drifted.sort();
196
+ added.sort();
197
+ removed.sort();
198
+ return { hasManifest: true, drifted, added, removed };
199
+ }
200
+
201
+ /**
202
+ * Write the plain-text HARNESS_VERSION file (single line, semver).
203
+ * @param {string} claudeDir
204
+ * @param {string} version
205
+ */
206
+ function writeHarnessVersion(claudeDir, version) {
207
+ const target = path.join(claudeDir, HARNESS_VERSION_FILENAME);
208
+ fs.writeFileSync(target, version + '\n', 'utf8');
209
+ }
210
+
211
+ /**
212
+ * Read HARNESS_VERSION, returning null if missing.
213
+ * @param {string} claudeDir
214
+ * @returns {string | null}
215
+ */
216
+ function readHarnessVersion(claudeDir) {
217
+ const target = path.join(claudeDir, HARNESS_VERSION_FILENAME);
218
+ if (!fs.existsSync(target)) return null;
219
+ try {
220
+ return fs.readFileSync(target, 'utf8').trim();
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ module.exports = {
227
+ MANIFEST_FILENAME,
228
+ HARNESS_VERSION_FILENAME,
229
+ LOCAL_DIR,
230
+ generateManifest,
231
+ writeManifest,
232
+ loadManifest,
233
+ detectDrift,
234
+ writeHarnessVersion,
235
+ readHarnessVersion,
236
+ };
@@ -0,0 +1,394 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * sync-all.js — Batch update of harness across multiple project subdirectories.
5
+ *
6
+ * Default = dry-run preview. `--apply` actually invokes the adoption pipeline
7
+ * via the main CLI's adoptProject() function.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { compareVersions } = require('./version-check');
13
+
14
+ const HARNESS_DIR_SKIP = new Set([
15
+ 'node_modules',
16
+ '.git',
17
+ '.idea',
18
+ '.vscode',
19
+ 'dist',
20
+ 'build',
21
+ 'target',
22
+ 'out',
23
+ ]);
24
+
25
+ /**
26
+ * @typedef {{
27
+ * key: 'stack'|'type'|'age'|'harness-version',
28
+ * value: string|number,
29
+ * op: string
30
+ * }} ParsedFilter
31
+ */
32
+
33
+ /**
34
+ * @typedef {{
35
+ * name: string,
36
+ * path: string,
37
+ * harnessVersion: string|null,
38
+ * projectType: string|null,
39
+ * primaryStack: string|null,
40
+ * ageMs: number
41
+ * }} ProjectCandidate
42
+ */
43
+
44
+ // ─── filter parsing ──────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Convert a duration string ("30d", "6m", "1y", "12h") to milliseconds.
48
+ * @param {string} s
49
+ * @returns {number|null}
50
+ */
51
+ function parseDuration(s) {
52
+ if (!s || typeof s !== 'string') return null;
53
+ const m = s.match(/^(\d+)([hdmy])$/i);
54
+ if (!m) return null;
55
+ const n = parseInt(m[1], 10);
56
+ const unit = m[2].toLowerCase();
57
+ switch (unit) {
58
+ case 'h':
59
+ return n * 60 * 60 * 1000;
60
+ case 'd':
61
+ return n * 24 * 60 * 60 * 1000;
62
+ case 'm':
63
+ return n * 30 * 24 * 60 * 60 * 1000; // month ≈ 30d
64
+ case 'y':
65
+ return n * 365 * 24 * 60 * 60 * 1000;
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Parse a single filter expression like "stack:java" or "harness-version:<0.2".
73
+ * @param {string} expr
74
+ * @returns {ParsedFilter|null}
75
+ */
76
+ function parseFilterExpr(expr) {
77
+ if (!expr || typeof expr !== 'string') return null;
78
+ const idx = expr.indexOf(':');
79
+ if (idx === -1) return null;
80
+ const key = expr.slice(0, idx).trim().toLowerCase();
81
+ const rawValue = expr.slice(idx + 1).trim();
82
+ if (!rawValue) return null;
83
+
84
+ switch (key) {
85
+ case 'stack':
86
+ case 'type':
87
+ return { key, value: rawValue.toLowerCase(), op: '=' };
88
+ case 'age': {
89
+ const ms = parseDuration(rawValue);
90
+ if (ms === null) return null;
91
+ return { key, value: ms, op: '>=' }; // ageMs >= threshold = "older than"
92
+ }
93
+ case 'harness-version': {
94
+ const opMatch = rawValue.match(/^(>=|<=|>|<|=)/);
95
+ if (opMatch) {
96
+ return { key, value: rawValue, op: opMatch[1] };
97
+ }
98
+ return { key, value: '=' + rawValue, op: '=' };
99
+ }
100
+ default:
101
+ return null;
102
+ }
103
+ }
104
+
105
+ // ─── CLAUDE.md parsing ───────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Extract a key from the `## Project Identity` block of a CLAUDE.md string.
109
+ * Returns the raw value (without surrounding backticks).
110
+ * @param {string} content
111
+ * @param {string} key
112
+ * @returns {string|null}
113
+ */
114
+ function extractIdentityField(content, key) {
115
+ if (!content) return null;
116
+ const idIdx = content.indexOf('## Project Identity');
117
+ if (idIdx === -1) return null;
118
+ const nextIdx = content.indexOf('\n## ', idIdx + 1);
119
+ const block = nextIdx === -1 ? content.slice(idIdx) : content.slice(idIdx, nextIdx);
120
+
121
+ const re = new RegExp(
122
+ `[*-]\\s*\\*\\*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\*\\*\\s*:\\s*\`?([^\`\n]+?)\`?\\s*$`,
123
+ 'im'
124
+ );
125
+ const m = block.match(re);
126
+ return m ? m[1].trim() : null;
127
+ }
128
+
129
+ // ─── project discovery ──────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Find candidate projects under dir (subdirs containing .claude/).
133
+ * @param {string} dir
134
+ * @returns {ProjectCandidate[]}
135
+ */
136
+ function discoverProjects(dir) {
137
+ const out = [];
138
+ let entries;
139
+ try {
140
+ entries = fs.readdirSync(dir, { withFileTypes: true });
141
+ } catch {
142
+ return out;
143
+ }
144
+
145
+ for (const ent of entries) {
146
+ if (!ent.isDirectory()) continue;
147
+ if (ent.name.startsWith('.')) continue;
148
+ if (HARNESS_DIR_SKIP.has(ent.name)) continue;
149
+
150
+ const projectPath = path.join(dir, ent.name);
151
+ const claudeDir = path.join(projectPath, '.claude');
152
+ if (!fs.existsSync(claudeDir)) continue;
153
+
154
+ const candidate = inspectProject(projectPath);
155
+ if (candidate) out.push(candidate);
156
+ }
157
+
158
+ return out;
159
+ }
160
+
161
+ /**
162
+ * Build a ProjectCandidate by reading .claude/HARNESS_VERSION + CLAUDE.md.
163
+ * @param {string} projectPath
164
+ * @returns {ProjectCandidate|null}
165
+ */
166
+ function inspectProject(projectPath) {
167
+ const claudeDir = path.join(projectPath, '.claude');
168
+ const versionFile = path.join(claudeDir, 'HARNESS_VERSION');
169
+ const claudeMdPath = path.join(projectPath, 'CLAUDE.md');
170
+
171
+ let harnessVersion = null;
172
+ try {
173
+ harnessVersion = fs.readFileSync(versionFile, 'utf8').trim() || null;
174
+ } catch {
175
+ harnessVersion = null;
176
+ }
177
+
178
+ let projectType = null;
179
+ let primaryStack = null;
180
+ try {
181
+ const content = fs.readFileSync(claudeMdPath, 'utf8');
182
+ projectType = extractIdentityField(content, 'Project type');
183
+ primaryStack = extractIdentityField(content, 'Primary stack');
184
+ } catch {
185
+ // no CLAUDE.md or unreadable
186
+ }
187
+
188
+ let ageMs = 0;
189
+ try {
190
+ const stat = fs.statSync(claudeDir);
191
+ ageMs = Date.now() - stat.mtimeMs;
192
+ } catch {
193
+ ageMs = 0;
194
+ }
195
+
196
+ return {
197
+ name: path.basename(projectPath),
198
+ path: projectPath,
199
+ harnessVersion,
200
+ projectType,
201
+ primaryStack,
202
+ ageMs,
203
+ };
204
+ }
205
+
206
+ // ─── filter application ──────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Check whether a project matches one parsed filter.
210
+ * @param {ProjectCandidate} proj
211
+ * @param {ParsedFilter} filter
212
+ * @returns {boolean}
213
+ */
214
+ function matchesFilter(proj, filter) {
215
+ if (!filter) return true;
216
+ switch (filter.key) {
217
+ case 'stack': {
218
+ const stack = (proj.primaryStack || '').toLowerCase();
219
+ return stack.includes(String(filter.value));
220
+ }
221
+ case 'type': {
222
+ const t = (proj.projectType || '').toLowerCase();
223
+ return t === String(filter.value) || t.includes(String(filter.value));
224
+ }
225
+ case 'age': {
226
+ return proj.ageMs >= Number(filter.value);
227
+ }
228
+ case 'harness-version': {
229
+ if (!proj.harnessVersion) return false;
230
+ const expr = String(filter.value);
231
+ const cmp = compareVersions(proj.harnessVersion, expr);
232
+ // compareVersions returns -1 when the version satisfies the constraint
233
+ // (or 0 for "=X" matches)
234
+ return cmp <= 0;
235
+ }
236
+ default:
237
+ return true;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Apply all filters (AND) and excludes (NOT) to a candidate list.
243
+ * @param {ProjectCandidate[]} candidates
244
+ * @param {ParsedFilter[]} filters
245
+ * @param {string[]} excludes
246
+ * @returns {ProjectCandidate[]}
247
+ */
248
+ function applyFilters(candidates, filters, excludes) {
249
+ const excludeSet = new Set((excludes || []).map((x) => x.toLowerCase()));
250
+ return candidates.filter((c) => {
251
+ if (excludeSet.has(c.name.toLowerCase())) return false;
252
+ return filters.every((f) => matchesFilter(c, f));
253
+ });
254
+ }
255
+
256
+ // ─── humanizers ──────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Convert ageMs to a short human-readable string.
260
+ * @param {number} ms
261
+ * @returns {string}
262
+ */
263
+ function humanAge(ms) {
264
+ if (!ms || ms < 0) return 'unknown';
265
+ const days = Math.floor(ms / (24 * 60 * 60 * 1000));
266
+ if (days < 1) return '<1d';
267
+ if (days < 30) return `${days}d`;
268
+ const months = Math.floor(days / 30);
269
+ if (months < 12) return `${months}m`;
270
+ const years = Math.floor(days / 365);
271
+ return `${years}y`;
272
+ }
273
+
274
+ // ─── runSyncAll ──────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * @typedef {{ filters: string[], excludes: string[], apply: boolean,
278
+ * adopt?: (opts: {cwd: string, sub: string|null, dryRun: boolean}) => void }} SyncOptions
279
+ */
280
+
281
+ /**
282
+ * Run sync-all over a directory.
283
+ *
284
+ * The `adopt` option lets the CLI inject its own adoptProject implementation
285
+ * (avoids a circular require with bin/cli.js). When omitted, lazy-loads
286
+ * `../cli.js#adoptProject`.
287
+ *
288
+ * @param {string} dir
289
+ * @param {SyncOptions} options
290
+ * @returns {{ matched: ProjectCandidate[], updated: number, skipped: number, errors: number }}
291
+ */
292
+ function runSyncAll(dir, options) {
293
+ const opts = options || {};
294
+ const filterStrs = opts.filters || [];
295
+ const excludes = opts.excludes || [];
296
+ const apply = !!opts.apply;
297
+
298
+ if (!dir || !fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
299
+ process.stderr.write(`Error: directory "${dir}" not found or not a directory.\n`);
300
+ process.exit(1);
301
+ }
302
+
303
+ // Parse filters
304
+ const parsedFilters = [];
305
+ for (const f of filterStrs) {
306
+ const parsed = parseFilterExpr(f);
307
+ if (!parsed) {
308
+ process.stderr.write(`Warning: ignoring unrecognized filter "${f}"\n`);
309
+ continue;
310
+ }
311
+ parsedFilters.push(parsed);
312
+ }
313
+
314
+ // Discover + filter
315
+ const candidates = discoverProjects(dir);
316
+ const matched = applyFilters(candidates, parsedFilters, excludes);
317
+
318
+ // Print plan
319
+ process.stdout.write(`\nkud sync-all — scanning ${dir}\n`);
320
+ process.stdout.write(`Found ${candidates.length} project(s) with .claude/\n`);
321
+ if (parsedFilters.length) {
322
+ process.stdout.write(`Filters: ${filterStrs.join(', ')}\n`);
323
+ }
324
+ if (excludes.length) {
325
+ process.stdout.write(`Excludes: ${excludes.join(', ')}\n`);
326
+ }
327
+ process.stdout.write(`Matched ${matched.length} project(s)${apply ? '' : ' (dry-run)'}\n\n`);
328
+
329
+ if (matched.length === 0) {
330
+ process.stdout.write('Nothing to do.\n');
331
+ return { matched: [], updated: 0, skipped: 0, errors: 0 };
332
+ }
333
+
334
+ // Print table
335
+ for (const p of matched) {
336
+ process.stdout.write(
337
+ ` ${p.name.padEnd(30)} ` +
338
+ `v${(p.harnessVersion || '?').padEnd(8)} ` +
339
+ `${(p.projectType || '?').padEnd(15)} ` +
340
+ `age:${humanAge(p.ageMs).padEnd(6)} ` +
341
+ `${(p.primaryStack || '').slice(0, 40)}\n`
342
+ );
343
+ }
344
+
345
+ if (!apply) {
346
+ process.stdout.write('\nDry-run only. Re-run with --apply to perform updates.\n');
347
+ return { matched, updated: 0, skipped: 0, errors: 0 };
348
+ }
349
+
350
+ // Resolve adopt function (lazy require to avoid cycles)
351
+ let adopt = opts.adopt;
352
+ if (!adopt) {
353
+ try {
354
+ const cli = require('../cli');
355
+ adopt = cli.adoptProject;
356
+ } catch (e) {
357
+ process.stderr.write(`Error: could not load adoptProject from bin/cli.js — ${e.message}\n`);
358
+ return { matched, updated: 0, skipped: 0, errors: matched.length };
359
+ }
360
+ }
361
+
362
+ // Apply per project
363
+ let updated = 0;
364
+ let errors = 0;
365
+ process.stdout.write('\nApplying updates...\n');
366
+ for (const p of matched) {
367
+ try {
368
+ adopt({ cwd: p.path, sub: null, dryRun: false });
369
+ process.stdout.write(` [OK] ${p.name}\n`);
370
+ updated += 1;
371
+ } catch (e) {
372
+ process.stdout.write(` [FAIL] ${p.name} — ${e.message}\n`);
373
+ errors += 1;
374
+ }
375
+ }
376
+
377
+ process.stdout.write(
378
+ `\nSummary: updated=${updated}, errors=${errors}, skipped=${matched.length - updated - errors}\n`
379
+ );
380
+ return { matched, updated, skipped: matched.length - updated - errors, errors };
381
+ }
382
+
383
+ module.exports = {
384
+ runSyncAll,
385
+ // exported for tests
386
+ parseDuration,
387
+ parseFilterExpr,
388
+ extractIdentityField,
389
+ discoverProjects,
390
+ inspectProject,
391
+ matchesFilter,
392
+ applyFilters,
393
+ humanAge,
394
+ };