@polymorphism-tech/morph-spec 8.46.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +53 -44
- package/README.md +3 -4
- package/bin/morph-spec.js +38 -55
- package/bin/validate.js +5 -134
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +41 -101
- package/framework/MORPH.md +95 -252
- package/framework/agents.json +75 -18
- package/framework/commands/create-umbraco-lp.md +2 -0
- package/framework/commands/migrate-lp-to-umbraco.md +2 -0
- package/framework/commands/morph-apply.md +5 -28
- package/framework/commands/morph-archive.md +2 -2
- package/framework/commands/morph-hotfix.md +8 -8
- package/framework/commands/morph-infra.md +17 -203
- package/framework/commands/morph-media.md +3 -0
- package/framework/commands/morph-preflight.md +7 -31
- package/framework/commands/morph-proposal.md +2 -50
- package/framework/commands/morph-status.md +3 -3
- package/framework/commands/morph-troubleshoot.md +2 -14
- package/framework/docs/CLI.md +585 -286
- package/framework/evals/review-checklist.md +82 -41
- package/framework/hooks/README.md +10 -11
- package/framework/hooks/claude-code/core/comment-guard.js +1 -1
- package/framework/hooks/claude-code/core/denylist.json +33 -3
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
- package/framework/hooks/claude-code/core/gate-guard.js +2 -11
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
- package/framework/hooks/claude-code/core/state-sync.js +17 -19
- package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
- package/framework/hooks/claude-code/core/validate-completion.js +33 -97
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
- package/framework/hooks/claude-code/core/worktree-context.js +5 -4
- package/framework/hooks/claude-code/statusline.py +55 -41
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/shared/activity-logger.js +1 -1
- package/framework/hooks/shared/build-check.js +10 -6
- package/framework/hooks/shared/command-tokens.js +169 -0
- package/framework/hooks/shared/comment-rules.js +1 -1
- package/framework/hooks/shared/comment-scanner.js +10 -23
- package/framework/hooks/shared/comment-stripper.js +1 -1
- package/framework/hooks/shared/e2e-check.js +67 -31
- package/framework/hooks/shared/errata.js +72 -0
- package/framework/hooks/shared/feature-locator.js +1 -1
- package/framework/hooks/shared/frontend-locations.js +6 -0
- package/framework/hooks/shared/gate-question.js +18 -0
- package/framework/hooks/shared/phase-utils.js +15 -32
- package/framework/hooks/shared/slice-progress.js +118 -0
- package/framework/hooks/shared/spawn-command.js +23 -0
- package/framework/hooks/shared/state-reader.js +61 -87
- package/framework/hooks/shared/state-writer.js +3 -3
- package/framework/mcp/registry.json +3 -4
- package/framework/phases.json +23 -39
- package/framework/rules/ai-agents-standards.md +18 -16
- package/framework/rules/asaas-standards.md +3 -1
- package/framework/rules/csharp-standards.md +10 -8
- package/framework/rules/data-standards.md +5 -5
- package/framework/rules/dotnet-templates.md +1 -1
- package/framework/rules/frontend-standards.md +4 -2
- package/framework/rules/gsap-standards.md +10 -5
- package/framework/rules/hangfire-standards.md +3 -1
- package/framework/rules/infrastructure-standards.md +4 -2
- package/framework/rules/neon-auth-standards.md +3 -1
- package/framework/rules/neon-standards.md +6 -4
- package/framework/rules/nextjs-standards.md +13 -11
- package/framework/rules/nextjs-templates.md +1 -1
- package/framework/rules/resend-standards.md +3 -1
- package/framework/schemas/config.schema.json +14 -9
- package/framework/schemas/feature.schema.json +15 -52
- package/framework/skills/README.md +4 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
- package/framework/skills/core/morph-apply/SKILL.md +150 -223
- package/framework/skills/core/morph-hotfix/SKILL.md +34 -43
- package/framework/skills/core/morph-machine/SKILL.md +4 -4
- package/framework/skills/core/morph-media/SKILL.md +3 -3
- package/framework/skills/core/morph-plan/SKILL.md +138 -300
- package/framework/skills/core/morph-review/SKILL.md +112 -137
- package/framework/skills/core/morph-standards/SKILL.md +29 -41
- package/framework/skills/core/morph-uiux/SKILL.md +78 -153
- package/framework/standards/STANDARDS.json +90 -1219
- package/framework/standards/STANDARDS.meta.json +1305 -0
- package/framework/standards/ai-agents/evals-with-cache.md +2 -3
- package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
- package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
- package/framework/standards/ai-agents/prompt-sources.md +6 -4
- package/framework/standards/ai-agents/providers/model-registry.md +1 -1
- package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
- package/framework/standards/ai-agents/setup.md +2 -2
- package/framework/standards/ai-agents/structured-output.md +1 -1
- package/framework/standards/ai-agents/sweet-spot.md +1 -1
- package/framework/standards/architecture/code-comments.md +8 -8
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
- package/framework/standards/backend/api/validation.md +135 -47
- package/framework/standards/backend/database/postgresql/database.md +11 -7
- package/framework/standards/backend/database/vector-search-rag.md +207 -324
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
- package/framework/standards/backend/dotnet/result-pattern.md +40 -6
- package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
- package/framework/standards/backend/dotnet/scrutor.md +1 -1
- package/framework/standards/backend/dotnet/testing.md +1 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
- package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
- package/framework/standards/data/nosql/cache/redis.md +120 -44
- package/framework/standards/data/nosql/cosmos-db.md +114 -71
- package/framework/standards/frontend/design-system/animations.md +1 -1
- package/framework/standards/frontend/design-system/naming.md +1 -1
- package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
- package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
- package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
- package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
- package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
- package/framework/standards/integration/mcp/mcp-tools.md +7 -7
- package/framework/templates/README.md +2 -3
- package/framework/templates/REGISTRY.json +6 -170
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
- package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
- package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
- package/framework/templates/docs/plan.md +72 -0
- package/framework/templates/dotnet/ai-kit/README.md +1 -9
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
- package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
- package/framework/templates/infrastructure/github/README.md +2 -1
- package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +2 -2
- package/src/commands/observability/cost.js +18 -60
- package/src/commands/observability/telemetry.js +3 -5
- package/src/commands/project/doctor-procs.js +1 -1
- package/src/commands/project/doctor.js +9 -9
- package/src/commands/project/fleet.js +4 -4
- package/src/commands/project/heavy.js +3 -3
- package/src/commands/project/init.js +6 -1
- package/src/commands/project/setup-infra-cmd.js +2 -1
- package/src/commands/project/stack-scan.js +58 -0
- package/src/commands/project/standards.js +79 -25
- package/src/commands/project/status.js +29 -69
- package/src/commands/project/update.js +33 -19
- package/src/commands/project/worktree.js +1 -1
- package/src/commands/state/advance.js +62 -46
- package/src/commands/state/approve.js +95 -138
- package/src/commands/state/archive.js +20 -84
- package/src/commands/state/auto-approval.js +68 -0
- package/src/commands/state/create.js +28 -1
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +11 -23
- package/src/commands/state/gate-check.js +86 -95
- package/src/commands/state/gate-decision.js +6 -4
- package/src/commands/state/handoff.js +1 -1
- package/src/commands/state/list.js +9 -9
- package/src/commands/state/report-check.js +157 -311
- package/src/commands/state/retype.js +9 -3
- package/src/commands/validation/mutate.js +77 -14
- package/src/commands/validation/plan-check.js +81 -0
- package/src/commands/validation/validate-feature.js +60 -135
- package/src/commands/validation/verify.js +41 -82
- package/src/core/paths/output-schema.js +20 -67
- package/src/core/state/gate-order.js +5 -6
- package/src/core/state/lifecycle-audit.js +2 -2
- package/src/core/state/state-manager.js +17 -98
- package/src/core/templates/template-registry.js +2 -2
- package/src/core/templates/template-seed.js +22 -0
- package/src/core/work-types.js +10 -10
- package/src/dashboard/app/App.jsx +0 -2
- package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
- package/src/dashboard/app/components/CommandPalette.jsx +7 -8
- package/src/dashboard/app/components/EntityLink.jsx +1 -2
- package/src/dashboard/app/components/Markdown.jsx +1 -1
- package/src/dashboard/app/components/Sidebar.jsx +1 -2
- package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
- package/src/dashboard/app/lib/phases.js +2 -4
- package/src/dashboard/app/pages/Activity.jsx +1 -1
- package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
- package/src/dashboard/app/pages/Features.jsx +9 -9
- package/src/dashboard/app/pages/Guide.jsx +2 -2
- package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
- package/src/dashboard/app/pages/RulesMap.jsx +3 -3
- package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
- package/src/dashboard/app/pages/Standards.jsx +1 -1
- package/src/dashboard/scanner/activity-scanner.js +6 -16
- package/src/dashboard/scanner/agents-scanner.js +1 -1
- package/src/dashboard/scanner/docs-scanner.js +5 -23
- package/src/dashboard/scanner/features-scanner.js +95 -60
- package/src/dashboard/scanner/index.js +0 -2
- package/src/dashboard/scanner/project-api.js +1 -1
- package/src/dashboard/scanner/rules-scanner.js +15 -10
- package/src/dashboard/scanner/skills-scanner.js +2 -3
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ci/ci-verify.js +17 -1
- package/src/lib/ci/github-ci.js +1 -1
- package/src/lib/detectors/index.js +3 -8
- package/src/lib/generators/handoff-generator.js +34 -65
- package/src/lib/generators/settings-generator.js +11 -89
- package/src/lib/git/evidence-staleness.js +1 -1
- package/src/lib/git/tree-fingerprint.js +19 -7
- package/src/lib/git/tree-state.js +2 -2
- package/src/lib/heavy/cleanup.js +1 -5
- package/src/lib/installers/memory-installer.js +6 -8
- package/src/lib/mutation/commands.js +3 -2
- package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
- package/src/lib/mutation/plan.js +14 -6
- package/src/lib/mutation/runner.js +1 -1
- package/src/lib/mutation/subset.js +46 -9
- package/src/lib/plan/artifact-vocabulary.js +60 -0
- package/src/lib/plan/checks.js +234 -0
- package/src/lib/plan/plan-parser.js +209 -0
- package/src/lib/plan/plan-template.js +18 -0
- package/src/lib/review-reports.js +222 -0
- package/src/lib/risk-detector.js +134 -135
- package/src/lib/specs/adr-index.js +50 -69
- package/src/lib/specs/design-seed.js +18 -0
- package/src/lib/specs/frontmatter.js +44 -0
- package/src/lib/specs/gfm-table.js +17 -0
- package/src/lib/specs/old-format.js +68 -0
- package/src/lib/specs/revocations.js +37 -95
- package/src/lib/specs/screen-checklist.js +18 -17
- package/src/lib/stack/dotnet-build-ignores.js +47 -0
- package/src/lib/stack/stack-profile.js +1 -1
- package/src/lib/stack/stack-scan.js +267 -0
- package/src/lib/standards/registry.js +43 -0
- package/src/lib/standards/sections.js +58 -0
- package/src/lib/tasks/eval-node.js +4 -12
- package/src/lib/tasks/test-nodes.js +1 -74
- package/src/lib/tasks/test-runner.js +75 -117
- package/src/lib/telemetry/metrics.js +8 -69
- package/src/lib/telemetry/telemetry-reader.js +5 -118
- package/src/lib/telemetry/verify-event.js +5 -7
- package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
- package/src/lib/validators/reuse/reuse-validator.js +24 -48
- package/src/lib/validators/reuse/symbol-scanner.js +2 -4
- package/src/lib/validators/traceability-check.js +85 -129
- package/src/lib/validators/validation-runner.js +8 -88
- package/src/lib/validators/verify-runner.js +134 -301
- package/src/lib/worktree/docker-audit.js +1 -2
- package/src/lib/worktree/fleet-scanner.js +25 -38
- package/src/scripts/setup-infra.js +42 -22
- package/src/utils/agents-installer.js +92 -13
- package/src/utils/claude-settings-manager.js +0 -35
- package/src/utils/file-copier.js +34 -6
- package/src/utils/hooks-installer.js +16 -9
- package/src/utils/skills-installer.js +1 -2
- package/framework/commands/commit.md +0 -166
- package/framework/evals/trace-schema.md +0 -67
- package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
- package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
- package/framework/hooks/git/pre-commit/specs.sh +0 -50
- package/framework/hooks/shared/tasks-validator.js +0 -111
- package/framework/rubrics/backend.json +0 -41
- package/framework/rubrics/default.json +0 -36
- package/framework/rubrics/frontend.json +0 -36
- package/framework/rules/morph-task-tracking.md +0 -54
- package/framework/rules/morph-workflow.md +0 -244
- package/framework/schemas/tasks.schema.json +0 -79
- package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
- package/framework/skills/core/morph-implement/SKILL.md +0 -194
- package/framework/skills/core/morph-proposal/SKILL.md +0 -152
- package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
- package/framework/templates/docs/proposal.md +0 -192
- package/framework/templates/docs/spec.md +0 -57
- package/framework/templates/docs/ui-components.md +0 -52
- package/framework/templates/docs/ui-design-system.md +0 -76
- package/framework/templates/docs/ui-flows.md +0 -167
- package/framework/templates/examples/spec-examples.md +0 -91
- package/framework/templates/feature/decisions.md +0 -124
- package/framework/templates/feature/recap.md +0 -145
- package/framework/templates/project/validate.js +0 -97
- package/framework/templates/tasks.template.json +0 -17
- package/src/commands/state/usecase.js +0 -143
- package/src/commands/tasks/dag.js +0 -181
- package/src/dashboard/app/pages/Rubrics.jsx +0 -97
- package/src/dashboard/scanner/rubrics-scanner.js +0 -12
- package/src/lib/agents/output-collector.js +0 -76
- package/src/lib/evaluator-report.js +0 -91
- package/src/lib/specs/acervo-format.js +0 -70
- package/src/lib/specs/acervo-lifecycle.js +0 -264
- package/src/lib/specs/done-criteria-check.js +0 -200
- package/src/lib/specs/usecase-check.js +0 -301
- package/src/lib/specs/usecase-promoter.js +0 -301
- package/src/lib/specs/usecase-registry.js +0 -237
- package/src/lib/tasks/dispatch-planner.js +0 -319
- package/src/lib/tasks/mermaid-renderer.js +0 -132
- package/src/lib/tasks/task-classifier.js +0 -30
- package/src/lib/tasks/task-parser.js +0 -209
- package/src/lib/tasks/tasks-json-parser.js +0 -296
- package/src/lib/tasks/test-scope.js +0 -597
- package/src/lib/validators/architecture/architecture-validator.js +0 -270
- package/src/lib/validators/architecture/index.js +0 -7
- package/src/lib/validators/css/css-validator.js +0 -340
- package/src/lib/validators/css/index.js +0 -7
- package/src/lib/validators/design-system/design-system-validator.js +0 -237
- package/src/lib/validators/design-system/index.js +0 -7
- package/src/lib/validators/score-aggregator.js +0 -244
- package/src/lib/validators/ui/index.js +0 -7
- package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* usecase-registry.js — atomic, cross-worktree UC-{nn} number allocation.
|
|
3
|
-
*
|
|
4
|
-
* Two parallel worktrees planning at the same time each read the acervo
|
|
5
|
-
* (`.morph/specs/usecases/`) plus their OWN feature's `2-plan/usecases/`, take
|
|
6
|
-
* the max `UC-{nn}` and add one — but a worktree only ever sees its OWN tree.
|
|
7
|
-
* Sibling features' use cases live in SIBLING worktrees (feature.json and every
|
|
8
|
-
* UC file travel on the branch, not through any shared filesystem). Two
|
|
9
|
-
* worktrees both computing max+1 from the same stale max both mint `UC-07` —
|
|
10
|
-
* and at promotion time `usecase-promoter.js` overwrites by ID: the SECOND
|
|
11
|
-
* feature to close silently destroys the FIRST one's use case. A user lost
|
|
12
|
-
* work this way in production.
|
|
13
|
-
*
|
|
14
|
-
* This module closes the gap the same way `env-allocator.js` (port blocks) and
|
|
15
|
-
* `claim-registry.js` (feature ownership) already do: a registry file under the
|
|
16
|
-
* git COMMON dir, the one location every linked worktree of a repo shares —
|
|
17
|
-
* `<git-common-dir>/morph-spec/uc-registry.json`. Writes are tmp+rename; a
|
|
18
|
-
* corrupt registry reads as empty (fail-open — the cost is a re-seed from disk,
|
|
19
|
-
* never a crash).
|
|
20
|
-
*
|
|
21
|
-
* Unlike the other two registries, correctness here cannot be advisory: the
|
|
22
|
-
* whole point is that two concurrent callers must never receive the same
|
|
23
|
-
* number. `env-allocator`/`claim-registry` key by FEATURE, so two different
|
|
24
|
-
* features racing never contend for the same key; two worktrees reserving UC
|
|
25
|
-
* numbers race for the SAME counter. So allocation runs inside a real mutual
|
|
26
|
-
* exclusion lock (`uc-registry.lock`, `open(..., 'wx')` — atomic create-if-absent
|
|
27
|
-
* on every platform Node targets, including Windows) instead of the bare
|
|
28
|
-
* read-modify-write the other two registries use.
|
|
29
|
-
*
|
|
30
|
-
* No git (no common dir resolvable): falls back to a project-local registry
|
|
31
|
-
* under `.morph/logs/` (already gitignored) — this still prevents a single
|
|
32
|
-
* session from reserving the same number twice across repeated calls, but
|
|
33
|
-
* carries none of the cross-worktree guarantee. Callers must surface that
|
|
34
|
-
* degradation to the user.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import {
|
|
38
|
-
closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
|
|
39
|
-
renameSync, statSync, unlinkSync, writeFileSync,
|
|
40
|
-
} from 'fs';
|
|
41
|
-
import { join } from 'path';
|
|
42
|
-
|
|
43
|
-
// From acervo-format.js, not usecase-promoter.js: the promoter depends on THIS
|
|
44
|
-
// module (to look up who reserved a number), and a two-way import is circular.
|
|
45
|
-
import { useCaseId } from './acervo-format.js';
|
|
46
|
-
|
|
47
|
-
/** `UC-7` (or any width) -> `UC-07` — the archive's zero-padded convention. */
|
|
48
|
-
function formatUcId(n) {
|
|
49
|
-
return `UC-${String(n).padStart(2, '0')}`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Directory holding both the registry file and its lock. */
|
|
53
|
-
export function registryDir({ gitCommonDir, cwd }) {
|
|
54
|
-
return gitCommonDir ? join(gitCommonDir, 'morph-spec') : join(cwd, '.morph', 'logs');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Registry file path — shared (git common dir) or local-fallback (no git). */
|
|
58
|
-
export function ucRegistryPath({ gitCommonDir, cwd }) {
|
|
59
|
-
return join(registryDir({ gitCommonDir, cwd }), 'uc-registry.json');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function readRegistry(path) {
|
|
63
|
-
try {
|
|
64
|
-
const raw = readFileSync(path, 'utf8').replace(/^/, '');
|
|
65
|
-
const parsed = JSON.parse(raw);
|
|
66
|
-
if (parsed && typeof parsed.reservations === 'object' && parsed.reservations !== null) {
|
|
67
|
-
return {
|
|
68
|
-
version: 1,
|
|
69
|
-
counter: Number.isInteger(parsed.counter) ? parsed.counter : 0,
|
|
70
|
-
reservations: parsed.reservations,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
} catch { /* fail-open: absent or corrupt registry restarts empty */ }
|
|
74
|
-
return { version: 1, counter: 0, reservations: {} };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function writeRegistry(path, registry) {
|
|
78
|
-
mkdirSync(join(path, '..'), { recursive: true });
|
|
79
|
-
const tmp = `${path}.tmp.${process.pid}`;
|
|
80
|
-
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
81
|
-
renameSync(tmp, path);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Synchronous sleep — Node (unlike browsers) allows `Atomics.wait` on the main thread. */
|
|
85
|
-
function sleepSync(ms) {
|
|
86
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Run `fn` while holding an exclusive filesystem lock at `lockPath`.
|
|
91
|
-
*
|
|
92
|
-
* `openSync(path, 'wx')` is `O_CREAT | O_EXCL` — atomic create-or-fail, the
|
|
93
|
-
* only primitive this needs and one that behaves identically on POSIX and
|
|
94
|
-
* Windows. A lock file whose mtime is older than `staleMs` is assumed to be
|
|
95
|
-
* left behind by a crashed process and is force-recovered — an orphaned lock
|
|
96
|
-
* must never strand every future reservation.
|
|
97
|
-
*
|
|
98
|
-
* @param {string} lockPath
|
|
99
|
-
* @param {() => T} fn
|
|
100
|
-
* @returns {T}
|
|
101
|
-
* @template T
|
|
102
|
-
*/
|
|
103
|
-
function withLock(lockPath, fn, { timeoutMs = 5000, pollMs = 20, staleMs = 15000 } = {}) {
|
|
104
|
-
mkdirSync(join(lockPath, '..'), { recursive: true });
|
|
105
|
-
const deadline = Date.now() + timeoutMs;
|
|
106
|
-
for (;;) {
|
|
107
|
-
let fd;
|
|
108
|
-
try {
|
|
109
|
-
fd = openSync(lockPath, 'wx');
|
|
110
|
-
} catch (err) {
|
|
111
|
-
if (err.code !== 'EEXIST') throw err;
|
|
112
|
-
try {
|
|
113
|
-
const st = statSync(lockPath);
|
|
114
|
-
if (Date.now() - st.mtimeMs > staleMs) {
|
|
115
|
-
try { unlinkSync(lockPath); } catch { /* raced someone else's cleanup */ }
|
|
116
|
-
continue; // retry immediately — the stale lock is (probably) gone now
|
|
117
|
-
}
|
|
118
|
-
} catch { continue; } // lock vanished between openSync and statSync — retry
|
|
119
|
-
if (Date.now() >= deadline) {
|
|
120
|
-
throw new Error(`usecase-registry: could not acquire lock at ${lockPath} within ${timeoutMs}ms`);
|
|
121
|
-
}
|
|
122
|
-
sleepSync(pollMs);
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
closeSync(fd);
|
|
127
|
-
return fn();
|
|
128
|
-
} finally {
|
|
129
|
-
try { unlinkSync(lockPath); } catch { /* best-effort */ }
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Highest `UC-{nn}` number found among files matching the pattern in `dir`. 0 if none/unreadable. */
|
|
135
|
-
function maxUcNumberInDir(dir) {
|
|
136
|
-
let max = 0;
|
|
137
|
-
try {
|
|
138
|
-
if (!existsSync(dir)) return 0;
|
|
139
|
-
for (const name of readdirSync(dir)) {
|
|
140
|
-
// Any `UC-{nn}-` prefix counts, canonical or not: a number already on disk is never handed out again.
|
|
141
|
-
const id = useCaseId(name);
|
|
142
|
-
if (!id) continue;
|
|
143
|
-
const n = parseInt(id.slice('UC-'.length), 10);
|
|
144
|
-
if (Number.isFinite(n) && n > max) max = n;
|
|
145
|
-
}
|
|
146
|
-
} catch { /* unreadable dir — treat as empty, never fatal */ }
|
|
147
|
-
return max;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Highest UC number across every feature's `2-plan/usecases/`, under both `.morph/features/` and `.morph/archive/`, in THIS tree. */
|
|
151
|
-
function maxUcNumberLocalTree(cwd) {
|
|
152
|
-
let max = 0;
|
|
153
|
-
for (const bucket of ['features', 'archive']) {
|
|
154
|
-
const bucketDir = join(cwd, '.morph', bucket);
|
|
155
|
-
try {
|
|
156
|
-
if (!existsSync(bucketDir)) continue;
|
|
157
|
-
for (const entry of readdirSync(bucketDir, { withFileTypes: true })) {
|
|
158
|
-
if (!entry.isDirectory()) continue;
|
|
159
|
-
max = Math.max(max, maxUcNumberInDir(join(bucketDir, entry.name, '2-plan', 'usecases')));
|
|
160
|
-
}
|
|
161
|
-
} catch { /* unreadable bucket — treat as empty */ }
|
|
162
|
-
}
|
|
163
|
-
return max;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* The highest UC number this project tree can see right now: the permanent
|
|
168
|
-
* acervo, plus every feature (active OR archived-but-not-yet-promoted, see
|
|
169
|
-
* `usecase-promoter.js`'s refusal path) in THIS tree. Exported for tests and
|
|
170
|
-
* for callers that want to preview the seed without reserving anything.
|
|
171
|
-
*
|
|
172
|
-
* @param {string} cwd
|
|
173
|
-
* @returns {number}
|
|
174
|
-
*/
|
|
175
|
-
export function scanLocalMax(cwd) {
|
|
176
|
-
const acervoMax = maxUcNumberInDir(join(cwd, '.morph', 'specs', 'usecases'));
|
|
177
|
-
return Math.max(acervoMax, maxUcNumberLocalTree(cwd));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Atomically reserve the next `count` UC numbers, shared across every worktree
|
|
182
|
-
* of this repo when a git common dir is resolvable.
|
|
183
|
-
*
|
|
184
|
-
* The seed is recomputed on EVERY call as `max(registry.counter, scanLocalMax(cwd))`
|
|
185
|
-
* — monotonic by construction (a max can only grow), so this doubles as both
|
|
186
|
-
* "seed the counter on the first-ever reservation" and "never regress even if
|
|
187
|
-
* the registry were somehow behind disk" without needing a separate seeded flag.
|
|
188
|
-
*
|
|
189
|
-
* @param {string} cwd
|
|
190
|
-
* @param {{ feature?: string|null, count?: number, gitCommonDir?: string|null }} opts
|
|
191
|
-
* @returns {{ numbers: string[], base: number, count: number, shared: boolean, registryPath: string }}
|
|
192
|
-
*/
|
|
193
|
-
export function reserveUseCases(cwd, { feature = null, count = 1, gitCommonDir = null } = {}) {
|
|
194
|
-
const n = Number.isInteger(count) && count > 0 ? count : 1;
|
|
195
|
-
const dir = registryDir({ gitCommonDir, cwd });
|
|
196
|
-
const path = ucRegistryPath({ gitCommonDir, cwd });
|
|
197
|
-
mkdirSync(dir, { recursive: true });
|
|
198
|
-
|
|
199
|
-
return withLock(join(dir, 'uc-registry.lock'), () => {
|
|
200
|
-
const registry = readRegistry(path);
|
|
201
|
-
const base = Math.max(registry.counter || 0, scanLocalMax(cwd));
|
|
202
|
-
|
|
203
|
-
// Test-only: widen the critical section so a concurrency test can prove
|
|
204
|
-
// two REAL processes racing for this lock actually serialize, instead of
|
|
205
|
-
// just happening not to overlap. Mirrors `finish.js`'s MORPH_GH_BIN test
|
|
206
|
-
// injection. Unset in every real invocation.
|
|
207
|
-
const testDelay = Number.parseInt(process.env.MORPH_UC_RESERVE_TEST_DELAY_MS, 10);
|
|
208
|
-
if (Number.isFinite(testDelay) && testDelay > 0) sleepSync(testDelay);
|
|
209
|
-
|
|
210
|
-
const numbers = [];
|
|
211
|
-
const now = new Date().toISOString();
|
|
212
|
-
for (let i = 1; i <= n; i += 1) {
|
|
213
|
-
const id = formatUcId(base + i);
|
|
214
|
-
numbers.push(id);
|
|
215
|
-
registry.reservations[id] = { feature: feature || null, reservedAt: now };
|
|
216
|
-
}
|
|
217
|
-
registry.counter = base + n;
|
|
218
|
-
writeRegistry(path, registry);
|
|
219
|
-
|
|
220
|
-
return { numbers, base, count: n, shared: !!gitCommonDir, registryPath: path };
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Which feature (if any) holds the reservation for a given `UC-{nn}` id.
|
|
226
|
-
* Read-only — no lock, a slightly stale read is harmless for this use (the
|
|
227
|
-
* promoter's collision backstop, never the allocation itself).
|
|
228
|
-
*
|
|
229
|
-
* @param {string} id - e.g. "UC-07"
|
|
230
|
-
* @param {{ cwd: string, gitCommonDir?: string|null }} opts
|
|
231
|
-
* @returns {string|null}
|
|
232
|
-
*/
|
|
233
|
-
export function reservationOwner(id, { cwd, gitCommonDir = null }) {
|
|
234
|
-
const path = ucRegistryPath({ gitCommonDir, cwd });
|
|
235
|
-
const registry = readRegistry(path);
|
|
236
|
-
return registry.reservations?.[id]?.feature ?? null;
|
|
237
|
-
}
|
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dispatch-planner.js — effort-weighted sequential vs parallel decision for a
|
|
3
|
-
* feature's tasks.json DAG.
|
|
4
|
-
*
|
|
5
|
-
* Encodes the dispatch rules from morph-apply §2 ("Análise de DAG") so the
|
|
6
|
-
* decision is deterministic and CLI-driven (`morph-spec dag <feature>`)
|
|
7
|
-
* instead of re-derived by the LLM each session:
|
|
8
|
-
*
|
|
9
|
-
* 1. <= 3 tasks total → sequential (dispatch overhead beats the work).
|
|
10
|
-
* 2. >= 2 independent group flows with enough weight → one agent per flow.
|
|
11
|
-
* 3. >= 3 independent roots with effort M or heavier → parallel root batch.
|
|
12
|
-
* 4. otherwise → sequential with auto-advance.
|
|
13
|
-
*
|
|
14
|
-
* Effort weighting: S=1, M=2, L=4, XL=8. S-only roots and light flows never
|
|
15
|
-
* earn their own agent — they are returned as `inline` (the orchestrator runs
|
|
16
|
-
* them in the sequential lane, or folds them into a single grouped agent), so
|
|
17
|
-
* dispatch overhead never exceeds the work being dispatched.
|
|
18
|
-
*
|
|
19
|
-
* Pure module — operates on a parsed tasks.json document, no I/O.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
export const EFFORT_WEIGHT = { S: 1, M: 2, L: 4, XL: 8 };
|
|
23
|
-
|
|
24
|
-
/** A root must weigh at least this (effort M) to earn its own agent. */
|
|
25
|
-
export const ROOT_DISPATCH_MIN_WEIGHT = 2;
|
|
26
|
-
|
|
27
|
-
/** A flow must weigh at least this (an L, or 2×M…) to earn its own agent. */
|
|
28
|
-
export const FLOW_DISPATCH_MIN_WEIGHT = 4;
|
|
29
|
-
|
|
30
|
-
/** Mirror of the morph-apply cap on simultaneous agents. */
|
|
31
|
-
export const MAX_PARALLEL_AGENTS = 4;
|
|
32
|
-
|
|
33
|
-
function weightOf(task) {
|
|
34
|
-
return EFFORT_WEIGHT[task.effort] ?? EFFORT_WEIGHT.M;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function groupOf(task) {
|
|
38
|
-
// Same fallback as groupTasks() in tasks-json-parser.js
|
|
39
|
-
return task.group || 'backend';
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Normalize a planned output path for collision comparison: forward slashes,
|
|
44
|
-
* collapsed repeated slashes, no leading `./` or `/`, no trailing slash,
|
|
45
|
-
* lowercased (Windows/macOS filesystems are case-insensitive). Returns '' for
|
|
46
|
-
* empty/nullish input. `..`/`.` segments are left as-is on purpose (resolving
|
|
47
|
-
* them would risk false-positives; two genuinely different relative spellings
|
|
48
|
-
* stay distinct).
|
|
49
|
-
*/
|
|
50
|
-
export function normalizeOutputPath(p) {
|
|
51
|
-
return String(p ?? '')
|
|
52
|
-
.trim()
|
|
53
|
-
.replace(/\\/g, '/')
|
|
54
|
-
.replace(/\/{2,}/g, '/')
|
|
55
|
-
.replace(/^\.\//, '')
|
|
56
|
-
.replace(/^\/+/, '')
|
|
57
|
-
.replace(/\/+$/, '')
|
|
58
|
-
.toLowerCase();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Two normalized paths "overlap" if they are equal, or one is a directory
|
|
63
|
-
* ancestor of the other — boundary-aware so `src/a` contains `src/a/b` but
|
|
64
|
-
* NOT `src/ab`. Empty paths never overlap.
|
|
65
|
-
*/
|
|
66
|
-
export function pathsOverlap(a, b) {
|
|
67
|
-
if (!a || !b) return false;
|
|
68
|
-
if (a === b) return true;
|
|
69
|
-
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
70
|
-
return longer.startsWith(`${shorter}/`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Decide whether a parallel batch's units would write to overlapping paths,
|
|
75
|
-
* and therefore whether each agent should run in its own git worktree
|
|
76
|
-
* (`isolation: "worktree"`, per morph-apply §3b-w) instead of the shared
|
|
77
|
-
* working tree.
|
|
78
|
-
*
|
|
79
|
-
* A *unit* is the set of task ids a single agent owns (one flow, or one batch
|
|
80
|
-
* root). Tasks WITHIN a unit run serially under that agent, so their outputs
|
|
81
|
-
* never race — only CROSS-unit overlaps matter. The recommendation is global
|
|
82
|
-
* per batch: any collision → isolate the whole batch. Units whose tasks
|
|
83
|
-
* declare no `outputs` are an *unknown footprint* — they do NOT force
|
|
84
|
-
* isolation (fail-open) but are flagged so the caller knows the dag can't
|
|
85
|
-
* prove the batch is collision-free.
|
|
86
|
-
*
|
|
87
|
-
* @param {Array<{ key: string, taskIds: string[] }>} units
|
|
88
|
-
* @param {Map<string, object>} tasksById
|
|
89
|
-
* @returns {{ recommended: 'none'|'worktree', collisions: Array<{path: string, units: string[], tasks: string[]}>, unknownFootprint: string[], reason: string }}
|
|
90
|
-
*/
|
|
91
|
-
export function buildIsolation(units, tasksById) {
|
|
92
|
-
const unknownFootprint = [];
|
|
93
|
-
const unitOutputs = units.map(unit => {
|
|
94
|
-
const outs = [];
|
|
95
|
-
for (const id of unit.taskIds) {
|
|
96
|
-
const task = tasksById.get(id);
|
|
97
|
-
const declared = Array.isArray(task?.outputs) ? task.outputs : [];
|
|
98
|
-
let validForTask = 0;
|
|
99
|
-
for (const raw of declared) {
|
|
100
|
-
const path = normalizeOutputPath(raw);
|
|
101
|
-
if (path) { outs.push({ path, task: id }); validForTask += 1; }
|
|
102
|
-
}
|
|
103
|
-
// No usable output paths (none declared, or all normalize to '') → the
|
|
104
|
-
// dag can't prove this task's footprint. Flag it (fail-open).
|
|
105
|
-
if (validForTask === 0) unknownFootprint.push(id);
|
|
106
|
-
}
|
|
107
|
-
return { key: unit.key, outs };
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const collisions = [];
|
|
111
|
-
for (let i = 0; i < unitOutputs.length; i++) {
|
|
112
|
-
for (let j = i + 1; j < unitOutputs.length; j++) {
|
|
113
|
-
for (const a of unitOutputs[i].outs) {
|
|
114
|
-
for (const b of unitOutputs[j].outs) {
|
|
115
|
-
if (pathsOverlap(a.path, b.path)) {
|
|
116
|
-
collisions.push({
|
|
117
|
-
path: a.path === b.path ? a.path : `${a.path} ∩ ${b.path}`,
|
|
118
|
-
units: [unitOutputs[i].key, unitOutputs[j].key],
|
|
119
|
-
tasks: [a.task, b.task],
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const recommended = collisions.length > 0 ? 'worktree' : 'none';
|
|
128
|
-
let reason;
|
|
129
|
-
if (collisions.length > 0) {
|
|
130
|
-
reason =
|
|
131
|
-
`${collisions.length} colisão(ões) de outputs entre unidades paralelas — ` +
|
|
132
|
-
`isole cada agente num worktree (morph-apply §3b-w)`;
|
|
133
|
-
} else if (unknownFootprint.length > 0) {
|
|
134
|
-
reason =
|
|
135
|
-
`sem colisão detectada, mas ${unknownFootprint.length} task(s) paralela(s) sem ` +
|
|
136
|
-
`outputs declarados (footprint desconhecido) — o dag não garante ausência de colisão`;
|
|
137
|
-
} else {
|
|
138
|
-
reason = 'nenhuma colisão de outputs entre unidades paralelas — batch na working tree compartilhada';
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return { recommended, collisions, unknownFootprint, reason };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Ids of tasks whose dependency chain crosses group boundaries — integration
|
|
146
|
-
* tasks and everything downstream of them. These never belong to a flow: they
|
|
147
|
-
* run in the sequential lane after the parallel batch converges.
|
|
148
|
-
*/
|
|
149
|
-
function crossGroupTaskIds(tasks) {
|
|
150
|
-
const byId = new Map(tasks.map(t => [t.id, t]));
|
|
151
|
-
const cross = new Set();
|
|
152
|
-
|
|
153
|
-
for (const task of tasks) {
|
|
154
|
-
for (const dep of task.dependencies) {
|
|
155
|
-
const depTask = byId.get(dep);
|
|
156
|
-
if (depTask && groupOf(depTask) !== groupOf(task)) {
|
|
157
|
-
cross.add(task.id);
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Propagate downstream: a task depending (transitively) on a cross-group
|
|
164
|
-
// task cannot run inside a single-group flow either.
|
|
165
|
-
let changed = true;
|
|
166
|
-
while (changed) {
|
|
167
|
-
changed = false;
|
|
168
|
-
for (const task of tasks) {
|
|
169
|
-
if (cross.has(task.id)) continue;
|
|
170
|
-
if (task.dependencies.some(dep => cross.has(dep))) {
|
|
171
|
-
cross.add(task.id);
|
|
172
|
-
changed = true;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return cross;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function buildStats(tasks, pending) {
|
|
181
|
-
const groups = {};
|
|
182
|
-
const weights = {};
|
|
183
|
-
for (const task of pending) {
|
|
184
|
-
const g = groupOf(task);
|
|
185
|
-
groups[g] = (groups[g] || 0) + 1;
|
|
186
|
-
weights[g] = (weights[g] || 0) + weightOf(task);
|
|
187
|
-
}
|
|
188
|
-
return { total: tasks.length, pending: pending.length, groups, weights };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Decide how to execute the remaining tasks of a parsed tasks.json document.
|
|
193
|
-
*
|
|
194
|
-
* @param {object} data - parsed tasks.json ({ version, feature, tasks[] })
|
|
195
|
-
* @returns {{
|
|
196
|
-
* mode: 'sequential' | 'parallel-flows' | 'parallel-roots',
|
|
197
|
-
* reason: string,
|
|
198
|
-
* stats: { total: number, pending: number, groups: object, weights: object },
|
|
199
|
-
* flows: Array<{ group: string, taskIds: string[], weight: number }>,
|
|
200
|
-
* batch: string[],
|
|
201
|
-
* inline: string[],
|
|
202
|
-
* sequentialRemainder: string[],
|
|
203
|
-
* isolation: { recommended: 'none'|'worktree', collisions: Array<{path: string, units: string[], tasks: string[]}>, unknownFootprint: string[], reason: string }
|
|
204
|
-
* }}
|
|
205
|
-
*/
|
|
206
|
-
export function planDispatch(data) {
|
|
207
|
-
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
208
|
-
const pending = tasks.filter(t => t.status === 'pending');
|
|
209
|
-
const stats = buildStats(tasks, pending);
|
|
210
|
-
const empty = { flows: [], batch: [], inline: [] };
|
|
211
|
-
const tasksById = new Map(tasks.map(t => [t.id, t]));
|
|
212
|
-
// A batch with no parallel units can't have concurrent-write collisions.
|
|
213
|
-
const noIsolation = buildIsolation([], tasksById);
|
|
214
|
-
|
|
215
|
-
if (pending.length === 0) {
|
|
216
|
-
return {
|
|
217
|
-
mode: 'sequential',
|
|
218
|
-
reason: 'nenhuma task pendente — nada a despachar',
|
|
219
|
-
stats, ...empty, sequentialRemainder: [], isolation: noIsolation,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Rule 1 — tiny plans never pay for dispatch.
|
|
224
|
-
if (tasks.length <= 3) {
|
|
225
|
-
return {
|
|
226
|
-
mode: 'sequential',
|
|
227
|
-
reason: `${tasks.length} task(s) no total (<= 3) — overhead de Agent dispatch não compensa`,
|
|
228
|
-
stats, ...empty, sequentialRemainder: pending.map(t => t.id), isolation: noIsolation,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Rule 2 — independent domain flows (backend ‖ frontend …).
|
|
233
|
-
const cross = crossGroupTaskIds(tasks);
|
|
234
|
-
const flowsMap = new Map();
|
|
235
|
-
for (const task of pending) {
|
|
236
|
-
if (cross.has(task.id)) continue;
|
|
237
|
-
const g = groupOf(task);
|
|
238
|
-
if (!flowsMap.has(g)) flowsMap.set(g, { group: g, taskIds: [], weight: 0 });
|
|
239
|
-
const flow = flowsMap.get(g);
|
|
240
|
-
flow.taskIds.push(task.id);
|
|
241
|
-
flow.weight += weightOf(task);
|
|
242
|
-
}
|
|
243
|
-
const allFlows = [...flowsMap.values()];
|
|
244
|
-
const worthyFlows = allFlows
|
|
245
|
-
.filter(f => f.weight >= FLOW_DISPATCH_MIN_WEIGHT)
|
|
246
|
-
.sort((a, b) => b.weight - a.weight);
|
|
247
|
-
const lightFlows = allFlows.filter(f => f.weight < FLOW_DISPATCH_MIN_WEIGHT);
|
|
248
|
-
|
|
249
|
-
if (worthyFlows.length >= 2) {
|
|
250
|
-
const flows = worthyFlows.slice(0, MAX_PARALLEL_AGENTS);
|
|
251
|
-
const overflow = worthyFlows.slice(MAX_PARALLEL_AGENTS);
|
|
252
|
-
const inline = [
|
|
253
|
-
...lightFlows.flatMap(f => f.taskIds),
|
|
254
|
-
...overflow.flatMap(f => f.taskIds),
|
|
255
|
-
];
|
|
256
|
-
const sequentialRemainder = pending
|
|
257
|
-
.filter(t => cross.has(t.id))
|
|
258
|
-
.map(t => t.id);
|
|
259
|
-
|
|
260
|
-
const flowDesc = flows.map(f => `${f.group}:${f.taskIds.length} (peso ${f.weight})`).join(' ‖ ');
|
|
261
|
-
return {
|
|
262
|
-
mode: 'parallel-flows',
|
|
263
|
-
reason:
|
|
264
|
-
`${flows.length} fluxos de domínio independentes com peso suficiente — ${flowDesc} → ` +
|
|
265
|
-
`1 agente por fluxo` +
|
|
266
|
-
(inline.length ? `; ${inline.length} task(s) leve(s) inline (sem dispatch próprio)` : '') +
|
|
267
|
-
(sequentialRemainder.length ? `; integração cross-group sequencial no fim (${sequentialRemainder.join(', ')})` : ''),
|
|
268
|
-
stats, flows, batch: [], inline, sequentialRemainder,
|
|
269
|
-
// Units = the dispatched flows (one agent each). Inline/overflow/cross-
|
|
270
|
-
// group remainder run in the sequential lane, never concurrently.
|
|
271
|
-
isolation: buildIsolation(
|
|
272
|
-
flows.map(f => ({ key: f.group, taskIds: f.taskIds })),
|
|
273
|
-
tasksById,
|
|
274
|
-
),
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Rule 3 — root fan-out, weighted by effort (S roots never earn an agent).
|
|
279
|
-
const roots = pending.filter(t => t.dependencies.length === 0);
|
|
280
|
-
const weightedRoots = roots
|
|
281
|
-
.filter(t => weightOf(t) >= ROOT_DISPATCH_MIN_WEIGHT)
|
|
282
|
-
.sort((a, b) => weightOf(b) - weightOf(a));
|
|
283
|
-
const sRoots = roots.filter(t => weightOf(t) < ROOT_DISPATCH_MIN_WEIGHT);
|
|
284
|
-
|
|
285
|
-
if (weightedRoots.length >= 3) {
|
|
286
|
-
const batch = weightedRoots.slice(0, MAX_PARALLEL_AGENTS).map(t => t.id);
|
|
287
|
-
const inline = sRoots.map(t => t.id);
|
|
288
|
-
const sequentialRemainder = pending
|
|
289
|
-
.filter(t => !batch.includes(t.id) && !inline.includes(t.id))
|
|
290
|
-
.map(t => t.id);
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
mode: 'parallel-roots',
|
|
294
|
-
reason:
|
|
295
|
-
`${weightedRoots.length} raízes independentes com effort M+ → batch paralelo (${batch.join(', ')})` +
|
|
296
|
-
(inline.length ? `; raízes S inline — não ganham dispatch próprio (${inline.join(', ')})` : '') +
|
|
297
|
-
`; restante sequencial`,
|
|
298
|
-
stats, flows: [], batch, inline, sequentialRemainder,
|
|
299
|
-
// Units = each batch root (one agent each); inline S-roots and the
|
|
300
|
-
// sequential remainder don't run concurrently with the batch.
|
|
301
|
-
isolation: buildIsolation(
|
|
302
|
-
batch.map(id => ({ key: id, taskIds: [id] })),
|
|
303
|
-
tasksById,
|
|
304
|
-
),
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Rule 4 — sequential.
|
|
309
|
-
const reason = roots.length >= 3
|
|
310
|
-
? `${roots.length} raízes independentes, mas apenas ${weightedRoots.length} com effort M+ ` +
|
|
311
|
-
`(raízes S não ganham dispatch próprio — overhead > trabalho) → sequencial com auto-advance`
|
|
312
|
-
: 'sem fluxos ou raízes independentes suficientes → sequencial com auto-advance';
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
mode: 'sequential',
|
|
316
|
-
reason,
|
|
317
|
-
stats, ...empty, sequentialRemainder: pending.map(t => t.id), isolation: noIsolation,
|
|
318
|
-
};
|
|
319
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* mermaid-renderer.js — pure renderer: tasks.json DAG -> Mermaid flowchart TD.
|
|
3
|
-
*
|
|
4
|
-
* Backs `morph-spec dag <feature> --mermaid` (S8-T1), so the plan's DAG can be
|
|
5
|
-
* visualized at Gate 2 (IndyDevDan rec. 2: draw the workflow in Mermaid before
|
|
6
|
-
* automating it).
|
|
7
|
-
*
|
|
8
|
-
* STRICT Mermaid rules enforced here (see project CLAUDE.md — violating these
|
|
9
|
-
* breaks rendering in Obsidian):
|
|
10
|
-
* - every node label wrapped in double quotes, no exceptions
|
|
11
|
-
* - labels never start with a numeric-with-dot prefix ("1. ", "2. ...") —
|
|
12
|
-
* Obsidian's markdown-list parser chokes on it even inside quotes
|
|
13
|
-
* - `&` inside labels -> `e`
|
|
14
|
-
* - line breaks inside labels -> `<br/>`, never a literal `\n`
|
|
15
|
-
* - double quotes inside titles are stripped so they can't break out of the
|
|
16
|
-
* surrounding label quotes
|
|
17
|
-
*
|
|
18
|
-
* Pure module: no fs, no Date.now()/Math.random(). Tasks are sorted by
|
|
19
|
-
* numeric id so output is stable regardless of input order.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
const MAX_TITLE_LENGTH = 40;
|
|
23
|
-
|
|
24
|
-
const VALID_STATUS_CLASSES = ['done', 'in_progress', 'blocked', 'skipped', 'pending'];
|
|
25
|
-
|
|
26
|
-
// Web-safe fill/stroke pairs, one per task status.
|
|
27
|
-
const CLASS_DEFS = [
|
|
28
|
-
'classDef done fill:#c6f6d5,stroke:#2f855a,color:#22543d',
|
|
29
|
-
'classDef in_progress fill:#bee3f8,stroke:#2b6cb0,color:#2a4365',
|
|
30
|
-
'classDef blocked fill:#feb2b2,stroke:#c53030,color:#63171b',
|
|
31
|
-
'classDef skipped fill:#e2e8f0,stroke:#718096,color:#2d3748',
|
|
32
|
-
'classDef pending fill:#edf2f7,stroke:#a0aec0,color:#2d3748',
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
function numericId(id) {
|
|
36
|
-
const match = /^T(\d+)$/.exec(id ?? '');
|
|
37
|
-
return match ? parseInt(match[1], 10) : Number.POSITIVE_INFINITY;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Sanitize arbitrary text for embedding inside a double-quoted Mermaid node
|
|
42
|
-
* label, per the project's strict Mermaid rules.
|
|
43
|
-
*
|
|
44
|
-
* - double quotes stripped (they would close the label's own quotes)
|
|
45
|
-
* - `&` -> `e`
|
|
46
|
-
* - truncated to MAX_TITLE_LENGTH chars + `…`
|
|
47
|
-
* - line breaks -> `<br/>`
|
|
48
|
-
*
|
|
49
|
-
* Ordering matters: truncation runs on the raw text BEFORE newlines expand to
|
|
50
|
-
* `<br/>`. Newline->`<br/>` is the only length-changing substitution (1 char ->
|
|
51
|
-
* 5); truncating after it could slice a tag mid-way ("...<b…"), so we truncate
|
|
52
|
-
* first — every emitted `<br/>` is then guaranteed whole. The char-for-char
|
|
53
|
-
* substitutions (`"`->`'`, `&`->`e`) don't move the truncation boundary.
|
|
54
|
-
*/
|
|
55
|
-
export function sanitizeLabel(text) {
|
|
56
|
-
let out = String(text ?? '');
|
|
57
|
-
out = out.replace(/"/g, "'");
|
|
58
|
-
out = out.replace(/&/g, 'e');
|
|
59
|
-
if (out.length > MAX_TITLE_LENGTH) {
|
|
60
|
-
out = `${out.slice(0, MAX_TITLE_LENGTH)}…`;
|
|
61
|
-
}
|
|
62
|
-
out = out.replace(/\r\n|\r|\n/g, '<br/>');
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Same fallback as groupOf() in dispatch-planner.js / groupTasks() in
|
|
68
|
-
* tasks-json-parser.js — tasks without a `group` field are treated as
|
|
69
|
-
* 'backend'.
|
|
70
|
-
*/
|
|
71
|
-
function groupOf(task) {
|
|
72
|
-
return task.group || 'backend';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function statusClass(status) {
|
|
76
|
-
return VALID_STATUS_CLASSES.includes(status) ? status : 'pending';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Render a parsed tasks.json document as a Mermaid `flowchart TD`.
|
|
81
|
-
*
|
|
82
|
-
* @param {{ tasks?: Array<{ id: string, title: string, dependencies?: string[], effort?: string, group?: string, status?: string }> }} tasksData
|
|
83
|
-
* @returns {string}
|
|
84
|
-
*/
|
|
85
|
-
export function renderMermaidFlowchart(tasksData) {
|
|
86
|
-
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
87
|
-
|
|
88
|
-
if (tasks.length === 0) {
|
|
89
|
-
return ['flowchart TD', ' empty["sem tasks"]'].join('\n');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const sorted = [...tasks].sort((a, b) => numericId(a.id) - numericId(b.id));
|
|
93
|
-
const knownIds = new Set(sorted.map(t => t.id));
|
|
94
|
-
|
|
95
|
-
// Group tasks by `group`, preserving the deterministic sorted-by-id order
|
|
96
|
-
// in which each group is first encountered.
|
|
97
|
-
const groups = new Map();
|
|
98
|
-
for (const task of sorted) {
|
|
99
|
-
const g = groupOf(task);
|
|
100
|
-
if (!groups.has(g)) groups.set(g, []);
|
|
101
|
-
groups.get(g).push(task);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const lines = ['flowchart TD'];
|
|
105
|
-
|
|
106
|
-
for (const [group, groupTasks] of groups) {
|
|
107
|
-
lines.push(` subgraph ${group}["${sanitizeLabel(group)}"]`);
|
|
108
|
-
for (const task of groupTasks) {
|
|
109
|
-
const label = `${task.id} · ${sanitizeLabel(task.title)} (${task.effort})`;
|
|
110
|
-
lines.push(` ${task.id}["${label}"]`);
|
|
111
|
-
}
|
|
112
|
-
lines.push(' end');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Edges — one per dependency. Dependencies pointing at a non-existent id
|
|
116
|
-
// are silently skipped (never throw on a dangling reference).
|
|
117
|
-
for (const task of sorted) {
|
|
118
|
-
const deps = Array.isArray(task.dependencies) ? task.dependencies : [];
|
|
119
|
-
for (const dep of deps) {
|
|
120
|
-
if (!knownIds.has(dep)) continue;
|
|
121
|
-
lines.push(` ${dep} --> ${task.id}`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
lines.push(...CLASS_DEFS);
|
|
126
|
-
|
|
127
|
-
for (const task of sorted) {
|
|
128
|
-
lines.push(` class ${task.id} ${statusClass(task.status)}`);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return lines.join('\n');
|
|
132
|
-
}
|