@polymorphism-tech/morph-spec 8.2.0 → 8.3.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 (54) hide show
  1. package/README.md +70 -99
  2. package/bin/morph-spec.js +13 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +76 -135
  5. package/docs/QUICKSTART.md +35 -51
  6. package/framework/CLAUDE.md +3 -2
  7. package/framework/MORPH.md +20 -2
  8. package/framework/agents.json +3 -1
  9. package/framework/hooks/README.md +3 -0
  10. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
  11. package/framework/hooks/claude-code/core/telemetry-log.js +102 -0
  12. package/framework/hooks/shared/build-check.js +8 -1
  13. package/framework/hooks/shared/payload-utils.js +16 -2
  14. package/framework/hooks/shared/phase-utils.js +2 -0
  15. package/framework/hooks/shared/telemetry-logger.js +75 -0
  16. package/framework/rules/ai-agents-standards.md +1 -0
  17. package/framework/schemas/feature.schema.json +0 -5
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +12 -7
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +22 -10
  25. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  26. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  27. package/framework/skills/core/morph-review/SKILL.md +19 -12
  28. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +43 -1
  31. package/framework/standards/ai-agents/observability-patterns.md +156 -0
  32. package/framework/standards/ai-agents/production.md +3 -47
  33. package/framework/templates/REGISTRY.json +0 -93
  34. package/package.json +1 -1
  35. package/src/commands/observability/telemetry.js +75 -0
  36. package/src/commands/project/worktree.js +150 -36
  37. package/src/commands/state/score.js +13 -2
  38. package/src/commands/tasks/dag.js +11 -3
  39. package/src/core/paths/output-schema.js +28 -0
  40. package/src/core/state/state-manager.js +0 -128
  41. package/src/dashboard/app/pages/LiveState.jsx +0 -43
  42. package/src/lib/detectors/design-system-detector.js +0 -43
  43. package/src/lib/generators/index.js +0 -1
  44. package/src/lib/stack/stack-profile.js +40 -9
  45. package/src/lib/tasks/tasks-json-parser.js +21 -0
  46. package/src/lib/tasks/test-runner.js +26 -4
  47. package/src/lib/telemetry/telemetry-reader.js +103 -0
  48. package/src/lib/validators/validation-runner.js +13 -74
  49. package/src/utils/hooks-installer.js +27 -3
  50. package/framework/templates/context/CONTEXT-FEATURE.md +0 -269
  51. package/framework/templates/context/CONTEXT.md +0 -181
  52. package/src/lib/agents/metrics-aggregator.js +0 -56
  53. package/src/lib/agents/metrics-calculator.js +0 -125
  54. package/src/lib/generators/metadata-extractor.js +0 -385
@@ -145,6 +145,38 @@ function getDateSuffix() {
145
145
 
146
146
  // ── Infrastructure linking ───────────────────────────────────────────────────
147
147
 
148
+ /**
149
+ * The infra directories morph itself links into a worktree — the ONLY ones
150
+ * `setupInfraLinks`/`worktreeLinkCommand --all`/`unlinkWorktreeInfraLinks` treat
151
+ * as KNOWN, and therefore the only ones `finish` may auto-remove via the
152
+ * already-proven-safe unlink mechanism (never by widening the reparse-point
153
+ * scan — see `findReparsePoints`).
154
+ *
155
+ * Always: `node_modules`, `.claude`. Conditionally: `.morph/framework` (client
156
+ * projects only) and `{project.frontendPath}/node_modules` — a monorepo
157
+ * frontend (e.g. a Next.js app under `apps/catalog-admin`) commonly has its OWN
158
+ * node_modules, a separate `npm install` from the root's, invisible to a static
159
+ * list. Fail-open: a missing/unreadable config.json just means no frontendPath.
160
+ *
161
+ * @param {string} mainCwd - Absolute path to the main project root
162
+ * @returns {string[]} relative paths, root-first
163
+ */
164
+ function resolveInfraLinkDirs(mainCwd) {
165
+ const dirs = ['node_modules', '.claude'];
166
+ if (existsSync(join(mainCwd, '.morph', 'framework'))) {
167
+ dirs.push(join('.morph', 'framework'));
168
+ }
169
+ try {
170
+ const configPath = join(mainCwd, '.morph', 'config', 'config.json');
171
+ if (existsSync(configPath)) {
172
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
173
+ const frontendPath = config?.project?.frontendPath;
174
+ if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
175
+ }
176
+ } catch { /* fail-open — no frontendPath to add */ }
177
+ return dirs;
178
+ }
179
+
148
180
  /**
149
181
  * Create junctions in the worktree for shared infrastructure directories
150
182
  * (node_modules/, .claude/, .morph/framework/).
@@ -169,16 +201,9 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
169
201
  const mainAbs = resolve(mainCwd);
170
202
  const wtAbs = resolve(worktreePath);
171
203
 
172
- let dirLinks;
173
- if (Array.isArray(opts.dirs) && opts.dirs.length) {
174
- dirLinks = opts.dirs;
175
- } else {
176
- dirLinks = ['node_modules', '.claude'];
177
- // .morph/framework/ — client projects (in framework repo, framework/ is tracked)
178
- if (existsSync(join(mainAbs, '.morph', 'framework'))) {
179
- dirLinks.push(join('.morph', 'framework'));
180
- }
181
- }
204
+ const dirLinks = (Array.isArray(opts.dirs) && opts.dirs.length)
205
+ ? opts.dirs
206
+ : resolveInfraLinkDirs(mainAbs);
182
207
 
183
208
  for (const rel of dirLinks) {
184
209
  const target = join(mainAbs, rel);
@@ -530,7 +555,7 @@ export function worktreeLinkCommand(options = {}) {
530
555
  }
531
556
 
532
557
  const dirs = options.all
533
- ? ['node_modules', '.claude', join('.morph', 'framework')]
558
+ ? resolveInfraLinkDirs(root)
534
559
  : ['node_modules'];
535
560
 
536
561
  const alreadyPresent = {};
@@ -624,12 +649,10 @@ function findWorktreeForFeature(feature, root) {
624
649
  return { path: match.path, branch: actual, expectedBranch };
625
650
  }
626
651
 
627
- /** The infra paths `setupInfraLinks` junctions into a worktree. */
628
- const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')];
629
-
630
652
  /**
631
653
  * Drop the infra junctions a worktree carries (`node_modules`, `.claude`,
632
- * `.morph/framework`) WITHOUT following them.
654
+ * `.morph/framework`, and the `project.frontendPath` node_modules if
655
+ * configured) WITHOUT following them.
633
656
  *
634
657
  * CRITICAL — this is what keeps `worktree finish` from destroying the project.
635
658
  * `git worktree remove` refuses a worktree still holding these ("contains
@@ -646,12 +669,19 @@ const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')]
646
669
  * target's contents, and `rmdirSync` fails on a real non-empty directory — the
647
670
  * safety that stops this from ever deleting genuine files.
648
671
  *
672
+ * This is deliberately narrow to the KNOWN inventory from `resolveInfraLinkDirs`
673
+ * — never widened to auto-remove any reparse point `findReparsePoints` might
674
+ * discover elsewhere. An unknown/nested link (npm link, npm workspaces) must
675
+ * still decline the removal; see the CANARY tests pinning that in worktree.test.js.
676
+ *
649
677
  * @param {string} worktreePath
678
+ * @param {string} mainCwd - Absolute path to the main project root, to resolve
679
+ * `project.frontendPath` from config.json
650
680
  * @returns {string[]} relative paths actually unlinked
651
681
  */
652
- export function unlinkWorktreeInfraLinks(worktreePath) {
682
+ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
653
683
  const removed = [];
654
- for (const rel of INFRA_LINK_RELS) {
684
+ for (const rel of resolveInfraLinkDirs(mainCwd)) {
655
685
  const p = join(worktreePath, rel);
656
686
  let st;
657
687
  try {
@@ -746,9 +776,60 @@ function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }
746
776
  */
747
777
  const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
748
778
 
779
+ /**
780
+ * Well-known, regenerable build artifacts — `bin/`, `obj/` (dotnet), a real
781
+ * `node_modules` left behind outside the setup-created junction, and
782
+ * `*.tsbuildinfo` (tsc incremental cache). These are the everyday leftovers of
783
+ * running a build/test cycle inside a worktree, not user data: they are fully
784
+ * reproducible from source, and requiring `--discard-ignored` for every finish
785
+ * that ever ran a build turns the flag from "explicit consent for a real risk"
786
+ * into a rubber stamp nobody reads anymore. Auto-discarding them (like any
787
+ * `discarded` entry, WITHOUT a backup — see the note below) is safe precisely
788
+ * because "regenerable" is a stronger guarantee than "backed up".
789
+ *
790
+ * Deliberately narrow — do NOT add speculative patterns (`dist/`, `.next/`)
791
+ * beyond what's proven to be pure noise; anything else stays in `blocking` by
792
+ * default, requiring the explicit `--discard-ignored` consent.
793
+ *
794
+ * @param {string} p - forward-slash relative path from `git status --ignored`
795
+ */
796
+ /**
797
+ * Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
798
+ * registration — see the call site (Validation 3 in worktreeFinishCommand)
799
+ * for why this is safe to exempt from the whole-root clean gate rather than a
800
+ * widened hole.
801
+ *
802
+ * PROVES it rather than assuming: the path must be exactly that sibling's
803
+ * `feature.json`, must parse, and its `worktree.path` must point at a
804
+ * currently-existing directory. A parse failure, a missing registration, or a
805
+ * STALE pointer (worktree already removed) is NOT exempted.
806
+ *
807
+ * @param {string} relPath - forward-slash path relative to `root`
808
+ * @param {string} root - absolute path to the primary root
809
+ * @param {string} feature - the feature THIS finish is acting on (never exempted — its own scope is the shed's job)
810
+ */
811
+ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
812
+ const m = relPath.match(/^\.morph\/features\/([^/]+)\/feature\.json$/);
813
+ if (!m || m[1] === feature) return false;
814
+ try {
815
+ const fj = JSON.parse(readFileSync(join(root, relPath), 'utf8'));
816
+ const wtPath = fj?.worktree?.path;
817
+ return typeof wtPath === 'string' && wtPath.length > 0 && existsSync(wtPath);
818
+ } catch {
819
+ return false;
820
+ }
821
+ }
822
+
823
+ function isDisposableBuildArtifact(p) {
824
+ if (/\.tsbuildinfo$/.test(p)) return true;
825
+ const segments = p.split('/').filter(Boolean);
826
+ return segments.some((s) => s === 'bin' || s === 'obj' || s === 'node_modules');
827
+ }
828
+
749
829
  /**
750
830
  * Split the worktree's GITIGNORED leftovers into what this command may remove
751
- * (morph's own, after a backup) and what it must refuse to destroy.
831
+ * (morph's own, after a backup, or a known disposable build artifact, without
832
+ * one) and what it must refuse to destroy.
752
833
  *
753
834
  * Why this exists: `git worktree remove` deletes gitignored files outright, and
754
835
  * the clean-check that gates it uses `git status --porcelain`, which omits them
@@ -762,34 +843,46 @@ const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
762
843
  *
763
844
  * @param {string} worktreePath
764
845
  * @param {{ discardIgnored?: boolean }} options
765
- * @returns {{ shieldable: string[], blocking: string[] }}
846
+ * @returns {{ shieldable: string[], blocking: string[], discarded: string[] }}
766
847
  */
767
848
  function classifyIgnoredLeftovers(worktreePath, options = {}) {
768
849
  let out;
769
850
  try {
770
851
  // `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
771
852
  // paths with spaces or non-ASCII (a pt-BR codebase has both) stay intact.
772
- out = execFileSync('git', ['status', '--porcelain', '--ignored', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
853
+ // `=matching` (not the traditional default): an ignored directory whose own
854
+ // name matches no pattern — e.g. `MyProj/`, ignored only because EVERYTHING
855
+ // inside it (`bin/`, `obj/`) does — otherwise collapses into one opaque
856
+ // `MyProj/` entry, hiding the actual disposable-artifact names from
857
+ // isDisposableBuildArtifact. A directory that IS itself the matched pattern
858
+ // (`node_modules/`) still reports as one entry either way — verified
859
+ // empirically, not assumed.
860
+ out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
773
861
  } catch {
774
- return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'] };
862
+ return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [] };
775
863
  }
776
864
 
777
865
  const shieldable = [];
778
- const blocking = [];
866
+ const blockingRest = [];
867
+ const disposable = [];
779
868
  for (const record of out.split('\0')) {
780
869
  if (!record || record.length < 4) continue;
781
870
  if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
782
871
  const p = record.slice(3).replace(/\\/g, '/');
783
872
  if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
784
- else blocking.push(p);
873
+ else if (isDisposableBuildArtifact(p)) disposable.push(p);
874
+ else blockingRest.push(p);
785
875
  }
786
- // Explicit consent clears the block — but the list is NOT thrown away. These
787
- // are the files that exist nowhere else, and they are the ones about to die;
788
- // reporting them is the least this can do. (Backing them up instead is not an
789
- // option: this bucket is where `node_modules`/`dist`/`.next` land, so a copy
790
- // is unbounded. The user declared them disposable — they still get told.)
791
- if (options.discardIgnored) return { shieldable, blocking: [], discarded: blocking };
792
- return { shieldable, blocking, discarded: [] };
876
+ // Explicit consent clears the rest of the block too — but the list is NOT
877
+ // thrown away. These are the files that exist nowhere else, and they are the
878
+ // ones about to die; reporting them is the least this can do. (Backing them
879
+ // up instead is not an option: this bucket is where arbitrarily large
880
+ // leftovers land, so a copy is unbounded. The user declared them
881
+ // disposable — they still get told.)
882
+ if (options.discardIgnored) return { shieldable, blocking: [], discarded: [...disposable, ...blockingRest] };
883
+ // Known build artifacts are discarded by default, no flag required — they
884
+ // are reproducible from source, unlike anything else in `blockingRest`.
885
+ return { shieldable, blocking: blockingRest, discarded: disposable };
793
886
  }
794
887
 
795
888
  /**
@@ -1015,15 +1108,34 @@ export async function worktreeFinishCommand(feature, options = {}) {
1015
1108
  }
1016
1109
  : {};
1017
1110
 
1018
- // Validation 3: primary root must be clean.
1019
- let rootStatus;
1111
+ // Validation 3: primary root must be clean — except a SIBLING feature's own
1112
+ // LIVE worktree registration (see isLiveSiblingWorktreeRegistration). That
1113
+ // artifact is how `runWorktreeSetup`/`create --worktree` are DESIGNED to
1114
+ // leave the root for that sibling's entire life (decidePersistence in
1115
+ // verify.js and the anti-dupe guard above both read the root's copy for
1116
+ // `.worktree.path`), so refusing THIS feature's finish over it would block
1117
+ // every OTHER feature for as long as any sibling stays open in its own
1118
+ // worktree — the exact "orphaned scaffold collides with finish" friction
1119
+ // from real multi-feature use. Anything else is still unexplained dirt and
1120
+ // still blocks, fail-closed, exactly as before.
1121
+ let rootStatusRaw;
1020
1122
  try {
1021
- rootStatus = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
1123
+ // `--untracked-files=all`, not the default `normal`: the default collapses
1124
+ // an entirely-untracked directory into one entry (e.g. `.morph/features/
1125
+ // sibling-feat/`, verified empirically) — which would never match the
1126
+ // exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects,
1127
+ // silently defeating the exemption for the very shape it exists to allow.
1128
+ rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
1022
1129
  } catch (err) {
1023
1130
  console.log(JSON.stringify({ finished: false, error: 'primary-root-status-failed', detail: (err.message || '').slice(0, 200), feature, ...backupNote }));
1024
1131
  process.exit(1);
1025
1132
  }
1026
- if (rootStatus.trim() !== '') {
1133
+ const unexplainedDirt = rootStatusRaw.split('\0').filter((record) => {
1134
+ if (!record || record.length < 4) return false;
1135
+ const p = record.slice(3).replace(/\\/g, '/');
1136
+ return !isLiveSiblingWorktreeRegistration(p, root, feature);
1137
+ });
1138
+ if (unexplainedDirt.length) {
1027
1139
  console.log(JSON.stringify({
1028
1140
  finished: false,
1029
1141
  error: 'primary-root-dirty',
@@ -1075,7 +1187,7 @@ export async function worktreeFinishCommand(feature, options = {}) {
1075
1187
  // Both gates below therefore PROVE-or-DECLINE. Declining costs a stale
1076
1188
  // directory the user can delete in one command; being wrong costs data that
1077
1189
  // exists nowhere else.
1078
- unlinkWorktreeInfraLinks(worktreePath);
1190
+ unlinkWorktreeInfraLinks(worktreePath, root);
1079
1191
 
1080
1192
  const scan = findReparsePoints(worktreePath);
1081
1193
  const ignored = classifyIgnoredLeftovers(worktreePath, options);
@@ -1202,7 +1314,9 @@ export async function worktreeFinishCommand(feature, options = {}) {
1202
1314
  ...(worktreeRemoved && ignored.discarded.length
1203
1315
  ? {
1204
1316
  discardedIgnored: ignored.discarded,
1205
- discardedNote: `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`,
1317
+ discardedNote: options.discardIgnored
1318
+ ? `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`
1319
+ : `artefatos de build reconhecidos (bin/, obj/, node_modules/, *.tsbuildinfo) — descartados por padrão, sem backup (regeneráveis a partir do source)`,
1206
1320
  }
1207
1321
  : {}),
1208
1322
  ...(worktreeRemoved
@@ -39,9 +39,20 @@ export async function scoreCommand(featureName, taskId, scoreArg, options = {})
39
39
 
40
40
  let dimensions;
41
41
  if (options.dims) {
42
- const parts = String(options.dims).split(',').map(s => s.trim());
42
+ // Commander captures --dims as variadic (`<dims...>`), so an UNQUOTED
43
+ // comma list typed in PowerShell — `--dims 9.5,9,9,9` — that arrives as
44
+ // 4 separate argv tokens (confirmed: Commander binds only the first
45
+ // token to a non-variadic option and silently drops the rest) is not
46
+ // lost. Accept either shape: one comma-joined string (the documented,
47
+ // quoted form) or N separate tokens — split on both separators so a
48
+ // mix of the two never matters either.
49
+ const raw = Array.isArray(options.dims) ? options.dims.join(' ') : String(options.dims);
50
+ const parts = raw.split(/[,\s]+/).filter(Boolean);
43
51
  if (parts.length !== DIM_KEYS.length) {
44
- console.error(chalk.red(`\n❌ --dims expects ${DIM_KEYS.length} comma-separated values in order a,c,q,t (architecture,contracts,code-quality,test-coverage)\n`));
52
+ console.error(chalk.red(
53
+ `\n❌ --dims expects ${DIM_KEYS.length} values in order a,c,q,t (architecture,contracts,code-quality,test-coverage) — got ${parts.length} (${JSON.stringify(parts)}).\n` +
54
+ ` Quote the value under PowerShell to keep it as one argument: --dims "9.5,9,9,9"\n`
55
+ ));
45
56
  process.exit(1);
46
57
  }
47
58
  dimensions = {};
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { existsSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { parseTasksJson } from '../../lib/tasks/tasks-json-parser.js';
4
+ import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
5
5
  import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
6
6
  import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
7
7
 
@@ -24,8 +24,16 @@ export async function dagCommand(featureName, options = {}) {
24
24
  process.exit(1);
25
25
  }
26
26
 
27
- // parseTasksJson validates the schema and throws with the error list.
28
- const data = await parseTasksJson(tasksPath);
27
+ // parseTasksJsonLenient validates the schema and throws with the error
28
+ // list, but defaults a missing `version` (legacy plans predating the
29
+ // field) instead of failing — the DAG analysis doesn't depend on it.
30
+ const { data, versionDefaulted } = await parseTasksJsonLenient(tasksPath);
31
+ if (versionDefaulted) {
32
+ console.error(chalk.yellow(
33
+ `⚠ tasks.json has no 'version' field — defaulting to "1.0" for this analysis.\n` +
34
+ ` Consider adding it back explicitly (safe: protect-spec-files allows backfilling it).`
35
+ ));
36
+ }
29
37
 
30
38
  // --mermaid short-circuits before the dispatch analysis: visualizing the
31
39
  // plan's DAG doesn't need a sequential-vs-parallel decision.
@@ -49,6 +49,11 @@ export const OUTPUT_SCHEMA = {
49
49
 
50
50
  // 4-review/
51
51
  reviewReport: { filename: 'review-report.md', phaseDir: '4-review', phase: 'review', protected: false },
52
+ // Durable report the dispatched morph-evaluator sub-agent writes itself
53
+ // (MORPH.md §6) — the orchestrator reads it and synthesizes reviewReport
54
+ // from it; the two never collide because one is raw sub-agent output and
55
+ // the other is the orchestrator's curated Gate 3 presentation.
56
+ evaluatorReport:{ filename: 'evaluator-report.md', phaseDir: '4-review', phase: 'review', protected: false },
52
57
  };
53
58
 
54
59
  // ============================================================================
@@ -89,6 +94,29 @@ export function getFeatureDir(featureName) {
89
94
  return join('.morph', 'features', featureName).replace(/\\/g, '/');
90
95
  }
91
96
 
97
+ /**
98
+ * Per-task sub-agent report — the durable "return value" channel for a
99
+ * dispatched implementation task (MORPH.md §6). A sub-agent's final text
100
+ * message may never reach the orchestrator (idle_notification with no text
101
+ * is an observed failure mode); the file it writes here always does. Doesn't
102
+ * fit OUTPUT_SCHEMA's one-fixed-filename-per-type model since the filename
103
+ * is parameterized by taskId, so it's a sibling helper instead of a schema
104
+ * entry.
105
+ *
106
+ * @param {string} featureName
107
+ * @param {string} taskId - e.g. "T7"
108
+ * @returns {string} morph-relative, forward-slash path
109
+ */
110
+ export function getTaskReportPath(featureName, taskId) {
111
+ return join('.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`)
112
+ .replace(/\\/g, '/');
113
+ }
114
+
115
+ /** Absolute filesystem variant of {@link getTaskReportPath}. */
116
+ export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
117
+ return join(projectRoot, '.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`);
118
+ }
119
+
92
120
  /**
93
121
  * Get all output paths for a feature, ready for state.json `outputs` block.
94
122
  */
@@ -472,7 +472,6 @@ async function ensureFeature(featureName) {
472
472
  status: "draft",
473
473
  createdAt: new Date().toISOString(),
474
474
  updatedAt: new Date().toISOString(),
475
- activeAgents: [],
476
475
  approvalGates: {
477
476
  proposal: { approved: false, timestamp: null, approvedBy: null },
478
477
  uiux: { approved: false, timestamp: null, approvedBy: null },
@@ -486,7 +485,6 @@ async function ensureFeature(featureName) {
486
485
  pending: 0
487
486
  },
488
487
  checkpoints: [],
489
- dispatchLog: [],
490
488
  trustConfig: {},
491
489
  contextBundles: [],
492
490
  fileChanges: []
@@ -533,7 +531,6 @@ export async function createFeature(featureName, options = {}) {
533
531
  workType: workType || 'feature',
534
532
  createdAt: now,
535
533
  updatedAt: now,
536
- activeAgents: [],
537
534
  approvalGates: {
538
535
  proposal: { approved: false, timestamp: null, approvedBy: null },
539
536
  uiux: { approved: false, timestamp: null, approvedBy: null },
@@ -549,7 +546,6 @@ export async function createFeature(featureName, options = {}) {
549
546
  pending: 0
550
547
  },
551
548
  checkpoints: [],
552
- dispatchLog: [],
553
549
  trustConfig: {},
554
550
  contextBundles: [],
555
551
  fileChanges: []
@@ -649,73 +645,6 @@ export async function addCheckpoint(featureName, note) {
649
645
  return checkpoint;
650
646
  }
651
647
 
652
- /**
653
- * Add agent to feature
654
- * @param {string} featureName - Feature name
655
- * @param {string} agentId - Agent ID
656
- * @returns {boolean} True if added, false if already exists
657
- */
658
- export async function addAgent(featureName, agentId) {
659
- await ensureFeature(featureName);
660
- const state = loadState(); // Load AFTER ensuring feature exists
661
-
662
- if (!state.features[featureName].activeAgents.includes(agentId)) {
663
- state.features[featureName].activeAgents.push(agentId);
664
- state.features[featureName].updatedAt = new Date().toISOString();
665
- saveState(state);
666
- return true;
667
- }
668
-
669
- return false;
670
- }
671
-
672
- /**
673
- * Remove agent from feature
674
- * @param {string} featureName - Feature name
675
- * @param {string} agentId - Agent ID
676
- * @returns {boolean} True if removed, false if not found
677
- */
678
- export function removeAgent(featureName, agentId) {
679
- const state = loadState();
680
-
681
- if (!state.features[featureName]) {
682
- throw new Error(`Feature '${featureName}' not found.`);
683
- }
684
-
685
- const index = state.features[featureName].activeAgents.indexOf(agentId);
686
- if (index > -1) {
687
- state.features[featureName].activeAgents.splice(index, 1);
688
- state.features[featureName].updatedAt = new Date().toISOString();
689
- saveState(state);
690
- return true;
691
- }
692
-
693
- return false;
694
- }
695
-
696
- /**
697
- * Log a dispatch event to state.json.
698
- * Appends entry to feature.dispatchLog, capped at 50 entries (oldest trimmed).
699
- *
700
- * @param {string} featureName - Feature name
701
- * @param {{ timestamp: string, phase: string, mode: string, agents: Array<{ id: string, subagentType: string, group: string, tasks: string[] }> }} entry - Dispatch event to log
702
- */
703
- export function logDispatch(featureName, entry) {
704
- const state = loadState();
705
- const feature = state.features?.[featureName];
706
- if (!feature) return;
707
-
708
- if (!Array.isArray(feature.dispatchLog)) feature.dispatchLog = [];
709
- feature.dispatchLog.push(entry);
710
-
711
- // Cap at 50 entries
712
- if (feature.dispatchLog.length > 50) {
713
- feature.dispatchLog = feature.dispatchLog.slice(-50);
714
- }
715
-
716
- saveState(state);
717
- }
718
-
719
648
  /**
720
649
  * Normalize output type from kebab-case to camelCase (BUG-002 fix - Enhanced)
721
650
  * Supports both kebab-case and camelCase input formats for all output types
@@ -1103,60 +1032,3 @@ export function getApprovalHistory(featureName) {
1103
1032
  // Sort by timestamp
1104
1033
  return history.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
1105
1034
  }
1106
-
1107
- /**
1108
- * Find which agent was assigned a specific task in the latest dispatch.
1109
- * Searches only the last dispatchLog entry.
1110
- *
1111
- * @param {string} featureName
1112
- * @param {string} taskId
1113
- * @returns {string|null} Agent ID or null if not found
1114
- */
1115
- export function resolveAgentForTask(featureName, taskId) {
1116
- const state = loadState();
1117
- const feature = state.features?.[featureName];
1118
- if (!feature) return null;
1119
-
1120
- const log = feature.dispatchLog;
1121
- if (!Array.isArray(log) || log.length === 0) return null;
1122
-
1123
- const latest = log[log.length - 1];
1124
- for (const agent of latest.agents || []) {
1125
- if (Array.isArray(agent.tasks) && agent.tasks.includes(taskId)) {
1126
- return agent.id;
1127
- }
1128
- }
1129
- return null;
1130
- }
1131
-
1132
- /**
1133
- * Update timing fields on a specific agent in the latest dispatch entry.
1134
- * Fail-open: returns silently if feature/dispatch/agent not found.
1135
- *
1136
- * @param {string} featureName
1137
- * @param {string} agentId - Agent ID to update
1138
- * @param {Object} updates - Fields to merge (startedAt, completedAt, tasksCompleted)
1139
- */
1140
- export function updateDispatchAgentTiming(featureName, agentId, updates) {
1141
- const state = loadState();
1142
- const feature = state.features?.[featureName];
1143
- if (!feature) return;
1144
-
1145
- const log = feature.dispatchLog;
1146
- if (!Array.isArray(log) || log.length === 0) return;
1147
-
1148
- const latest = log[log.length - 1];
1149
- const agent = (latest.agents || []).find(a => a.id === agentId);
1150
- if (!agent) return;
1151
-
1152
- Object.assign(agent, updates);
1153
-
1154
- // If all agents now have completedAt, set entry.completedAt to the latest
1155
- const allComplete = (latest.agents || []).every(a => a.completedAt);
1156
- if (allComplete && latest.agents.length > 0) {
1157
- const timestamps = latest.agents.map(a => new Date(a.completedAt).getTime());
1158
- latest.completedAt = new Date(Math.max(...timestamps)).toISOString();
1159
- }
1160
-
1161
- saveState(state);
1162
- }
@@ -98,33 +98,6 @@ function TaskList({ taskList }) {
98
98
  );
99
99
  }
100
100
 
101
- function DispatchLog({ dispatchLog }) {
102
- if (!dispatchLog?.length) return null;
103
-
104
- return (
105
- <div className="mt-3">
106
- <p className="text-gray-500 text-[10px] uppercase tracking-wide mb-1.5">Dispatch Log</p>
107
- <div className="space-y-1 border-l-2 border-gray-700 pl-3">
108
- {dispatchLog.map((entry, i) => (
109
- <div key={i} className="relative">
110
- <span className="absolute -left-4 top-1 w-2 h-2 rounded-full bg-gray-600 border border-gray-500" />
111
- <div className="text-[10px] text-gray-500">
112
- {entry.timestamp && (
113
- <span className="font-mono mr-2">
114
- {new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
115
- </span>
116
- )}
117
- <span className="text-blue-400 font-mono">{entry.agent || entry.agentId}</span>
118
- {entry.task && <span className="text-gray-400 ml-1">→ {entry.task}</span>}
119
- {entry.action && <span className="text-gray-400 ml-1">{entry.action}</span>}
120
- </div>
121
- </div>
122
- ))}
123
- </div>
124
- </div>
125
- );
126
- }
127
-
128
101
  function FeatureCard({ featureId, feature }) {
129
102
  const status = feature.status || 'draft';
130
103
  const phase = feature.phase || feature.currentPhase || PHASE_ORDER[0];
@@ -134,7 +107,6 @@ function FeatureCard({ featureId, feature }) {
134
107
  : typeof feature.taskList === 'object' && feature.taskList !== null
135
108
  ? Object.values(feature.taskList)
136
109
  : [];
137
- const dispatchLog = feature.dispatchLog || [];
138
110
 
139
111
  return (
140
112
  <div className="bg-gray-900 border border-gray-700 rounded-xl p-4 space-y-3">
@@ -169,21 +141,6 @@ function FeatureCard({ featureId, feature }) {
169
141
  <TaskList taskList={taskList} />
170
142
  </div>
171
143
  )}
172
-
173
- {/* Dispatch log */}
174
- <DispatchLog dispatchLog={dispatchLog} />
175
-
176
- {/* Active agents */}
177
- {feature.activeAgents?.length > 0 && (
178
- <div>
179
- <p className="text-gray-500 text-[10px] uppercase tracking-wide mb-1.5">Active Agents</p>
180
- <div className="flex flex-wrap gap-1">
181
- {feature.activeAgents.map((a) => (
182
- <span key={a} className="px-2 py-0.5 bg-gray-800 border border-gray-700 rounded text-[10px] font-mono text-gray-300">{a}</span>
183
- ))}
184
- </div>
185
- </div>
186
- )}
187
144
  </div>
188
145
  );
189
146
  }
@@ -150,49 +150,6 @@ export function detectDesignSystem(projectPath, featureName = null) {
150
150
  return result;
151
151
  }
152
152
 
153
- /**
154
- * Check if UI agents are active for a feature
155
- * @param {string} projectPath - Root path of the project
156
- * @param {string} featureName - Feature name
157
- * @returns {boolean} True if UI agents are active
158
- */
159
- export function hasUIAgentsActive(projectPath, featureName) {
160
- try {
161
- const statePath = join(projectPath, '.morph/state.json');
162
-
163
- if (!existsSync(statePath)) {
164
- return false;
165
- }
166
-
167
- const state = JSON.parse(readFileSync(statePath, 'utf8'));
168
-
169
- // Handle both array and object format
170
- let feature;
171
- if (Array.isArray(state.features)) {
172
- feature = state.features.find(f => f.name === featureName);
173
- } else if (state.features && typeof state.features === 'object') {
174
- feature = state.features[featureName];
175
- }
176
-
177
- if (!feature || !feature.activeAgents) {
178
- return false;
179
- }
180
-
181
- // UI design agents that require a design system before implement phase.
182
- // Implementation-only agents (nextjs-expert, css-specialist)
183
- // build from existing designs and do NOT trigger this gate.
184
- const uiAgents = [
185
- 'ui-designer',
186
- 'uiux-designer',
187
- 'ui-ux-designer',
188
- ];
189
-
190
- return feature.activeAgents.some(agentId => uiAgents.includes(agentId));
191
- } catch {
192
- return false;
193
- }
194
- }
195
-
196
153
  /**
197
154
  * Get recommended design system locations based on project type
198
155
  * @param {string} projectPath - Root path of the project
@@ -6,6 +6,5 @@
6
6
 
7
7
  export * from './design-system-generator.js';
8
8
  export * from './handoff-generator.js';
9
- export * from './metadata-extractor.js';
10
9
  export * from './architecture-scanner.js';
11
10
  export * from './scaffold-generator.js';