@polymorphism-tech/morph-spec 10.1.1 → 10.5.2
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 +3 -7
- package/bin/morph-spec.js +10 -41
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +1 -2
- package/framework/commands/morph-archive.md +1 -1
- package/framework/commands/morph-preflight.md +3 -4
- package/framework/docs/CLI.md +45 -22
- package/framework/evals/review-checklist.md +5 -5
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/core/heavy-gate.js +21 -2
- package/framework/hooks/claude-code/core/validate-completion.js +0 -35
- package/framework/hooks/shared/e2e-check.js +44 -7
- package/framework/hooks/shared/heavy-command.js +12 -1
- package/framework/hooks/shared/heavy-config.js +12 -0
- package/framework/hooks/shared/host-services.js +9 -6
- package/framework/hooks/shared/next-step.js +12 -8
- package/framework/phases.json +4 -4
- package/framework/rules/plan-grammar.md +4 -1
- package/framework/schemas/config.schema.json +59 -1
- package/framework/schemas/feature.schema.json +15 -0
- 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 +8 -5
- package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
- package/framework/skills/core/morph-apply/references/machine.md +3 -2
- package/framework/skills/core/morph-apply/references/review.md +9 -7
- package/framework/skills/core/morph-plan/SKILL.md +7 -6
- package/framework/skills/core/morph-plan/references/critique.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +2 -2
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/docs/plan.md +2 -1
- package/framework/templates/examples/design-system-examples.md +0 -3
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/project/doctor.js +0 -1
- package/src/commands/project/heavy.js +13 -0
- package/src/commands/state/approve.js +9 -109
- package/src/commands/state/report-check.js +44 -2
- package/src/commands/validation/verify.js +57 -7
- package/src/core/state/state-manager.js +0 -24
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/heavy/cleanup.js +8 -2
- package/src/lib/heavy/gate.js +24 -1
- package/src/lib/heavy/reclaim.js +107 -0
- package/src/lib/heavy/watchdog.js +46 -0
- package/src/lib/next-step.js +1 -0
- package/src/lib/plan/checks.js +1 -1
- package/src/lib/plan/critique.js +23 -3
- package/src/lib/procs/prune.js +23 -1
- package/src/lib/review-confirmation.js +67 -0
- package/src/lib/review-reports.js +1 -0
- package/src/lib/risk-detector.js +2 -2
- package/src/lib/tasks/test-nodes.js +10 -0
- package/src/lib/tasks/test-runner.js +37 -43
- package/src/lib/telemetry/telemetry-reader.js +1 -2
- package/src/lib/telemetry/verify-event.js +7 -0
- package/src/lib/validators/verify-cache.js +63 -0
- package/src/lib/validators/verify-runner.js +131 -26
- package/src/lib/verify/affected.js +141 -0
- package/src/lib/verify/detached.js +154 -0
- package/src/utils/agents-installer.js +2 -2
- package/src/utils/hooks-installer.js +0 -20
- package/bin/validate.js +0 -212
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
- package/src/commands/observability/telemetry.js +0 -60
- package/src/commands/validation/validate-feature.js +0 -337
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
} from '../tasks/eval-node.js';
|
|
23
23
|
import { runValidation } from './validation-runner.js';
|
|
24
24
|
import { createHeavyGate } from '../heavy/gate.js';
|
|
25
|
+
import { selectAffected } from '../verify/affected.js';
|
|
26
|
+
import {
|
|
27
|
+
cacheEntryFrom, planReuse, readVerifyCache, reusedEntry, writeVerifyCache,
|
|
28
|
+
} from './verify-cache.js';
|
|
25
29
|
|
|
26
30
|
const DEFAULT_BUILD_TIMEOUT = 1_800_000;
|
|
27
31
|
|
|
@@ -45,12 +49,34 @@ export class VerifyUsageError extends Error {
|
|
|
45
49
|
export async function runVerify(projectPath, featureName, options = {}) {
|
|
46
50
|
const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
|
|
47
51
|
try {
|
|
48
|
-
return await runVerifyGated(projectPath, featureName, options, gate);
|
|
52
|
+
return await runVerifyGated(projectPath, featureName, applyAffectedScope(projectPath, options), gate);
|
|
49
53
|
} finally {
|
|
50
54
|
gate.release();
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
export function withCpuShare(node, share) {
|
|
59
|
+
if (!share || node.runner !== 'vitest' || /--max-?workers/i.test(node.command ?? '')) return node;
|
|
60
|
+
return { ...node, command: `${node.command} --maxWorkers=${share}` };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function applyAffectedScope(projectPath, options) {
|
|
64
|
+
if (!options.affected) return options;
|
|
65
|
+
const conflict = [['filter', '--filter'], ['recordBaseline', '--record-baseline'], ['refreshCache', '--refresh-cache'], ['linux', '--linux']]
|
|
66
|
+
.find(([key]) => options[key]);
|
|
67
|
+
if (conflict) throw new VerifyUsageError(`--affected escolhe os testes sozinho; não combina com ${conflict[1]}`);
|
|
68
|
+
|
|
69
|
+
const selection = selectAffected({ projectPath, nodes: resolveTestNodes(projectPath).nodes, deps: options._affectedDeps });
|
|
70
|
+
if (selection.scope === 'full') return { ...options, escalatedBecause: selection.reason };
|
|
71
|
+
return {
|
|
72
|
+
...options,
|
|
73
|
+
selection,
|
|
74
|
+
skipE2e: true,
|
|
75
|
+
skipBuild: options.skipBuild || selection.docsOnly,
|
|
76
|
+
skipTests: options.skipTests || selection.nodes.length === 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
55
81
|
const {
|
|
56
82
|
timeout = DEFAULT_BUILD_TIMEOUT,
|
|
@@ -62,6 +88,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
62
88
|
refreshCache = false,
|
|
63
89
|
linux = false,
|
|
64
90
|
linuxImage = null,
|
|
91
|
+
fingerprint = null,
|
|
92
|
+
fresh = false,
|
|
65
93
|
_runLinuxSuite = runLinuxSuite,
|
|
66
94
|
_exec = undefined,
|
|
67
95
|
_runBuildCheck = runBuildCheck,
|
|
@@ -76,7 +104,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
76
104
|
|
|
77
105
|
// Usage errors are judged before the build: paying for a build to discover a typo
|
|
78
106
|
// is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
|
|
79
|
-
const
|
|
107
|
+
const resolvedPlan = resolveTestNodes(projectPath);
|
|
108
|
+
const testPlan = options.selection ? { ...resolvedPlan, nodes: options.selection.nodes } : resolvedPlan;
|
|
80
109
|
const filter = parseTestFilter(options.filter);
|
|
81
110
|
if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
|
|
82
111
|
const declared = testPlan.nodes.map((node) => node.id).join(', ') || '(none)';
|
|
@@ -116,14 +145,29 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
116
145
|
|
|
117
146
|
const nodes = {};
|
|
118
147
|
const feedback = [];
|
|
148
|
+
if (options.escalatedBecause) feedback.push(`[verify] rodada completa: ${options.escalatedBecause}`);
|
|
149
|
+
if (options.selection) {
|
|
150
|
+
const kept = options.selection.nodes.map((n) => n.id).join(', ') || 'nenhuma';
|
|
151
|
+
feedback.push(`[verify] rodada afetada, suítes: ${kept}; a completa roda uma vez antes da aprovação`);
|
|
152
|
+
}
|
|
119
153
|
|
|
120
|
-
|
|
154
|
+
// Only a complete run feeds or reads the cache: a partial one proves nothing about the nodes it left out.
|
|
155
|
+
const cacheDir = fingerprint && !filter && !skipTests && !skipBuild && !skipE2e && !recordBaseline
|
|
156
|
+
&& !refreshCache && !linux && !freshE2e
|
|
157
|
+
? resolveGitCommonDir(projectPath)
|
|
158
|
+
: null;
|
|
159
|
+
const reuse = planReuse(cacheDir && !fresh ? readVerifyCache(cacheDir, featureName) : null, fingerprint);
|
|
160
|
+
|
|
161
|
+
nodes.build = reuse.build
|
|
162
|
+
? reusedEntry(reuse.build)
|
|
163
|
+
: await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
|
|
121
164
|
appendBuildFeedback(feedback, nodes.build);
|
|
122
165
|
|
|
123
166
|
// Only a TYPED --timeout reaches the tests: the build default would outrank
|
|
124
167
|
// config.project.testTimeoutMs and the per-project scaling.
|
|
125
168
|
const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
|
|
126
169
|
const stackNodes = [];
|
|
170
|
+
const buildFacts = { solutionBuilt: builtDotnet(nodes.build), builtTargets: new Set() };
|
|
127
171
|
nodes.tests = await runTestsNode({
|
|
128
172
|
projectPath,
|
|
129
173
|
testPlan,
|
|
@@ -136,6 +180,8 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
136
180
|
linuxImage,
|
|
137
181
|
gate,
|
|
138
182
|
stackNodes,
|
|
183
|
+
reuse,
|
|
184
|
+
buildFacts,
|
|
139
185
|
_runTestSuite,
|
|
140
186
|
_runLinuxSuite,
|
|
141
187
|
_exec,
|
|
@@ -145,25 +191,28 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
145
191
|
nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
|
|
146
192
|
appendValidatorsFeedback(feedback, nodes.validators);
|
|
147
193
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
194
|
+
// A suite with `e2e: true` still to run needs the stack's env, which only a real run returns.
|
|
195
|
+
const e2e = reuse.e2e && stackNodes.length === 0 && nodes.tests.status !== 'fail'
|
|
196
|
+
? { node: reusedEntry(reuse.e2e), stackEnv: null }
|
|
197
|
+
: await runE2eNode({
|
|
198
|
+
projectPath,
|
|
199
|
+
featureName,
|
|
200
|
+
skipE2e,
|
|
201
|
+
freshE2e,
|
|
202
|
+
buildNode: nodes.build,
|
|
203
|
+
testsNode: nodes.tests,
|
|
204
|
+
hasStackNodes: stackNodes.length > 0,
|
|
205
|
+
gate,
|
|
206
|
+
_runE2eCheck,
|
|
207
|
+
_e2eApplicability,
|
|
208
|
+
});
|
|
160
209
|
nodes.e2e = e2e.node;
|
|
161
210
|
appendE2eFeedback(feedback, nodes.e2e);
|
|
162
211
|
|
|
163
212
|
if (stackNodes.length > 0) {
|
|
164
213
|
nodes.tests = await runStackTestNodes({
|
|
165
214
|
projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
|
|
166
|
-
timeout: explicitTimeout, gate, _runTestSuite,
|
|
215
|
+
timeout: explicitTimeout, gate, buildFacts, _runTestSuite,
|
|
167
216
|
});
|
|
168
217
|
appendTestsFeedback(feedback, nodes.tests);
|
|
169
218
|
}
|
|
@@ -180,17 +229,43 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
|
|
|
180
229
|
|
|
181
230
|
const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
|
|
182
231
|
|
|
232
|
+
if (cacheDir) writeVerifyCache(cacheDir, featureName, cacheEntryFrom(fingerprint, nodes));
|
|
233
|
+
|
|
234
|
+
const reused = reusedNames(nodes);
|
|
235
|
+
if (reused.length > 0) feedback.push(`[verify] aproveitado da rodada anterior na mesma árvore: ${reused.join(', ')}`);
|
|
236
|
+
const flaky = flakyNames(nodes, reuse.retried);
|
|
237
|
+
if (flaky.length > 0) feedback.push(`[verify] vermelho sem mudança de código, verde agora (instável): ${flaky.join(', ')}`);
|
|
238
|
+
|
|
183
239
|
return {
|
|
184
240
|
version: 1,
|
|
185
241
|
feature: featureName,
|
|
186
242
|
stack,
|
|
187
243
|
status,
|
|
244
|
+
scope: options.selection ? 'affected' : 'full',
|
|
188
245
|
nodes,
|
|
189
246
|
feedback,
|
|
247
|
+
reused,
|
|
248
|
+
flaky,
|
|
190
249
|
...(baseline ? { baseline } : {}),
|
|
191
250
|
};
|
|
192
251
|
}
|
|
193
252
|
|
|
253
|
+
function reusedNames(nodes) {
|
|
254
|
+
return [
|
|
255
|
+
...(nodes.build.reused ? ['build'] : []),
|
|
256
|
+
...(nodes.tests.runs || []).filter((run) => run.reused).map((run) => `tests/${run.id}`),
|
|
257
|
+
...(nodes.e2e.reused ? ['e2e'] : []),
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function flakyNames(nodes, retried) {
|
|
262
|
+
return [
|
|
263
|
+
...(nodes.tests.runs || []).filter((run) => run.status === 'pass' && retried.has(run.id)).map((run) => `tests/${run.id}`),
|
|
264
|
+
...(nodes.e2e.status === 'pass' && retried.has('e2e') ? ['e2e'] : []),
|
|
265
|
+
...(nodes.e2e.flakySpecs || []).map((spec) => `e2e: ${spec}`),
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
|
|
194
269
|
/**
|
|
195
270
|
* A root `dotnet build` with no solution at the root fails MSB1003: build the
|
|
196
271
|
* repository's single solution instead, or say which candidates and which key decide.
|
|
@@ -259,7 +334,7 @@ function appendBuildFeedback(feedback, buildNode) {
|
|
|
259
334
|
// with `status` because verify.js and the persisted stamp read `nodes.tests.status`.
|
|
260
335
|
async function runTestsNode({
|
|
261
336
|
projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
|
|
262
|
-
linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
|
|
337
|
+
linux = false, linuxImage = null, gate, stackNodes = [], reuse, buildFacts, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
|
|
263
338
|
}) {
|
|
264
339
|
const { nodes, source } = testPlan;
|
|
265
340
|
const command = nodes.map((n) => n.command).filter(Boolean).join(' && ') || null;
|
|
@@ -282,24 +357,53 @@ async function runTestsNode({
|
|
|
282
357
|
return rollUpTestRuns(runs, { command, source, environment });
|
|
283
358
|
}
|
|
284
359
|
|
|
360
|
+
const declaredOrder = new Map(nodes.map((node, index) => [node.id, index]));
|
|
361
|
+
const redFirst = [...nodes].sort((a, b) => reuse.redFirst.has(b.id) - reuse.redFirst.has(a.id));
|
|
362
|
+
|
|
363
|
+
// A round chasing a red suite stays serial: it must be able to stop at the first one.
|
|
364
|
+
const together = reuse.redFirst.size > 0
|
|
365
|
+
? []
|
|
366
|
+
: nodes.filter((node) => node.parallel && !node.e2e && !reuse.tests.has(node.id));
|
|
367
|
+
const finished = new Map(await Promise.all(together.map(async (node) => [node.id, await runOneTestNode({
|
|
368
|
+
projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
|
|
369
|
+
})])));
|
|
370
|
+
|
|
285
371
|
const runs = [];
|
|
286
|
-
|
|
372
|
+
let stillRed = null;
|
|
373
|
+
for (const node of redFirst) {
|
|
374
|
+
if (reuse.tests.has(node.id)) {
|
|
375
|
+
runs.push(reusedEntry(reuse.tests.get(node.id)));
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (finished.has(node.id)) {
|
|
379
|
+
runs.push(finished.get(node.id));
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (stillRed) {
|
|
383
|
+
runs.push({ ...emptyRunEntry(node), reason: `adiado: ${stillRed} segue vermelho` });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
287
386
|
if (node.e2e && !preDecideRun({ node, filter, projectPath, refreshCache })) {
|
|
288
387
|
stackNodes.push(node);
|
|
289
388
|
runs.push(emptyRunEntry(node));
|
|
290
389
|
continue;
|
|
291
390
|
}
|
|
292
|
-
|
|
293
|
-
projectPath, node, filter, timeout, refreshCache, gate, _runTestSuite,
|
|
294
|
-
})
|
|
391
|
+
const run = await runOneTestNode({
|
|
392
|
+
projectPath, node, filter, timeout, refreshCache, gate, buildFacts, _runTestSuite,
|
|
393
|
+
});
|
|
394
|
+
runs.push(run);
|
|
395
|
+
if (run.status === 'fail' && reuse.redFirst.has(node.id)) stillRed = node.id;
|
|
295
396
|
}
|
|
296
397
|
|
|
398
|
+
runs.sort((a, b) => declaredOrder.get(a.id) - declaredOrder.get(b.id));
|
|
297
399
|
return rollUpTestRuns(runs, { command, source });
|
|
298
400
|
}
|
|
299
401
|
|
|
300
402
|
// No stack, no run: the skip carries the e2e verdict as reason AND warning, so the
|
|
301
403
|
// suite never disappears behind a green rollup.
|
|
302
|
-
|
|
404
|
+
const builtDotnet = (buildNode) => buildNode.status === 'pass' && /\bdotnet build\b/.test(buildNode.command ?? '');
|
|
405
|
+
|
|
406
|
+
async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, buildFacts, _runTestSuite }) {
|
|
303
407
|
const byId = new Map(stackNodes.map((node) => [node.id, node]));
|
|
304
408
|
const runs = [];
|
|
305
409
|
for (const run of testsNode.runs) {
|
|
@@ -310,7 +414,7 @@ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode,
|
|
|
310
414
|
runs.push({ ...run, reason, warnings: [reason] });
|
|
311
415
|
continue;
|
|
312
416
|
}
|
|
313
|
-
runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, _runTestSuite }));
|
|
417
|
+
runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, buildFacts, _runTestSuite }));
|
|
314
418
|
}
|
|
315
419
|
return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
|
|
316
420
|
}
|
|
@@ -364,7 +468,7 @@ async function runNodesInLinux({
|
|
|
364
468
|
}
|
|
365
469
|
|
|
366
470
|
async function runOneTestNode({
|
|
367
|
-
projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
|
|
471
|
+
projectPath, node, filter, timeout, refreshCache = false, gate, env = null, buildFacts = {}, _runTestSuite,
|
|
368
472
|
}) {
|
|
369
473
|
const decided = preDecideRun({ node, filter, projectPath, refreshCache });
|
|
370
474
|
if (decided) return decided;
|
|
@@ -380,7 +484,7 @@ async function runOneTestNode({
|
|
|
380
484
|
const start = Date.now();
|
|
381
485
|
const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
|
|
382
486
|
const result = await _runTestSuite(projectPath, {
|
|
383
|
-
timeout, node, ...(childEnv ? { env: childEnv } : {}),
|
|
487
|
+
timeout, node: withCpuShare(node, gate.cpuShare), ...buildFacts, background: node.parallel === true, ...(childEnv ? { env: childEnv } : {}),
|
|
384
488
|
});
|
|
385
489
|
const durationMs = Date.now() - start;
|
|
386
490
|
const logPath = writeFailedRunLog(projectPath, node, result);
|
|
@@ -522,7 +626,7 @@ function judgeRun({ node, result, durationMs, logPath = null }) {
|
|
|
522
626
|
if (verdict.verdict === 'drop') warnings.push(verdict.reason);
|
|
523
627
|
|
|
524
628
|
// Without a baseline, zero is the floor: a green run over no test is a suite that stopped running.
|
|
525
|
-
if (result.passed && count === 0 && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
629
|
+
if (result.passed && count === 0 && !node.allowEmpty && node.runner !== EVAL_RUNNER && !Number.isInteger(node.expectedCount)) {
|
|
526
630
|
return { ...entry, status: 'fail', durationMs, count, warnings, reason: 'suíte verde sem nenhum teste executado', output: '' };
|
|
527
631
|
}
|
|
528
632
|
|
|
@@ -763,6 +867,7 @@ async function runE2eNode({
|
|
|
763
867
|
output: tailLines(result.output || '', OUTPUT_TAIL_LINES),
|
|
764
868
|
count: Number.isInteger(result.count) ? result.count : null,
|
|
765
869
|
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
|
870
|
+
flakySpecs: result.flakySpecs ?? [],
|
|
766
871
|
},
|
|
767
872
|
stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
|
|
768
873
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, extname, join } from 'node:path';
|
|
4
|
+
import { changedPathsAgainst, detectDefaultBranch } from '../git/default-branch.js';
|
|
5
|
+
import { resolveBaseRef } from '../git/branch-classifier.js';
|
|
6
|
+
import { contractsTouched, migrationsTouched } from '../risk-detector.js';
|
|
7
|
+
import { isTestFile } from '../validators/reuse/symbol-scanner.js';
|
|
8
|
+
|
|
9
|
+
const MAX_TEST_CLASSES = 40;
|
|
10
|
+
const INLINE_FILTER = /(^|\s)(--filter|-Filter)\s+(?:"([^"]*)"|(\S+))/i;
|
|
11
|
+
const SCRIPT_IN_COMMAND = /([\w./\\-]+\.(?:ps1|sh))\b/i;
|
|
12
|
+
const PS_FILTER_PARAM = /\[string\]\s*\$Filter\b(?:\s*=\s*['"]([^'"]*)['"])?/i;
|
|
13
|
+
const PS_FILTER_USED = /dotnet\s+test[^\n]*\$Filter\b/i;
|
|
14
|
+
const FORWARDS_ARGS = /dotnet\s+test[^\n]*("?\$@"?|\$\{@\}|@args|\$args)/i;
|
|
15
|
+
const EVAL_NODE =/(^|[-_])evals?($|[-_])/i;
|
|
16
|
+
const DOC_PATH =/\.(md|mdx|txt|png|jpe?g|gif|svg|webp)$/i;
|
|
17
|
+
const DOTNET_SOURCE = /\.(cs|razor|cshtml)$/i;
|
|
18
|
+
const SCRIPT_SOURCE = /\.(tsx?|jsx?|mjs|cjs|css|json)$/i;
|
|
19
|
+
// Everything compiles or resolves through these, so the diff says nothing about what they reach.
|
|
20
|
+
const WIDE_PATH = /(^|\/)(package(-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|global\.json|Directory\.(Build|Packages)\.(props|targets)|tsconfig[^/]*\.json|vitest\.config\.[^/]+|[^/]+\.(csproj|sln|slnx))$/i;
|
|
21
|
+
|
|
22
|
+
const defaultGit = (args, cwd) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
|
|
23
|
+
|
|
24
|
+
function mergeBase(baseRef, cwd, git) {
|
|
25
|
+
try {
|
|
26
|
+
return git(['merge-base', 'HEAD', baseRef], cwd).trim() || null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function testFilesNaming(names, cwd, git) {
|
|
33
|
+
if (names.length === 0) return [];
|
|
34
|
+
try {
|
|
35
|
+
const output = git(['grep', '-lw', ...names.flatMap((n) => ['-e', n]), '--', '*.cs'], cwd);
|
|
36
|
+
return output.split(/\r?\n/).filter((path) => path && isTestFile(path));
|
|
37
|
+
} catch {
|
|
38
|
+
// git grep exits 1 when nothing matches.
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const classOf = (path) => basename(path, extname(path));
|
|
44
|
+
|
|
45
|
+
const readText = (path) => {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(path, 'utf8');
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Reads the wrapper the command calls, so a project gets the narrowing without declaring anything. */
|
|
54
|
+
function scriptFilter(node, cwd, read) {
|
|
55
|
+
const script = SCRIPT_IN_COMMAND.exec(node.command ?? '')?.[1];
|
|
56
|
+
const text = script ? read(join(cwd, node.cwd ?? '.', script)) : null;
|
|
57
|
+
if (!text) return null;
|
|
58
|
+
if (PS_FILTER_USED.test(text)) {
|
|
59
|
+
const param = PS_FILTER_PARAM.exec(text);
|
|
60
|
+
if (param) return { flag: '-Filter', baseFilter: param[1] ?? null };
|
|
61
|
+
}
|
|
62
|
+
return FORWARDS_ARGS.test(text) ? { flag: '--filter' } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function narrowDotnet(node, classes, cwd, read) {
|
|
66
|
+
const selected = classes.map((c) => `FullyQualifiedName~${c}`).join('|');
|
|
67
|
+
const inline = INLINE_FILTER.exec(node.command ?? '');
|
|
68
|
+
if (inline) {
|
|
69
|
+
const [whole, lead, flag, quoted, bare] = inline;
|
|
70
|
+
return node.command.replace(whole, `${lead}${flag} "(${quoted ?? bare})&(${selected})"`);
|
|
71
|
+
}
|
|
72
|
+
const plain = /\bdotnet\s+test\b/.test(node.command ?? '') ? { flag: '--filter' } : null;
|
|
73
|
+
const how = node.affected ?? plain ?? scriptFilter(node, cwd, read);
|
|
74
|
+
if (!how) return null;
|
|
75
|
+
const filter = how.baseFilter ? `(${how.baseFilter})&(${selected})` : selected;
|
|
76
|
+
return `${node.command} ${how.flag} "${filter}"`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function selectDotnet(node, relevant, cwd, git, read) {
|
|
80
|
+
const tests = relevant.filter(isTestFile);
|
|
81
|
+
const sources = relevant.filter((p) => !isTestFile(p)).map(classOf);
|
|
82
|
+
const classes = [...new Set([...tests, ...testFilesNaming(sources, cwd, git)].map(classOf))].sort();
|
|
83
|
+
if (classes.length === 0) return { skip: true };
|
|
84
|
+
if (classes.length > MAX_TEST_CLASSES) return { wide: classes.length };
|
|
85
|
+
const command = narrowDotnet(node, classes, cwd, read);
|
|
86
|
+
// A command nothing can narrow runs whole, as it would without this.
|
|
87
|
+
return command ? { node: { ...node, command, allowEmpty: true }, classes } : { node };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function selectVitest(node, base) {
|
|
91
|
+
return { node: { ...node, command: `${node.command} --changed ${base} --passWithNoTests`, allowEmpty: true } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function selectNode(node, { changed, base, cwd, git, read }) {
|
|
95
|
+
// A project may run its evals as a plain dotnet node: they cost money, so the name counts too.
|
|
96
|
+
if (node.e2e || node.evals || EVAL_NODE.test(node.id ?? '')) return { skip: true };
|
|
97
|
+
const prefix = node.cwd && node.cwd !== '.' ? `${node.cwd.replace(/\\/g, '/').replace(/\/$/, '')}/` : '';
|
|
98
|
+
const inside = changed.filter((p) => p.startsWith(prefix));
|
|
99
|
+
if (node.runner === 'dotnet') {
|
|
100
|
+
const relevant = inside.filter((p) => DOTNET_SOURCE.test(p));
|
|
101
|
+
return relevant.length ? selectDotnet(node, relevant, cwd, git, read) : { skip: true };
|
|
102
|
+
}
|
|
103
|
+
const relevant = inside.filter((p) => SCRIPT_SOURCE.test(p));
|
|
104
|
+
if (relevant.length === 0) return { skip: true };
|
|
105
|
+
return node.runner === 'vitest' ? selectVitest(node, base) : { node };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @returns {{ scope: 'full', reason: string } | { scope: 'affected', base: string, docsOnly: boolean, nodes: object[], skipped: string[] }} */
|
|
109
|
+
export function selectAffected({ projectPath, nodes, deps = {} }) {
|
|
110
|
+
const {
|
|
111
|
+
git = defaultGit, changedPaths = changedPathsAgainst, read = readText,
|
|
112
|
+
baseRefOf = (p) => resolveBaseRef(detectDefaultBranch(p), p),
|
|
113
|
+
} = deps;
|
|
114
|
+
const baseRef = baseRefOf(projectPath);
|
|
115
|
+
const base = baseRef ? mergeBase(baseRef, projectPath, git) : null;
|
|
116
|
+
if (!base) return { scope: 'full', reason: 'sem base de comparação com a branch padrão' };
|
|
117
|
+
|
|
118
|
+
const changed = changedPaths(base, projectPath)
|
|
119
|
+
.map((p) => p.replace(/\\/g, '/'))
|
|
120
|
+
.filter((p) => !p.startsWith('.morph/'));
|
|
121
|
+
|
|
122
|
+
const migration = migrationsTouched(changed);
|
|
123
|
+
if (migration.length) return { scope: 'full', reason: `migration tocada: ${migration[0]}` };
|
|
124
|
+
const contract = contractsTouched(changed);
|
|
125
|
+
if (contract.length) return { scope: 'full', reason: `contrato tocado: ${contract[0]}` };
|
|
126
|
+
// A test project's own file reaches only tests, which the complete run still covers.
|
|
127
|
+
const wide = changed.find((p) => WIDE_PATH.test(p) && !isTestFile(p));
|
|
128
|
+
if (wide) return { scope: 'full', reason: `arquivo que alcança o projeto inteiro: ${wide}` };
|
|
129
|
+
|
|
130
|
+
const code = changed.filter((p) => !DOC_PATH.test(p));
|
|
131
|
+
const context = { changed: code, base, cwd: projectPath, git, read };
|
|
132
|
+
const selected = [];
|
|
133
|
+
const skipped = [];
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
const choice = selectNode(node, context);
|
|
136
|
+
if (choice.wide) return { scope: 'full', reason: `código compartilhado: ${choice.wide} classes de teste alcançadas` };
|
|
137
|
+
if (choice.skip) skipped.push(node.id);
|
|
138
|
+
else selected.push(choice.node);
|
|
139
|
+
}
|
|
140
|
+
return { scope: 'affected', base, docsOnly: code.length === 0, nodes: selected, skipped };
|
|
141
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
6
|
+
import { heavyDir } from '../../../framework/hooks/shared/heavy-holders.js';
|
|
7
|
+
import { pidAlive } from '../../../framework/hooks/shared/process-alive.js';
|
|
8
|
+
import { treeFingerprint } from '../git/tree-fingerprint.js';
|
|
9
|
+
|
|
10
|
+
export const DETACH_STAGE_ENV = 'MORPH_VERIFY_STAGE';
|
|
11
|
+
export const DETACH_RUN_ENV = 'MORPH_VERIFY_RUN';
|
|
12
|
+
export const DETACH_WORKER_ENV = 'MORPH_VERIFY_WORKER_PID';
|
|
13
|
+
|
|
14
|
+
const POLL_MS = 2_000;
|
|
15
|
+
const MAX_RUN_MS = 3 * 60 * 60_000;
|
|
16
|
+
|
|
17
|
+
export const EXIT_BUSY = 3;
|
|
18
|
+
|
|
19
|
+
export function runPaths(cwd, feature, env = process.env) {
|
|
20
|
+
const id = createHash('sha1').update(`${cwd}\0${feature}`).digest('hex').slice(0, 12);
|
|
21
|
+
const base = join(heavyDir(env), `verify-${id}`);
|
|
22
|
+
return { run: `${base}.json`, exit: `${base}.exit`, out: `${base}.out`, err: `${base}.err` };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const readJson = (path) => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readExit = (path) => {
|
|
34
|
+
try {
|
|
35
|
+
return Number.parseInt(readFileSync(path, 'utf8'), 10);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const readText = (path) => {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(path, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const removeRun = (paths) => {
|
|
50
|
+
for (const file of Object.values(paths)) rmSync(file, { force: true });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function matchesRun(record, { key, args }) {
|
|
54
|
+
return record?.key === key && JSON.stringify(record.args) === JSON.stringify(args);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isRunning(record, paths, { isAlive = pidAlive, now = Date.now } = {}) {
|
|
58
|
+
if (!record || existsSync(paths.exit)) return false;
|
|
59
|
+
return isAlive(record.pid) && now() - record.startedAt < MAX_RUN_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const runFilesOf = (runFile) => ({
|
|
63
|
+
run: runFile,
|
|
64
|
+
exit: runFile.replace(/\.json$/, '.exit'),
|
|
65
|
+
out: runFile.replace(/\.json$/, '.out'),
|
|
66
|
+
err: runFile.replace(/\.json$/, '.err'),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function spawnStage(stage, { argv, cwd, env, paths, script, spawnFn = spawn }) {
|
|
70
|
+
mkdirSync(join(paths.run, '..'), { recursive: true });
|
|
71
|
+
const out = openSync(paths.out, 'a');
|
|
72
|
+
const err = openSync(paths.err, 'a');
|
|
73
|
+
try {
|
|
74
|
+
const child = spawnFn(process.execPath, [script, ...argv], {
|
|
75
|
+
cwd,
|
|
76
|
+
detached: true,
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
stdio: ['ignore', out, err],
|
|
79
|
+
env: { ...env, [DETACH_STAGE_ENV]: stage, [DETACH_RUN_ENV]: paths.run },
|
|
80
|
+
});
|
|
81
|
+
child.unref();
|
|
82
|
+
return child;
|
|
83
|
+
} finally {
|
|
84
|
+
closeSync(out);
|
|
85
|
+
closeSync(err);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function markWorkerExit(runFile, { processRef = process } = {}) {
|
|
90
|
+
const exitFile = runFilesOf(runFile).exit;
|
|
91
|
+
processRef.once('exit', (code) => {
|
|
92
|
+
try {
|
|
93
|
+
writeFileSync(exitFile, String(code ?? 1));
|
|
94
|
+
} catch {}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The launcher exists to die: killing a process tree walks parent pids, so a worker whose
|
|
100
|
+
* parent has exited is out of reach of whatever kills the session's background shell.
|
|
101
|
+
*/
|
|
102
|
+
export function launchWorker({ env = process.env, cwd = process.cwd(), argv, script = process.argv[1], spawnFn }) {
|
|
103
|
+
const runFile = env[DETACH_RUN_ENV];
|
|
104
|
+
const paths = runFilesOf(runFile);
|
|
105
|
+
const child = spawnStage('worker', { argv, cwd, env, paths, script, spawnFn });
|
|
106
|
+
writeFileSync(runFile, JSON.stringify({ ...readJson(runFile), pid: child.pid }));
|
|
107
|
+
spawnStage('watchdog', {
|
|
108
|
+
argv, cwd, env: { ...env, [DETACH_WORKER_ENV]: String(child.pid) }, paths, script, spawnFn,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function superviseVerify({
|
|
113
|
+
feature, args, cwd = process.cwd(), env = process.env, script = process.argv[1], log = (m) => process.stderr.write(`${m}\n`),
|
|
114
|
+
deps = {},
|
|
115
|
+
}) {
|
|
116
|
+
const {
|
|
117
|
+
fingerprint = treeFingerprint, spawnFn, isAlive = pidAlive, now = Date.now, wait = sleep, pollMs = POLL_MS,
|
|
118
|
+
} = deps;
|
|
119
|
+
const paths = runPaths(cwd, feature, env);
|
|
120
|
+
const key = fingerprint(cwd);
|
|
121
|
+
let record = readJson(paths.run);
|
|
122
|
+
const liveness = { isAlive, now };
|
|
123
|
+
|
|
124
|
+
if (isRunning(record, paths, liveness) && !matchesRun(record, { key, args })) {
|
|
125
|
+
log('verify: outra rodada desta feature ainda corre, sobre outra árvore; espero ela acabar');
|
|
126
|
+
while (isRunning(record, paths, liveness)) await wait(pollMs);
|
|
127
|
+
record = null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const attachable = matchesRun(record, { key, args }) && (isRunning(record, paths, liveness) || existsSync(paths.exit));
|
|
131
|
+
if (attachable) {
|
|
132
|
+
log(`verify: retomo a rodada destacada iniciada às ${new Date(record.startedAt).toLocaleTimeString()}`);
|
|
133
|
+
} else {
|
|
134
|
+
removeRun(paths);
|
|
135
|
+
mkdirSync(join(paths.run, '..'), { recursive: true });
|
|
136
|
+
record = { pid: null, key, args, startedAt: now() };
|
|
137
|
+
writeFileSync(paths.run, JSON.stringify(record));
|
|
138
|
+
spawnStage('launch', { argv: args, cwd, env, paths, script, spawnFn });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
while (!existsSync(paths.exit)) {
|
|
142
|
+
const current = readJson(paths.run);
|
|
143
|
+
const launching = !current?.pid && now() - record.startedAt < 30_000;
|
|
144
|
+
if (!launching && !isRunning(current, paths, liveness)) break;
|
|
145
|
+
await wait(pollMs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const exitCode = readExit(paths.exit);
|
|
149
|
+
const stdout = readText(paths.out);
|
|
150
|
+
const stderr = readText(paths.err);
|
|
151
|
+
removeRun(paths);
|
|
152
|
+
const died = exitCode === null || (exitCode === EXIT_BUSY && !stdout.trim());
|
|
153
|
+
return { exitCode: exitCode ?? EXIT_BUSY, stdout, stderr, died };
|
|
154
|
+
}
|
|
@@ -183,7 +183,7 @@ function buildFrontmatter(agent, description, knownMcpIds, maxTurns) {
|
|
|
183
183
|
`maxTurns: ${maxTurns}`,
|
|
184
184
|
];
|
|
185
185
|
if (agent.color) lines.push(`color: ${agent.color}`);
|
|
186
|
-
lines.push(
|
|
186
|
+
lines.push('');
|
|
187
187
|
return lines.join('\n');
|
|
188
188
|
}
|
|
189
189
|
|
|
@@ -208,7 +208,7 @@ export const REVIEW_REPORT_CONTRACT = [
|
|
|
208
208
|
'tabela `## Bloqueadores` (`| id | tipo | onde | evidência |`; vazia = `Nenhum.`) e `## Notas`. ' +
|
|
209
209
|
'Bloqueador só com evidência e tipo; o resto é nota, e nota nunca pede rodada nova.',
|
|
210
210
|
'',
|
|
211
|
-
'Numa confirmação (
|
|
211
|
+
'Numa confirmação (um dispatch novo te traz depois da correção, com o `sha` novo) acrescente ao fim uma seção nova com ' +
|
|
212
212
|
`\`${VERDICT_TOKEN}\` e \`${REVIEWED_SHA_TOKEN} {sha novo}\`: se você abriu bloqueadores, julgue só eles; ` +
|
|
213
213
|
'se não abriu, julgue só o delta `{sha anterior}..{sha novo}` — o que a correção abriu. ' +
|
|
214
214
|
'O relatório é append-only: vale o último veredito e, na `## Checks`, a última linha de cada Cn.',
|
|
@@ -6,7 +6,6 @@ import { realEffects } from '../lib/installers/effects.js';
|
|
|
6
6
|
|
|
7
7
|
const HOOKS_VERSION = '3.17.0';
|
|
8
8
|
|
|
9
|
-
const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
|
|
10
9
|
|
|
11
10
|
// `Edit(path)` only: an Edit rule also covers Write, while a `Write(path)` rule
|
|
12
11
|
// matches nothing and makes Claude Code warn on every session start.
|
|
@@ -195,7 +194,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
195
194
|
const managed = settings._morph_managed || {};
|
|
196
195
|
const alreadyCurrent = managed.hooks_version === HOOKS_VERSION;
|
|
197
196
|
|
|
198
|
-
removeOldDispatch(settings);
|
|
199
197
|
removeMorphHooks(settings);
|
|
200
198
|
|
|
201
199
|
let installed = 0;
|
|
@@ -250,8 +248,6 @@ export async function installClaudeHooks(targetPath, { fx = realEffects() } = {}
|
|
|
250
248
|
const STALE_ALLOW = [
|
|
251
249
|
'Write(.morph/features/**)',
|
|
252
250
|
'Write(.morph/context/**)',
|
|
253
|
-
'Bash(morph-spec mutate:*)',
|
|
254
|
-
'Bash(npx morph-spec mutate:*)',
|
|
255
251
|
];
|
|
256
252
|
settings.permissions.allow = settings.permissions.allow.filter(
|
|
257
253
|
p => !MORPH_ALLOW.includes(p) && !STALE_ALLOW.includes(p)
|
|
@@ -319,22 +315,6 @@ export async function installGlobalStatusline(hooksSourceDir, globalClaudeDirOve
|
|
|
319
315
|
return { installed: true, globalDir };
|
|
320
316
|
}
|
|
321
317
|
|
|
322
|
-
function removeOldDispatch(settings) {
|
|
323
|
-
if (!settings.hooks?.PostToolUse) return;
|
|
324
|
-
|
|
325
|
-
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
|
|
326
|
-
if (!Array.isArray(entry.hooks)) return true;
|
|
327
|
-
return !entry.hooks.some(h =>
|
|
328
|
-
h.command === OLD_DISPATCH_COMMAND ||
|
|
329
|
-
(typeof h.command === 'string' && h.command.includes('agent-teams/dispatch.js'))
|
|
330
|
-
);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
if (settings.hooks.PostToolUse.length === 0) {
|
|
334
|
-
delete settings.hooks.PostToolUse;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
318
|
function removeMorphHooks(settings) {
|
|
339
319
|
const morphPathPattern = /framework\/hooks\/claude-code\//;
|
|
340
320
|
|