@polymorphism-tech/morph-spec 9.4.0 → 9.6.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 +1 -1
- package/README.md +2 -2
- package/bin/morph-spec.js +1 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +23 -33
- package/framework/agents.json +6 -7
- package/framework/docs/CLI.md +12 -8
- package/framework/hooks/README.md +2 -2
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/comment-guard.js +1 -0
- package/framework/hooks/claude-code/core/denylist.json +63 -1
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +0 -8
- package/framework/hooks/claude-code/core/gate-guard.js +0 -8
- package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
- package/framework/hooks/claude-code/core/loop-detect.js +0 -3
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +2 -1
- package/framework/hooks/claude-code/core/protect-spec-files.js +0 -7
- package/framework/hooks/claude-code/core/validate-completion.js +0 -4
- package/framework/hooks/claude-code/core/worktree-context.js +0 -2
- package/framework/hooks/claude-code/statusline.py +3 -44
- package/framework/hooks/shared/build-check.js +12 -19
- package/framework/hooks/shared/comment-rules.js +11 -1
- package/framework/hooks/shared/feature-locator.js +7 -5
- package/framework/hooks/shared/heavy-command.js +12 -0
- package/framework/hooks/shared/heavy-config.js +28 -1
- package/framework/hooks/shared/heavy-slots.js +41 -5
- package/framework/hooks/shared/hook-response.js +0 -4
- package/framework/hooks/shared/payload-utils.js +1 -35
- package/framework/hooks/shared/phase-utils.js +0 -12
- package/framework/hooks/shared/slice-progress.js +0 -2
- package/framework/hooks/shared/state-reader.js +0 -19
- package/framework/hooks/shared/telemetry-logger.js +1 -1
- package/framework/rules/frontend-standards.md +1 -2
- package/framework/rules/nextjs-standards.md +9 -8
- package/framework/schemas/config.schema.json +12 -1
- 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 +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-machine/SKILL.md +2 -2
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +2 -2
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +32 -29
- package/framework/standards/STANDARDS.meta.json +13 -15
- package/framework/standards/architecture/code-comments.md +1 -1
- package/framework/standards/frontend/admin-app/data-table.md +1 -3
- package/framework/standards/frontend/admin-app/page-structure.md +8 -4
- package/framework/standards/frontend/nextjs/app-router.md +3 -4
- package/framework/standards/frontend/nextjs/components.md +2 -15
- package/framework/standards/frontend/nextjs/data-fetching.md +2 -3
- package/framework/standards/frontend/nextjs/lint.md +141 -0
- package/framework/standards/frontend/nextjs/motion-patterns.md +2 -7
- package/framework/standards/frontend/nextjs/naming-conventions.md +1 -2
- package/framework/standards/frontend/nextjs/nextjs-patterns.md +5 -7
- package/framework/standards/frontend/nextjs/project-structure.md +3 -2
- package/framework/standards/frontend/nextjs/state-management.md +6 -7
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +1 -0
- package/framework/templates/README.md +4 -4
- package/framework/templates/REGISTRY.json +21 -309
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +54 -56
- package/package.json +3 -2
- package/src/commands/e2e/e2e.js +3 -0
- package/src/commands/project/env.js +3 -3
- package/src/commands/project/heavy.js +10 -3
- package/src/commands/project/worktree.js +2 -1
- package/src/commands/state/finish.js +17 -3
- package/src/commands/validation/mutate.js +2 -2
- package/src/commands/validation/verify.js +3 -0
- package/src/core/paths/output-schema.js +0 -9
- package/src/core/state/feature-identity.js +0 -2
- package/src/core/state/state-manager.js +1 -169
- package/src/core/templates/template-renderer.js +0 -23
- package/src/dashboard/app/lib/format.js +0 -7
- package/src/dashboard/app/lib/phases.js +0 -7
- package/src/dashboard/scanner/activity-scanner.js +0 -4
- package/src/lib/ai/ai-project-scan.js +1 -6
- package/src/lib/e2e/compose-env.js +26 -3
- package/src/lib/graph/graphify-runtime.js +0 -1
- package/src/lib/heavy/gate.js +23 -4
- package/src/lib/heavy/live-stacks.js +64 -0
- package/src/lib/mutation/snapshot.js +1 -5
- package/src/lib/risk-detector.js +9 -6
- package/src/lib/stack/stack-profile.js +0 -4
- package/src/lib/stack/stack-scan.js +15 -4
- package/src/lib/tasks/eval-node.js +1 -7
- package/src/lib/validators/validation-runner.js +0 -43
- package/src/lib/validators/verify-runner.js +18 -2
- package/src/lib/worktree/docker-audit.js +7 -3
- package/src/lib/worktree/env-allocator.js +27 -5
- package/src/lib/worktree/ignored-triage.js +8 -0
- package/src/utils/agents-installer.js +12 -19
- package/src/utils/claude-settings-manager.js +0 -28
- package/src/utils/file-copier.js +0 -53
- package/framework/hooks/shared/activity-logger.js +0 -131
- package/framework/standards/frontend/design-system/naming.md +0 -68
- package/src/commands/validation/index.js +0 -2
- package/src/lib/validators/code-budget/index.js +0 -2
- package/src/lib/validators/contracts/index.js +0 -1
- package/src/lib/validators/nextjs/index.js +0 -1
- package/src/lib/validators/packages/index.js +0 -1
- package/src/lib/validators/reuse/index.js +0 -2
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import { loadState, stateExists, getActiveFeature } from '../../shared/state-reader.js';
|
|
5
5
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
6
6
|
import { gateFromQuestion } from '../../shared/gate-question.js';
|
|
7
|
-
import { logHookActivity } from '../../shared/activity-logger.js';
|
|
8
7
|
import { resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
9
8
|
import { locateFeatureForQuestion } from '../../shared/feature-locator.js';
|
|
10
9
|
|
|
@@ -61,7 +60,6 @@ function checkAskUserQuestion(input, sessionRoot) {
|
|
|
61
60
|
const decision = getDecision(state, feature, gate);
|
|
62
61
|
if (!decision) continue;
|
|
63
62
|
if (decision.pause === false) {
|
|
64
|
-
logHookActivity('gate-guard', 'PostToolUse', 'divergent', cwd);
|
|
65
63
|
return contextResult(
|
|
66
64
|
`⚠️ [morph-spec] GATE OVER-PAUSE — você apresentou o Gate "${gate}" via AskUserQuestion, ` +
|
|
67
65
|
`mas a decisão registrada em state.json (gateDecisions.${gate}) era pause:false ` +
|
|
@@ -72,7 +70,6 @@ function checkAskUserQuestion(input, sessionRoot) {
|
|
|
72
70
|
'PostToolUse'
|
|
73
71
|
);
|
|
74
72
|
}
|
|
75
|
-
logHookActivity('gate-guard', 'PostToolUse', 'coherent', cwd);
|
|
76
73
|
return null;
|
|
77
74
|
}
|
|
78
75
|
return null;
|
|
@@ -94,8 +91,6 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
94
91
|
if (!state) return null;
|
|
95
92
|
const feature = featureFromStatePath(filePath) || getActiveFeature(cwd)?.name;
|
|
96
93
|
if (!feature) return null;
|
|
97
|
-
|
|
98
|
-
let checked = false;
|
|
99
94
|
const gates = state.features?.[feature]?.approvalGates ?? {};
|
|
100
95
|
for (const gate of ['plan', 'uiux', 'review']) {
|
|
101
96
|
const g = gates[gate];
|
|
@@ -104,7 +99,6 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
104
99
|
const decision = getDecision(state, feature, gate);
|
|
105
100
|
if (!decision) continue;
|
|
106
101
|
if (decision.pause === true) {
|
|
107
|
-
logHookActivity('gate-guard', 'PostToolUse', 'divergent', cwd);
|
|
108
102
|
return contextResult(
|
|
109
103
|
`⚠️ [morph-spec] GATE SKIPPED PAUSE — o Gate "${gate}" foi marcado approvedBy:"auto", ` +
|
|
110
104
|
`mas a decisão registrada (gateDecisions.${gate}) era pause:true ` +
|
|
@@ -114,9 +108,7 @@ function checkStateEdit(input, sessionRoot) {
|
|
|
114
108
|
'PostToolUse'
|
|
115
109
|
);
|
|
116
110
|
}
|
|
117
|
-
checked = true;
|
|
118
111
|
}
|
|
119
|
-
if (checked) logHookActivity('gate-guard', 'PostToolUse', 'coherent', cwd);
|
|
120
112
|
return null;
|
|
121
113
|
}
|
|
122
114
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { stateExists } from '../../shared/state-reader.js';
|
|
7
7
|
import { blockResult, runStandalone } from '../../shared/hook-response.js';
|
|
8
|
-
import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
|
|
8
|
+
import { heavyClass, heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
|
|
9
9
|
|
|
10
10
|
const HOOK_BUDGET_SEC = 540;
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ export async function run(payload, deps = {}) {
|
|
|
18
18
|
if (!kind || !payload?.tool_use_id) return null;
|
|
19
19
|
|
|
20
20
|
// Loaded only here: this link runs on every Bash call and almost none is heavy.
|
|
21
|
-
const { readHeavyConfig } = await import('../../shared/heavy-config.js');
|
|
21
|
+
const { minFreeMemFor, readHeavyConfig } = await import('../../shared/heavy-config.js');
|
|
22
22
|
const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
|
|
23
23
|
const env = deps.env ?? process.env;
|
|
24
24
|
const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
|
|
@@ -26,6 +26,7 @@ export async function run(payload, deps = {}) {
|
|
|
26
26
|
const config = readHeavyConfig(dir, env);
|
|
27
27
|
if (!config.enabled || !config.gateBash) return null;
|
|
28
28
|
|
|
29
|
+
const memClass = heavyClass(kind);
|
|
29
30
|
const result = (deps.acquire ?? acquireHeavy)({
|
|
30
31
|
dir: heavyDir(env),
|
|
31
32
|
slots: config.slots,
|
|
@@ -33,7 +34,8 @@ export async function run(payload, deps = {}) {
|
|
|
33
34
|
feature: null,
|
|
34
35
|
node: null,
|
|
35
36
|
cwd: dir,
|
|
36
|
-
|
|
37
|
+
kind: memClass,
|
|
38
|
+
minFreeMemGb: minFreeMemFor(config, memClass),
|
|
37
39
|
waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
|
|
38
40
|
maxHoldMs: config.maxHoldMinutes * 60_000,
|
|
39
41
|
lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
|
|
@@ -5,7 +5,6 @@ import { join } from 'path';
|
|
|
5
5
|
import { stateExists } from '../../shared/state-reader.js';
|
|
6
6
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
7
7
|
import { getCommandSignature } from '../../shared/payload-utils.js';
|
|
8
|
-
import { logEvent } from '../../shared/activity-logger.js';
|
|
9
8
|
|
|
10
9
|
const MAX_BUFFER = 15;
|
|
11
10
|
const LOOP_WINDOW = 8;
|
|
@@ -60,8 +59,6 @@ export async function run(payload) {
|
|
|
60
59
|
|
|
61
60
|
writeFileSync(cooldownPath, JSON.stringify({ lastDetected: new Date().toISOString() }), 'utf-8');
|
|
62
61
|
|
|
63
|
-
logEvent('loop_detected', { signatures: uniqueSigs.size }, process.cwd());
|
|
64
|
-
|
|
65
62
|
const sigList = [...uniqueSigs].join(', ');
|
|
66
63
|
return contextResult(
|
|
67
64
|
`⚠️ [morph-spec] LOOP DETECTED — the last ${LOOP_WINDOW} actions repeat only ${uniqueSigs.size} pattern(s): ${sigList}\n\n` +
|
|
@@ -54,8 +54,9 @@ export async function run(payload) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
for (const rule of denylist.executableArgs || []) {
|
|
57
|
+
if (rule.subagentOnly && !payload.agent_id) continue;
|
|
57
58
|
if (matchesExecutableRule(command, rule)) {
|
|
58
|
-
const shown = [rule.executable, ...(rule.flags || []).map((group) => group[0]), rule.args[0]].join(' ');
|
|
59
|
+
const shown = rule.display ?? [rule.executable, ...(rule.flags || []).map((group) => group[0]), rule.args[0]].join(' ');
|
|
59
60
|
return blockResult(`Comando bloqueado pela denylist: \`${shown}\` ${rule.reason}.`);
|
|
60
61
|
}
|
|
61
62
|
}
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
PROTECTED_SPEC_FILES
|
|
10
10
|
} from '../../shared/phase-utils.js';
|
|
11
11
|
import { blockResult, runStandalone } from '../../shared/hook-response.js';
|
|
12
|
-
import { logHookActivity } from '../../shared/activity-logger.js';
|
|
13
12
|
import { getFilePath, getToolName, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
14
13
|
import { ERRATA_HEADING, errataViolation } from '../../shared/errata.js';
|
|
15
14
|
|
|
@@ -57,7 +56,6 @@ export async function run(payload) {
|
|
|
57
56
|
// Generated only by the CLI: a hand-written one would carry checks the parser never judged.
|
|
58
57
|
// Decided before the creation carve-out, since chore/hotfix are born with the plan gate stamped.
|
|
59
58
|
if (filename === CHECKS_FILE) {
|
|
60
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
61
59
|
return blockResult(
|
|
62
60
|
`MORPH-SPEC: '${CHECKS_FILE}' is generated — never written by hand.\n` +
|
|
63
61
|
`Edit 2-plan/plan.md and run \`morph-spec plan-check ${featureName}\`: it parses the plan and writes ${CHECKS_FILE}.\n` +
|
|
@@ -67,7 +65,6 @@ export async function run(payload) {
|
|
|
67
65
|
}
|
|
68
66
|
|
|
69
67
|
if (!isGateApproved(featureName, requiredGate, root)) {
|
|
70
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
71
68
|
return null;
|
|
72
69
|
}
|
|
73
70
|
|
|
@@ -76,7 +73,6 @@ export async function run(payload) {
|
|
|
76
73
|
// A file not yet on disk is its CREATION (chore/hotfix are born with `plan` stamped), not an edit of a frozen spec.
|
|
77
74
|
// The criterion is existence, never `approvedBy`: by signature, deleting a spec first would license rewriting it.
|
|
78
75
|
if (!existsSync(absPath)) {
|
|
79
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'ok:absent');
|
|
80
76
|
return null;
|
|
81
77
|
}
|
|
82
78
|
|
|
@@ -90,11 +86,8 @@ export async function run(payload) {
|
|
|
90
86
|
} catch {}
|
|
91
87
|
|
|
92
88
|
if (errataCheck?.ok) {
|
|
93
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'ok');
|
|
94
89
|
return null;
|
|
95
90
|
}
|
|
96
|
-
|
|
97
|
-
logHookActivity('protect-spec-files', 'PreToolUse', 'blocked');
|
|
98
91
|
return blockResult(
|
|
99
92
|
`MORPH-SPEC: '${filename}' is locked — the '${requiredGate}' gate has been approved.\n` +
|
|
100
93
|
`Editing an approved artifact breaks its contract.\n\n` +
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
stateExists, getActiveFeature, getMostRecentFeature, derivePhaseForFeature,
|
|
8
8
|
} from '../../shared/state-reader.js';
|
|
9
9
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
10
|
-
import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
|
|
11
10
|
import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
|
|
12
11
|
import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
|
|
13
12
|
import { sliceProgress, sliceReportPath, inspectReport } from '../../shared/slice-progress.js';
|
|
@@ -145,9 +144,6 @@ export async function run(payload) {
|
|
|
145
144
|
'MORPH-SPEC:',
|
|
146
145
|
...warnings.map(w => ` ${w}`),
|
|
147
146
|
].join('\n');
|
|
148
|
-
|
|
149
|
-
logHookActivity('validate-completion', 'Stop', `warnings(${warnings.length})`);
|
|
150
|
-
logEvent('session_end', { feature: name, phase: currentPhase, warnings: warnings.length });
|
|
151
147
|
return contextResult(message, 'Stop');
|
|
152
148
|
} catch {
|
|
153
149
|
return null;
|
|
@@ -13,7 +13,6 @@ import { sliceProgress } from '../../shared/slice-progress.js';
|
|
|
13
13
|
import { collectNextSignals, decideNext, formatNext } from '../../shared/next-step.js';
|
|
14
14
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
15
15
|
import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
|
|
16
|
-
import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
|
|
17
16
|
import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
|
|
18
17
|
import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
|
|
19
18
|
import { kickAgentReap } from '../../shared/agent-reap.js';
|
|
@@ -226,7 +225,6 @@ export async function run(payload) {
|
|
|
226
225
|
return null;
|
|
227
226
|
}
|
|
228
227
|
|
|
229
|
-
try { rotateActivityLogIfLarge(dir); } catch {}
|
|
230
228
|
try { rotateEventsLogIfLarge(dir); } catch {}
|
|
231
229
|
// Only the ROOT's graph is refreshed in background: a detached process holding a worktree directory made `finish` fail on win32.
|
|
232
230
|
try { seedFromPrimary(dir); } catch {}
|
|
@@ -457,49 +457,13 @@ def get_active_features(cwd):
|
|
|
457
457
|
return result
|
|
458
458
|
|
|
459
459
|
|
|
460
|
-
def get_last_hook(cwd):
|
|
461
|
-
"""Name of the most recent hook from .morph/logs/activity.jsonl.
|
|
462
|
-
|
|
463
|
-
activity-logger.js (R1.2) writes append-only JSONL now, one event per
|
|
464
|
-
line (`{"kind": "hook", ...}` or `{"kind": "event", ...}`), instead of a
|
|
465
|
-
single JSON doc rewritten whole on every call. The log can grow up to the
|
|
466
|
-
rotation threshold (2 MB, see ROTATE_THRESHOLD_BYTES in activity-logger.js)
|
|
467
|
-
between SessionStarts, and this function runs on every prompt redraw, so
|
|
468
|
-
only the trailing slice is read — never the whole file.
|
|
469
|
-
|
|
470
|
-
The age is deliberately not shown: activity-logger.js only records
|
|
471
|
-
HH:MM:SS, so any age computed from it is wrong across midnight.
|
|
472
|
-
"""
|
|
473
|
-
try:
|
|
474
|
-
path = Path(cwd) / '.morph' / 'logs' / 'activity.jsonl'
|
|
475
|
-
size = path.stat().st_size
|
|
476
|
-
tail_bytes = 8192
|
|
477
|
-
with path.open('rb') as f:
|
|
478
|
-
if size > tail_bytes:
|
|
479
|
-
f.seek(-tail_bytes, 2)
|
|
480
|
-
tail = f.read().decode('utf-8', errors='ignore')
|
|
481
|
-
for line in reversed(tail.splitlines()):
|
|
482
|
-
line = line.strip()
|
|
483
|
-
if not line:
|
|
484
|
-
continue
|
|
485
|
-
try:
|
|
486
|
-
obj = json.loads(line)
|
|
487
|
-
except Exception:
|
|
488
|
-
continue
|
|
489
|
-
if obj.get('kind') == 'hook':
|
|
490
|
-
return obj.get('name') or None
|
|
491
|
-
return None
|
|
492
|
-
except Exception:
|
|
493
|
-
return None
|
|
494
|
-
|
|
495
|
-
|
|
496
460
|
# ── Line renderers ────────────────────────────────────────────────────────────
|
|
497
461
|
|
|
498
|
-
def render_morph_line(feature,
|
|
462
|
+
def render_morph_line(feature, columns):
|
|
499
463
|
"""Line 0 — the active MORPH feature.
|
|
500
464
|
|
|
501
465
|
Goes through join_to_width like the status line: a feature name plus phase,
|
|
502
|
-
slice bar
|
|
466
|
+
slice bar and gate is the longest thing this script prints, and a
|
|
503
467
|
wrapping line 0 would defeat the whole point of the single-row layout.
|
|
504
468
|
"""
|
|
505
469
|
parts = [(0, f"{CYAN}{BOLD}► {feature['name']}{R}")]
|
|
@@ -523,9 +487,6 @@ def render_morph_line(feature, hook_name, columns):
|
|
|
523
487
|
if feature['pending']:
|
|
524
488
|
parts.append((2, f"{RED}⛔ gate: {feature['pending']}{R}"))
|
|
525
489
|
|
|
526
|
-
if hook_name:
|
|
527
|
-
parts.append((4, f"{GRAY}🪝 {hook_name}{R}"))
|
|
528
|
-
|
|
529
490
|
return join_to_width(parts, columns)
|
|
530
491
|
|
|
531
492
|
|
|
@@ -722,10 +683,8 @@ def main():
|
|
|
722
683
|
# worktree. In the primary tree it resurrects stale feature names
|
|
723
684
|
# during unrelated work, so there the status line is a single row.
|
|
724
685
|
if get_worktree_label(payload, cwd):
|
|
725
|
-
hook_name = get_last_hook(cwd)
|
|
726
686
|
for feature in get_active_features(cwd):
|
|
727
|
-
print(render_morph_line(feature,
|
|
728
|
-
hook_name = None # annotate the first line only
|
|
687
|
+
print(render_morph_line(feature, columns))
|
|
729
688
|
|
|
730
689
|
status_line = render_status_line(payload, cwd, settings, columns)
|
|
731
690
|
if status_line:
|
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
|
|
5
5
|
const DOTNET_BUILD_CMD = 'dotnet build --no-restore --nologo -v q';
|
|
6
6
|
const TSC_BUILD_CMD = 'npx tsc --noEmit';
|
|
7
|
+
const ESLINT_CONFIGS = ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs'];
|
|
7
8
|
|
|
8
9
|
export function isDotnetStack(s) {
|
|
9
10
|
return s.includes('dotnet') || s.includes('aspnet');
|
|
@@ -13,26 +14,9 @@ function isFrontendStack(s) {
|
|
|
13
14
|
return s.includes('nextjs') || s.includes('node') || s.includes('react') || s.includes('vue');
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
// Single stack only: a compound stack resolves to `dotnet build` alone. Use
|
|
17
|
-
// `getBuildSteps()` for those.
|
|
18
|
-
export function getBuildCommand(stack, hasTsConfig = false) {
|
|
19
|
-
if (!stack) return null;
|
|
20
|
-
const s = stack.toLowerCase();
|
|
21
|
-
|
|
22
|
-
if (isDotnetStack(s)) {
|
|
23
|
-
return DOTNET_BUILD_CMD;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (isFrontendStack(s) && hasTsConfig) {
|
|
27
|
-
return TSC_BUILD_CMD;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
17
|
// A compound stack checks BOTH sides, each from its own directory.
|
|
34
18
|
// `backendTarget` names the solution when the backend directory holds none.
|
|
35
|
-
export function getBuildSteps(stack, { cwd, frontendPath, backendPath, backendTarget } = {}) {
|
|
19
|
+
export function getBuildSteps(stack, { cwd, frontendPath, backendPath, backendTarget, lintFiles = [] } = {}) {
|
|
36
20
|
if (!stack || !cwd) return [];
|
|
37
21
|
const s = stack.toLowerCase();
|
|
38
22
|
const steps = [];
|
|
@@ -51,6 +35,15 @@ export function getBuildSteps(stack, { cwd, frontendPath, backendPath, backendTa
|
|
|
51
35
|
) {
|
|
52
36
|
steps.push({ command: TSC_BUILD_CMD, cwd: frontendDir });
|
|
53
37
|
}
|
|
38
|
+
// Only the files the feature touched: legacy lint debt never turns a feature's verify red.
|
|
39
|
+
if (
|
|
40
|
+
lintFiles.length > 0 &&
|
|
41
|
+
ESLINT_CONFIGS.some((name) => existsSync(join(frontendDir, name))) &&
|
|
42
|
+
existsSync(join(frontendDir, 'node_modules', 'eslint'))
|
|
43
|
+
) {
|
|
44
|
+
const files = lintFiles.map((file) => JSON.stringify(file)).join(' ');
|
|
45
|
+
steps.push({ command: `npx eslint --quiet --no-warn-ignored ${files}`, cwd: frontendDir });
|
|
46
|
+
}
|
|
54
47
|
}
|
|
55
48
|
|
|
56
49
|
return steps;
|
|
@@ -68,7 +61,7 @@ export function parseBuildResult(exitCode, output) {
|
|
|
68
61
|
|
|
69
62
|
/** @returns {{ status: 'pass'|'fail'|'skip', output: string }} */
|
|
70
63
|
export function runBuildCheck(cwd, stack, timeout = 30000, paths = {}) {
|
|
71
|
-
const steps = getBuildSteps(stack, { cwd, frontendPath: paths.frontendPath, backendPath: paths.backendPath, backendTarget: paths.backendTarget });
|
|
64
|
+
const steps = getBuildSteps(stack, { cwd, frontendPath: paths.frontendPath, backendPath: paths.backendPath, backendTarget: paths.backendTarget, lintFiles: paths.lintFiles });
|
|
72
65
|
if (steps.length === 0) return { status: 'skip', output: '' };
|
|
73
66
|
|
|
74
67
|
const failures = [];
|
|
@@ -37,6 +37,15 @@ function isBanner(text) {
|
|
|
37
37
|
return words === '' || /^[-=*#~_+]{3,}.*[-=*#~_+]{3,}$/.test(body);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const CODE_KEYWORD = /^(?:const|let|var|return|await|throw|if|else|for|foreach|while|switch|import|export|using|new|public|private|protected|internal|function|class)\b/;
|
|
41
|
+
|
|
42
|
+
function isCommentedCode(text) {
|
|
43
|
+
const body = bodyOf(text);
|
|
44
|
+
if (/^[}\])]+[;,)]*$/.test(body)) return true;
|
|
45
|
+
if (/\{$/.test(body)) return CODE_KEYWORD.test(body) || /[=(]/.test(body);
|
|
46
|
+
return /;$/.test(body) && (CODE_KEYWORD.test(body) || /[=(.]/.test(body));
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
function isDocComment(text, isCs) {
|
|
41
50
|
return isCs ? /^\/\/\/(?!\/)/.test(text) : /^\/\*\*(?!\*)/.test(text);
|
|
42
51
|
}
|
|
@@ -103,7 +112,7 @@ function addedRegionLines(before, after) {
|
|
|
103
112
|
return out;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
/** @returns {Array<{ line: number, rule: 'process'|'banner'|'doc-comment'|'long-block', text: string }>} */
|
|
115
|
+
/** @returns {Array<{ line: number, rule: 'process'|'banner'|'commented-code'|'doc-comment'|'long-block', text: string }>} */
|
|
107
116
|
export function commentFindings({ file, before, after, docCommentsForbidden: docForbidden = false }) {
|
|
108
117
|
const isCs = /\.cs$/i.test(file);
|
|
109
118
|
const all = commentLines(after, file);
|
|
@@ -120,6 +129,7 @@ export function commentFindings({ file, before, after, docCommentsForbidden: doc
|
|
|
120
129
|
if (ALLOWED.some((re) => re.test(text))) continue;
|
|
121
130
|
if (citesProcess(text)) flag(line, 'process', text);
|
|
122
131
|
else if (isBanner(text)) flag(line, 'banner', text);
|
|
132
|
+
else if (!isDocComment(text, isCs) && isCommentedCode(text)) flag(line, 'commented-code', text);
|
|
123
133
|
}
|
|
124
134
|
for (const { line, text } of addedRegionLines(before, after)) flag(line, 'banner', text);
|
|
125
135
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
6
|
import { loadState } from './state-reader.js';
|
|
7
|
+
import { morphBranchFeature } from './git-paths.js';
|
|
7
8
|
|
|
8
9
|
// `loadState`, not a directory listing: it also sees features only the index carries.
|
|
9
10
|
export function featureNamesIn(root) {
|
|
@@ -55,26 +56,27 @@ export function unrecognizedSlugLike(text, names) {
|
|
|
55
56
|
return tokens.find((t) => !known.has(t)) ?? null;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
// The
|
|
59
|
-
// `morph-spec approve`
|
|
59
|
+
// The tree on `morph/<f>` owns the feature and wins: the primary root only carries its index
|
|
60
|
+
// entry, and `morph-spec approve` refuses any other tree. Without an owner, the session root wins.
|
|
60
61
|
export function locateFeatureForQuestion(text, sessionRoot) {
|
|
61
62
|
const trees = allWorkTrees(sessionRoot);
|
|
62
63
|
const localNames = featureNamesIn(sessionRoot);
|
|
64
|
+
const ownerOr = (name, root) => trees.find((t) => morphBranchFeature(t) === name) ?? root;
|
|
63
65
|
|
|
64
66
|
const local = featureNamedIn(text, localNames);
|
|
65
|
-
if (local) return { name: local, root: sessionRoot };
|
|
67
|
+
if (local) return { name: local, root: ownerOr(local, sessionRoot) };
|
|
66
68
|
|
|
67
69
|
for (const root of trees) {
|
|
68
70
|
if (root === sessionRoot) continue;
|
|
69
71
|
const found = featureNamedIn(text, featureNamesIn(root));
|
|
70
|
-
if (found) return { name: found, root };
|
|
72
|
+
if (found) return { name: found, root: ownerOr(found, root) };
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const allNames = trees.flatMap(featureNamesIn);
|
|
74
76
|
if (unrecognizedSlugLike(text, allNames)) return null;
|
|
75
77
|
|
|
76
78
|
// With exactly ONE feature in the tree there is nothing else the answer could be about.
|
|
77
|
-
if (localNames.length === 1) return { name: localNames[0], root: sessionRoot };
|
|
79
|
+
if (localNames.length === 1) return { name: localNames[0], root: ownerOr(localNames[0], sessionRoot) };
|
|
78
80
|
|
|
79
81
|
return null;
|
|
80
82
|
}
|
|
@@ -106,6 +106,18 @@ export function heavyKind(command) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const E2E_CLASS = /\be2e\b|playwright|compose up/;
|
|
110
|
+
const UNIT_CLASS = /^(tests|(npx )?(vitest|jest)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?))$/;
|
|
111
|
+
|
|
112
|
+
// Maps a `heavyKind` label or a verify node to the RAM class it pays. `dotnet test` and anything
|
|
113
|
+
// unknown stay `build`: they compile before running, and the fallback must never lower a floor.
|
|
114
|
+
export function heavyClass(label) {
|
|
115
|
+
const s = String(label ?? '').toLowerCase().trim();
|
|
116
|
+
if (E2E_CLASS.test(s)) return 'e2e';
|
|
117
|
+
if (UNIT_CLASS.test(s)) return 'unit';
|
|
118
|
+
return 'build';
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
// The lease has no live process to watch: this TTL is the only thing that frees it after a
|
|
110
122
|
// denied or interrupted call.
|
|
111
123
|
const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
|
|
@@ -7,6 +7,7 @@ export const HEAVY_DEFAULTS = Object.freeze({
|
|
|
7
7
|
enabled: true,
|
|
8
8
|
slots: 2,
|
|
9
9
|
minFreeMemGb: 4,
|
|
10
|
+
minFreeMemGbByKind: Object.freeze({ unit: 1.5, build: 4, e2e: 4 }),
|
|
10
11
|
waitTimeoutSec: 600,
|
|
11
12
|
maxHoldMinutes: 90,
|
|
12
13
|
shutdownBuildServer: true,
|
|
@@ -14,11 +15,16 @@ export const HEAVY_DEFAULTS = Object.freeze({
|
|
|
14
15
|
gateBash: false,
|
|
15
16
|
});
|
|
16
17
|
|
|
18
|
+
export const HEAVY_KINDS = Object.freeze(['unit', 'build', 'e2e']);
|
|
19
|
+
|
|
17
20
|
const isBool = (v) => typeof v === 'boolean';
|
|
21
|
+
const isGb = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
22
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
18
23
|
const VALID = {
|
|
19
24
|
enabled: isBool,
|
|
20
25
|
slots: (v) => Number.isInteger(v) && v >= 1,
|
|
21
|
-
minFreeMemGb:
|
|
26
|
+
minFreeMemGb: isGb,
|
|
27
|
+
minFreeMemGbByKind: isPlainObject,
|
|
22
28
|
waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
|
|
23
29
|
maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
|
|
24
30
|
shutdownBuildServer: isBool,
|
|
@@ -40,6 +46,7 @@ export function resolveHeavyConfig(config, env = process.env) {
|
|
|
40
46
|
const out = { ...HEAVY_DEFAULTS };
|
|
41
47
|
const warnings = [];
|
|
42
48
|
const raw = config?.heavy;
|
|
49
|
+
let byKind = {};
|
|
43
50
|
|
|
44
51
|
if (raw !== undefined) {
|
|
45
52
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
@@ -47,12 +54,15 @@ export function resolveHeavyConfig(config, env = process.env) {
|
|
|
47
54
|
} else {
|
|
48
55
|
for (const [key, value] of Object.entries(raw)) {
|
|
49
56
|
if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
|
|
57
|
+
else if (key === 'minFreeMemGbByKind' && VALID[key](value)) byKind = value;
|
|
50
58
|
else if (VALID[key](value)) out[key] = value;
|
|
51
59
|
else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
out.minFreeMemGbByKind = resolveByKind(byKind, isGb(raw?.minFreeMemGb) ? raw.minFreeMemGb : null, warnings);
|
|
65
|
+
|
|
56
66
|
if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
|
|
57
67
|
const envSlots = Number(env.MORPH_HEAVY_SLOTS);
|
|
58
68
|
if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
|
|
@@ -60,6 +70,23 @@ export function resolveHeavyConfig(config, env = process.env) {
|
|
|
60
70
|
return { ...out, warnings };
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
// An explicit `minFreeMemGb` without a per-kind entry still governs that kind:
|
|
74
|
+
// a project that set 0 (RAM check off) or a stricter floor keeps it after the split.
|
|
75
|
+
function resolveByKind(byKind, explicitFloor, warnings) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const kind of HEAVY_KINDS) out[kind] = explicitFloor ?? HEAVY_DEFAULTS.minFreeMemGbByKind[kind];
|
|
78
|
+
for (const [kind, value] of Object.entries(byKind)) {
|
|
79
|
+
if (!HEAVY_KINDS.includes(kind)) warnings.push(`heavy.minFreeMemGbByKind.${kind} não existe; use ${HEAVY_KINDS.join(', ')}`);
|
|
80
|
+
else if (isGb(value)) out[kind] = value;
|
|
81
|
+
else warnings.push(`heavy.minFreeMemGbByKind.${kind} inválida (${JSON.stringify(value)}); usando ${out[kind]}`);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function minFreeMemFor(config, kind) {
|
|
87
|
+
return config.minFreeMemGbByKind?.[kind] ?? config.minFreeMemGb;
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export function readHeavyConfig(projectPath, env = process.env) {
|
|
64
91
|
return resolveHeavyConfig(readProjectConfig(projectPath), env);
|
|
65
92
|
}
|
|
@@ -49,6 +49,8 @@ function liveHoldersPruningStale(dir, ctx) {
|
|
|
49
49
|
return live;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const kindSuffix = (kind) => (kind ? ` (${kind})` : '');
|
|
53
|
+
|
|
52
54
|
function blockedText(blocked, slots, now) {
|
|
53
55
|
const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
|
|
54
56
|
if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
|
|
@@ -56,14 +58,14 @@ function blockedText(blocked, slots, now) {
|
|
|
56
58
|
const cause = blocked.holders.length
|
|
57
59
|
? `slots em uso: ${holders}`
|
|
58
60
|
: 'nenhum slot em uso: a pressão vem de fora do semáforo (vmmem do WSL, IDE, outros processos)';
|
|
59
|
-
return `aguardando RAM livre: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
|
|
61
|
+
return `aguardando RAM livre${kindSuffix(blocked.kind)}: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
|
|
60
62
|
}
|
|
61
63
|
return `aguardando slot pesado (${blocked.holders.length}/${slots}): ${holders}`;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
function tryClaim(dir, opts, ctx) {
|
|
65
67
|
const {
|
|
66
|
-
slots, label, feature, node, cwd, minFreeMemGb, freeMem, lease: leaseSpec,
|
|
68
|
+
slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, lease: leaseSpec,
|
|
67
69
|
} = opts;
|
|
68
70
|
const guarded = withClaimMutex(dir, () => {
|
|
69
71
|
const holders = liveHoldersPruningStale(dir, ctx);
|
|
@@ -71,7 +73,7 @@ function tryClaim(dir, opts, ctx) {
|
|
|
71
73
|
if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
|
|
72
74
|
if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
|
|
73
75
|
const freeGb = freeMem() / GB;
|
|
74
|
-
if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb } };
|
|
76
|
+
if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb, kind } };
|
|
75
77
|
|
|
76
78
|
for (let i = 0; i < slots + holders.length + 1; i++) {
|
|
77
79
|
const path = join(dir, `slot-${i}.lock`);
|
|
@@ -124,7 +126,13 @@ export function acquireHeavy(opts) {
|
|
|
124
126
|
const { blocked } = attempt;
|
|
125
127
|
if (waited >= waitTimeoutMs) {
|
|
126
128
|
return {
|
|
127
|
-
status: 'busy',
|
|
129
|
+
status: 'busy',
|
|
130
|
+
reason: blocked.reason,
|
|
131
|
+
holders: blocked.holders,
|
|
132
|
+
freeGb: blocked.freeGb ?? null,
|
|
133
|
+
minFreeMemGb: claimOpts.minFreeMemGb,
|
|
134
|
+
kind: opts.kind ?? null,
|
|
135
|
+
waitedMs: waited,
|
|
128
136
|
};
|
|
129
137
|
}
|
|
130
138
|
if (blocked.reason !== 'contended' && now() - lastReport >= REPORT_EVERY_MS) {
|
|
@@ -135,6 +143,32 @@ export function acquireHeavy(opts) {
|
|
|
135
143
|
}
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
// For a holder whose next node needs more RAM than it was admitted with: it keeps its slot and
|
|
147
|
+
// waits only on memory, since re-claiming would hit the re-entrant path and skip the check.
|
|
148
|
+
export function awaitFreeMemory(opts) {
|
|
149
|
+
const {
|
|
150
|
+
minFreeMemGb, waitTimeoutMs, kind = null,
|
|
151
|
+
now = Date.now, sleep = sleepSync, freeMem = freemem, log = (m) => process.stderr.write(`${m}\n`),
|
|
152
|
+
} = opts;
|
|
153
|
+
const start = now();
|
|
154
|
+
let lastReport = -Infinity;
|
|
155
|
+
for (;;) {
|
|
156
|
+
const freeGb = freeMem() / GB;
|
|
157
|
+
const waited = now() - start;
|
|
158
|
+
if (!(minFreeMemGb > 0) || freeGb >= minFreeMemGb) return { status: 'acquired', waitedMs: waited };
|
|
159
|
+
if (waited >= waitTimeoutMs) {
|
|
160
|
+
return {
|
|
161
|
+
status: 'busy', reason: 'memory', holders: [], freeGb, minFreeMemGb, kind, waitedMs: waited,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (now() - lastReport >= REPORT_EVERY_MS) {
|
|
165
|
+
log(`aguardando RAM livre${kindSuffix(kind)}: ${freeGb.toFixed(1)} GB < ${minFreeMemGb} GB mínimos, segurando o slot já tomado`);
|
|
166
|
+
lastReport = now();
|
|
167
|
+
}
|
|
168
|
+
sleep(Math.min(POLL_MS, Math.max(1, waitTimeoutMs - waited)));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
export function updateLease(lease, patch) {
|
|
139
173
|
if (!lease) return;
|
|
140
174
|
try {
|
|
@@ -168,7 +202,9 @@ export function releaseLeaseById(dir, id) {
|
|
|
168
202
|
export function describeBusy(result, config) {
|
|
169
203
|
const waited = Math.round(result.waitedMs / 1000);
|
|
170
204
|
if (result.reason === 'memory') {
|
|
171
|
-
|
|
205
|
+
const floor = result.minFreeMemGb ?? config.minFreeMemGb;
|
|
206
|
+
const key = result.kind ? `heavy.minFreeMemGbByKind.${result.kind}` : 'heavy.minFreeMemGb';
|
|
207
|
+
return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${floor} GB mínimos${kindSuffix(result.kind)}, ${key})`;
|
|
172
208
|
}
|
|
173
209
|
if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
|
|
174
210
|
return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
|
|
@@ -30,10 +30,6 @@ export function blockResult(reason) {
|
|
|
30
30
|
return { decision: 'block', reason };
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function approveResult(context) {
|
|
34
|
-
return context ? { decision: 'approve', additionalContext: context } : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
33
|
export function contextResult(text, eventName) {
|
|
38
34
|
return { hookSpecificOutput: { hookEventName: eventName, additionalContext: text } };
|
|
39
35
|
}
|
|
@@ -1,29 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join, isAbsolute } from 'path';
|
|
4
|
-
|
|
5
|
-
// A sub-agent may edit inside its own worktree: the build root comes from the
|
|
6
|
-
// file, not from the session's cwd.
|
|
7
|
-
export function resolveRootForFile(filePath, fallbackCwd, markers = ['package.json']) {
|
|
8
|
-
try {
|
|
9
|
-
if (!filePath) return fallbackCwd;
|
|
10
|
-
let p = String(filePath).replace(/\\/g, '/');
|
|
11
|
-
if (!isAbsolute(p)) p = join(fallbackCwd, p);
|
|
12
|
-
|
|
13
|
-
let dir = dirname(p);
|
|
14
|
-
let prev = null;
|
|
15
|
-
while (dir && dir !== prev) {
|
|
16
|
-
for (const marker of markers) {
|
|
17
|
-
if (existsSync(join(dir, marker))) return dir;
|
|
18
|
-
}
|
|
19
|
-
prev = dir;
|
|
20
|
-
dir = dirname(dir);
|
|
21
|
-
}
|
|
22
|
-
return fallbackCwd;
|
|
23
|
-
} catch {
|
|
24
|
-
return fallbackCwd;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
2
|
+
import { join, isAbsolute } from 'path';
|
|
27
3
|
|
|
28
4
|
// State is resolved from the target PATH, never the session: a session in the
|
|
29
5
|
// primary root may write a feature that lives in a worktree. `lastIndexOf` picks
|
|
@@ -58,16 +34,6 @@ export function getFilePath(payload) {
|
|
|
58
34
|
return raw.replace(/\\/g, '/');
|
|
59
35
|
}
|
|
60
36
|
|
|
61
|
-
export function getContentToValidate(payload) {
|
|
62
|
-
const input = payload?.tool_input;
|
|
63
|
-
if (!input) return '';
|
|
64
|
-
return input.content || input.new_string || '';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function isWriteOperation(payload) {
|
|
68
|
-
return typeof payload?.tool_input?.content === 'string';
|
|
69
|
-
}
|
|
70
|
-
|
|
71
37
|
export function getToolName(payload) {
|
|
72
38
|
return payload?.tool_name || 'unknown';
|
|
73
39
|
}
|