@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.
- package/README.md +70 -99
- package/bin/morph-spec.js +13 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +76 -135
- package/docs/QUICKSTART.md +35 -51
- package/framework/CLAUDE.md +3 -2
- package/framework/MORPH.md +20 -2
- package/framework/agents.json +3 -1
- package/framework/hooks/README.md +3 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +102 -0
- package/framework/hooks/shared/build-check.js +8 -1
- package/framework/hooks/shared/payload-utils.js +16 -2
- package/framework/hooks/shared/phase-utils.js +2 -0
- package/framework/hooks/shared/telemetry-logger.js +75 -0
- package/framework/rules/ai-agents-standards.md +1 -0
- package/framework/schemas/feature.schema.json +0 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +12 -7
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +22 -10
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +19 -12
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +43 -1
- package/framework/standards/ai-agents/observability-patterns.md +156 -0
- package/framework/standards/ai-agents/production.md +3 -47
- package/framework/templates/REGISTRY.json +0 -93
- package/package.json +1 -1
- package/src/commands/observability/telemetry.js +75 -0
- package/src/commands/project/worktree.js +150 -36
- package/src/commands/state/score.js +13 -2
- package/src/commands/tasks/dag.js +11 -3
- package/src/core/paths/output-schema.js +28 -0
- package/src/core/state/state-manager.js +0 -128
- package/src/dashboard/app/pages/LiveState.jsx +0 -43
- package/src/lib/detectors/design-system-detector.js +0 -43
- package/src/lib/generators/index.js +0 -1
- package/src/lib/stack/stack-profile.js +40 -9
- package/src/lib/tasks/tasks-json-parser.js +21 -0
- package/src/lib/tasks/test-runner.js +26 -4
- package/src/lib/telemetry/telemetry-reader.js +103 -0
- package/src/lib/validators/validation-runner.js +13 -74
- package/src/utils/hooks-installer.js +27 -3
- package/framework/templates/context/CONTEXT-FEATURE.md +0 -269
- package/framework/templates/context/CONTEXT.md +0 -181
- package/src/lib/agents/metrics-aggregator.js +0 -56
- package/src/lib/agents/metrics-calculator.js +0 -125
- 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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
787
|
-
// are the files that exist nowhere else, and they are the
|
|
788
|
-
// reporting them is the least this can do. (Backing them
|
|
789
|
-
// option: this bucket is where
|
|
790
|
-
// is unbounded. The user declared them
|
|
791
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
//
|
|
28
|
-
|
|
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
|