@polymorphism-tech/morph-spec 4.10.5 → 4.10.7

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 (54) hide show
  1. package/CLAUDE.md +3 -0
  2. package/README.md +3 -3
  3. package/bin/morph-spec.js +90 -1
  4. package/bin/task-manager.js +59 -6
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/agents/backend/hangfire-orchestrator.md +1 -1
  9. package/framework/agents/backend/ms-agent-expert.md +2 -1
  10. package/framework/agents/integrations/asaas-financial.md +1 -1
  11. package/framework/agents/integrations/clerk-auth.md +1 -2
  12. package/framework/agents/integrations/hangfire-integration.md +1 -1
  13. package/framework/agents/integrations/resend-email.md +1 -1
  14. package/framework/agents.json +345 -708
  15. package/framework/hooks/claude-code/post-tool-use/dispatch.js +10 -5
  16. package/framework/skills/README.md +0 -1
  17. package/framework/skills/level-1-workflows/morph-phase-clarify/SKILL.md +1 -1
  18. package/framework/skills/level-1-workflows/morph-phase-codebase-analysis/SKILL.md +1 -1
  19. package/framework/skills/level-1-workflows/morph-phase-design/SKILL.md +1 -1
  20. package/framework/skills/level-1-workflows/morph-phase-implement/SKILL.md +42 -13
  21. package/framework/skills/level-1-workflows/morph-phase-plan/SKILL.md +1 -1
  22. package/framework/skills/level-1-workflows/morph-phase-setup/SKILL.md +16 -12
  23. package/framework/skills/level-1-workflows/morph-phase-tasks/SKILL.md +1 -1
  24. package/framework/skills/level-1-workflows/morph-phase-uiux/SKILL.md +1 -1
  25. package/framework/skills/level-1-workflows/morph-scope-escalation/SKILL.md +1 -1
  26. package/package.json +1 -1
  27. package/src/commands/agents/agent-metrics.js +174 -0
  28. package/src/commands/agents/audit-dispatch.js +82 -0
  29. package/src/commands/agents/detect-agents.js +275 -0
  30. package/src/commands/agents/dispatch-agents.js +41 -135
  31. package/src/commands/agents/index.js +9 -0
  32. package/src/commands/agents/manage-agents.js +352 -0
  33. package/src/commands/project/doctor.js +61 -2
  34. package/src/commands/project/init.js +10 -2
  35. package/src/commands/project/update.js +12 -25
  36. package/src/commands/state/advance-phase.js +11 -3
  37. package/src/core/state/state-manager.js +81 -0
  38. package/src/lib/agents/agents-manager.js +349 -0
  39. package/src/lib/agents/metrics-calculator.js +125 -0
  40. package/src/lib/agents/output-collector.js +75 -0
  41. package/src/lib/generators/context-generator.js +8 -7
  42. package/src/lib/tasks/task-parser.js +94 -3
  43. package/src/lib/validators/checkpoint-persistence.js +39 -0
  44. package/src/lib/validators/content/content-validator.js +9 -1
  45. package/src/lib/validators/spec-validator.js +2 -2
  46. package/src/lib/validators/validation-runner.js +20 -0
  47. package/src/scripts/setup-infra.js +1 -17
  48. package/src/utils/agent-id-mapper.js +11 -0
  49. package/src/utils/agents-installer.js +40 -8
  50. package/src/utils/claude-settings-manager.js +1 -1
  51. package/src/utils/hooks-installer.js +0 -245
  52. package/src/utils/tier-helpers.js +24 -0
  53. package/framework/agents/architecture/po-pm-advisor.md +0 -203
  54. package/framework/hooks/claude-code/user-prompt/set-terminal-title.js +0 -68
@@ -1,13 +1,104 @@
1
1
  /**
2
- * Task Parser — Shared utilities for reading and syncing task state.
2
+ * Task Parser — Shared utilities for reading, parsing, and grouping tasks.
3
3
  *
4
- * Extracted from bin/task-manager.cjs so both the task-manager and any
5
- * future consumers can share the same parsing logic without duplication.
4
+ * Extracted from bin/task-manager.cjs and dispatch-agents.js so all
5
+ * consumers share the same parsing logic without duplication.
6
6
  */
7
7
 
8
8
  import { readFile } from 'fs/promises';
9
+ import { readFileSync, existsSync } from 'fs';
9
10
  import { join } from 'path';
10
11
 
12
+ // ============================================================================
13
+ // TASK_CATEGORY_TO_GROUP — maps task categories to parallel groups
14
+ // ============================================================================
15
+
16
+ const TASK_CATEGORY_TO_GROUP = {
17
+ 'domain': 'backend',
18
+ 'domain-bc': 'backend',
19
+ 'infrastructure': 'backend',
20
+ 'application': 'backend',
21
+ 'presentation': 'frontend',
22
+ 'tests': 'tests',
23
+ 'infra': 'infra',
24
+ 'docs': 'docs',
25
+ };
26
+
27
+ // ============================================================================
28
+ // parseTaskList — structured task parsing with error reporting
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Parse tasks.md content into structured task objects with error reporting.
33
+ *
34
+ * @param {string} content - Raw tasks.md content
35
+ * @returns {{ tasks: ParsedTask[], errors: TaskParseError[], groups: Object<string, string[]> }}
36
+ */
37
+ export function parseTaskList(content) {
38
+ const tasks = [];
39
+ const errors = [];
40
+ const groups = {};
41
+
42
+ const taskHeadingRe = /^###\s+(T\d{3,})\s*[—–:\-]\s*(.+)$/gm;
43
+ const groupRe = /(?:\*\*Group\*\*\s*:\s*`?([a-z-]+)`?|- \*\*Group:\*\*\s*`?([a-z-]+)`?)/i;
44
+ const categoryRe = /(?:\*\*category\*\*\s*:\s*`?([a-z-]+)`?|category:\s*([a-z-]+))/i;
45
+
46
+ let headingMatch;
47
+ const taskPositions = [];
48
+
49
+ while ((headingMatch = taskHeadingRe.exec(content)) !== null) {
50
+ taskPositions.push({ id: headingMatch[1], title: headingMatch[2].trim(), pos: headingMatch.index });
51
+ }
52
+
53
+ for (let i = 0; i < taskPositions.length; i++) {
54
+ const { id, title, pos } = taskPositions[i];
55
+ const end = i + 1 < taskPositions.length ? taskPositions[i + 1].pos : content.length;
56
+ const block = content.slice(pos, end);
57
+
58
+ // Try group field first (v4), fall back to category field (v3)
59
+ const grpMatch = groupRe.exec(block);
60
+ const catMatch = categoryRe.exec(block);
61
+ let group;
62
+ if (grpMatch) {
63
+ group = (grpMatch[1] || grpMatch[2]).toLowerCase();
64
+ } else if (catMatch) {
65
+ const category = (catMatch[1] || catMatch[2]).toLowerCase();
66
+ group = TASK_CATEGORY_TO_GROUP[category] || 'backend';
67
+ } else {
68
+ group = 'backend';
69
+ }
70
+
71
+ tasks.push({ id, title, group });
72
+
73
+ if (!groups[group]) groups[group] = [];
74
+ groups[group].push(id);
75
+ }
76
+
77
+ return { tasks, errors, groups };
78
+ }
79
+
80
+ // ============================================================================
81
+ // parseAndGroupTasks — drop-in replacement for dispatch-agents.js
82
+ // ============================================================================
83
+
84
+ /**
85
+ * Parse tasks.md and return a map of { parallelGroup -> taskId[] }.
86
+ * Gracefully returns {} if the file doesn't exist or is unparseable.
87
+ *
88
+ * @param {string} tasksPath - Absolute path to tasks.md
89
+ * @returns {Object<string, string[]>}
90
+ */
91
+ export function parseAndGroupTasks(tasksPath) {
92
+ if (!existsSync(tasksPath)) return {};
93
+
94
+ try {
95
+ const content = readFileSync(tasksPath, 'utf8');
96
+ return parseTaskList(content).groups;
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+
11
102
  // ============================================================================
12
103
  // parseTasksMd
13
104
  // ============================================================================
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Checkpoint Validation Persistence
3
+ *
4
+ * Persists checkpoint validation results to disk for traceability.
5
+ * Each checkpoint writes a JSON file to:
6
+ * .morph/features/{feature}/5-implement/validations/checkpoint-{N}.json
7
+ */
8
+
9
+ import { mkdirSync, writeFileSync } from 'fs';
10
+ import { join } from 'path';
11
+
12
+ /**
13
+ * Persist a checkpoint validation result to disk.
14
+ *
15
+ * @param {string} projectPath - Project root
16
+ * @param {string} featureName - Feature name
17
+ * @param {{ checkpointNum: number, timestamp: string, passed: boolean, validators: Object, summary: string, feature?: string, phase?: string }} result - Validation result
18
+ * @returns {string} Path to the saved JSON file
19
+ */
20
+ export function persistCheckpointValidation(projectPath, featureName, result) {
21
+ const dir = join(projectPath, `.morph/features/${featureName}/5-implement/validations`);
22
+ mkdirSync(dir, { recursive: true });
23
+
24
+ const filename = `checkpoint-${result.checkpointNum}.json`;
25
+ const filePath = join(dir, filename);
26
+
27
+ const output = {
28
+ checkpointNum: result.checkpointNum,
29
+ timestamp: result.timestamp || new Date().toISOString(),
30
+ passed: result.passed,
31
+ validators: result.validators || {},
32
+ summary: result.summary || '',
33
+ feature: result.feature || featureName,
34
+ phase: result.phase || 'implement',
35
+ };
36
+
37
+ writeFileSync(filePath, JSON.stringify(output, null, 2), 'utf8');
38
+ return filePath;
39
+ }
@@ -41,6 +41,14 @@ export function validateSpecContent(specPath) {
41
41
  const errors = [];
42
42
  const warnings = [];
43
43
 
44
+ // Missing sections are warnings, not errors — not every spec needs all sections
45
+ // (e.g., frontend-only features may not have Data Model, CLI tools may not have Technical Architecture)
46
+ if (missing.length > 0) {
47
+ missing.forEach(section => {
48
+ warnings.push(`Missing recommended section: ${section}`);
49
+ });
50
+ }
51
+
44
52
  // Check minimum content length
45
53
  if (content.length < 500) {
46
54
  warnings.push('Spec seems very short (< 500 characters). Consider adding more detail.');
@@ -70,7 +78,7 @@ export function validateSpecContent(specPath) {
70
78
  }
71
79
 
72
80
  return {
73
- valid: missing.length === 0 && errors.length === 0,
81
+ valid: errors.length === 0,
74
82
  missing,
75
83
  errors,
76
84
  warnings,
@@ -75,9 +75,9 @@ function validateSpecMd(content) {
75
75
  for (const section of requiredSections) {
76
76
  if (!section.pattern.test(content)) {
77
77
  issues.push({
78
- level: 'error',
78
+ level: 'warning',
79
79
  type: 'spec',
80
- message: `Missing required section: "${section.name}"`,
80
+ message: `Missing recommended section: "${section.name}"`,
81
81
  solution: `Add ## ${section.name} section to spec.md`
82
82
  });
83
83
  }
@@ -20,6 +20,15 @@ const DOTNET_ONLY_VALIDATORS = new Set([
20
20
  'blazor-state', 'contract-compliance'
21
21
  ]);
22
22
 
23
+ /**
24
+ * Heavy validators skipped in 'light' mode (checkpoint-only).
25
+ * Light mode runs only spec, content, and stack-specific lightweight validators.
26
+ */
27
+ const HEAVY_VALIDATORS = new Set([
28
+ 'architecture', 'packages', 'contract-compliance',
29
+ 'blazor-concurrency', 'css', 'design-system'
30
+ ]);
31
+
23
32
  /**
24
33
  * Load agent → validators map from agents.json (data-driven)
25
34
  * @param {string} projectPath - Root path of the project
@@ -74,6 +83,17 @@ export async function runValidation(projectPath, featureName, options = {}) {
74
83
  // Determine which validators to run
75
84
  const validatorIds = options.validators || detectValidators(featureName, projectPath);
76
85
 
86
+ // Filter by mode: 'light' skips heavy validators, 'full' (default) runs all
87
+ const mode = options.mode || 'full';
88
+ if (mode === 'light') {
89
+ const beforeCount = validatorIds.length;
90
+ for (let i = validatorIds.length - 1; i >= 0; i--) {
91
+ if (HEAVY_VALIDATORS.has(validatorIds[i])) {
92
+ validatorIds.splice(i, 1);
93
+ }
94
+ }
95
+ }
96
+
77
97
  // Merge constraint-enabled validators
78
98
  validatorIds.push(...constraints.enabledValidators);
79
99
 
@@ -27,7 +27,7 @@ import {
27
27
  import { parseStacks, getProjectTags, shouldInstall } from '../lib/stack-filter.js';
28
28
  import { injectMorphImport } from '../utils/claude-md-injector.js';
29
29
  import { saveProjectMorphVersion, getInstalledCLIVersion } from '../utils/version-checker.js';
30
- import { installClaudeHooks, installGlobalStatusline, installVSCodeTerminalSettings, installShellIntegration } from '../utils/claude-settings-manager.js';
30
+ import { installClaudeHooks, installGlobalStatusline } from '../utils/claude-settings-manager.js';
31
31
  import { installSkills } from '../utils/skills-installer.js';
32
32
  import { installAgents, installDomainAgents } from '../utils/agents-installer.js';
33
33
 
@@ -238,22 +238,6 @@ export async function setupInfra(targetPath, { _exec = execSync } = {}) {
238
238
  log('Step 16: Installing Claude Code hooks...');
239
239
  await installClaudeHooks(targetPath);
240
240
 
241
- // --- 16a. installShellIntegration (non-critical) ---
242
- log('Step 16a: Installing shell integration...');
243
- try {
244
- await installShellIntegration();
245
- } catch {
246
- // Non-critical — shell profile may not be writable
247
- }
248
-
249
- // --- 16b. installVSCodeTerminalSettings (non-critical) ---
250
- log('Step 16b: Configuring VS Code terminal settings...');
251
- try {
252
- await installVSCodeTerminalSettings();
253
- } catch {
254
- // Non-critical — VS Code settings may not be present
255
- }
256
-
257
241
  // --- 17. saveProjectMorphVersion ---
258
242
  log('Step 17: Saving version info...');
259
243
  const cliVersion = getInstalledCLIVersion();
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Map an agents.json ID to the Claude Code subagent_type.
3
+ *
4
+ * @param {string} id - Agent ID from agents.json (e.g., "nextjs-expert")
5
+ * @param {number} [tier] - Agent tier (1-4). Defaults to non-validator.
6
+ * @returns {string} subagent_type matching .claude/agents/ filename stem
7
+ */
8
+ export function agentIdToSubagentType(id, tier) {
9
+ const prefix = tier === 4 ? 'morph-validator-' : 'morph-';
10
+ return `${prefix}${id}`;
11
+ }
@@ -8,7 +8,7 @@
8
8
  * maxTurns, skills, memory) followed by the agent's spawn prompt as the body.
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
11
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
12
12
  import { join } from 'path';
13
13
  import { parseStacks, shouldInstall } from '../lib/stack-filter.js';
14
14
 
@@ -88,6 +88,13 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
88
88
  if (allPossible.has(file) && !installed.has(file)) {
89
89
  unlinkSync(join(targetDir, file));
90
90
  }
91
+ // Remove morph-domain-{name}.md when morph-{name}.md was installed from agents.json
92
+ if (file.startsWith('morph-domain-')) {
93
+ const suffix = file.slice('morph-domain-'.length);
94
+ if (installed.has(`morph-${suffix}`)) {
95
+ unlinkSync(join(targetDir, file));
96
+ }
97
+ }
91
98
  }
92
99
 
93
100
  return { tier1, tier2, tier3, tier4 };
@@ -170,6 +177,19 @@ function buildDescription(agent) {
170
177
  return base.length > 200 ? base.slice(0, 197) + '...' : base;
171
178
  }
172
179
 
180
+ /**
181
+ * Serialize an AgentStandard to a readable markdown line.
182
+ *
183
+ * @param {string|{id: string, scope?: string, priority?: string}} standard
184
+ * @returns {string} e.g., "- core-coding (all, required)" or "- core-coding"
185
+ */
186
+ export function formatStandardRef(standard) {
187
+ if (typeof standard === 'string') return `- ${standard}`;
188
+ const id = standard.id || String(standard);
189
+ const parts = [standard.scope, standard.priority].filter(Boolean);
190
+ return parts.length > 0 ? `- ${id} (${parts.join(', ')})` : `- ${id}`;
191
+ }
192
+
173
193
  function buildBody(agent) {
174
194
  const lines = [];
175
195
  if (agent.teammate?.spawn_prompt) {
@@ -181,7 +201,7 @@ function buildBody(agent) {
181
201
  }
182
202
  }
183
203
  if (agent.standards?.length) {
184
- lines.push(`\n## Standards to Reference\n${agent.standards.map(s => `- ${s}`).join('\n')}`);
204
+ lines.push(`\n## Standards to Reference\n${agent.standards.map(formatStandardRef).join('\n')}`);
185
205
  }
186
206
  return lines.join('\n');
187
207
  }
@@ -270,11 +290,19 @@ export async function installDomainAgents(projectDir, frameworkDir = 'framework'
270
290
  '',
271
291
  ].join('\n');
272
292
 
273
- const filename = `morph-domain-${name}.md`;
274
- const targetPath = join(targetDir, filename);
275
- writeFileSync(targetPath, `---\n${frontmatter}---\n\n${body.trimStart()}`, 'utf-8');
276
- installed.add(filename);
277
- specialists++;
293
+ // If agents.json already generated morph-{name}.md, append domain content there
294
+ const agentsJsonFile = `morph-${name}.md`;
295
+ const agentsJsonPath = join(targetDir, agentsJsonFile);
296
+ if (existsSync(agentsJsonPath)) {
297
+ appendFileSync(agentsJsonPath, `\n\n---\n\n## Domain Knowledge\n\n${body.trimStart()}`, 'utf-8');
298
+ specialists++;
299
+ } else {
300
+ const filename = `morph-domain-${name}.md`;
301
+ const targetPath = join(targetDir, filename);
302
+ writeFileSync(targetPath, `---\n${frontmatter}---\n\n${body.trimStart()}`, 'utf-8');
303
+ installed.add(filename);
304
+ specialists++;
305
+ }
278
306
  }
279
307
 
280
308
  // Cleanup orphan morph-domain-* files
@@ -333,7 +361,7 @@ export function checkMissingAgents(projectDir, frameworkDir, projectTags = []) {
333
361
  }
334
362
  }
335
363
 
336
- // Check domain agents
364
+ // Check domain agents (skip if merged into agents.json entry)
337
365
  const domainsDir = join(frameworkDir, 'agents');
338
366
  if (existsSync(domainsDir)) {
339
367
  for (const filePath of collectSkillFiles(domainsDir)) {
@@ -342,6 +370,10 @@ export function checkMissingAgents(projectDir, frameworkDir, projectTags = []) {
342
370
  if (!name) continue;
343
371
  if (!shouldInstall(stacks, projectTags)) continue;
344
372
 
373
+ // If agents.json has morph-{name}.md, domain content is merged there
374
+ const agentsJsonFile = `morph-${name}.md`;
375
+ if (installedFiles.has(agentsJsonFile)) continue;
376
+
345
377
  expected++;
346
378
  const filename = `morph-domain-${name}.md`;
347
379
  if (!installedFiles.has(filename)) {
@@ -17,7 +17,7 @@ const MORPH_MANAGED_KEY = '_morph_managed';
17
17
  // Re-export from hooks-installer (v2 registry-based)
18
18
  // ============================================================================
19
19
 
20
- export { installClaudeHooks, installGlobalStatusline, installVSCodeTerminalSettings, installShellIntegration, getHooksVersion } from './hooks-installer.js';
20
+ export { installClaudeHooks, installGlobalStatusline, getHooksVersion } from './hooks-installer.js';
21
21
  export { installAgents } from './agents-installer.js';
22
22
 
23
23
  // ============================================================================
@@ -79,10 +79,6 @@ const MORPH_HOOKS = [
79
79
  type: 'command',
80
80
  command: 'node framework/hooks/claude-code/user-prompt/enrich-prompt.js'
81
81
  },
82
- {
83
- type: 'command',
84
- command: 'node framework/hooks/claude-code/user-prompt/set-terminal-title.js'
85
- }
86
82
  ]
87
83
  },
88
84
 
@@ -416,248 +412,7 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
416
412
  return { installed: true, globalDir };
417
413
  }
418
414
 
419
- /**
420
- * Install VS Code terminal settings to enable OSC title sequences.
421
- *
422
- * Sets `terminal.integrated.tabs.title` to include `${sequence}` so VS Code
423
- * renders OSC 0 escape sequences written by the set-terminal-title hook.
424
- *
425
- * Idempotent — only writes if the setting is missing or doesn't already
426
- * include `${sequence}`.
427
- *
428
- * @param {string} [homeDirOverride] - Override home directory (for testing)
429
- * @returns {Promise<{ updated: boolean, path: string|null, alreadySet: boolean }>}
430
- */
431
- export async function installVSCodeTerminalSettings(homeDirOverride = null) {
432
- const home = homeDirOverride || homedir();
433
-
434
- let settingsPath;
435
- if (process.platform === 'win32') {
436
- settingsPath = join(home, 'AppData', 'Roaming', 'Code', 'User', 'settings.json');
437
- } else if (process.platform === 'darwin') {
438
- settingsPath = join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json');
439
- } else {
440
- settingsPath = join(home, '.config', 'Code', 'User', 'settings.json');
441
- }
442
-
443
- if (!existsSync(settingsPath)) {
444
- return { updated: false, path: settingsPath, alreadySet: false };
445
- }
446
-
447
- let settings;
448
- try {
449
- settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
450
- } catch {
451
- settings = {};
452
- }
453
-
454
- const KEY = 'terminal.integrated.tabs.title';
455
- const current = settings[KEY] || '';
456
- if (current.includes('${sequence}')) {
457
- return { updated: false, path: settingsPath, alreadySet: true };
458
- }
459
415
 
460
- settings[KEY] = '${sequence}${separator}${local}';
461
- await writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n', 'utf-8');
462
- return { updated: true, path: settingsPath, alreadySet: false };
463
- }
464
-
465
- /**
466
- * Install shell integration for Claude Code terminal title.
467
- *
468
- * Writes a wrapper function to the user's shell profile that:
469
- * 1. Starts a background job watching ~/.claude/terminal_title
470
- * 2. Updates the terminal tab title in real time via Console.Title (PS) / OSC 0 (bash)
471
- * 3. Resets the title when the `claude` command exits
472
- *
473
- * On Windows: installs to PowerShell 5.1 and/or PowerShell 7 profiles.
474
- * On Linux/macOS: installs to ~/.bashrc and/or ~/.zshrc (if they exist).
475
- *
476
- * Idempotent — skips if the integration block is already present.
477
- *
478
- * @param {string} [homeDirOverride] - Override home directory (for testing)
479
- * @returns {Promise<{ installed: string[], skipped: string[], errors: string[] }>}
480
- */
481
- export async function installShellIntegration(homeDirOverride = null) {
482
- const home = homeDirOverride || homedir();
483
- const installed = [];
484
- const skipped = [];
485
- const errors = [];
486
-
487
- if (process.platform === 'win32') {
488
- const profiles = [
489
- join(home, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
490
- join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
491
- ];
492
- for (const profilePath of profiles) {
493
- try {
494
- const result = await _appendShellBlock(profilePath, _buildPowerShellBlock(), _psAlreadyInstalled);
495
- if (result === 'installed') installed.push(profilePath);
496
- else skipped.push(profilePath);
497
- } catch (err) {
498
- errors.push(profilePath + ': ' + err.message);
499
- }
500
- }
501
- } else {
502
- const bashBlock = _buildBashBlock();
503
- for (const profilePath of [join(home, '.bashrc'), join(home, '.zshrc')]) {
504
- if (!existsSync(profilePath)) continue;
505
- try {
506
- const result = await _appendShellBlock(profilePath, bashBlock, _bashAlreadyInstalled);
507
- if (result === 'installed') installed.push(profilePath);
508
- else skipped.push(profilePath);
509
- } catch (err) {
510
- errors.push(profilePath + ': ' + err.message);
511
- }
512
- }
513
- }
514
-
515
- return { installed, skipped, errors };
516
- }
517
-
518
- /** @returns {string} PowerShell integration block */
519
- function _buildPowerShellBlock() {
520
- return [
521
- '',
522
- '# === Claude Code terminal title integration (morph-spec) ===',
523
- '# Per-session title file prevents cross-session title contamination.',
524
- '$script:_claudeExe = Get-Command claude -CommandType Application -ErrorAction SilentlyContinue |',
525
- ' Select-Object -First 1 -ExpandProperty Source',
526
- '',
527
- 'function _Start-ClaudeTitleWatcher {',
528
- ' param([string]$TitleFile)',
529
- ' $rs = [runspacefactory]::CreateRunspace()',
530
- ' $rs.Open()',
531
- ' $ps = [powershell]::Create()',
532
- ' $ps.Runspace = $rs',
533
- ' [void]$ps.AddScript({',
534
- ' param($f)',
535
- ' $last = ""',
536
- ' while ($true) {',
537
- ' if (Test-Path $f) {',
538
- ' try {',
539
- ' $t = (Get-Content $f -Raw -ErrorAction Stop).Trim()',
540
- ' if ($t -and $t -ne $last) {',
541
- ' [Console]::Title = $t',
542
- ' $last = $t',
543
- ' }',
544
- ' } catch {}',
545
- ' }',
546
- ' Start-Sleep -Milliseconds 500',
547
- ' }',
548
- ' }).AddArgument($TitleFile)',
549
- ' $ps.BeginInvoke() | Out-Null',
550
- ' return $ps',
551
- '}',
552
- '',
553
- 'function Invoke-Claude {',
554
- ' [CmdletBinding()]',
555
- ' param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments)',
556
- ' if (-not $script:_claudeExe) { Write-Error "claude executable not found in PATH"; return }',
557
- ' # Per-session file: each Invoke-Claude gets its own title file so multiple',
558
- ' # open terminals don\'t overwrite each other\'s titles.',
559
- ' $sessionTitleFile = Join-Path $HOME ".claude\\terminal_title_$(New-Guid)"',
560
- ' $env:MORPH_TERMINAL_TITLE_FILE = $sessionTitleFile',
561
- ' $watcher = _Start-ClaudeTitleWatcher -TitleFile $sessionTitleFile',
562
- ' try {',
563
- ' & $script:_claudeExe @Arguments',
564
- ' } finally {',
565
- ' try { $watcher.Stop(); $watcher.Dispose() } catch {}',
566
- ' $env:MORPH_TERMINAL_TITLE_FILE = $null',
567
- ' Remove-Item $sessionTitleFile -ErrorAction SilentlyContinue',
568
- ' [Console]::Title = "PowerShell"',
569
- ' }',
570
- '}',
571
- 'Set-Alias -Name claude -Value Invoke-Claude -Force',
572
- '# === End Claude Code terminal title integration (morph-spec) ===',
573
- ].join('\n');
574
- }
575
-
576
- /** @returns {string} Bash/Zsh integration block */
577
- function _buildBashBlock() {
578
- return [
579
- '',
580
- '# === Claude Code terminal title integration (morph-spec) ===',
581
- '# Per-session title file prevents cross-session title contamination.',
582
- '_claude_title_watcher() {',
583
- ' local title_file="$1"',
584
- ' local last_title=""',
585
- ' while true; do',
586
- ' if [ -f "$title_file" ]; then',
587
- ' local current',
588
- ' current=$(cat "$title_file" 2>/dev/null)',
589
- ' if [ -n "$current" ] && [ "$current" != "$last_title" ]; then',
590
- " printf '\\e]0;%s\\a' \"$current\"",
591
- ' last_title="$current"',
592
- ' fi',
593
- ' fi',
594
- ' sleep 0.5',
595
- ' done',
596
- '}',
597
- '',
598
- 'claude() {',
599
- ' # Per-session file: each invocation gets its own title file so multiple',
600
- ' # open terminals don\'t overwrite each other\'s titles.',
601
- ' local SESSION_TITLE_FILE',
602
- ' SESSION_TITLE_FILE="$HOME/.claude/terminal_title_$$_$RANDOM"',
603
- ' export MORPH_TERMINAL_TITLE_FILE="$SESSION_TITLE_FILE"',
604
- ' _claude_title_watcher "$SESSION_TITLE_FILE" &',
605
- ' local WATCHER_PID=$!',
606
- ' command claude "$@"',
607
- ' local EXIT_CODE=$?',
608
- ' kill "$WATCHER_PID" 2>/dev/null',
609
- ' wait "$WATCHER_PID" 2>/dev/null',
610
- ' unset MORPH_TERMINAL_TITLE_FILE',
611
- ' rm -f "$SESSION_TITLE_FILE"',
612
- " printf '\\e]0;claude\\a'",
613
- ' return $EXIT_CODE',
614
- '}',
615
- '# === End Claude Code terminal title integration (morph-spec) ===',
616
- ].join('\n');
617
- }
618
-
619
- /** Check if PowerShell profile already has the integration */
620
- function _psAlreadyInstalled(content) {
621
- return content.includes('Invoke-Claude') || content.includes('_Start-ClaudeTitleWatcher');
622
- }
623
-
624
- /** Check if bash/zsh profile already has the integration */
625
- function _bashAlreadyInstalled(content) {
626
- return content.includes('_claude_title_watcher') || content.includes('command claude "$@"');
627
- }
628
-
629
- /**
630
- * Append a shell block to a profile file if not already present.
631
- * Creates the file (and parent dirs) if it doesn't exist.
632
- * @param {string} profilePath
633
- * @param {string} block
634
- * @param {(content: string) => boolean} isInstalled
635
- * @returns {Promise<'installed'|'skipped'>}
636
- */
637
- async function _appendShellBlock(profilePath, block, isInstalled) {
638
- let existing = '';
639
- let isUtf16le = false;
640
-
641
- if (existsSync(profilePath)) {
642
- const raw = await readFile(profilePath);
643
- // Detect UTF-16LE: starts with FF FE BOM or has null bytes in the first bytes
644
- isUtf16le = (raw[0] === 0xFF && raw[1] === 0xFE) ||
645
- (raw.length >= 4 && raw[1] === 0 && raw[3] === 0);
646
- existing = isUtf16le ? raw.toString('utf16le').replace(/^\uFEFF/, '') : raw.toString('utf-8');
647
- }
648
-
649
- if (isInstalled(existing)) return 'skipped';
650
-
651
- const dir = dirname(profilePath);
652
- if (!existsSync(dir)) {
653
- await mkdir(dir, { recursive: true });
654
- }
655
-
656
- // Always write as UTF-8; trim trailing whitespace from UTF-16LE-decoded content
657
- const base = isUtf16le ? existing.trimEnd() + '\n' : existing;
658
- await writeFile(profilePath, base + block + '\n', 'utf-8');
659
- return 'installed';
660
- }
661
416
 
662
417
  /**
663
418
  * Remove old v1 agent-teams/dispatch.js hook entries.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tier-to-role helpers for agents.json v4.0
3
+ *
4
+ * Derives role strings from tier numbers, eliminating the need
5
+ * for an explicit `role` field on each agent.
6
+ *
7
+ * @module tier-helpers
8
+ */
9
+
10
+ const TIER_ROLES = {
11
+ 1: 'orchestrator',
12
+ 2: 'leader',
13
+ 3: 'specialist',
14
+ 4: 'validator',
15
+ };
16
+
17
+ /**
18
+ * Derive role string from agent tier number.
19
+ * @param {number} tier - Agent tier (1-4)
20
+ * @returns {string} Role name
21
+ */
22
+ export function tierToRole(tier) {
23
+ return TIER_ROLES[tier] || 'specialist';
24
+ }