@polymorphism-tech/morph-spec 7.8.0 → 7.9.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 (35) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +30 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +3 -1
  7. package/framework/MORPH.md +4 -3
  8. package/framework/evals/trace-schema.md +22 -12
  9. package/framework/hooks/README.md +2 -0
  10. package/framework/hooks/claude-code/core/subagent-report-guard.js +95 -0
  11. package/framework/hooks/claude-code/core/trace-autogen.js +183 -0
  12. package/framework/hooks/shared/tasks-validator.js +47 -4
  13. package/framework/schemas/feature.schema.json +128 -0
  14. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  15. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  16. package/framework/skills/core/morph-apply/SKILL.md +23 -10
  17. package/framework/skills/core/morph-brainstorming/SKILL.md +8 -4
  18. package/framework/skills/core/morph-eval/SKILL.md +3 -6
  19. package/framework/skills/core/morph-implement/SKILL.md +28 -15
  20. package/framework/skills/core/morph-plan/SKILL.md +8 -4
  21. package/framework/skills/core/morph-proposal/SKILL.md +2 -2
  22. package/framework/skills/core/morph-review/SKILL.md +14 -12
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/project/doctor.js +23 -18
  27. package/src/commands/project/update.js +21 -11
  28. package/src/commands/state/create.js +58 -0
  29. package/src/commands/state/gate-decision.js +99 -0
  30. package/src/core/state/state-manager.js +55 -0
  31. package/src/scripts/setup-infra.js +2 -0
  32. package/src/utils/agents-installer.js +25 -10
  33. package/src/utils/banner.js +38 -6
  34. package/src/utils/hooks-installer.js +32 -4
  35. package/src/utils/skills-installer.js +2 -0
@@ -30,7 +30,7 @@ import { installSkills } from '../../utils/skills-installer.js';
30
30
  import { installAgents, installDomainAgents } from '../../utils/agents-installer.js';
31
31
  import { injectMorphImport } from '../../utils/claude-md-injector.js';
32
32
  import { getProjectTags } from '../../lib/stack-filter.js';
33
- import { updateClaudeMdAgentsBlock } from '../../scripts/setup-infra.js';
33
+ import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
34
34
 
35
35
  /**
36
36
  * Backup user's config.json before cleaning
@@ -322,28 +322,35 @@ export async function updateCommand(options) {
322
322
  logger.warn(' ⚠ framework/commands/ source missing — commands not updated');
323
323
  }
324
324
 
325
- // Compute project tags for stack-aware installation of skills, agents, and rules
325
+ // Compute project tags for stack-aware installation of skills, agents, and rules.
326
+ // MUST come from the pre-clean backup: cleanFrameworkDirs() deleted .morph/config/
327
+ // above and restoreUserConfig() only runs at the end — re-reading the file here
328
+ // yields [] and silently downgrades the whole install to universal-only assets
329
+ // (the ".claude/agents/ only has morph-evaluator.md after update" bug).
326
330
  let projectTags = [];
327
331
  try {
328
- const cfg = await readJson(join(morphPath, 'config', 'config.json'));
329
- projectTags = getProjectTags(cfg);
330
- } catch { /* ignore — config may not exist yet */ }
332
+ if (configBackup) projectTags = getProjectTags(configBackup);
333
+ } catch { /* ignore — malformed config */ }
331
334
 
332
335
  // Sync morph skills to .claude/skills/ for native Claude Code discovery
333
336
  updateSpinner.text = 'Syncing morph skills to .claude/skills/...';
334
- await installSkills(targetPath, { projectTags });
337
+ const skillsResult = await installSkills(targetPath, { projectTags });
335
338
 
336
339
  // Sync native subagents to .claude/agents/
337
340
  updateSpinner.text = 'Syncing agents to .claude/agents/...';
338
341
  const agentsResult = await installAgents(targetPath, frameworkDir, { projectTags });
339
342
  await installDomainAgents(targetPath, frameworkDir, { projectTags });
340
343
 
341
- // Sync path-scoped rules to .claude/rules/
344
+ // Sync path-scoped rules to .claude/rules/ — stack-filtered with orphan
345
+ // cleanup (parity with setup-infra), so `update` is a true superset of
346
+ // `setup-infra --reinstall` and also covers project.stack changes.
342
347
  updateSpinner.text = 'Syncing rules to .claude/rules/...';
343
348
  const rulesSrc = join(frameworkDir, 'rules');
344
349
  const rulesDest = join(claudeDest, 'rules');
350
+ let rulesResult = null;
345
351
  if (await pathExists(rulesSrc)) {
346
- await copyDirectory(rulesSrc, rulesDest);
352
+ await ensureDir(rulesDest);
353
+ rulesResult = installRulesForStack(rulesSrc, rulesDest, projectTags);
347
354
  }
348
355
 
349
356
  // Sync runtime CLAUDE.md to .claude/CLAUDE.md and derive the personas
@@ -414,10 +421,13 @@ export async function updateCommand(options) {
414
421
  logger.dim(' ✓ .morph/framework/schemas/ + rubrics/ + evals/ (bar 9 + LLM rubrics)');
415
422
  if (commandsCopied) logger.dim(' ✓ .claude/commands/');
416
423
 
424
+ // Counts make a silent stack-filter downgrade visible at a glance —
425
+ // a dotnet project reporting "1 persona" is a red flag, not a success.
426
+ const tagsLabel = projectTags.length > 0 ? projectTags.join('+') : 'universal only';
417
427
  logger.dim(` ✓ .claude/settings.local.json (${hooksResult.installed} hooks installed)`);
418
- logger.dim(' ✓ .claude/skills/ (skills installed as directories)');
419
- logger.dim(' ✓ .claude/agents/ (native subagents refreshed)');
420
- logger.dim(' ✓ .claude/rules/ (path-scoped rules synced)');
428
+ logger.dim(` ✓ .claude/skills/ (${skillsResult?.installed ?? '?'} skills — stack: ${tagsLabel})`);
429
+ logger.dim(` ✓ .claude/agents/ (${agentsResult?.agents?.length ?? '?'} personas — stack: ${tagsLabel})`);
430
+ logger.dim(` ✓ .claude/rules/ (${rulesResult?.installed ?? '?'} stack-filtered rules)`);
421
431
  logger.dim(' ✓ .claude/CLAUDE.md (runtime quick reference)');
422
432
  logger.dim(' ✓ ~/.claude/statusline.sh (global statusline synced)');
423
433
  logger.dim(' ✓ CLAUDE.md');
@@ -0,0 +1,58 @@
1
+ import chalk from 'chalk';
2
+ import { mkdirSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { createFeature } from '../../core/state/state-manager.js';
5
+
6
+ /** Feature names are kebab-case: lowercase alphanumerics and hyphens. */
7
+ const FEATURE_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
8
+
9
+ const VALID_STATUSES = ['draft', 'in_progress', 'completed'];
10
+
11
+ /**
12
+ * `morph-spec create <feature>` — explicit feature birth: creates
13
+ * `.morph/features/{feature}/0-proposal/` and the canonical feature.json
14
+ * (shape documented in framework/schemas/feature.schema.json).
15
+ *
16
+ * Replaces the implicit birth via the state-sync hook on the first Write —
17
+ * the skills call this at proposal start so the authoritative state exists
18
+ * with the full canonical shape before any artifact lands. Idempotent: an
19
+ * existing feature is reported and left untouched.
20
+ */
21
+ export async function createCommand(featureName, options = {}) {
22
+ try {
23
+ if (!FEATURE_NAME_RE.test(featureName)) {
24
+ console.error(chalk.red(`\n❌ Invalid feature name: "${featureName}"`));
25
+ console.error(chalk.gray('Use kebab-case: lowercase letters, digits and hyphens (e.g. "user-onboarding")\n'));
26
+ process.exit(1);
27
+ }
28
+
29
+ if (options.status !== undefined && !VALID_STATUSES.includes(options.status)) {
30
+ console.error(chalk.red(`\n❌ Invalid --status: "${options.status}"`));
31
+ console.error(chalk.gray(`Valid statuses: ${VALID_STATUSES.join(', ')}\n`));
32
+ process.exit(1);
33
+ }
34
+
35
+ const { feature, created } = await createFeature(featureName, { status: options.status });
36
+
37
+ // The proposal folder is the first working directory of every feature.
38
+ const featureDir = join(process.cwd(), '.morph', 'features', featureName);
39
+ mkdirSync(join(featureDir, '0-proposal'), { recursive: true });
40
+
41
+ if (options.json) {
42
+ console.log(JSON.stringify({ feature: featureName, created, ...feature }, null, 2));
43
+ return;
44
+ }
45
+
46
+ if (created) {
47
+ console.log(chalk.green(`\n✅ Feature created: ${featureName}`));
48
+ console.log(chalk.gray(` .morph/features/${featureName}/feature.json (canonical shape) + 0-proposal/`));
49
+ console.log(chalk.gray(` status: ${feature.status} | createdAt: ${feature.createdAt}\n`));
50
+ } else {
51
+ console.log(chalk.yellow(`\n⚠️ Feature "${featureName}" already exists — left untouched`));
52
+ console.log(chalk.gray(` status: ${feature.status} | createdAt: ${feature.createdAt || 'unknown'}\n`));
53
+ }
54
+ } catch (error) {
55
+ console.error(chalk.red(`\n❌ Error creating feature: ${error.message}\n`));
56
+ process.exit(1);
57
+ }
58
+ }
@@ -0,0 +1,99 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
+
6
+ const VALID_GATES = ['proposal', 'uiux', 'plan', 'review'];
7
+
8
+ function parsePauseValue(raw) {
9
+ const v = String(raw).toLowerCase();
10
+ if (v === 'true') return true;
11
+ if (v === 'false') return false;
12
+ console.error(chalk.red(`\n❌ Invalid --pause: "${raw}" — expected true or false\n`));
13
+ process.exit(1);
14
+ }
15
+
16
+ /** trust from the committed config (BOM-tolerant), defaulting to "auto". */
17
+ function readTrustFromConfig() {
18
+ try {
19
+ const path = join(process.cwd(), '.morph', 'config', 'config.json');
20
+ if (!existsSync(path)) return 'auto';
21
+ const raw = readFileSync(path, 'utf8').replace(/^/, '');
22
+ return JSON.parse(raw).trust || 'auto';
23
+ } catch {
24
+ return 'auto';
25
+ }
26
+ }
27
+
28
+ /**
29
+ * `morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>]
30
+ * [--signals <json>] [--trust <mode>]` — record the trust decision for a gate
31
+ * under `gateDecisions` in the authoritative feature.json.
32
+ *
33
+ * Same philosophy as `morph-spec score`: the timestamp (`decidedAt`) is
34
+ * stamped server-side and the write is atomic (state-manager). This replaces
35
+ * the manual Edit of feature.json from the skills — which collided with the
36
+ * state-sync hook rewriting the file between the agent's Read and Edit
37
+ * ("file modified since read"). The canonical shape is documented in
38
+ * framework/schemas/feature.schema.json and consumed by gate-guard.js.
39
+ */
40
+ export async function gateDecisionCommand(featureName, gateName, options = {}) {
41
+ try {
42
+ if (!VALID_GATES.includes(gateName)) {
43
+ console.error(chalk.red(`\n❌ Invalid gate: ${gateName}`));
44
+ console.error(chalk.gray(`Valid gates: ${VALID_GATES.join(', ')}\n`));
45
+ process.exit(1);
46
+ }
47
+
48
+ if (options.pause === undefined) {
49
+ console.error(chalk.red('\n❌ --pause is required (true or false)\n'));
50
+ process.exit(1);
51
+ }
52
+ const pause = parsePauseValue(options.pause);
53
+
54
+ const feature = getFeature(featureName);
55
+ if (!feature) {
56
+ console.error(chalk.red(`\n❌ Feature not found: ${featureName}\n`));
57
+ process.exit(1);
58
+ }
59
+
60
+ let signals = {};
61
+ if (options.signals !== undefined) {
62
+ try {
63
+ signals = JSON.parse(options.signals);
64
+ } catch (err) {
65
+ console.error(chalk.red(`\n❌ Invalid --signals: not valid JSON (${err.message})\n`));
66
+ process.exit(1);
67
+ }
68
+ if (!signals || typeof signals !== 'object' || Array.isArray(signals)) {
69
+ console.error(chalk.red('\n❌ Invalid --signals: expected a JSON object, e.g. \'{"taskCount":12}\'\n'));
70
+ process.exit(1);
71
+ }
72
+ }
73
+
74
+ const entry = {
75
+ pause,
76
+ reason: options.reason || (pause ? 'risco detectado' : 'nenhum sinal de risco'),
77
+ signals,
78
+ trust: options.trust || readTrustFromConfig(),
79
+ decidedAt: new Date().toISOString(),
80
+ };
81
+
82
+ await updateFeature(featureName, `gateDecisions.${gateName}`, entry);
83
+
84
+ if (options.json) {
85
+ console.log(JSON.stringify({ feature: featureName, gate: gateName, ...entry }, null, 2));
86
+ return;
87
+ }
88
+
89
+ console.log(chalk.green(`\n✅ Gate decision recorded: ${featureName}/${gateName} → pause: ${entry.pause}`));
90
+ console.log(chalk.gray(` reason: ${entry.reason} | trust: ${entry.trust} | decidedAt: ${entry.decidedAt}`));
91
+ if (Object.keys(entry.signals).length > 0) {
92
+ console.log(chalk.gray(` signals: ${JSON.stringify(entry.signals)}`));
93
+ }
94
+ console.log('');
95
+ } catch (error) {
96
+ console.error(chalk.red(`\n❌ Error recording gate decision: ${error.message}\n`));
97
+ process.exit(1);
98
+ }
99
+ }
@@ -484,6 +484,61 @@ async function ensureFeature(featureName) {
484
484
  return state.features[featureName];
485
485
  }
486
486
 
487
+ /**
488
+ * Explicitly create a feature with the canonical shape (documented in
489
+ * framework/schemas/feature.schema.json). Idempotent: an existing feature is
490
+ * returned untouched. This is the server-side birth path behind
491
+ * `morph-spec create` — replacing the implicit registration on the first
492
+ * Write into .morph/features/ (the state-sync hook remains as fallback).
493
+ *
494
+ * Works even before any state exists (scaffolds the root index), so a
495
+ * feature can be born as the very first morph action in a project.
496
+ *
497
+ * @param {string} featureName - Feature name (kebab-case)
498
+ * @param {{ status?: string }} [options]
499
+ * @returns {Promise<{ feature: Object, created: boolean }>}
500
+ */
501
+ export async function createFeature(featureName, options = {}) {
502
+ const { status = 'in_progress' } = options;
503
+ const state = loadState(false) || scaffoldState();
504
+
505
+ const existing = state.features[featureName];
506
+ if (existing) {
507
+ return { feature: existing, created: false };
508
+ }
509
+
510
+ const now = new Date().toISOString();
511
+ state.features[featureName] = {
512
+ status,
513
+ createdAt: now,
514
+ updatedAt: now,
515
+ activeAgents: [],
516
+ approvalGates: {
517
+ proposal: { approved: false, timestamp: null, approvedBy: null },
518
+ uiux: { approved: false, timestamp: null, approvedBy: null },
519
+ plan: { approved: false, timestamp: null, approvedBy: null },
520
+ review: { approved: false, timestamp: null, approvedBy: null }
521
+ },
522
+ gateDecisions: {},
523
+ taskScores: {},
524
+ tasks: {
525
+ total: 0,
526
+ completed: 0,
527
+ inProgress: 0,
528
+ pending: 0
529
+ },
530
+ checkpoints: [],
531
+ dispatchLog: [],
532
+ trustConfig: {},
533
+ contextBundles: [],
534
+ fileChanges: []
535
+ };
536
+
537
+ state.metadata.totalFeatures = Object.keys(state.features).length;
538
+ saveState(state);
539
+ return { feature: state.features[featureName], created: true };
540
+ }
541
+
487
542
  /**
488
543
  * Update feature property (supports nested keys like "tasks.completed")
489
544
  * @param {string} featureName - Feature name
@@ -77,6 +77,8 @@ export function installRulesForStack(rulesSrc, rulesDest, projectTags) {
77
77
  unlinkSync(join(rulesDest, file));
78
78
  }
79
79
  }
80
+
81
+ return { installed: installed.size };
80
82
  }
81
83
 
82
84
  /**
@@ -45,6 +45,26 @@ export function loadKnownMcpIds(frameworkDir) {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Personas install-all default (friction fix, MORPH_GHLBrain 2026-07-11):
50
+ * with NO known stack tags (greenfield / install before config.json exists)
51
+ * EVERY persona is generated — a project that dispatches `morph-{id}` must
52
+ * find the subagent registered, and an install that only lands
53
+ * morph-evaluator forces every domain dispatch into the general-purpose
54
+ * fallback. Stack filtering only applies when the project's stack is KNOWN
55
+ * (tags non-empty): the fallback stays reserved for stacks the project
56
+ * genuinely does not have. Note this deliberately differs from rules/skills
57
+ * filtering (stack-filter.js), which stays minimal on greenfield.
58
+ *
59
+ * @param {object} agent - agents.json entry (with `stack`)
60
+ * @param {string[]} projectTags
61
+ * @returns {boolean}
62
+ */
63
+ export function agentEligible(agent, projectTags = []) {
64
+ if (projectTags.length === 0) return true;
65
+ return shouldInstall(agent.stack ?? ['*'], projectTags);
66
+ }
67
+
48
68
  const MANAGED_HEADER =
49
69
  '<!-- MANAGED BY morph-spec — generated from .morph/framework/agents.json (single source of truth).\n' +
50
70
  ' Do NOT edit by hand: changes are overwritten on `morph-spec update` / `setup-infra --reinstall`.\n' +
@@ -75,11 +95,8 @@ export async function installAgents(projectDir, frameworkDir = 'framework', opti
75
95
 
76
96
  const knownMcpIds = loadKnownMcpIds(frameworkDir);
77
97
 
78
- // Filter by stack
79
- const eligible = agents.filter(a => {
80
- const stack = a.stack ?? ['*'];
81
- return shouldInstall(stack, projectTags);
82
- });
98
+ // Filter by stack — install-all when the stack is unknown (empty tags).
99
+ const eligible = agents.filter(a => agentEligible(a, projectTags));
83
100
 
84
101
  // Build set of all possible filenames for orphan cleanup
85
102
  const allPossible = new Set(agents.map(a => `morph-${a.id}.md`));
@@ -231,12 +248,11 @@ export function checkMissingAgents(projectDir, frameworkDir, projectTags = []) {
231
248
  let expected = 0;
232
249
 
233
250
  for (const a of agents) {
234
- const stack = a.stack ?? ['*'];
235
- if (!shouldInstall(stack, projectTags)) continue;
251
+ if (!agentEligible(a, projectTags)) continue;
236
252
  expected++;
237
253
  const filename = `morph-${a.id}.md`;
238
254
  if (!installedFiles.has(filename)) {
239
- missing.push({ name: a.title ?? a.id, stacks: stack });
255
+ missing.push({ name: a.title ?? a.id, stacks: a.stack ?? ['*'] });
240
256
  }
241
257
  }
242
258
 
@@ -276,8 +292,7 @@ export function checkAgentsDrift(projectDir, frameworkDir, projectTags = []) {
276
292
  let expected = 0;
277
293
 
278
294
  for (const agent of agents) {
279
- const stack = agent.stack ?? ['*'];
280
- if (!shouldInstall(stack, projectTags)) continue;
295
+ if (!agentEligible(agent, projectTags)) continue;
281
296
  expected++;
282
297
  const filename = `morph-${agent.id}.md`;
283
298
  const filePath = join(targetDir, filename);
@@ -1,6 +1,8 @@
1
1
  // src/utils/banner.js
2
2
  import chalk from "chalk";
3
- import { type as osType, release as osRelease } from "os";
3
+ import { type as osType, release as osRelease, homedir } from "os";
4
+ import { join } from "path";
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
6
 
5
7
  const BRAND = chalk.hex("#7C3AED");
6
8
  const DIM = chalk.dim;
@@ -54,6 +56,39 @@ export function renderLogo(version) {
54
56
  return `\n${art}\n${rule}\n`;
55
57
  }
56
58
 
59
+ /**
60
+ * Print the install banner once per version change, on the first CLI run
61
+ * after an install/upgrade. npm ≥7 swallows lifecycle-script output, so the
62
+ * postinstall banner is invisible on a normal `npm i -g` — this is the
63
+ * fallback that guarantees the next-steps guidance is actually seen.
64
+ *
65
+ * Marker: ~/.claude/morph-spec-banner-version (last version shown).
66
+ * Skips silently when stdout is not a TTY (piped/parsed output) or when
67
+ * MORPH_QUIET=1. Fail-open: any fs error just skips the banner.
68
+ *
69
+ * @param {{version: string, nodeVersion: string, markerDir?: string}} opts
70
+ * @returns {boolean} true if the banner was printed
71
+ */
72
+ export function maybePrintFirstRunBanner({ version, nodeVersion, markerDir }) {
73
+ try {
74
+ if (!process.stdout.isTTY) return false;
75
+ if (process.env.MORPH_QUIET === "1") return false;
76
+
77
+ const dir = markerDir ?? join(homedir(), ".claude");
78
+ const marker = join(dir, "morph-spec-banner-version");
79
+ const last = existsSync(marker) ? readFileSync(marker, "utf8").trim() : null;
80
+ if (last === version) return false;
81
+
82
+ mkdirSync(dir, { recursive: true });
83
+ writeFileSync(marker, version, "utf8");
84
+
85
+ printInstallBanner({ version, nodeVersion, results: [] });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
57
92
  function platformLabel() {
58
93
  const t = osType();
59
94
  const r = osRelease().split(".").slice(0, 2).join(".");
@@ -78,18 +113,15 @@ export function printInstallBanner({ version, nodeVersion, results = [] }) {
78
113
  ` ${DIM("Spec-driven development harness for Claude Code")}`,
79
114
  ` ${DIM("by Polymorphism Tech")} ${DIM("·")} ${DIM("Node")} ${chalk.white(nodeVersion)} ${DIM("·")} ${chalk.white(platformLabel())}`,
80
115
  "",
81
- sep("Installed"),
82
- ...itemLines,
83
- "",
116
+ ...(itemLines.length > 0 ? [sep("Installed"), ...itemLines, ""] : []),
84
117
  sep("Next · new project"),
85
118
  ` ${chalk.cyan("1.")} ${DIM("cd")} ${chalk.white("your-project")} ${DIM("(git repo)")}`,
86
119
  ` ${chalk.cyan("2.")} Run ${chalk.bold.white("morph-spec init")}`,
87
120
  ` ${chalk.cyan("3.")} Open it in Claude Code and run ${chalk.bold.white("/morph-init")}`,
88
121
  "",
89
122
  sep("Next · project already on morph-spec"),
90
- ` ${chalk.cyan("1.")} Run ${chalk.bold.white("morph-spec update")} ${DIM("— sync everything")}`,
123
+ ` ${chalk.cyan("1.")} Run ${chalk.bold.white("morph-spec update")} ${DIM("— after a CLI upgrade or a project.stack change: full stack-aware resync")}`,
91
124
  ` ${chalk.cyan("2.")} Run ${chalk.bold.white("morph-spec doctor")} ${DIM("— validate installation")}`,
92
- ` ${chalk.cyan("3.")} ${DIM("Check")} ${chalk.white(".morph/config/config.json → project.stack")} ${DIM("(drives which tools install)")}`,
93
125
  "",
94
126
  ].join("\n"),
95
127
  );
@@ -15,7 +15,7 @@ import { homedir } from 'os';
15
15
  import { execSync } from 'child_process';
16
16
 
17
17
  /** Current hooks schema version — bump when hook definitions change */
18
- const HOOKS_VERSION = '3.0.0';
18
+ const HOOKS_VERSION = '3.1.0';
19
19
 
20
20
  /** Marker for old dispatch.js (v1) */
21
21
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
@@ -103,7 +103,9 @@ const MORPH_HOOKS = [
103
103
  },
104
104
 
105
105
  // === PostToolUse: Write|Edit — typecheck (debounced, silent on success) +
106
- // tasks.json schema guard (validate-and-warn; status enum, effort enum) ===
106
+ // tasks.json schema guard (validate-and-warn; status enum, effort enum) +
107
+ // trace auto-generation (tasks.json write → .morph/traces/, replaces the
108
+ // manual trace-writing step the skills used to carry) ===
107
109
  {
108
110
  event: 'PostToolUse',
109
111
  matcher: 'Write|Edit',
@@ -116,6 +118,11 @@ const MORPH_HOOKS = [
116
118
  type: 'command',
117
119
  command: 'node framework/hooks/claude-code/core/tasks-json-guard.js',
118
120
  timeout: 3000
121
+ },
122
+ {
123
+ type: 'command',
124
+ command: 'node framework/hooks/claude-code/core/trace-autogen.js',
125
+ timeout: 3000
119
126
  }
120
127
  ]
121
128
  },
@@ -153,13 +160,34 @@ const MORPH_HOOKS = [
153
160
  }]
154
161
  },
155
162
 
156
- // === Stop — build check + session snapshot ===
163
+ // === Stop — build check + session snapshot + dispatch-report guard ===
164
+ // subagent-report-guard blocks (once) a dispatched sub-agent stopping
165
+ // without SendMessage — clause 1 of the dispatch protocol. In the
166
+ // orchestrator's session it is inert (marker never in a user message).
157
167
  {
158
168
  event: 'Stop',
159
169
  matcher: null,
170
+ hooks: [
171
+ {
172
+ type: 'command',
173
+ command: 'node framework/hooks/claude-code/core/validate-completion.js'
174
+ },
175
+ {
176
+ type: 'command',
177
+ command: 'node framework/hooks/claude-code/core/subagent-report-guard.js',
178
+ timeout: 5000
179
+ }
180
+ ]
181
+ },
182
+
183
+ // === SubagentStop — dispatch-report guard for Task-tool subagents ===
184
+ {
185
+ event: 'SubagentStop',
186
+ matcher: null,
160
187
  hooks: [{
161
188
  type: 'command',
162
- command: 'node framework/hooks/claude-code/core/validate-completion.js'
189
+ command: 'node framework/hooks/claude-code/core/subagent-report-guard.js',
190
+ timeout: 5000
163
191
  }]
164
192
  }
165
193
  ];
@@ -169,4 +169,6 @@ export async function installSkills(projectDir, { projectTags = [] } = {}) {
169
169
  rmSync(join(claudeSkillsDir, entry), { recursive: true, force: true });
170
170
  }
171
171
  }
172
+
173
+ return { installed: installed.size };
172
174
  }