@polymorphism-tech/morph-spec 8.40.0 → 8.42.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 +7 -5
- package/README.md +3 -3
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +9 -8
- package/framework/agents.json +14 -8
- package/framework/docs/CLI.md +5 -3
- package/framework/hooks/README.md +2 -1
- package/framework/hooks/claude-code/core/chain.js +2 -0
- package/framework/hooks/claude-code/core/comment-guard.js +107 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +39 -8
- package/framework/hooks/claude-code/core/worktree-create.js +22 -6
- package/framework/hooks/shared/comment-rules.js +171 -0
- package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
- package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
- package/framework/hooks/shared/git-paths.js +38 -5
- package/framework/hooks/shared/source-files.js +34 -0
- package/framework/mcp/registry.json +26 -65
- 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 +58 -26
- 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 +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +46 -25
- package/framework/skills/core/morph-media/SKILL.md +2 -2
- package/framework/skills/core/morph-plan/SKILL.md +7 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +3 -3
- package/framework/standards/PROVENANCE.json +4 -4
- package/framework/standards/STANDARDS.json +7 -7
- package/framework/standards/architecture/code-comments.md +9 -2
- package/framework/standards/backend/dotnet/testing.md +4 -2
- package/framework/standards/frontend/design-system/ai-image-generation.md +42 -28
- package/framework/standards/frontend/design-system/ai-video-generation.md +24 -9
- package/framework/standards/frontend/nextjs/testing.md +4 -2
- package/framework/standards/integration/mcp/mcp-tools.md +19 -12
- package/package.json +1 -1
- package/src/commands/project/doctor.js +9 -1
- package/src/commands/project/worktree.js +60 -4
- package/src/commands/state/gate-check.js +5 -0
- package/src/commands/tasks/dag.js +27 -4
- package/src/commands/validation/verify.js +22 -38
- package/src/lib/ai/code-audit.js +1 -1
- package/src/lib/ai/registry-audit.js +1 -1
- package/src/lib/risk-detector.js +13 -0
- package/src/lib/tasks/dotnet-test-introspection.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +21 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
- package/src/lib/validators/code-budget/index.js +1 -1
- package/src/lib/validators/reuse/symbol-scanner.js +1 -28
- package/src/lib/worktree/env-allocator.js +9 -3
- package/src/utils/agents-installer.js +51 -1
- package/src/utils/hooks-installer.js +3 -2
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { execSync, execFileSync } from 'child_process';
|
|
38
|
-
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
|
|
38
|
+
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatSync, unlinkSync, rmdirSync, cpSync, copyFileSync, readdirSync } from 'fs';
|
|
39
39
|
import { join, resolve, dirname, basename } from 'path';
|
|
40
40
|
import chalk from 'chalk';
|
|
41
41
|
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
@@ -612,6 +612,43 @@ export function buildTaskBranchName(name) {
|
|
|
612
612
|
return `morph-task/${name}`;
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
+
const ROOT_ENV_FILE_RE = /^\.env(\..+)?$/;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Copy the root's `.env` / `.env.*` into a new tree: a sub-agent that builds
|
|
619
|
+
* or runs the app needs the same secrets, and they are never committed. Only
|
|
620
|
+
* a REAL file (never a link — nothing in the tree may reach the root through
|
|
621
|
+
* one), only when the tree lacks it (never overwrite), and only when git
|
|
622
|
+
* IGNORES it there: an untracked-but-visible copy would dirty the tree, and
|
|
623
|
+
* `finish`/`worktree remove --if-clean` refuse a dirty tree. At teardown the
|
|
624
|
+
* copy is byte-identical to the root's, which `triageIgnoredPaths` classifies
|
|
625
|
+
* as REPLICABLE — deleted with nothing lost.
|
|
626
|
+
*
|
|
627
|
+
* @param {string} worktreePath
|
|
628
|
+
* @param {string} root
|
|
629
|
+
* @returns {string[]} names copied
|
|
630
|
+
*/
|
|
631
|
+
export function copyRootEnvFiles(worktreePath, root) {
|
|
632
|
+
let names;
|
|
633
|
+
try {
|
|
634
|
+
names = readdirSync(root).filter((n) => ROOT_ENV_FILE_RE.test(n));
|
|
635
|
+
} catch {
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
const copied = [];
|
|
639
|
+
for (const name of names) {
|
|
640
|
+
const dst = join(worktreePath, name);
|
|
641
|
+
try {
|
|
642
|
+
if (!lstatSync(join(root, name)).isFile()) continue;
|
|
643
|
+
try { lstatSync(dst); continue; } catch { /* absent — not even a dangling link */ }
|
|
644
|
+
execFileSync('git', ['check-ignore', '-q', '--', name], { cwd: worktreePath, stdio: 'pipe' });
|
|
645
|
+
copyFileSync(join(root, name), dst);
|
|
646
|
+
copied.push(name);
|
|
647
|
+
} catch { /* not ignored, unreadable or unwritable — leave it out */ }
|
|
648
|
+
}
|
|
649
|
+
return copied;
|
|
650
|
+
}
|
|
651
|
+
|
|
615
652
|
/**
|
|
616
653
|
* Non-exiting core of `worktree provision` — the tree `claude -w`,
|
|
617
654
|
* `EnterWorktree` and `Agent isolation:"worktree"` ask for when the name is
|
|
@@ -668,7 +705,11 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
668
705
|
execFileSync('git', ['worktree', 'add', worktreePath, branch], { cwd: root, stdio: 'pipe' });
|
|
669
706
|
|
|
670
707
|
setupInfraLinks(worktreePath, root);
|
|
671
|
-
const
|
|
708
|
+
const envFiles = copyRootEnvFiles(worktreePath, root);
|
|
709
|
+
// A tree born from `morph/<f>` works FOR that feature: MORPH_FEATURE says
|
|
710
|
+
// so, while the port block stays the task's own (`task/<name>`).
|
|
711
|
+
const forFeature = String(options.base || '').match(/^morph\/(.+)$/)?.[1] ?? null;
|
|
712
|
+
const envBlock = await allocateWorktreeEnv(worktreePath, root, `task/${name}`, { morphFeature: forFeature });
|
|
672
713
|
const materialized = await ensureLocalState(worktreePath);
|
|
673
714
|
const graph = seedWorktreeGraph(worktreePath, root);
|
|
674
715
|
|
|
@@ -681,6 +722,8 @@ export async function runWorktreeProvision(name, options = {}) {
|
|
|
681
722
|
branchedFrom,
|
|
682
723
|
kind: 'task-isolation',
|
|
683
724
|
name,
|
|
725
|
+
...(forFeature ? { feature: forFeature } : {}),
|
|
726
|
+
...(envFiles.length ? { envFiles } : {}),
|
|
684
727
|
materialized: materialized.materialized,
|
|
685
728
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
686
729
|
...(graph.seeded ? { graph: 'seeded' } : {}),
|
|
@@ -797,12 +840,25 @@ export function envKeyForWorktree(worktreePath, root) {
|
|
|
797
840
|
* Shared by `setup` (feature key), `provision` (task key) and `link --all`
|
|
798
841
|
* (regeneration), so the manifest has exactly one writer.
|
|
799
842
|
*
|
|
843
|
+
* `morphFeature` (task trees born from `morph/<f>`) only renames the
|
|
844
|
+
* `MORPH_FEATURE` line. Left undefined — the `link --all` regeneration, which
|
|
845
|
+
* derives the key from the branch and cannot see the base — the value the
|
|
846
|
+
* file already carries is kept, so a repair never forgets the feature.
|
|
847
|
+
*
|
|
800
848
|
* @param {string} worktreePath
|
|
801
849
|
* @param {string} root - primary root
|
|
802
850
|
* @param {string} envKey - `<feature>` or `task/<name>`
|
|
851
|
+
* @param {{ morphFeature?: string|null }} [opts]
|
|
803
852
|
* @returns {Promise<{ index: number, base: number, range: [number, number] }|null>}
|
|
804
853
|
*/
|
|
805
|
-
export async function allocateWorktreeEnv(worktreePath, root, envKey) {
|
|
854
|
+
export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFeature } = {}) {
|
|
855
|
+
const envPath = join(worktreePath, '.morph', 'worktree.env');
|
|
856
|
+
if (morphFeature === undefined && envKey.startsWith('task/')) {
|
|
857
|
+
try {
|
|
858
|
+
const kept = readFileSync(envPath, 'utf8').match(/^MORPH_FEATURE=(.+)$/m)?.[1]?.trim();
|
|
859
|
+
morphFeature = kept && kept !== envKey ? kept : null;
|
|
860
|
+
} catch { morphFeature = null; }
|
|
861
|
+
}
|
|
806
862
|
try {
|
|
807
863
|
const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
|
|
808
864
|
await import('../../lib/worktree/env-allocator.js');
|
|
@@ -816,7 +872,7 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey) {
|
|
|
816
872
|
stack = cfg?.project?.stack || '';
|
|
817
873
|
} catch { /* fail-open — stackless env still carries PORT */ }
|
|
818
874
|
mkdirSync(join(worktreePath, '.morph'), { recursive: true });
|
|
819
|
-
writeFileSync(
|
|
875
|
+
writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature }), 'utf8');
|
|
820
876
|
excludeWorktreePaths(root, [join('.morph', 'worktree.env')]);
|
|
821
877
|
return block;
|
|
822
878
|
} catch {
|
|
@@ -9,6 +9,7 @@ import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-de
|
|
|
9
9
|
import { minimatch } from 'minimatch';
|
|
10
10
|
import { detectDefaultBranch, getHeadSha, getDiffStat, changedPathsAgainst } from '../../lib/git/default-branch.js';
|
|
11
11
|
import { readMorphConfig } from '../../lib/tasks/test-runner.js';
|
|
12
|
+
import { oversizedTasks } from '../../lib/tasks/tasks-json-parser.js';
|
|
12
13
|
import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -196,6 +197,10 @@ function collectSignals(featureName, feature, cwd, gateName) {
|
|
|
196
197
|
designSystemDivergence: false,
|
|
197
198
|
};
|
|
198
199
|
|
|
200
|
+
// Fato de disco, fora de OVERRIDABLE_SIGNAL_KEYS: task que não cabe num
|
|
201
|
+
// dispatch se fatia no plano, não depois de um sub-agent bater no teto.
|
|
202
|
+
if (gateName === 'plan') signals.oversizedTasks = oversizedTasks({ tasks });
|
|
203
|
+
|
|
199
204
|
// Só onde o checklist decide algo: no fecho do design e no ship. Fato de
|
|
200
205
|
// disco, fora de OVERRIDABLE_SIGNAL_KEYS como `mutationProof`.
|
|
201
206
|
if (gateName === 'uiux' || gateName === 'review') {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { existsSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
|
|
4
|
+
import { parseTasksJsonLenient, oversizedTasks, MAX_TASK_OUTPUTS } 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
|
import { checkUseCases } from '../../lib/specs/usecase-check.js';
|
|
@@ -16,6 +16,15 @@ export function printLifecycleWarnings(lifecycle) {
|
|
|
16
16
|
for (const w of lifecycle.warnings) console.error(chalk.yellow(` ${w}`));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function planGateApproved(featureDir) {
|
|
20
|
+
try {
|
|
21
|
+
const feature = JSON.parse(readFileSync(join(featureDir, 'feature.json'), 'utf8'));
|
|
22
|
+
return feature?.approvalGates?.plan?.approved === true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
/**
|
|
20
29
|
* `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
|
|
21
30
|
* sequential vs parallel dispatch decision, weighted by effort.
|
|
@@ -72,11 +81,17 @@ export async function dagCommand(featureName, options = {}) {
|
|
|
72
81
|
// revocation rows the finish would fail to apply. Warnings only.
|
|
73
82
|
const lifecycle = checkLifecycle({ cwd: process.cwd(), featureDir, tasksData: data });
|
|
74
83
|
|
|
84
|
+
// Task acima do teto não cabe num dispatch. Antes do Gate 2 é erro (o plano
|
|
85
|
+
// ainda fatia); depois é aviso, para não travar feature em voo.
|
|
86
|
+
const oversized = oversizedTasks(data);
|
|
87
|
+
const sizeBlocks = oversized.length > 0 && !planGateApproved(featureDir);
|
|
88
|
+
const taskSize = { max: MAX_TASK_OUTPUTS, oversized, blocking: sizeBlocks };
|
|
89
|
+
|
|
75
90
|
const plan = planDispatch(data);
|
|
76
91
|
|
|
77
92
|
if (options.json) {
|
|
78
|
-
console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria, lifecycle }, null, 2));
|
|
79
|
-
if (ucCheck.errors.length) process.exit(1);
|
|
93
|
+
console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria, lifecycle, taskSize }, null, 2));
|
|
94
|
+
if (ucCheck.errors.length || sizeBlocks) process.exit(1);
|
|
80
95
|
return;
|
|
81
96
|
}
|
|
82
97
|
|
|
@@ -88,6 +103,14 @@ export async function dagCommand(featureName, options = {}) {
|
|
|
88
103
|
console.error(chalk.gray('\n Corrija a referência no tasks.json ou restaure o use case.\n'));
|
|
89
104
|
process.exit(1);
|
|
90
105
|
}
|
|
106
|
+
if (oversized.length) {
|
|
107
|
+
const tone = sizeBlocks ? chalk.red : chalk.yellow;
|
|
108
|
+
const head = sizeBlocks ? '❌' : '⚠';
|
|
109
|
+
console.error(tone(`\n${head} Tamanho de task — ${plural(oversized.length, 'task', 'tasks')} acima de ${MAX_TASK_OUTPUTS} outputs`));
|
|
110
|
+
for (const t of oversized) console.error(tone(` ${t.id}: ${t.outputs} outputs`));
|
|
111
|
+
console.error(chalk.gray(' Fatie por pós-condição: uma task cabe num dispatch (morph-plan, "Tamanho da task").\n'));
|
|
112
|
+
if (sizeBlocks) process.exit(1);
|
|
113
|
+
}
|
|
91
114
|
if (ucCheck.warnings.length) {
|
|
92
115
|
console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
|
|
93
116
|
for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
3
|
-
import { resolve, join } from 'path';
|
|
2
|
+
import { resolve } from 'path';
|
|
4
3
|
import { getFeature, updateFeature } from '../../core/state/state-manager.js';
|
|
5
4
|
import { runVerify, readTask, VerifyUsageError } from '../../lib/validators/verify-runner.js';
|
|
6
5
|
import { resolvePrimaryRoot } from '../project/worktree.js';
|
|
6
|
+
import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
|
|
7
|
+
import { canonicalPath } from '../../lib/tasks/test-runner.js';
|
|
7
8
|
import { getHeadSha } from '../../lib/git/default-branch.js';
|
|
8
9
|
import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
|
|
9
10
|
import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../lib/telemetry/verify-event.js';
|
|
@@ -93,25 +94,25 @@ function formatVerifyResult(result) {
|
|
|
93
94
|
return lines.join('\n');
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
const samePath = (a, b) => canonicalPath(a).replace(/\\/g, '/') === canonicalPath(b).replace(/\\/g, '/');
|
|
98
|
+
|
|
96
99
|
/**
|
|
97
|
-
*
|
|
100
|
+
* Persist only in the tree that OWNS the feature — the same predicate
|
|
101
|
+
* `list`/`handoff`/the mutator guard read (`resolveFeatureTree`), asked from
|
|
102
|
+
* the PRIMARY ROOT so every tree gets the same answer. Any other tree (a
|
|
103
|
+
* sub-agent's `morph-task/*` tree branched from `morph/{f}`, a scratch
|
|
104
|
+
* checkout) must never write feature.json: its copy is not the one the gates
|
|
105
|
+
* read, and the write would merge-conflict with the owner's.
|
|
98
106
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
107
|
+
* The old rule read only the root's `worktree.path` pointer, which a feature
|
|
108
|
+
* created INSIDE its worktree never has: a `worktrees/homolog-v2` on
|
|
109
|
+
* `morph/chat-demo-simulacao` measured `unregistered-worktree` on 11/11 runs,
|
|
110
|
+
* and `repeatOf` (computed only when persisting) never fired. Git's worktree
|
|
111
|
+
* registry names that tree as the owner.
|
|
104
112
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* it never touches the worktree's own branch-local copy. So this must read
|
|
109
|
-
* the ROOT's feature.json to find the registration, never the cwd-local one
|
|
110
|
-
* (`getFeature(featureName)` resolves relative to `process.cwd()`, which
|
|
111
|
-
* inside a worktree is the branch-local copy and will never carry
|
|
112
|
-
* `.worktree` — reading it here would make this branch permanently dead code
|
|
113
|
-
* for the exact case it exists to handle: verify running from inside a
|
|
114
|
-
* registered worktree).
|
|
113
|
+
* The root is not special: with a live worktree owning the feature, the
|
|
114
|
+
* root's copy is the birth-instant skeleton and a stamp there is drift —
|
|
115
|
+
* `owner-tree-elsewhere`, the verify side of `assertOwnerTree`.
|
|
115
116
|
*
|
|
116
117
|
* @param {{ cwd: string, featureName: string, noPersist: boolean }} params
|
|
117
118
|
* @returns {{ persist: boolean, reason: string|null }}
|
|
@@ -120,26 +121,9 @@ function decidePersistence({ cwd, featureName, noPersist }) {
|
|
|
120
121
|
if (noPersist) return { persist: false, reason: 'no-persist-flag' };
|
|
121
122
|
|
|
122
123
|
const root = resolvePrimaryRoot(cwd);
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// cwd is (or looks like) a worktree — consult the ROOT's feature.json for
|
|
128
|
-
// the registration. Fail-open: an unreadable/missing root feature.json
|
|
129
|
-
// means we can't confirm registration, so treat as unregistered.
|
|
130
|
-
let registeredWt = null;
|
|
131
|
-
try {
|
|
132
|
-
const rootFeatureJsonPath = join(root, '.morph', 'features', featureName, 'feature.json');
|
|
133
|
-
const rootFeature = JSON.parse(readFileSync(rootFeatureJsonPath, 'utf8'));
|
|
134
|
-
registeredWt = rootFeature.worktree?.path
|
|
135
|
-
? String(rootFeature.worktree.path).replace(/\\/g, '/')
|
|
136
|
-
: null;
|
|
137
|
-
} catch {
|
|
138
|
-
registeredWt = null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (registeredWt && normalizedCwd === registeredWt) return { persist: true, reason: null };
|
|
142
|
-
return { persist: false, reason: 'unregistered-worktree' };
|
|
124
|
+
const owner = resolveFeatureTree(featureName, root);
|
|
125
|
+
if (owner.tree && samePath(owner.tree, cwd)) return { persist: true, reason: null };
|
|
126
|
+
return { persist: false, reason: samePath(root, cwd) ? 'owner-tree-elsewhere' : 'unregistered-worktree' };
|
|
143
127
|
}
|
|
144
128
|
|
|
145
129
|
/**
|
package/src/lib/ai/code-audit.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { readFileSync } from 'node:fs';
|
|
26
26
|
import { relative } from 'node:path';
|
|
27
27
|
import { collectProjectFiles } from './ai-project-scan.js';
|
|
28
|
-
import { stripComments } from '
|
|
28
|
+
import { stripComments } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
29
29
|
|
|
30
30
|
/** Símbolos renomeados antes do 1.0 GA, e o nome vivo de cada um. */
|
|
31
31
|
export const RENAMED_SYMBOLS = {
|
|
@@ -22,7 +22,7 @@ import { readFileSync } from 'node:fs';
|
|
|
22
22
|
import { existsSync } from 'node:fs';
|
|
23
23
|
import { isAbsolute, join, basename } from 'node:path';
|
|
24
24
|
import { collectProjectFiles } from './ai-project-scan.js';
|
|
25
|
-
import { jsoncToJson } from '
|
|
25
|
+
import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
26
26
|
|
|
27
27
|
/** Limite de tamanho para um JSON candidato (bytes de texto) — evita ler dumps. */
|
|
28
28
|
const MAX_JSON_CHARS = 512 * 1024;
|
package/src/lib/risk-detector.js
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* section?, uncovered? } derivado do 1-design/mockups.md. No uiux, checklist
|
|
28
28
|
* de tela incompleto pausa; no ship, item decidido sem a conferência do
|
|
29
29
|
* avaliador pausa. Mockup legado é isento nos dois, com a razão listada
|
|
30
|
+
* - oversizedTasks + gate: array — tasks acima de MAX_TASK_OUTPUTS outputs;
|
|
31
|
+
* pausa SÓ no plano (gate 'plan'): a task tem de caber num dispatch
|
|
30
32
|
* - evalScore: number — morph-eval score (pause if < 9)
|
|
31
33
|
* - taskCount: number — total tasks (pause if > 20)
|
|
32
34
|
*/
|
|
@@ -425,6 +427,14 @@ export function shouldPauseGate(config, signals) {
|
|
|
425
427
|
};
|
|
426
428
|
}
|
|
427
429
|
|
|
430
|
+
if (signals.gate === 'plan' && Array.isArray(signals.oversizedTasks) && signals.oversizedTasks.length > 0) {
|
|
431
|
+
const list = signals.oversizedTasks.map((t) => `${t.id} (${t.outputs})`).join(', ');
|
|
432
|
+
return {
|
|
433
|
+
pause: true,
|
|
434
|
+
reason: `task grande demais para um dispatch: ${list} outputs — fatie por pós-condição`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
428
438
|
if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
|
|
429
439
|
return {
|
|
430
440
|
pause: true,
|
|
@@ -498,6 +508,9 @@ export function getRiskSignals(signals) {
|
|
|
498
508
|
out.push(`prompt/modelo tocado (${touched.length}) com eval ${signals.evalsStatus ?? 'não declarado'}`);
|
|
499
509
|
}
|
|
500
510
|
}
|
|
511
|
+
if (signals.gate === 'plan' && Array.isArray(signals.oversizedTasks) && signals.oversizedTasks.length > 0) {
|
|
512
|
+
out.push(`tasks grandes demais: ${signals.oversizedTasks.map((t) => t.id).join(', ')}`);
|
|
513
|
+
}
|
|
501
514
|
if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
|
|
502
515
|
out.push(`eval score ${signals.evalScore}/10`);
|
|
503
516
|
}
|
|
@@ -23,7 +23,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
|
23
23
|
import { dirname, join, resolve } from 'path';
|
|
24
24
|
|
|
25
25
|
import { isInside } from './test-runner.js';
|
|
26
|
-
import { jsoncToJson } from '
|
|
26
|
+
import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
|
|
27
27
|
|
|
28
28
|
const MTP_RUNNER = 'Microsoft.Testing.Platform';
|
|
29
29
|
const FLOOR_PROPERTY = /<TestingPlatformCommandLineArguments\b[^>]*>[^<]*--minimum-expected-tests/i;
|
|
@@ -193,6 +193,27 @@ export function parseTasksJsonSync(filePath) {
|
|
|
193
193
|
return data;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Teto de `outputs` por task: a task inteira tem de caber num dispatch. Medido
|
|
198
|
+
* em campo (chat-demo-simulacao): tasks de 12 a 30 outputs levaram 6 de 10
|
|
199
|
+
* sub-agents ao teto de turnos, e cada retomada custou um contexto frio.
|
|
200
|
+
*/
|
|
201
|
+
export const MAX_TASK_OUTPUTS = 8;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A régua única de "task grande demais", lida pelo `dag` e pelo `gate-check`.
|
|
205
|
+
* Fora de `validateTasksJson` de propósito: `outputs` segue mutável depois do
|
|
206
|
+
* Gate 2, e o teto num write de status travaria feature em voo.
|
|
207
|
+
*
|
|
208
|
+
* @returns {{ id: string, outputs: number }[]}
|
|
209
|
+
*/
|
|
210
|
+
export function oversizedTasks(data) {
|
|
211
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
212
|
+
return tasks
|
|
213
|
+
.filter(t => Array.isArray(t?.outputs) && t.outputs.length > MAX_TASK_OUTPUTS)
|
|
214
|
+
.map(t => ({ id: t.id, outputs: t.outputs.length }));
|
|
215
|
+
}
|
|
216
|
+
|
|
196
217
|
/**
|
|
197
218
|
* @returns {object[]} tasks with status === 'pending'
|
|
198
219
|
*/
|
|
@@ -21,8 +21,9 @@ import { join } from 'path';
|
|
|
21
21
|
|
|
22
22
|
import { addedLinesAgainst, detectDefaultBranch } from '../../git/default-branch.js';
|
|
23
23
|
import { resolveBaseRef } from '../../git/branch-classifier.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { isTestFile } from '../reuse/symbol-scanner.js';
|
|
25
|
+
import { isGenerated, isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
|
|
26
|
+
import { citesProcess, commentLines } from '../../../../framework/hooks/shared/comment-scanner.js';
|
|
26
27
|
|
|
27
28
|
/** Razão teste ÷ produção acima da qual se avisa. */
|
|
28
29
|
export const MAX_TEST_RATIO = 1.5;
|
|
@@ -39,9 +40,6 @@ export const MIN_CODE_LINES = 40;
|
|
|
39
40
|
/** Quantos locais citar no aviso de comentário antes de resumir com "(+N)". */
|
|
40
41
|
const MAX_CITED = 4;
|
|
41
42
|
|
|
42
|
-
/** Código gerado não é escrito por ninguém, então não entra em nenhum dos lados. */
|
|
43
|
-
const GENERATED = /(?:\.Designer|\.g|\.g\.i)\.cs$|ModelSnapshot\.cs$/i;
|
|
44
|
-
|
|
45
43
|
const decimal = (n) => n.toFixed(1).replace('.', ',');
|
|
46
44
|
|
|
47
45
|
/**
|
|
@@ -56,7 +54,7 @@ export function measureCodeBudget(files) {
|
|
|
56
54
|
const measured = { commentLines: 0, processLines: [], testLines: 0, productionLines: 0, commentsByFile: {} };
|
|
57
55
|
|
|
58
56
|
for (const { path, content, added } of files) {
|
|
59
|
-
if (!isProjectSource(path) ||
|
|
57
|
+
if (!isProjectSource(path) || isGenerated(path)) continue;
|
|
60
58
|
const addedSet = added ? new Set(added) : null;
|
|
61
59
|
const isAdded = (n) => addedSet === null || addedSet.has(n);
|
|
62
60
|
|
|
@@ -18,24 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { isTestPath, languageOf } from '../../graph/sanitize.js';
|
|
21
|
-
|
|
22
|
-
/** Extensões que este scanner sabe ler. Fora daqui, o arquivo é ignorado. */
|
|
23
|
-
const SCANNABLE = /\.(cs|ts|tsx|js|jsx|mjs|cjs)$/i;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Diretórios que contêm arquivos com extensão de código mas que não são o
|
|
27
|
-
* código-fonte do projeto. `.morph/` é o caso que apareceu de verdade: o
|
|
28
|
-
* `archive/{feature}/1-design/contracts.cs` de uma feature arquivada entra no
|
|
29
|
-
* grafo do ProspectPRO como um tipo `RoulettePriority` a mais, e acusaria a
|
|
30
|
-
* entidade real de duplicar o próprio artefato de planejamento.
|
|
31
|
-
*/
|
|
32
|
-
const NOT_SOURCE = /(^|\/)(?:\.morph|node_modules|dist|build|out|\.next|bin|obj|coverage)\//;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Declaration file do TypeScript. `auth-debug.d.ts` + `auth-debug.ts`
|
|
36
|
-
* declarando o mesmo tipo é o mecanismo da linguagem, não duplicação.
|
|
37
|
-
*/
|
|
38
|
-
const DECLARATION_FILE = /\.d\.ts$/i;
|
|
21
|
+
import { isProjectSource } from '../../../../framework/hooks/shared/source-files.js';
|
|
39
22
|
|
|
40
23
|
/**
|
|
41
24
|
* Nomes que a convenção usa como tipo LOCAL de arquivo, não como conceito do
|
|
@@ -63,16 +46,6 @@ const WEB_DECL =
|
|
|
63
46
|
/** Linha que é claramente comentário — não vale a pena parsear. */
|
|
64
47
|
const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|<!--)/;
|
|
65
48
|
|
|
66
|
-
/**
|
|
67
|
-
* @param {string} sourceFile
|
|
68
|
-
* @returns {boolean} código do projeto numa extensão lida, fora de `.d.ts` e de
|
|
69
|
-
* pasta que não é fonte — teste incluído
|
|
70
|
-
*/
|
|
71
|
-
export function isProjectSource(sourceFile) {
|
|
72
|
-
const f = String(sourceFile || '').replace(/\\/g, '/');
|
|
73
|
-
return SCANNABLE.test(f) && !DECLARATION_FILE.test(f) && !NOT_SOURCE.test(f);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
49
|
/**
|
|
77
50
|
* @param {string} sourceFile
|
|
78
51
|
* @returns {boolean} arquivo de teste, pelo mesmo predicado do grafo
|
|
@@ -191,16 +191,22 @@ export function portLayout(block, { stack = '', feature = '' } = {}) {
|
|
|
191
191
|
* feature, and exactly the `portLayout()` pairs — nothing else, so the file
|
|
192
192
|
* cannot drift from what compose receives.
|
|
193
193
|
*
|
|
194
|
-
*
|
|
194
|
+
* `morphFeature` is for a task tree born from `morph/<f>`: `MORPH_FEATURE`
|
|
195
|
+
* names the feature it works for, while the block and `COMPOSE_PROJECT_NAME`
|
|
196
|
+
* stay keyed by `feature` (`task/<n>`) — keying them by `<f>` would hand the
|
|
197
|
+
* sub-agent the feature's own ports and compose project.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} feature - the env-registry key: `<feature>` or `task/<name>`
|
|
195
200
|
* @param {{ base: number, range: [number, number] }} block
|
|
196
201
|
* @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
|
|
202
|
+
* @param {{ morphFeature?: string|null }} [opts]
|
|
197
203
|
* @returns {string}
|
|
198
204
|
*/
|
|
199
|
-
export function buildEnvFileContent(feature, block, stack = '') {
|
|
205
|
+
export function buildEnvFileContent(feature, block, stack = '', { morphFeature = null } = {}) {
|
|
200
206
|
const lines = [
|
|
201
207
|
`# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
|
|
202
208
|
'# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
|
|
203
|
-
`MORPH_FEATURE=${feature}`,
|
|
209
|
+
`MORPH_FEATURE=${morphFeature || feature}`,
|
|
204
210
|
...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
|
|
205
211
|
];
|
|
206
212
|
return `${lines.join('\n')}\n`;
|
|
@@ -102,6 +102,35 @@ export function resolveMaxTurns(agent) {
|
|
|
102
102
|
return agent.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** Values Claude Code accepts in a subagent's `effort` frontmatter. */
|
|
106
|
+
export const VALID_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The ONE rule for a valid `effort` — same four readers as `maxTurnsProblem`.
|
|
110
|
+
* Absent means the frontmatter carries no `effort:` line and the sub-agent
|
|
111
|
+
* inherits the session's effort, byte-for-byte what the generator emitted
|
|
112
|
+
* before the field existed.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} value - the raw agents.json value
|
|
115
|
+
* @returns {string|null} what was expected, or null when valid
|
|
116
|
+
*/
|
|
117
|
+
export function effortProblem(value) {
|
|
118
|
+
if (value === undefined) return null;
|
|
119
|
+
return VALID_EFFORTS.includes(value) ? null : `esperado um de ${VALID_EFFORTS.join('|')}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} id - persona id (agents.json key)
|
|
124
|
+
* @param {unknown} value - the raw agents.json value
|
|
125
|
+
* @returns {string|null} null when valid
|
|
126
|
+
*/
|
|
127
|
+
export function effortError(id, value) {
|
|
128
|
+
const problem = effortProblem(value);
|
|
129
|
+
if (!problem) return null;
|
|
130
|
+
return `agents.json: persona "${id}" tem effort inválido (${JSON.stringify(value)}) — ${problem}. ` +
|
|
131
|
+
'Remova o campo para herdar o effort da sessão.';
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
/** A surface id is the key the mockup checklist names (`Superfície: admin-app`). */
|
|
106
135
|
const SURFACE_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
107
136
|
|
|
@@ -192,7 +221,9 @@ export function personaStandardIds(persona) {
|
|
|
192
221
|
|
|
193
222
|
/** The first validity error of a persona entry, or null — what drift reports as `invalid`. */
|
|
194
223
|
function personaError(agent) {
|
|
195
|
-
return maxTurnsError(agent.id, agent.maxTurns)
|
|
224
|
+
return maxTurnsError(agent.id, agent.maxTurns)
|
|
225
|
+
?? surfacesError(agent.id, agent.surfaces, agent.standards)
|
|
226
|
+
?? effortError(agent.id, agent.effort);
|
|
196
227
|
}
|
|
197
228
|
|
|
198
229
|
/**
|
|
@@ -376,6 +407,8 @@ export function renderAgentMd(agent, knownMcpIds = null) {
|
|
|
376
407
|
const maxTurns = resolveMaxTurns(agent);
|
|
377
408
|
const surfaces = surfacesError(agent.id, agent.surfaces, agent.standards);
|
|
378
409
|
if (surfaces) throw new Error(surfaces);
|
|
410
|
+
const effort = effortError(agent.id, agent.effort);
|
|
411
|
+
if (effort) throw new Error(effort);
|
|
379
412
|
const frontmatter = buildFrontmatter(agent, description, knownMcpIds, maxTurns);
|
|
380
413
|
const body = buildBody(agent, maxTurns);
|
|
381
414
|
return `---\n${frontmatter}---\n\n${MANAGED_HEADER}\n\n${body}\n`;
|
|
@@ -409,6 +442,7 @@ function buildFrontmatter(agent, description, knownMcpIds, maxTurns) {
|
|
|
409
442
|
// exactly what the hardcoded `maxTurns: 20` rendered.
|
|
410
443
|
`maxTurns: ${maxTurns}`,
|
|
411
444
|
];
|
|
445
|
+
if (agent.effort) lines.push(`effort: ${agent.effort}`);
|
|
412
446
|
if (agent.color) lines.push(`color: ${agent.color}`);
|
|
413
447
|
lines.push(`memory: project`, '');
|
|
414
448
|
return lines.join('\n');
|
|
@@ -475,6 +509,22 @@ function buildBody(agent, maxTurns) {
|
|
|
475
509
|
'`PARCIAL` on line 1, done, remaining, files, no score (final report only) — then continue or stop, your call; the final report replaces it.',
|
|
476
510
|
);
|
|
477
511
|
|
|
512
|
+
// The dispatch template is paraphrased by the orchestrator in the field; this
|
|
513
|
+
// body is the only text every implementer dispatch is guaranteed to carry.
|
|
514
|
+
// Gated on `effort` so a persona without it renders byte-for-byte as before.
|
|
515
|
+
if (agent.effort && isImplementer(agent)) {
|
|
516
|
+
lines.push(
|
|
517
|
+
'',
|
|
518
|
+
'## Working fast',
|
|
519
|
+
'',
|
|
520
|
+
'Independent tool calls go in the SAME response: read every listed file at once, fire the Greps together. ' +
|
|
521
|
+
'Use Read/Grep/Glob, never grep/sed/cat/find via Bash. Check a library API in the `context7` MCP before guessing ' +
|
|
522
|
+
'(load a deferred `mcp__` tool with ToolSearch). Write the tests the budget asks for, then run only your own ' +
|
|
523
|
+
'filter ONCE before the report — no red→green cycles, no `morph-spec verify` (the orchestrator proves it). ' +
|
|
524
|
+
'Never alter, revert or move production code to watch a test fail: mutation proof belongs to the Gate 3 evaluator.',
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
478
528
|
return lines.join('\n');
|
|
479
529
|
}
|
|
480
530
|
|
|
@@ -216,14 +216,15 @@ const MORPH_HOOKS = [
|
|
|
216
216
|
// order as before. post-edit-typecheck declared no timeout of its own (it
|
|
217
217
|
// can legitimately spend up to its internal TSC_TIMEOUT_MS=30000 running
|
|
218
218
|
// `npx tsc --noEmit`), so the chain declares none rather than under-
|
|
219
|
-
// provisioning it with just the sum of the other two links' 3000ms each.
|
|
219
|
+
// provisioning it with just the sum of the other two links' 3000ms each.
|
|
220
|
+
// comment-guard is LAST: it blocks, and a block ends the chain. ===
|
|
220
221
|
{
|
|
221
222
|
event: 'PostToolUse',
|
|
222
223
|
matcher: 'Write|Edit',
|
|
223
224
|
hooks: [{
|
|
224
225
|
type: 'command',
|
|
225
226
|
command: 'node framework/hooks/claude-code/core/chain.js',
|
|
226
|
-
args: ['post-edit-typecheck', 'tasks-json-guard', 'trace-autogen']
|
|
227
|
+
args: ['post-edit-typecheck', 'tasks-json-guard', 'trace-autogen', 'comment-guard']
|
|
227
228
|
}]
|
|
228
229
|
},
|
|
229
230
|
|