@harness-forge/cli 1.2.1 → 1.2.3

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 (59) hide show
  1. package/.agents/skills/hforge-analyze/SKILL.md +35 -0
  2. package/.agents/skills/hforge-decide/SKILL.md +29 -0
  3. package/.agents/skills/hforge-init/SKILL.md +34 -0
  4. package/.agents/skills/hforge-refresh/SKILL.md +28 -0
  5. package/.agents/skills/hforge-review/SKILL.md +29 -0
  6. package/AGENTS.md +8 -1
  7. package/README.md +19 -0
  8. package/commands/hforge-analyze.md +55 -0
  9. package/commands/hforge-cartograph.md +35 -0
  10. package/commands/hforge-commands.md +34 -0
  11. package/commands/hforge-decide.md +35 -0
  12. package/commands/hforge-init.md +37 -0
  13. package/commands/hforge-recommend.md +34 -0
  14. package/commands/hforge-recursive.md +34 -0
  15. package/commands/hforge-refresh.md +35 -0
  16. package/commands/hforge-review.md +36 -0
  17. package/commands/hforge-status.md +34 -0
  18. package/commands/hforge-task.md +35 -0
  19. package/commands/hforge-update.md +34 -0
  20. package/dist/application/install/agent-manifest.d.ts +8 -0
  21. package/dist/application/install/agent-manifest.d.ts.map +1 -1
  22. package/dist/application/install/agent-manifest.js +7 -0
  23. package/dist/application/install/agent-manifest.js.map +1 -1
  24. package/dist/application/recommendations/recommend-bundles.d.ts.map +1 -1
  25. package/dist/application/recommendations/recommend-bundles.js +41 -2
  26. package/dist/application/recommendations/recommend-bundles.js.map +1 -1
  27. package/dist/application/runtime/command-catalog.d.ts +7 -0
  28. package/dist/application/runtime/command-catalog.d.ts.map +1 -1
  29. package/dist/application/runtime/command-catalog.js +101 -0
  30. package/dist/application/runtime/command-catalog.js.map +1 -1
  31. package/dist/cli/commands/commands.d.ts.map +1 -1
  32. package/dist/cli/commands/commands.js +5 -1
  33. package/dist/cli/commands/commands.js.map +1 -1
  34. package/dist/domain/intelligence/repo-intelligence.js +1 -1
  35. package/dist/domain/intelligence/repo-intelligence.js.map +1 -1
  36. package/docs/agent-usage-playbook.md +206 -0
  37. package/docs/agents.md +2 -0
  38. package/docs/commands.md +27 -0
  39. package/manifests/bundles/core.json +13 -1
  40. package/manifests/catalog/compatibility-matrix.json +171 -1
  41. package/manifests/catalog/package-surface.json +48 -0
  42. package/package.json +1 -1
  43. package/scripts/intelligence/shared.mjs +243 -58
  44. package/skills/hforge-analyze/SKILL.md +40 -0
  45. package/skills/hforge-analyze/references/analysis-order.md +15 -0
  46. package/skills/hforge-analyze/references/decision-promotion.md +9 -0
  47. package/skills/hforge-analyze/references/output-contract.md +7 -0
  48. package/skills/hforge-decide/SKILL.md +58 -0
  49. package/skills/hforge-decide/references/decision-rubric.md +18 -0
  50. package/skills/hforge-decide/references/output-contract.md +7 -0
  51. package/skills/hforge-init/SKILL.md +58 -0
  52. package/skills/hforge-init/references/bootstrap-order.md +7 -0
  53. package/skills/hforge-init/references/output-contract.md +7 -0
  54. package/skills/hforge-refresh/SKILL.md +52 -0
  55. package/skills/hforge-refresh/references/output-contract.md +7 -0
  56. package/skills/hforge-refresh/references/refresh-order.md +5 -0
  57. package/skills/hforge-review/SKILL.md +57 -0
  58. package/skills/hforge-review/references/output-contract.md +7 -0
  59. package/skills/hforge-review/references/review-order.md +7 -0
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
4
4
 
5
5
  const IGNORED_DIRS = new Set([
6
6
  ".git",
7
+ ".claude",
8
+ ".codex",
9
+ ".cursor",
7
10
  ".hforge",
8
11
  ".next",
9
12
  ".nuxt",
@@ -17,11 +20,78 @@ const IGNORED_DIRS = new Set([
17
20
  "vendor"
18
21
  ]);
19
22
 
23
+ const LOW_SIGNAL_SEGMENTS = new Set([
24
+ ".tmp",
25
+ "tmp",
26
+ "temp",
27
+ "fixtures",
28
+ "__fixtures__",
29
+ "__snapshots__",
30
+ "snapshots",
31
+ "archive",
32
+ "archives"
33
+ ]);
34
+
20
35
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
21
36
 
22
- function bump(map, key, evidence) {
37
+ function hasLowSignalSegment(file) {
38
+ return file.split("/").some((segment) => LOW_SIGNAL_SEGMENTS.has(segment));
39
+ }
40
+
41
+ function isSupportSurface(file) {
42
+ return (
43
+ file.startsWith(".claude/") ||
44
+ file.startsWith(".codex/") ||
45
+ file.startsWith(".cursor/") ||
46
+ file.startsWith(".agents/") ||
47
+ file.startsWith(".github/") ||
48
+ file.startsWith(".hforge/")
49
+ );
50
+ }
51
+
52
+ function isDocPath(file) {
53
+ return /\.(md|mdx|txt|rst)$/i.test(file);
54
+ }
55
+
56
+ function isRelevantForRisk(file) {
57
+ return !hasLowSignalSegment(file) && !isDocPath(file) && !isSupportSurface(file);
58
+ }
59
+
60
+ function languageWeightForPath(file) {
61
+ const normalized = file.replaceAll("\\", "/");
62
+
63
+ if (isSupportSurface(normalized) || hasLowSignalSegment(normalized)) {
64
+ return 0;
65
+ }
66
+
67
+ if (
68
+ normalized.startsWith("src/") ||
69
+ normalized.startsWith("app/") ||
70
+ normalized.startsWith("lib/") ||
71
+ normalized.startsWith("seed/") ||
72
+ normalized.startsWith("data/")
73
+ ) {
74
+ return 2;
75
+ }
76
+
77
+ if (
78
+ normalized.startsWith("scripts/") ||
79
+ normalized.startsWith("tools/") ||
80
+ normalized.startsWith("bin/")
81
+ ) {
82
+ return 0.35;
83
+ }
84
+
85
+ if (normalized.startsWith("tests/") || normalized.includes("/tests/")) {
86
+ return 0.5;
87
+ }
88
+
89
+ return 1;
90
+ }
91
+
92
+ function bump(map, key, evidence, amount = 1) {
23
93
  const current = map.get(key) ?? { count: 0, evidence: [] };
24
- current.count += 1;
94
+ current.count += amount;
25
95
  if (evidence && current.evidence.length < 5 && !current.evidence.includes(evidence)) {
26
96
  current.evidence.push(evidence);
27
97
  }
@@ -103,43 +173,61 @@ async function walk(root, visit, base = root) {
103
173
 
104
174
  function detectLanguages(files) {
105
175
  const languageCounts = new Map();
176
+ const hasCppSource = files.some((file) => {
177
+ const weight = languageWeightForPath(file);
178
+ return weight > 0 && /\.(cpp|cxx|cc|hpp|hh)$/i.test(file);
179
+ });
106
180
 
107
181
  for (const file of files) {
108
- if (/\.(ts|tsx|js|jsx)$/i.test(file)) {
109
- bump(languageCounts, "typescript", file);
182
+ const weight = languageWeightForPath(file);
183
+ if (weight <= 0) {
184
+ continue;
185
+ }
186
+
187
+ if (/\.(ts|tsx)$/i.test(file)) {
188
+ bump(languageCounts, "typescript", file, weight);
189
+ }
190
+ if (/\.(js|jsx)$/i.test(file)) {
191
+ bump(languageCounts, "javascript", file, weight);
110
192
  }
111
193
  if (/\.py$/i.test(file)) {
112
- bump(languageCounts, "python", file);
194
+ bump(languageCounts, "python", file, weight);
113
195
  }
114
196
  if (/\.go$/i.test(file)) {
115
- bump(languageCounts, "go", file);
197
+ bump(languageCounts, "go", file, weight);
116
198
  }
117
199
  if (/\.java$/i.test(file)) {
118
- bump(languageCounts, "java", file);
200
+ bump(languageCounts, "java", file, weight);
119
201
  }
120
202
  if (/\.(kt|kts)$/i.test(file)) {
121
- bump(languageCounts, "kotlin", file);
203
+ bump(languageCounts, "kotlin", file, weight);
122
204
  }
123
205
  if (/\.rs$/i.test(file)) {
124
- bump(languageCounts, "rust", file);
206
+ bump(languageCounts, "rust", file, weight);
125
207
  }
126
- if (/\.(cpp|cxx|cc|hpp|hh|h)$/i.test(file)) {
127
- bump(languageCounts, "cpp", file);
208
+ if (/\.(cpp|cxx|cc|hpp|hh)$/i.test(file)) {
209
+ bump(languageCounts, "cpp", file, weight);
210
+ }
211
+ if (/\.h$/i.test(file) && hasCppSource) {
212
+ bump(languageCounts, "cpp", file, weight);
128
213
  }
129
214
  if (/\.php$/i.test(file)) {
130
- bump(languageCounts, "php", file);
215
+ bump(languageCounts, "php", file, weight);
131
216
  }
132
217
  if (/\.(pl|pm)$/i.test(file)) {
133
- bump(languageCounts, "perl", file);
218
+ bump(languageCounts, "perl", file, weight);
134
219
  }
135
220
  if (/\.swift$/i.test(file)) {
136
- bump(languageCounts, "swift", file);
221
+ bump(languageCounts, "swift", file, weight);
222
+ }
223
+ if (/\.lua$/i.test(file)) {
224
+ bump(languageCounts, "lua", file, weight);
137
225
  }
138
226
  if (/\.(sh|bash|zsh)$/i.test(file)) {
139
- bump(languageCounts, "shell", file);
227
+ bump(languageCounts, "shell", file, weight);
140
228
  }
141
229
  if (/\.(cs|csproj|sln)$/i.test(file)) {
142
- bump(languageCounts, "dotnet", file);
230
+ bump(languageCounts, "dotnet", file, weight);
143
231
  }
144
232
  }
145
233
 
@@ -157,7 +245,7 @@ function detectLanguages(files) {
157
245
 
158
246
  function detectBuildSignals(files, packageJson) {
159
247
  const signals = [];
160
- const fileSet = new Set(files);
248
+ const fileSet = new Set(files.filter((file) => !hasLowSignalSegment(file)));
161
249
 
162
250
  if (packageJson) {
163
251
  const packageManager = typeof packageJson.packageManager === "string" ? packageJson.packageManager.split("@")[0] : "npm";
@@ -185,7 +273,7 @@ function detectBuildSignals(files, packageJson) {
185
273
  ];
186
274
 
187
275
  for (const [signalId, pattern] of directBuildMarkers) {
188
- const evidence = files.filter((file) => pattern.test(path.posix.basename(file)));
276
+ const evidence = [...fileSet].filter((file) => pattern.test(path.posix.basename(file)));
189
277
  if (evidence.length > 0) {
190
278
  signals.push(createSignal(signalId, evidence, 0.8));
191
279
  }
@@ -204,8 +292,11 @@ function detectTestSignals(files, packageJson, textSources) {
204
292
  ...Object.keys(packageJson?.dependencies ?? {}),
205
293
  ...Object.keys(packageJson?.devDependencies ?? {})
206
294
  ]);
207
- const javascriptTestFiles = files.filter(
208
- (file) => /(^|\/)(__tests__|tests?)\//i.test(file) && /\.(m?[jt]sx?|cjs|mjs)$/i.test(file) || /\.(test|spec)\.(m?[jt]sx?)$/i.test(file)
295
+ const testFiles = files.filter((file) => !hasLowSignalSegment(file));
296
+ const javascriptTestFiles = testFiles.filter(
297
+ (file) =>
298
+ ((/(^|\/)(__tests__|tests?)\//i.test(file) && /\.(m?[jt]sx?|cjs|mjs)$/i.test(file)) ||
299
+ /\.(test|spec)\.(m?[jt]sx?)$/i.test(file))
209
300
  );
210
301
 
211
302
  if (dependencyNames.has("vitest")) {
@@ -217,17 +308,17 @@ function detectTestSignals(files, packageJson, textSources) {
217
308
  if (javascriptTestFiles.length > 0) {
218
309
  signals.push(createSignal("test:javascript-files", javascriptTestFiles, 0.8));
219
310
  }
220
- if (files.some((file) => /^tests\/.+\.py$/i.test(file) || /test_.+\.py$/i.test(file))) {
221
- signals.push(createSignal("test:pytest", files.filter((file) => /^tests\/.+\.py$/i.test(file) || /test_.+\.py$/i.test(file)), 0.8));
311
+ if (testFiles.some((file) => /^tests\/.+\.py$/i.test(file) || /test_.+\.py$/i.test(file))) {
312
+ signals.push(createSignal("test:pytest", testFiles.filter((file) => /^tests\/.+\.py$/i.test(file) || /test_.+\.py$/i.test(file)), 0.8));
222
313
  }
223
- if (files.some((file) => /_test\.go$/i.test(file))) {
224
- signals.push(createSignal("test:go", files.filter((file) => /_test\.go$/i.test(file)), 0.8));
314
+ if (testFiles.some((file) => /_test\.go$/i.test(file))) {
315
+ signals.push(createSignal("test:go", testFiles.filter((file) => /_test\.go$/i.test(file)), 0.8));
225
316
  }
226
- if (files.some((file) => /src\/test\/java\//i.test(file))) {
227
- signals.push(createSignal("test:junit", files.filter((file) => /src\/test\/java\//i.test(file)), 0.8));
317
+ if (testFiles.some((file) => /src\/test\/java\//i.test(file))) {
318
+ signals.push(createSignal("test:junit", testFiles.filter((file) => /src\/test\/java\//i.test(file)), 0.8));
228
319
  }
229
- if (files.some((file) => /\.Tests?\.csproj$/i.test(file) || /tests\//i.test(file) && /\.cs$/i.test(file))) {
230
- signals.push(createSignal("test:xunit", files.filter((file) => /\.Tests?\.csproj$/i.test(file) || (/tests\//i.test(file) && /\.cs$/i.test(file))), 0.8));
320
+ if (testFiles.some((file) => /\.Tests?\.csproj$/i.test(file) || (/tests\//i.test(file) && /\.cs$/i.test(file)))) {
321
+ signals.push(createSignal("test:xunit", testFiles.filter((file) => /\.Tests?\.csproj$/i.test(file) || (/tests\//i.test(file) && /\.cs$/i.test(file))), 0.8));
231
322
  }
232
323
 
233
324
  const pyproject = textSources.get("pyproject.toml") ?? "";
@@ -240,6 +331,7 @@ function detectTestSignals(files, packageJson, textSources) {
240
331
 
241
332
  function detectDeploymentSignals(files) {
242
333
  const signals = [];
334
+ const deploymentFiles = files.filter((file) => !hasLowSignalSegment(file));
243
335
 
244
336
  const markers = [
245
337
  ["deploy:docker", /^Dockerfile$/i],
@@ -253,7 +345,7 @@ function detectDeploymentSignals(files) {
253
345
  ];
254
346
 
255
347
  for (const [signalId, pattern] of markers) {
256
- const evidence = files.filter((file) => pattern.test(file));
348
+ const evidence = deploymentFiles.filter((file) => pattern.test(file));
257
349
  if (evidence.length > 0) {
258
350
  signals.push(createSignal(signalId, evidence, 0.8));
259
351
  }
@@ -264,18 +356,21 @@ function detectDeploymentSignals(files) {
264
356
 
265
357
  function detectRiskSignals(files, testSignals) {
266
358
  const signals = [];
359
+ const riskFiles = files.filter(isRelevantForRisk);
360
+ const securityEvidence = files.filter(
361
+ (file) => !hasLowSignalSegment(file) && !isSupportSurface(file) && /(security|auth|threat|iam|permissions?)/i.test(file)
362
+ );
267
363
 
268
- const securityEvidence = files.filter((file) => /(security|auth|threat|iam|permissions?)/i.test(file));
269
364
  if (securityEvidence.length > 0) {
270
365
  signals.push(createSignal("security", securityEvidence, 0.86));
271
366
  }
272
367
 
273
- const legacyEvidence = files.filter((file) => /(legacy|deprecated|old)/i.test(file));
368
+ const legacyEvidence = riskFiles.filter((file) => /(legacy|deprecated|old)/i.test(file));
274
369
  if (legacyEvidence.length > 0) {
275
370
  signals.push(createSignal("legacy", legacyEvidence, 0.88));
276
371
  }
277
372
 
278
- const migrationEvidence = files.filter((file) => /(migration|migrate|flyway|liquibase)/i.test(file));
373
+ const migrationEvidence = riskFiles.filter((file) => /(migration|migrate|flyway|liquibase)/i.test(file));
279
374
  if (migrationEvidence.length > 0) {
280
375
  signals.push(createSignal("migration", migrationEvidence, 0.82));
281
376
  }
@@ -289,7 +384,7 @@ function detectRiskSignals(files, testSignals) {
289
384
 
290
385
  function detectMissingValidationSurfaces(files, testSignals, buildSignals) {
291
386
  const missing = [];
292
- const fileSet = new Set(files);
387
+ const fileSet = new Set(files.filter((file) => !hasLowSignalSegment(file)));
293
388
 
294
389
  if (testSignals.length === 0) {
295
390
  missing.push(
@@ -297,12 +392,15 @@ function detectMissingValidationSurfaces(files, testSignals, buildSignals) {
297
392
  );
298
393
  }
299
394
 
300
- const hasCi = files.some((file) => /^\.github\/workflows\//i.test(file));
395
+ const hasCi = [...fileSet].some((file) => /^\.github\/workflows\//i.test(file));
301
396
  if (!hasCi) {
302
397
  missing.push(createSignal("validation:missing-ci", ["No GitHub Actions workflows detected."], 0.75));
303
398
  }
304
399
 
305
- const hasLint = buildSignals.some((signal) => signal.id.startsWith("build:typescript")) || fileSet.has(".eslintrc") || fileSet.has("eslint.config.js");
400
+ const hasLint =
401
+ buildSignals.some((signal) => signal.id.startsWith("build:typescript")) ||
402
+ fileSet.has(".eslintrc") ||
403
+ fileSet.has("eslint.config.js");
306
404
  if (!hasLint) {
307
405
  missing.push(createSignal("validation:missing-lint", ["No lint configuration detected."], 0.65));
308
406
  }
@@ -318,6 +416,7 @@ function detectFrameworksFromText(facts) {
318
416
  ...Object.keys(manifest.devDependencies ?? {})
319
417
  ])
320
418
  );
419
+ const frameworkFiles = files.filter((file) => !hasLowSignalSegment(file));
321
420
  const frameworks = [];
322
421
  const pushMatch = (id, confidence, evidence) => {
323
422
  frameworks.push({
@@ -327,26 +426,56 @@ function detectFrameworksFromText(facts) {
327
426
  });
328
427
  };
329
428
 
330
- if (packageDeps.has("react") || files.some((file) => /\.tsx$/i.test(file))) {
331
- pushMatch("react", packageDeps.has("react") ? 0.96 : 0.7, packageDeps.has("react") ? ["package.json dependency react"] : files.filter((file) => /\.tsx$/i.test(file)));
429
+ if (packageDeps.has("react") || frameworkFiles.some((file) => /\.tsx$/i.test(file))) {
430
+ pushMatch(
431
+ "react",
432
+ packageDeps.has("react") ? 0.96 : 0.7,
433
+ packageDeps.has("react") ? ["package.json dependency react"] : frameworkFiles.filter((file) => /\.tsx$/i.test(file))
434
+ );
332
435
  }
333
- if (packageDeps.has("vite") || files.some((file) => /^vite\.config\./i.test(path.posix.basename(file)))) {
334
- pushMatch("vite", packageDeps.has("vite") ? 0.95 : 0.8, packageDeps.has("vite") ? ["package.json dependency vite"] : files.filter((file) => /^vite\.config\./i.test(path.posix.basename(file))));
436
+ if (packageDeps.has("vite") || frameworkFiles.some((file) => /^vite\.config\./i.test(path.posix.basename(file)))) {
437
+ pushMatch(
438
+ "vite",
439
+ packageDeps.has("vite") ? 0.95 : 0.8,
440
+ packageDeps.has("vite")
441
+ ? ["package.json dependency vite"]
442
+ : frameworkFiles.filter((file) => /^vite\.config\./i.test(path.posix.basename(file)))
443
+ );
335
444
  }
336
445
  if (packageDeps.has("express")) {
337
446
  pushMatch("express", 0.95, ["package.json dependency express"]);
338
447
  }
339
- if (packageDeps.has("next") || files.some((file) => /^next\.config\./i.test(path.posix.basename(file)))) {
340
- pushMatch("nextjs", packageDeps.has("next") ? 0.97 : 0.84, packageDeps.has("next") ? ["package.json dependency next"] : files.filter((file) => /^next\.config\./i.test(path.posix.basename(file))));
448
+ if (packageDeps.has("next") || frameworkFiles.some((file) => /^next\.config\./i.test(path.posix.basename(file)))) {
449
+ pushMatch(
450
+ "nextjs",
451
+ packageDeps.has("next") ? 0.97 : 0.84,
452
+ packageDeps.has("next")
453
+ ? ["package.json dependency next"]
454
+ : frameworkFiles.filter((file) => /^next\.config\./i.test(path.posix.basename(file)))
455
+ );
341
456
  }
342
457
 
343
458
  const pyproject = textSources.get("pyproject.toml") ?? "";
344
459
  const requirements = textSources.get("requirements.txt") ?? "";
345
460
  if (pyproject.includes("fastapi") || requirements.includes("fastapi")) {
346
- pushMatch("fastapi", 0.95, [pyproject.includes("fastapi") ? "pyproject.toml dependency fastapi" : "requirements.txt dependency fastapi"]);
461
+ pushMatch(
462
+ "fastapi",
463
+ 0.95,
464
+ [pyproject.includes("fastapi") ? "pyproject.toml dependency fastapi" : "requirements.txt dependency fastapi"]
465
+ );
347
466
  }
348
- if (pyproject.includes("django") || requirements.includes("django") || files.includes("manage.py")) {
349
- pushMatch("django", 0.94, [files.includes("manage.py") ? "manage.py" : pyproject.includes("django") ? "pyproject.toml dependency django" : "requirements.txt dependency django"]);
467
+ if (pyproject.includes("django") || requirements.includes("django") || frameworkFiles.includes("manage.py")) {
468
+ pushMatch(
469
+ "django",
470
+ 0.94,
471
+ [
472
+ frameworkFiles.includes("manage.py")
473
+ ? "manage.py"
474
+ : pyproject.includes("django")
475
+ ? "pyproject.toml dependency django"
476
+ : "requirements.txt dependency django"
477
+ ]
478
+ );
350
479
  }
351
480
 
352
481
  const pom = textSources.get("pom.xml") ?? "";
@@ -361,7 +490,11 @@ function detectFrameworksFromText(facts) {
361
490
  const csproj = [...textSources.entries()].find(([filePath]) => /\.csproj$/i.test(filePath))?.[1] ?? "";
362
491
  const programCs = textSources.get("Program.cs") ?? "";
363
492
  if (csproj.includes("Microsoft.NET.Sdk.Web") || programCs.includes("WebApplication.CreateBuilder")) {
364
- pushMatch("aspnet-core", csproj.includes("Microsoft.NET.Sdk.Web") ? 0.96 : 0.86, [csproj.includes("Microsoft.NET.Sdk.Web") ? "Api.csproj Microsoft.NET.Sdk.Web" : "Program.cs WebApplication.CreateBuilder"]);
493
+ pushMatch(
494
+ "aspnet-core",
495
+ csproj.includes("Microsoft.NET.Sdk.Web") ? 0.96 : 0.86,
496
+ [csproj.includes("Microsoft.NET.Sdk.Web") ? "Api.csproj Microsoft.NET.Sdk.Web" : "Program.cs WebApplication.CreateBuilder"]
497
+ );
365
498
  }
366
499
 
367
500
  const goMod = textSources.get("go.mod") ?? "";
@@ -370,15 +503,27 @@ function detectFrameworksFromText(facts) {
370
503
  .map(([, content]) => content)
371
504
  .join("\n");
372
505
  if (goMod.includes("gin-gonic/gin") || goSources.includes("gin.Default(")) {
373
- pushMatch("gin", goMod.includes("gin-gonic/gin") ? 0.93 : 0.82, [goMod.includes("gin-gonic/gin") ? "go.mod gin-gonic/gin" : "gin.Default()"]);
506
+ pushMatch(
507
+ "gin",
508
+ goMod.includes("gin-gonic/gin") ? 0.93 : 0.82,
509
+ [goMod.includes("gin-gonic/gin") ? "go.mod gin-gonic/gin" : "gin.Default()"]
510
+ );
374
511
  }
375
512
 
376
513
  const composer = textSources.get("composer.json") ?? "";
377
- if (composer.includes("symfony/") || files.includes("bin/console")) {
378
- pushMatch("symfony", composer.includes("symfony/") ? 0.92 : 0.82, [composer.includes("symfony/") ? "composer.json symfony/*" : "bin/console"]);
514
+ if (composer.includes("symfony/") || frameworkFiles.includes("bin/console")) {
515
+ pushMatch(
516
+ "symfony",
517
+ composer.includes("symfony/") ? 0.92 : 0.82,
518
+ [composer.includes("symfony/") ? "composer.json symfony/*" : "bin/console"]
519
+ );
379
520
  }
380
- if (composer.includes("laravel/framework") || files.includes("artisan")) {
381
- pushMatch("laravel", composer.includes("laravel/framework") ? 0.96 : 0.88, [composer.includes("laravel/framework") ? "composer.json laravel/framework" : "artisan"]);
521
+ if (composer.includes("laravel/framework") || frameworkFiles.includes("artisan")) {
522
+ pushMatch(
523
+ "laravel",
524
+ composer.includes("laravel/framework") ? 0.96 : 0.88,
525
+ [composer.includes("laravel/framework") ? "composer.json laravel/framework" : "artisan"]
526
+ );
382
527
  }
383
528
 
384
529
  return sortSignals(frameworks);
@@ -386,7 +531,7 @@ function detectFrameworksFromText(facts) {
386
531
 
387
532
  function detectRepoType(facts) {
388
533
  const { files, buildSignals, frameworkMatches, riskSignals, packageJson } = facts;
389
- const fileSet = new Set(files);
534
+ const fileSet = new Set(files.filter((file) => !hasLowSignalSegment(file)));
390
535
 
391
536
  if (buildSignals.some((signal) => signal.id.startsWith("workspace:"))) {
392
537
  return "monorepo";
@@ -394,7 +539,11 @@ function detectRepoType(facts) {
394
539
  if (frameworkMatches.some((match) => ["react", "vite", "nextjs"].includes(match.id))) {
395
540
  return "app";
396
541
  }
397
- if (frameworkMatches.some((match) => ["express", "fastapi", "django", "spring-boot", "aspnet-core", "gin", "ktor", "symfony", "laravel"].includes(match.id))) {
542
+ if (
543
+ frameworkMatches.some((match) =>
544
+ ["express", "fastapi", "django", "spring-boot", "aspnet-core", "gin", "ktor", "symfony", "laravel"].includes(match.id)
545
+ )
546
+ ) {
398
547
  return "service";
399
548
  }
400
549
  if (fileSet.has("cmd/hforge/main.go") || packageJson?.bin) {
@@ -403,20 +552,25 @@ function detectRepoType(facts) {
403
552
  if (riskSignals.some((signal) => signal.id === "legacy")) {
404
553
  return "legacy";
405
554
  }
406
- if (files.some((file) => file.startsWith("scripts/")) && files.every((file) => !file.startsWith("src/"))) {
555
+ if ([...fileSet].some((file) => file.startsWith("scripts/")) && [...fileSet].every((file) => !file.startsWith("src/"))) {
407
556
  return "automation";
408
557
  }
409
558
 
410
559
  return "library";
411
560
  }
412
561
 
413
- function normalizeRecommendations(result, frameworkCatalog) {
562
+ function normalizeRecommendations(result, frameworkCatalog, languageCatalog) {
414
563
  const bundleRecommendations = [];
415
564
  const profileRecommendations = [];
416
565
  const skillRecommendations = [];
417
566
  const validationRecommendations = [];
567
+ const knownLanguageIds = new Set(Object.keys(languageCatalog.languages ?? {}));
418
568
 
419
569
  for (const language of result.dominantLanguages.slice(0, 3)) {
570
+ if (!knownLanguageIds.has(language.id)) {
571
+ continue;
572
+ }
573
+
420
574
  bundleRecommendations.push({
421
575
  id: `lang:${language.id}`,
422
576
  kind: "bundle",
@@ -460,6 +614,7 @@ function normalizeRecommendations(result, frameworkCatalog) {
460
614
  });
461
615
 
462
616
  const riskIds = new Set(result.riskSignals.map((signal) => signal.id));
617
+ const languageIds = new Set(result.dominantLanguages.map((language) => language.id));
463
618
  if (result.repoType === "monorepo" || result.repoType === "legacy" || result.dominantLanguages.length > 1) {
464
619
  skillRecommendations.push({
465
620
  id: "skill:repo-onboarding",
@@ -477,12 +632,29 @@ function normalizeRecommendations(result, frameworkCatalog) {
477
632
  });
478
633
  }
479
634
 
480
- if (riskIds.has("security") || result.frameworkMatches.some((framework) => ["express", "fastapi", "django", "spring-boot", "aspnet-core", "gin", "ktor", "symfony", "laravel"].includes(framework.id))) {
635
+ if (languageIds.has("javascript")) {
636
+ skillRecommendations.push({
637
+ id: "skill:javascript-engineering",
638
+ kind: "skill",
639
+ confidence: 0.84,
640
+ evidence: result.dominantLanguages.find((language) => language.id === "javascript")?.evidence ?? [],
641
+ why: "JavaScript-heavy repositories benefit from the dedicated JavaScript engineering skill without forcing the TypeScript bundle."
642
+ });
643
+ }
644
+
645
+ if (
646
+ riskIds.has("security") ||
647
+ result.frameworkMatches.some((framework) =>
648
+ ["express", "fastapi", "django", "spring-boot", "aspnet-core", "gin", "ktor", "symfony", "laravel"].includes(framework.id)
649
+ )
650
+ ) {
481
651
  skillRecommendations.push({
482
652
  id: "skill:security-scan",
483
653
  kind: "skill",
484
654
  confidence: riskIds.has("security") ? 0.95 : 0.76,
485
- evidence: riskIds.has("security") ? result.riskSignals.find((signal) => signal.id === "security")?.evidence ?? [] : result.frameworkMatches.map((framework) => `framework:${framework.id}`),
655
+ evidence: riskIds.has("security")
656
+ ? result.riskSignals.find((signal) => signal.id === "security")?.evidence ?? []
657
+ : result.frameworkMatches.map((framework) => `framework:${framework.id}`),
486
658
  why: "Service and security-sensitive repositories need an explicit security boundary review."
487
659
  });
488
660
  }
@@ -552,6 +724,10 @@ export async function collectRepoFacts(root) {
552
724
 
553
725
  const interestingFiles = new Set(
554
726
  files.filter((file) => {
727
+ if (hasLowSignalSegment(file)) {
728
+ return false;
729
+ }
730
+
555
731
  const baseName = path.posix.basename(file);
556
732
  return (
557
733
  baseName === "package.json" ||
@@ -579,7 +755,9 @@ export async function collectRepoFacts(root) {
579
755
  })
580
756
  );
581
757
 
582
- const packageManifestPaths = files.filter((file) => path.posix.basename(file) === "package.json");
758
+ const packageManifestPaths = files.filter(
759
+ (file) => path.posix.basename(file) === "package.json" && !hasLowSignalSegment(file)
760
+ );
583
761
  const packageManifestEntries = await Promise.all(
584
762
  packageManifestPaths.map(async (relativePath) => ({
585
763
  relativePath,
@@ -628,13 +806,20 @@ export async function loadFrameworkCatalog() {
628
806
  return JSON.parse(content);
629
807
  }
630
808
 
809
+ export async function loadLanguageCatalog() {
810
+ const languageCatalogPath = path.join(PACKAGE_ROOT, "manifests", "catalog", "language-assets.json");
811
+ const content = await fs.readFile(languageCatalogPath, "utf8");
812
+ return JSON.parse(content);
813
+ }
814
+
631
815
  export async function scoreRecommendations(root) {
632
816
  const facts = await collectRepoFacts(root);
633
817
  const frameworkCatalog = await loadFrameworkCatalog();
818
+ const languageCatalog = await loadLanguageCatalog();
634
819
 
635
820
  return {
636
821
  ...facts,
637
- recommendations: normalizeRecommendations(facts, frameworkCatalog)
822
+ recommendations: normalizeRecommendations(facts, frameworkCatalog, languageCatalog)
638
823
  };
639
824
  }
640
825
 
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: hforge-analyze
3
+ description: repository analysis, runtime fact refresh, evidence-backed notes, and decision capture using harness forge surfaces. use when the user asks to analyze the repo, understand what matters next, gather findings, inspect risks, or turn architecture-significant changes into durable decision records.
4
+ ---
5
+
6
+ # HForge Analyze
7
+
8
+ ## Trigger Signals
9
+
10
+ - the user asks for repo analysis, understanding, onboarding, or a summary of what matters next
11
+ - the repo already has Harness Forge runtime artifacts that should be reused instead of rediscovered
12
+ - there are risks, validation gaps, stale findings, or architecture-significant changes to capture
13
+ - the next safe step depends on understanding repo shape, runtime findings, or decision coverage
14
+
15
+ ## Inspect First
16
+
17
+ - `AGENTS.md`
18
+ - `.hforge/agent-manifest.json`
19
+ - `.hforge/runtime/index.json`
20
+ - `.hforge/runtime/repo/repo-map.json`
21
+ - `.hforge/runtime/repo/recommendations.json`
22
+ - `.hforge/runtime/repo/instruction-plan.json`
23
+ - `.hforge/runtime/findings/risk-signals.json`
24
+ - `.hforge/runtime/findings/validation-gaps.json`
25
+ - `.hforge/runtime/decisions/index.json`
26
+
27
+ ## Workflow
28
+
29
+ 1. confirm the workspace is initialized with `hforge status --root . --json`
30
+ 2. if runtime artifacts are missing, initialize first with `hforge bootstrap --root . --yes`
31
+ 3. if runtime artifacts are stale, run `hforge refresh --root . --json`
32
+ 4. run `hforge review --root . --json` when runtime health, decision coverage, or stale task artifacts matter
33
+ 5. summarize repo shape, dominant implementation surfaces, risks, validation gaps, and next actions using the existing runtime as the primary source of truth
34
+ 6. when the analysis reveals architecture-significant work, create or update a durable ASR or ADR under `.hforge/runtime/decisions/`
35
+
36
+ ## References
37
+
38
+ - `skills/hforge-analyze/references/analysis-order.md`
39
+ - `skills/hforge-analyze/references/output-contract.md`
40
+ - `skills/hforge-analyze/references/decision-promotion.md`
@@ -0,0 +1,15 @@
1
+ # Analysis order
2
+
3
+ Use this order to keep analysis deterministic and low-noise.
4
+
5
+ 1. `hforge status --root . --json`
6
+ 2. `.hforge/agent-manifest.json`
7
+ 3. `.hforge/runtime/index.json`
8
+ 4. `.hforge/runtime/repo/repo-map.json`
9
+ 5. `.hforge/runtime/repo/recommendations.json`
10
+ 6. `.hforge/runtime/repo/instruction-plan.json`
11
+ 7. `.hforge/runtime/findings/risk-signals.json`
12
+ 8. `.hforge/runtime/findings/validation-gaps.json`
13
+ 9. `.hforge/runtime/decisions/index.json`
14
+ 10. `hforge review --root . --json` when runtime health or stale tasks matter
15
+ 11. `hforge refresh --root . --json` only when the existing runtime is missing or stale
@@ -0,0 +1,9 @@
1
+ # Decision promotion
2
+
3
+ Promote a finding into `.hforge/runtime/decisions/` when one or more of these are true:
4
+ - the change affects runtime boundaries, package layout, support posture, or multi-target behavior
5
+ - the choice has meaningful trade-offs and should stay reviewable later
6
+ - the work changes upgrade, migration, validation, or release expectations
7
+
8
+ Use an ASR when the direction is still being evaluated.
9
+ Use an ADR when the decision is accepted enough to guide future work.
@@ -0,0 +1,7 @@
1
+ # Output contract
2
+
3
+ A good `/hforge-analyze` result should include:
4
+ - a short repo-shape summary
5
+ - 3 to 7 high-signal findings, not a raw dump
6
+ - the concrete runtime artifact or command that supports each next step
7
+ - explicit decision candidates when the work is architecture-significant