trackfw 3.0.0 → 4.0.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 (59) hide show
  1. package/README.md +94 -3
  2. package/package.json +1 -1
  3. package/src/commands/barrier.js +358 -0
  4. package/src/commands/discover.js +18 -0
  5. package/src/commands/help.js +99 -9
  6. package/src/commands/index.js +3 -0
  7. package/src/commands/init.js +40 -2
  8. package/src/commands/log.js +2 -1
  9. package/src/commands/note.js +24 -0
  10. package/src/commands/req.js +7 -1
  11. package/src/commands/roadmap.js +4 -3
  12. package/src/commands/ship.js +49 -0
  13. package/src/commands/update-harness.js +208 -0
  14. package/src/commands/update.js +206 -72
  15. package/src/config/index.js +5 -2
  16. package/src/forge/adapter.js +189 -0
  17. package/src/forge/resolve.js +191 -0
  18. package/src/generators/adr.js +13 -4
  19. package/src/generators/date.js +23 -0
  20. package/src/generators/init.js +229 -39
  21. package/src/generators/note.js +154 -0
  22. package/src/generators/req.js +88 -5
  23. package/src/generators/roadmap.js +96 -20
  24. package/src/i18n/locales/en-US.json +2 -1
  25. package/src/i18n/locales/es-ES.json +2 -1
  26. package/src/i18n/locales/pt-BR.json +2 -1
  27. package/src/identity/preset.js +22 -0
  28. package/src/integrations/assets/agents/architect.md +35 -0
  29. package/src/integrations/assets/agents/backend.md +32 -0
  30. package/src/integrations/assets/agents/code-quality.md +32 -0
  31. package/src/integrations/assets/agents/data.md +32 -0
  32. package/src/integrations/assets/agents/dba.md +32 -0
  33. package/src/integrations/assets/agents/frontend.md +32 -0
  34. package/src/integrations/assets/agents/iac.md +46 -0
  35. package/src/integrations/assets/agents/infra.md +34 -0
  36. package/src/integrations/assets/agents/qa.md +32 -0
  37. package/src/integrations/assets/agents/security.md +32 -0
  38. package/src/integrations/assets/agents/tooling.md +47 -0
  39. package/src/integrations/assets/agents/ux.md +32 -0
  40. package/src/integrations/assets/catalog.json +16 -2
  41. package/src/integrations/assets/skills/architecture.md +66 -0
  42. package/src/integrations/assets/skills/backend.md +67 -0
  43. package/src/integrations/assets/skills/code-quality.md +78 -0
  44. package/src/integrations/assets/skills/data.md +75 -0
  45. package/src/integrations/assets/skills/dba.md +77 -0
  46. package/src/integrations/assets/skills/frontend.md +70 -0
  47. package/src/integrations/assets/skills/iac.md +90 -0
  48. package/src/integrations/assets/skills/infra.md +62 -0
  49. package/src/integrations/assets/skills/qa.md +63 -0
  50. package/src/integrations/assets/skills/security.md +74 -0
  51. package/src/integrations/assets/skills/tooling.md +80 -0
  52. package/src/integrations/assets/skills/ux.md +70 -0
  53. package/src/integrations/catalog.js +25 -1
  54. package/src/integrations/index.js +20 -2
  55. package/src/integrations/render.js +57 -6
  56. package/src/lib/update-engine.js +217 -0
  57. package/src/ship/runner.js +454 -0
  58. package/src/validator/index.js +323 -111
  59. package/src/validator/traceid.js +1 -1
@@ -10,6 +10,7 @@ const cmd = new Command('init')
10
10
  cmd.description(t('init.description'))
11
11
  cmd.option('--ai-tools <tools>', 'Comma-separated AI tools to configure (claude,codex,gemini,antigravity,cursor,copilot,windsurf,amazonq,kiro)', '')
12
12
  cmd.option('--identity-preset <preset>', `Agent identity preset: none, neutral, ${identityStore.presetNames().join(', ')}`, 'none')
13
+ cmd.option('--forge <value>', 'Forge platform: github, gitlab, bitbucket, azure', '')
13
14
  cmd.action(async (options, command) => {
14
15
  const os = require('os')
15
16
  const path = require('path')
@@ -29,6 +30,14 @@ cmd.action(async (options, command) => {
29
30
  }
30
31
  }
31
32
 
33
+ // --forge: validate unconditionally above the non-TTY early return so that
34
+ // an invalid value fails loudly in CI instead of silently no-op'ing.
35
+ const forgeChanged = command.getOptionValueSource('forge') === 'cli'
36
+ if (forgeChanged && options.forge) {
37
+ const { validateForge } = require('../forge/resolve')
38
+ validateForge(options.forge) // throws on invalid value
39
+ }
40
+
32
41
  // Pula o wizard de identidade inteiramente quando a flag foi passada
33
42
  // explicitamente (já tratado acima) ou quando um arquivo de identidade já
34
43
  // existe — re-executar init nunca deve sobrescrever silenciosamente uma
@@ -45,6 +54,7 @@ cmd.action(async (options, command) => {
45
54
  pkgManager: 'npm',
46
55
  hooks: 'none',
47
56
  ci: 'none',
57
+ forge: forgeChanged ? options.forge : '',
48
58
  }
49
59
  await generators.scaffold(cfg)
50
60
  const aiTools = String(options.aiTools || '').split(',').map(tool => tool.trim()).filter(Boolean)
@@ -62,7 +72,17 @@ cmd.action(async (options, command) => {
62
72
 
63
73
  const { input, select, checkbox } = require('@inquirer/prompts')
64
74
 
65
- let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, aiTools, requireReqInCommit, scope
75
+ // Detect forge from the current dir to prefill the wizard default.
76
+ const { resolveFromRepo: resolveForgeFromRepo } = require('../forge/resolve')
77
+ let detectedForge = ''
78
+ if (!forgeChanged) {
79
+ try {
80
+ const res = resolveForgeFromRepo('', '', process.cwd())
81
+ if (res.source !== 'none') detectedForge = res.forge
82
+ } catch (_) {}
83
+ }
84
+
85
+ let projectName, projectType, frontend, pkgManager, backend, backendFramework, hooks, ci, forgeValue, aiTools, requireReqInCommit, scope
66
86
 
67
87
  try {
68
88
  projectName = await input({
@@ -163,6 +183,22 @@ cmd.action(async (options, command) => {
163
183
  ],
164
184
  })
165
185
 
186
+ if (!forgeChanged) {
187
+ forgeValue = await select({
188
+ message: t('init.prompt.forge'),
189
+ default: detectedForge,
190
+ choices: [
191
+ { name: 'Auto-detect (omit key)', value: '' },
192
+ { name: 'GitHub', value: 'github' },
193
+ { name: 'GitLab', value: 'gitlab' },
194
+ { name: 'Bitbucket', value: 'bitbucket' },
195
+ { name: 'Azure DevOps', value: 'azure' },
196
+ ],
197
+ })
198
+ } else {
199
+ forgeValue = options.forge
200
+ }
201
+
166
202
  requireReqInCommit = false
167
203
  if (hooks !== 'none') {
168
204
  const { confirm: confirmPrompt } = require('@inquirer/prompts')
@@ -202,6 +238,7 @@ cmd.action(async (options, command) => {
202
238
  pkgManager: 'npm',
203
239
  hooks: 'none',
204
240
  ci: 'none',
241
+ forge: forgeChanged ? options.forge : '',
205
242
  }
206
243
  await generators.scaffold(cfg)
207
244
  console.log(`\n${t('init.success')}`)
@@ -229,6 +266,7 @@ cmd.action(async (options, command) => {
229
266
  pkgManager: 'npm',
230
267
  hooks: 'none',
231
268
  ci: 'none',
269
+ forge: forgeChanged ? options.forge : '',
232
270
  })
233
271
  console.log(`\n${t('init.success')}`)
234
272
  require('../generators/init').printArchitectNextSteps(process.cwd())
@@ -236,7 +274,7 @@ cmd.action(async (options, command) => {
236
274
  }
237
275
  }
238
276
 
239
- const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, requireReqInCommit }
277
+ const cfg = { projectName, projectType, frontend, backend, backendFramework, pkgManager, hooks, ci, forge: forgeValue || '', requireReqInCommit }
240
278
  await generators.scaffold(cfg)
241
279
 
242
280
  for (const tool of (aiTools || [])) await generators.installIntegrationTarget(tool, process.cwd(), scope)
@@ -3,13 +3,14 @@ const { Command } = require('commander')
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const { t } = require('../i18n')
6
+ const config = require('../config')
6
7
 
7
8
  const cmd = new Command('log')
8
9
  cmd.description(t('log.description'))
9
10
  cmd.option('--tail <n>', t('log.tail'), '20')
10
11
  cmd.action(async (opts) => {
11
12
  const tail = parseInt(opts.tail, 10)
12
- const logPath = path.join('docs', 'roadmaps', '.trackfw-log')
13
+ const logPath = path.join(config.load().roadmapDir, '.trackfw-log')
13
14
 
14
15
  if (!fs.existsSync(logPath)) {
15
16
  console.log(t('log.empty'))
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { newNote } = require('../generators/note')
5
+
6
+ function createNoteCommand() {
7
+ const note = new Command('note').description('Manage vault knowledge notes')
8
+
9
+ note
10
+ .command('new <title>')
11
+ .description('Create a new knowledge note in vault/notes/')
12
+ .action((title) => {
13
+ try {
14
+ newNote(title)
15
+ } catch (err) {
16
+ console.error(`error: ${err.message}`)
17
+ process.exit(1)
18
+ }
19
+ })
20
+
21
+ return note
22
+ }
23
+
24
+ module.exports = createNoteCommand()
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
  const { Command } = require('commander')
3
- const { listREQs } = require('../generators/req')
3
+ const { listREQs, moveREQ } = require('../generators/req')
4
4
  const { t } = require('../i18n')
5
5
 
6
6
  const cmd = new Command('req')
@@ -67,4 +67,10 @@ cmd.command('list')
67
67
  listREQs(require('../config').load().reqDir)
68
68
  })
69
69
 
70
+ cmd.command('move <name> <status>')
71
+ .description('Update a REQ status in place')
72
+ .action(async (name, status) => {
73
+ moveREQ(name, status)
74
+ })
75
+
70
76
  module.exports = cmd
@@ -8,15 +8,16 @@ cmd.description(t('roadmap.description'))
8
8
 
9
9
  cmd.command('new')
10
10
  .description(t('roadmap.new.description'))
11
- .option('-t, --title <title>', 'Roadmap title')
11
+ .argument('[title]', 'Roadmap title')
12
+ .option('-t, --title <title>', 'Roadmap title (alias)')
12
13
  .option('-r, --req <path>', 'Path to the linked REQ')
13
14
  .option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
14
- .action(async (opts) => {
15
+ .action(async (titleArg, opts) => {
15
16
  if (opts.fromReq) {
16
17
  newRoadmapFromReq(opts.fromReq)
17
18
  return
18
19
  }
19
- const title = opts.title || 'New Roadmap'
20
+ const title = titleArg || opts.title || 'New Roadmap'
20
21
  const reqPath = opts.req || ''
21
22
  newRoadmap(title, reqPath)
22
23
  })
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+
3
+ const { Command } = require('commander')
4
+ const { runShip } = require('../ship/runner')
5
+ const config = require('../config')
6
+
7
+ function createShipCommand() {
8
+ const cmd = new Command('ship')
9
+ cmd
10
+ .description(
11
+ 'trackfw ship runs a governed delivery sequence:\n\n' +
12
+ ' 1. Validates branch name — must match feat|fix|refactor/<slug>\n' +
13
+ ' 2. Validates governance — REQ + roadmap in wip/ must exist\n' +
14
+ ' (hard gate: not affected by lenient mode or per-rule severity)\n' +
15
+ ' 3. Detects pending squash-merges in other branches (advisory only)\n' +
16
+ ' 4. Reviews what is staged (git status --short + git diff --cached --stat)\n' +
17
+ ' 5. Commits with Conventional Commits format (-m is required)\n' +
18
+ ' 6. Pushes to origin (adds -u if no upstream is configured yet)\n' +
19
+ ' 7. Opens PR/MR via the resolved forge CLI (or prints URL if CLI is absent)\n\n' +
20
+ "Stage your files explicitly before running ship.\n" +
21
+ "This command never executes 'git add .' or 'git add -A'."
22
+ )
23
+ .option('-m, --message <msg>', 'Commit message (Conventional Commits format required)')
24
+ .option('--dry-run', 'Print what would be done without executing write commands', false)
25
+ .option('--no-pr', 'Skip PR/MR creation after push')
26
+ .option('--forge <forge>', 'Override forge detection (github, gitlab, bitbucket, azure)', '')
27
+ .action((options) => {
28
+ const cfg = config.load()
29
+ const exitCode = runShip(
30
+ {
31
+ message: options.message || '',
32
+ dryRun: options.dryRun || false,
33
+ noPR: options.pr === false,
34
+ forge: options.forge || '',
35
+ },
36
+ {
37
+ configForge: cfg.forge || '',
38
+ repoDir: '.',
39
+ }
40
+ )
41
+ if (exitCode !== 0) {
42
+ process.exit(exitCode)
43
+ }
44
+ })
45
+
46
+ return cmd
47
+ }
48
+
49
+ module.exports = createShipCommand()
@@ -0,0 +1,208 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const os = require('os')
5
+ const path = require('path')
6
+ const identityStore = require('../identity')
7
+ const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
8
+ const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
9
+
10
+ // `trackfw update harness` is the global counterpart to `trackfw update` —
11
+ // see docs/cli-parity.md, "`trackfw update` vs `trackfw update harness`".
12
+ // It never requires trackfw.yaml or a project cwd, and its only job is to
13
+ // update rules/agents/skills ALREADY INSTALLED under the user's home
14
+ // directory (~/.claude and equivalents). It never installs a target that is
15
+ // not already present unless --install-missing is passed.
16
+ //
17
+ // Target universe (fixed declared order, mirrors internal/commands/
18
+ // update_harness.go and pypi/trackfw/commands/update_harness.py):
19
+ // 1. claude-skill — ~/.claude/skills/trackfw/SKILL.md (legacy
20
+ // governance meta-skill, previously written by the
21
+ // removed `update` step "skill global"; this is the
22
+ // exact target named in the frozen contract
23
+ // example in docs/cli-parity.md).
24
+ // 2..N. <tool>-agents / <tool>-skills — one target per (catalog target,
25
+ // kind) pair, in catalog.targets declared order,
26
+ // covering the project-independent, home-rooted
27
+ // agents/skills catalog (npm/src/integrations).
28
+ //
29
+ // Ambiguity (reported, not resolved unilaterally — see ML-6C handoff
30
+ // report): each <tool>-<kind> target aggregates potentially many catalog
31
+ // items (e.g. claude-agents bundles all 12 agent personas) into a single
32
+ // state. The contract does not specify item-level granularity for harness
33
+ // targets, so this implementation reports the bundle-level outcome:
34
+ // - every item not-installed -> missing
35
+ // - any item unmanaged/modified -> skipped (never overwritten)
36
+ // - otherwise, at least one item written -> updated
37
+ // - otherwise (all current) -> skipped
38
+
39
+ // claudeSkillContent — mirrors generators/init.js:installSkillsForce
40
+ // (npm/src/generators/init.js:1242) byte-for-byte. Duplicated here, not
41
+ // imported, because installSkillsForce() bakes os.homedir() internally with
42
+ // an unconditional overwrite and no dry-run mode — it cannot be reused
43
+ // without either mutating on --dry-run or forking its signature. Reported
44
+ // as a follow-up cleanup opportunity in the ML-6C handoff report.
45
+ function claudeSkillContent() {
46
+ return `---
47
+ name: trackfw
48
+ description: "trackfw — Governed Software Delivery: ADR → REQ → ROADMAP → kanban"
49
+ signature: "📦 trackfw - Governed Delivery"
50
+ ---
51
+
52
+ # trackfw — Modo de Operação
53
+
54
+ Este projeto usa **trackfw** para governança de entrega de software.
55
+ Cadeia: **ADR → REQ → ROADMAP** · Estados: \`backlog / wip / blocked / done / abandoned\`
56
+
57
+ ## Comandos principais
58
+
59
+ - \`trackfw context\` — contexto de trabalho atual (sempre execute primeiro)
60
+ - \`trackfw status\` — todos os artefatos e estados
61
+ - \`trackfw validate\` — valida consistência de governança
62
+ - \`trackfw roadmap move <nome> <estado>\` — transição de estado
63
+ - \`trackfw serve\` — board Kanban em http://localhost:4080
64
+
65
+ ## Protocolo de agente
66
+
67
+ 1. Antes de iniciar: \`trackfw context\` + ler \`docs/agents-working-context.md\`
68
+ 2. Após concluir: atualizar \`docs/agents-working-context.md\`
69
+ 3. Antes de PR: \`trackfw validate\` deve passar com zero violations
70
+ `
71
+ }
72
+
73
+ function claudeSkillTarget(homeRoot, { dryRun, installMissing }) {
74
+ const id = 'claude-skill'
75
+ const filePath = path.join(homeRoot, '.claude', 'skills', 'trackfw', 'SKILL.md')
76
+ const displayPath = tildeify(homeRoot, filePath)
77
+ const desired = claudeSkillContent()
78
+
79
+ try {
80
+ const exists = fs.existsSync(filePath)
81
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
82
+
83
+ if (!exists && !installMissing) return { id, state: 'missing', path: displayPath }
84
+ if (exists && actual === desired) return { id, state: 'skipped', path: displayPath }
85
+
86
+ if (dryRun) return { id, state: 'updated', path: displayPath }
87
+
88
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
89
+ fs.writeFileSync(filePath, desired, 'utf8')
90
+ return { id, state: 'updated', path: displayPath }
91
+ } catch (e) {
92
+ return { id, state: 'failed', path: displayPath, message: e.message }
93
+ }
94
+ }
95
+
96
+ // catalogBundleTarget — one target per (tool, kind) pair at global scope.
97
+ // Uses IntegrationManager.inspect (read-only) to classify every catalog
98
+ // item under that pair, then only calls manager.update() for the subset
99
+ // that needs a write — and never for --dry-run. displayPath is derived from
100
+ // the catalog itself (globalGroupPath), not from any individual plan's
101
+ // destination, so it never depends on catalog item iteration order (this is
102
+ // what previously caused the claude-skills path to diverge from the Python
103
+ // CLI — see docs/cli-parity.md, "Declared harness targets — pinned list").
104
+ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, installMissing }) {
105
+ const id = `${toolId}-${kind}`
106
+ let displayPath = `~/.${toolId}`
107
+ try {
108
+ displayPath = globalGroupPath(toolId, kind)
109
+ const plans = buildPlans(kind, { targets: [toolId], scope: 'global', identity: identityConfig })
110
+ if (!plans.length) return { id, state: 'missing', path: displayPath }
111
+
112
+ const manager = new IntegrationManager({ homeRoot })
113
+ const statuses = manager.inspect(plans)
114
+
115
+ const allNotInstalled = statuses.every((s) => s.state === 'not-installed')
116
+ const anyModified = statuses.some((s) => s.state === 'modified')
117
+
118
+ if (allNotInstalled && !installMissing) return { id, state: 'missing', path: displayPath }
119
+
120
+ const toWrite = plans.filter((_, index) => {
121
+ const state = statuses[index].state
122
+ if (state === 'outdated') return true
123
+ if (state === 'not-installed') return installMissing
124
+ return false
125
+ })
126
+
127
+ if (!toWrite.length) {
128
+ if (allNotInstalled) return { id, state: 'missing', path: displayPath }
129
+ return { id, state: 'skipped', path: displayPath }
130
+ }
131
+
132
+ if (!dryRun) manager.update(toWrite)
133
+ return { id, state: 'updated', path: displayPath }
134
+ } catch (e) {
135
+ return { id, state: 'failed', path: displayPath, message: e.message }
136
+ }
137
+ }
138
+
139
+ const HARNESS_TARGET_IDS = ['claude-skill']
140
+ for (const target of catalog.targets) {
141
+ HARNESS_TARGET_IDS.push(`${target.id}-agents`, `${target.id}-skills`)
142
+ }
143
+
144
+ // buildHarnessTargets — `wanted` (nullable) restricts which targets are
145
+ // even computed/applied. This must happen HERE, not as a post-hoc filter
146
+ // on the returned array: catalogBundleTarget/claudeSkillTarget call
147
+ // manager.update() as a side effect, so filtering after construction would
148
+ // still have mutated every unrequested target's files on disk.
149
+ function buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing }, wanted) {
150
+ const include = (id) => !wanted || wanted.includes(id)
151
+ const targets = []
152
+ if (include('claude-skill')) targets.push(claudeSkillTarget(homeRoot, { dryRun, installMissing }))
153
+ for (const target of catalog.targets) {
154
+ const agentsId = `${target.id}-agents`
155
+ const skillsId = `${target.id}-skills`
156
+ if (include(agentsId)) targets.push(catalogBundleTarget(target.id, 'agents', homeRoot, identityConfig, { dryRun, installMissing }))
157
+ if (include(skillsId)) targets.push(catalogBundleTarget(target.id, 'skills', homeRoot, identityConfig, { dryRun, installMissing }))
158
+ }
159
+ return targets
160
+ }
161
+
162
+ // run — entry point invoked by `trackfw update harness`. Deliberately a
163
+ // plain function, not its own commander.Command: nesting a Command that
164
+ // redeclares the SAME flag names (--json, --dry-run, --targets,
165
+ // --install-missing) as its parent ('update') triggers a commander@12
166
+ // parsing quirk where the flag binds to the ANCESTOR command's opts()
167
+ // instead of the child's, silently producing `{}` in the child action no
168
+ // matter what was passed on the command line (reproduced and confirmed in
169
+ // isolation while building this ML — see the vault note this ML links).
170
+ // `update.js` instead parses `update harness --json` as ITS OWN single
171
+ // command with an optional positional `[mode]` argument, so there is only
172
+ // ever one Option object per flag name; it calls `run(options)` here when
173
+ // `mode === 'harness'`.
174
+ function run(options) {
175
+ let wanted
176
+ try {
177
+ const requested = options.targets ? String(options.targets).split(',').map((s) => s.trim()).filter(Boolean) : []
178
+ wanted = validateTargets(HARNESS_TARGET_IDS, requested)
179
+ } catch (e) {
180
+ console.error(`✗ ${e.message}`)
181
+ process.exit(1)
182
+ }
183
+
184
+ const homeRoot = os.homedir()
185
+ const dryRun = Boolean(options.dryRun)
186
+ const installMissing = Boolean(options.installMissing)
187
+
188
+ // Identidade resolvida do disco antes de buildPlans — pular esta etapa
189
+ // reverteria silenciosamente os nomes customizados para os defaults
190
+ // neutros (mesma justificativa de npm/src/integrations/index.js:execute).
191
+ const identityConfig = identityStore.load(homeRoot)
192
+
193
+ const targets = options.json
194
+ ? silenceConsole(() => buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing }, wanted))
195
+ : buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing }, wanted)
196
+
197
+ const doc = buildDocument('harness', dryRun, targets)
198
+
199
+ if (options.json) {
200
+ console.log(JSON.stringify(doc, null, 2))
201
+ } else {
202
+ console.log(humanReport('harness', dryRun, targets))
203
+ }
204
+
205
+ if (doc.summary.failed > 0) process.exitCode = 1
206
+ }
207
+
208
+ module.exports = { run, HARNESS_TARGET_IDS, buildHarnessTargets, claudeSkillContent }