diff --git a/.core/reference/runtime.go b/.core/reference/runtime.go index 92e6e14..83dbab0 100644 --- a/.core/reference/runtime.go +++ b/.core/reference/runtime.go @@ -161,8 +161,8 @@ func (r *Runtime) ServiceStartup(ctx context.Context, options any) Result { // ServiceShutdown stops all services via the embedded Core. func (r *Runtime) ServiceShutdown(ctx context.Context) Result { - if r.Core != nil { - return r.Core.ServiceShutdown(ctx) + if r == nil || r.Core == nil { + return Result{OK: true} } - return Result{OK: true} + return r.Core.ServiceShutdown(ctx) } diff --git a/claude/hermes_runner_mcp/README.md b/claude/hermes_runner_mcp/README.md index daceb43..4fcdf8e 100644 --- a/claude/hermes_runner_mcp/README.md +++ b/claude/hermes_runner_mcp/README.md @@ -13,13 +13,13 @@ pip install -e . ## Claude Code ```bash -claude mcp add hermes-runner -- hermes-runner-mcp --hermes-url=http://localhost:8642 --api-key=$HERMES_API_KEY +claude mcp add hermes-runner -- hermes-runner-mcp --hermes-url=http://localhost:8642 ``` ## Configuration - `--hermes-url`: Hermes gateway base URL. Defaults to `http://localhost:8642/`. -- `--api-key`: Hermes gateway API key. Falls back to `HERMES_API_KEY`. +- `--api-key`: Hermes gateway API key. Defaults to `HERMES_API_KEY`, so prefer setting the environment variable instead of passing the secret on the command line. ## Tools diff --git a/claude/hermes_runner_mcp/server.py b/claude/hermes_runner_mcp/server.py index f41d6ad..9952928 100644 --- a/claude/hermes_runner_mcp/server.py +++ b/claude/hermes_runner_mcp/server.py @@ -12,7 +12,7 @@ import sys import threading from dataclasses import dataclass from typing import Any, Literal -from urllib.parse import urljoin +from urllib.parse import quote, urljoin import httpx from pydantic import BaseModel, Field, ValidationError @@ -205,13 +205,14 @@ class HermesGatewayClient: ("run", "status_url"), ) if status_url is None: - status_url = urljoin(self.base_url, f"runs/{run_id}") + status_url = urljoin(self.base_url, f"runs/{quote(run_id, safe='')}") return DispatchResult(run_id=run_id, status_url=status_url) def status(self, run_id: str) -> StatusResult: + encoded_run_id = quote(run_id, safe="") response = self._request_json( "GET", - (f"runs/{run_id}", f"status/{run_id}"), + (f"runs/{encoded_run_id}", f"status/{encoded_run_id}"), ) raw_state = self._require_string( response, @@ -242,9 +243,10 @@ class HermesGatewayClient: return StatusResult(state=state, progress=progress, last_event=last_event) def fetch(self, run_id: str) -> FetchResult: + encoded_run_id = quote(run_id, safe="") response = self._request_json( "GET", - (f"runs/{run_id}/fetch", f"fetch/{run_id}"), + (f"runs/{encoded_run_id}/fetch", f"fetch/{encoded_run_id}"), ) output = self._find_value( response, @@ -510,7 +512,7 @@ class MinimalMCPServer: request_id = payload.get("id") if not isinstance(method, str): - return None + return None if "id" not in payload else self._error(request_id, -32600, "Invalid Request") params = payload.get("params") @@ -760,6 +762,7 @@ def main(argv: list[str] | None = None) -> int: stop_event = threading.Event() client = HermesGatewayClient(args.hermes_url, args.api_key) handler = HermesToolHandler(client) + exit_code = 0 try: fastmcp_server = build_fastmcp_server(handler) @@ -767,18 +770,17 @@ def main(argv: list[str] | None = None) -> int: install_signal_handlers(stop_event, exit_immediately=True) LOGGER.info("starting Hermes Runner MCP with official mcp SDK") fastmcp_server.run() - return 0 - - install_signal_handlers(stop_event, exit_immediately=False) - LOGGER.info("starting Hermes Runner MCP with minimal JSON-RPC stdio fallback") - MinimalMCPServer(handler, stop_event).serve() - return 0 + else: + install_signal_handlers(stop_event, exit_immediately=False) + LOGGER.info("starting Hermes Runner MCP with minimal JSON-RPC stdio fallback") + MinimalMCPServer(handler, stop_event).serve() except KeyboardInterrupt: LOGGER.info("shutting down after keyboard interrupt") - return 0 finally: client.close() + return exit_code + if __name__ == "__main__": raise SystemExit(main()) diff --git a/composer.json b/composer.json index 3f5db6c..0005757 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "autoload": { "psr-4": { "Core\\Mod\\Agentic\\": "php/", - "Core\\Service\\Agentic\\": "php/Service/" + "Core\\Service\\Agentic\\": "php/Service/", + "Core\\Mcp\\": "php/Mcp/" } }, "autoload-dev": { diff --git a/go.mod b/go.mod index 23bbf6c..042c798 100644 --- a/go.mod +++ b/go.mod @@ -133,8 +133,8 @@ require ( google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.47.0 // indirect +modernc.org/memory v1.11.0 // indirect +modernc.org/sqlite v1.47.0 // indirect ) replace dappco.re/go/mcp => ../mcp diff --git a/php/Actions/Brain/RememberKnowledge.php b/php/Actions/Brain/RememberKnowledge.php index 933e3c0..2b32920 100644 --- a/php/Actions/Brain/RememberKnowledge.php +++ b/php/Actions/Brain/RememberKnowledge.php @@ -1,5 +1,7 @@ 128) { + throw new \InvalidArgumentException('org must not exceed 128 characters'); + } + } + return $this->brain->remember([ 'workspace_id' => $workspaceId, 'agent_id' => $agentId, 'type' => $type, 'content' => $content, 'tags' => $tags, - 'org' => $data['org'] ?? null, + 'org' => $org, 'project' => $data['project'] ?? null, 'confidence' => $confidence, 'supersedes_id' => $supersedes, diff --git a/php/Actions/Credits/AwardCredits.php b/php/Actions/Credits/AwardCredits.php index 7cc3651..5e41a81 100644 --- a/php/Actions/Credits/AwardCredits.php +++ b/php/Actions/Credits/AwardCredits.php @@ -56,6 +56,7 @@ class AwardCredits if ($fleetTaskId !== null) { $fleetTask = FleetTask::query() ->where('workspace_id', $workspaceId) + ->lockForUpdate() ->find($fleetTaskId); if (! $fleetTask instanceof FleetTask) { diff --git a/php/Actions/Forge/ManagePullRequest.php b/php/Actions/Forge/ManagePullRequest.php index 9137c63..76c185e 100644 --- a/php/Actions/Forge/ManagePullRequest.php +++ b/php/Actions/Forge/ManagePullRequest.php @@ -1,5 +1,7 @@ resolveMetaReader($owner, $repo); - $prMeta = $metaReader->getPRMeta($prNumber); + + try { + $prMeta = $metaReader->getPRMeta($prNumber); + } catch (\Throwable $exception) { + return [ + 'merged' => false, + 'reason' => 'meta_unavailable', + ]; + } if ($prMeta->state !== 'open') { return ['merged' => false, 'reason' => 'not_open']; @@ -55,7 +65,14 @@ class ManagePullRequest return ['merged' => false, 'reason' => 'checks_pending']; } - $forge->mergePullRequest($owner, $repo, $prNumber); + try { + $forge->mergePullRequest($owner, $repo, $prNumber); + } catch (\Throwable $exception) { + return [ + 'merged' => false, + 'reason' => 'merge_failed', + ]; + } return ['merged' => true, 'pr_number' => $prNumber]; } diff --git a/php/Actions/Forge/ScanForWork.php b/php/Actions/Forge/ScanForWork.php index 2f3bd7a..b8fefbb 100644 --- a/php/Actions/Forge/ScanForWork.php +++ b/php/Actions/Forge/ScanForWork.php @@ -1,5 +1,7 @@ getEpicMeta($epicNumber); + try { + $epicMeta = $metaReader->getEpicMeta($epicNumber); + } catch (\Throwable $exception) { + logger()->warning('ScanForWork skipped epic metadata fetch', [ + 'epic_number' => $epicNumber, + 'error' => $exception->getMessage(), + ]); + + continue; + } foreach ($epicMeta->children as $childMeta) { if ($childMeta->checkedBool) { @@ -84,7 +95,16 @@ class ScanForWork continue; } - $issueState = $metaReader->getIssueState($childMeta->issueId); + try { + $issueState = $metaReader->getIssueState($childMeta->issueId); + } catch (\Throwable $exception) { + logger()->warning('ScanForWork skipped issue state fetch', [ + 'issue_number' => $childMeta->issueId, + 'error' => $exception->getMessage(), + ]); + + continue; + } if ($issueState->state !== 'open') { continue; diff --git a/php/Agentic/Data/CreditTransaction.php b/php/Agentic/Data/CreditTransaction.php index 0aeea45..ce8c6c0 100644 --- a/php/Agentic/Data/CreditTransaction.php +++ b/php/Agentic/Data/CreditTransaction.php @@ -25,22 +25,29 @@ final readonly class CreditTransaction public static function fromModel(object $entry): self { $createdAt = $entry->created_at ?? null; + if ($createdAt === null) { + throw new \InvalidArgumentException('CreditTransaction requires a created_at value.'); + } if ($createdAt instanceof CarbonImmutable) { $immutable = $createdAt; } elseif ($createdAt instanceof CarbonInterface) { $immutable = CarbonImmutable::instance($createdAt); } else { - $immutable = CarbonImmutable::parse((string) ($createdAt ?? 'now')); + try { + $immutable = CarbonImmutable::parse((string) $createdAt); + } catch (\Throwable) { + throw new \InvalidArgumentException('CreditTransaction requires a valid created_at value.'); + } } return new self( - id: isset($entry->id) ? (int) $entry->id : null, - workspaceId: (int) ($entry->workspace_id ?? 0), - fleetNodeId: isset($entry->fleet_node_id) ? (int) $entry->fleet_node_id : null, - taskType: (string) ($entry->task_type ?? ''), - amount: (int) ($entry->amount ?? 0), - balanceAfter: (int) ($entry->balance_after ?? 0), + id: self::optionalInt($entry->id ?? null, 'id'), + workspaceId: self::requireInt($entry->workspace_id ?? null, 'workspace_id'), + fleetNodeId: self::optionalInt($entry->fleet_node_id ?? null, 'fleet_node_id'), + taskType: self::requireString($entry->task_type ?? null, 'task_type'), + amount: self::requireInt($entry->amount ?? null, 'amount'), + balanceAfter: self::requireInt($entry->balance_after ?? null, 'balance_after'), description: isset($entry->description) ? (string) $entry->description : null, createdAt: $immutable, ); @@ -59,4 +66,57 @@ final readonly class CreditTransaction 'created_at' => $this->createdAt->toIso8601String(), ]; } + + private static function requireInt(mixed $value, string $field): int + { + if ($value === null) { + throw new \InvalidArgumentException(sprintf( + 'CreditTransaction requires %s.', + $field, + )); + } + + return self::coerceInt($value, $field); + } + + private static function optionalInt(mixed $value, string $field): ?int + { + if ($value === null) { + return null; + } + + return self::coerceInt($value, $field); + } + + private static function coerceInt(mixed $value, string $field): int + { + if (is_int($value)) { + return $value; + } + + if (is_float($value) && floor($value) === $value) { + return (int) $value; + } + + if (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) { + return (int) $value; + } + + throw new \InvalidArgumentException(sprintf( + 'CreditTransaction requires integer %s.', + $field, + )); + } + + private static function requireString(mixed $value, string $field): string + { + if (! is_string($value) || trim($value) === '') { + throw new \InvalidArgumentException(sprintf( + 'CreditTransaction requires non-empty %s.', + $field, + )); + } + + return $value; + } } diff --git a/php/Agentic/Livewire/CreditLedger.php b/php/Agentic/Livewire/CreditLedger.php index 1326417..c96f64f 100644 --- a/php/Agentic/Livewire/CreditLedger.php +++ b/php/Agentic/Livewire/CreditLedger.php @@ -14,6 +14,7 @@ use Core\Mod\Agentic\Models\FleetNode; use Core\Tenant\Models\Workspace; use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Validation\Rule; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -180,7 +181,14 @@ class CreditLedger extends Component { $this->validate([ 'workspaceId' => 'required|integer|min:1', - 'selectedAgentId' => 'required|string|max:255', + 'selectedAgentId' => [ + 'required', + 'string', + 'max:255', + Rule::exists(FleetNode::class, 'agent_id')->where( + fn ($query) => $query->where('workspace_id', $this->workspaceId), + ), + ], 'adjustmentAmount' => 'required|integer|min:1|max:100000', 'adjustmentReason' => 'nullable|string|max:1000', ]); diff --git a/php/Agentic/Livewire/FleetOverview.php b/php/Agentic/Livewire/FleetOverview.php index af78fad..fe2baf8 100644 --- a/php/Agentic/Livewire/FleetOverview.php +++ b/php/Agentic/Livewire/FleetOverview.php @@ -13,6 +13,7 @@ use Core\Mod\Agentic\Models\FleetNode; use Core\Tenant\Models\Workspace; use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Validation\Rule; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -152,7 +153,14 @@ class FleetOverview extends Component { $this->validate([ 'workspaceId' => 'required|integer|min:1', - 'dispatchAgentId' => 'required|string|max:255', + 'dispatchAgentId' => [ + 'required', + 'string', + 'max:255', + Rule::exists(FleetNode::class, 'agent_id')->where( + fn ($query) => $query->where('workspace_id', $this->workspaceId), + ), + ], 'dispatchRepo' => 'required|string|max:255', 'dispatchTask' => 'required|string|max:10000', 'dispatchBranch' => 'nullable|string|max:255', diff --git a/php/Console/Commands/AgenticSyncPluginsCcCommand.php b/php/Console/Commands/AgenticSyncPluginsCcCommand.php index 86b06b2..fffea14 100644 --- a/php/Console/Commands/AgenticSyncPluginsCcCommand.php +++ b/php/Console/Commands/AgenticSyncPluginsCcCommand.php @@ -22,7 +22,14 @@ class AgenticSyncPluginsCcCommand extends Command public function handle(): int { - $pluginsPath = $this->pluginsPath(); + try { + $pluginsPath = $this->pluginsPath(); + } catch (\RuntimeException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + $pluginNames = $this->discoverPluginNames($pluginsPath); if ($pluginNames === []) { @@ -51,15 +58,13 @@ class AgenticSyncPluginsCcCommand extends Command } if ($nameMatches->count() > 1) { - $report[] = $this->unmappedRow($pluginName, 'ambiguous name match'); - - continue; + $pendingPluginNames[$pluginName] = 'ambiguous name match'; + } else { + $pendingPluginNames[$pluginName] = 'no enabled profile'; } - - $pendingPluginNames[] = $pluginName; } - foreach ($pendingPluginNames as $pluginName) { + foreach ($pendingPluginNames as $pluginName => $fallbackReason) { $pluginNameMatches = $this->matchProfilesByPluginCcName($profiles, $pluginName, $claimedProfileIds); if ($pluginNameMatches->count() === 1) { @@ -76,7 +81,7 @@ class AgenticSyncPluginsCcCommand extends Command continue; } - $report[] = $this->unmappedRow($pluginName, 'no enabled profile'); + $report[] = $this->unmappedRow($pluginName, $fallbackReason); } usort($report, static fn (array $left, array $right): int => strcmp((string) $left['plugin'], (string) $right['plugin'])); @@ -264,7 +269,11 @@ class AgenticSyncPluginsCcCommand extends Command $home = $_SERVER['HOME'] ?? $_ENV['HOME'] ?? ''; } - return rtrim((string) $home, '/').'/.claude/plugins'; + if (! is_string($home) || $home === '') { + throw new \RuntimeException('Unable to resolve HOME for Claude Code plugin discovery.'); + } + + return rtrim($home, '/').'/.claude/plugins'; } private function normalise(string $value): string diff --git a/php/Console/Commands/BrainPruneCommand.php b/php/Console/Commands/BrainPruneCommand.php index e1e082b..aa576c3 100644 --- a/php/Console/Commands/BrainPruneCommand.php +++ b/php/Console/Commands/BrainPruneCommand.php @@ -49,8 +49,7 @@ class BrainPruneCommand extends Command $query->chunkById($chunkSize, function (Collection $memories) use (&$pruned): void { foreach ($memories as $memory) { - DeleteFromIndex::dispatch($memory->id); - $memory->forceDelete(); + DeleteFromIndex::dispatch((string) $memory->id, true); $pruned++; } }); diff --git a/php/Controllers/Api/Credits/CreditsController.php b/php/Controllers/Api/Credits/CreditsController.php index 228f5c1..ecc2c86 100644 --- a/php/Controllers/Api/Credits/CreditsController.php +++ b/php/Controllers/Api/Credits/CreditsController.php @@ -17,7 +17,7 @@ class CreditsController extends Controller { public function balance(Request $request): JsonResponse { - $workspaceId = (int) $request->attributes->get('workspace_id'); + $workspaceId = $this->workspaceIdFrom($request); $service = $this->resolveCreditService(); $payload = $service !== null && method_exists($service, 'balance') @@ -34,7 +34,7 @@ class CreditsController extends Controller 'reason' => 'required|string|max:1000', ]); - $workspaceId = (int) $request->attributes->get('workspace_id'); + $workspaceId = $this->workspaceIdFrom($request); $service = $this->resolveCreditService(); $entry = $service !== null && method_exists($service, 'deduct') @@ -51,7 +51,7 @@ class CreditsController extends Controller 'reason' => 'required|string|max:1000', ]); - $workspaceId = (int) $request->attributes->get('workspace_id'); + $workspaceId = $this->workspaceIdFrom($request); $service = $this->resolveCreditService(); $entry = $service !== null && method_exists($service, 'refund') @@ -67,7 +67,7 @@ class CreditsController extends Controller 'limit' => 'nullable|integer|min:1|max:500', ]); - $workspaceId = (int) $request->attributes->get('workspace_id'); + $workspaceId = $this->workspaceIdFrom($request); $limit = (int) ($validated['limit'] ?? 50); $service = $this->resolveCreditService(); @@ -93,6 +93,15 @@ class CreditsController extends Controller ]); } + private function workspaceIdFrom(Request $request): int + { + $workspaceId = filter_var($request->attributes->get('workspace_id'), FILTER_VALIDATE_INT); + + abort_if($workspaceId === false || $workspaceId < 1, 400, 'workspace_id attribute is required.'); + + return (int) $workspaceId; + } + /** * @return array */ diff --git a/php/Jobs/CaptureDispatchResultJob.php b/php/Jobs/CaptureDispatchResultJob.php index bd273b6..f513aa8 100644 --- a/php/Jobs/CaptureDispatchResultJob.php +++ b/php/Jobs/CaptureDispatchResultJob.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Jobs; +use Core\Mod\Agentic\Models\AgentProfile; use Core\Mod\Agentic\Services\MantisClient; use Core\Mod\Agentic\Services\ShaExtractor; use Illuminate\Bus\Queueable; @@ -69,7 +70,7 @@ class CaptureDispatchResultJob implements ShouldQueue public int $ticketId, public array $response, public ?string $repo = null, - public ?\Mod\AgentProfile $profile = null, + public ?AgentProfile $profile = null, ) { $this->onQueue('ai'); } @@ -112,7 +113,7 @@ class CaptureDispatchResultJob implements ShouldQueue string $repo, string $forgeUrl, string $summaryLine, - ?\Mod\AgentProfile $profile = null, + ?AgentProfile $profile = null, ): string { $profileName = $this->profileName($profile); @@ -248,7 +249,7 @@ class CaptureDispatchResultJob implements ShouldQueue return 'https://forge.lthn.sh/'.$repo.'/commit/'.$sha; } - private function profileName(?\Mod\AgentProfile $profile = null): string + private function profileName(?AgentProfile $profile = null): string { $name = $profile?->name; diff --git a/php/Jobs/DeleteFromIndex.php b/php/Jobs/DeleteFromIndex.php index b249d68..e492a39 100644 --- a/php/Jobs/DeleteFromIndex.php +++ b/php/Jobs/DeleteFromIndex.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Jobs; +use Core\Mod\Agentic\Models\BrainMemory; use Core\Mod\Agentic\Services\BrainService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -26,11 +27,21 @@ class DeleteFromIndex implements ShouldQueue public function __construct( public string $memoryId, + public bool $forceDeleteRecord = false, ) {} public function handle(BrainService $brain): void { + $memory = BrainMemory::withTrashed()->find($this->memoryId); + if ($memory instanceof BrainMemory && $memory->deleted_at === null) { + return; + } + $brain->qdrantDelete([$this->memoryId]); $brain->elasticDelete($this->memoryId); + + if ($this->forceDeleteRecord && $memory instanceof BrainMemory && $memory->deleted_at !== null) { + $memory->forceDelete(); + } } } diff --git a/php/Mcp/Console/McpAgentServerCommand.php b/php/Mcp/Console/McpAgentServerCommand.php index 08c726f..6ecd93e 100644 --- a/php/Mcp/Console/McpAgentServerCommand.php +++ b/php/Mcp/Console/McpAgentServerCommand.php @@ -246,7 +246,6 @@ class McpAgentServerCommand extends Command ]); } - $consumedQuota = $quotaService->consume($workspaceId); $startedAt = microtime(true); try { @@ -255,6 +254,7 @@ class McpAgentServerCommand extends Command 'request_id' => $id, 'transport' => 'stdio', ]); + $consumedQuota = $quotaService->consume($workspaceId); $durationMs = (int) round((microtime(true) - $startedAt) * 1000); if ($query !== null) { diff --git a/php/Mcp/Exceptions/CircuitOpenException.php b/php/Mcp/Exceptions/CircuitOpenException.php new file mode 100644 index 0000000..a81433b --- /dev/null +++ b/php/Mcp/Exceptions/CircuitOpenException.php @@ -0,0 +1,22 @@ + (array) $column, DB::select(sprintf( - $driver === 'sqlite' ? 'PRAGMA table_info("%s")' : 'DESCRIBE `%s`', - $tableName, - ))); + $statement = $driver === 'sqlite' + ? 'PRAGMA table_info('.$this->quoteIdentifier($tableName, $driver).')' + : 'DESCRIBE '.$this->quoteIdentifier($tableName, $driver); + + return array_map(static fn (object $column): array => (array) $column, DB::select($statement)); } catch (\Throwable) { return []; } } + + protected function quoteIdentifier(string $identifier, string $driver): string + { + if ($driver === 'sqlite') { + return '"'.str_replace('"', '""', $identifier).'"'; + } + + return '`'.str_replace('`', '``', $identifier).'`'; + } } diff --git a/php/Mcp/Services/CircuitBreaker.php b/php/Mcp/Services/CircuitBreaker.php index e4ebe2f..6a95f8a 100644 --- a/php/Mcp/Services/CircuitBreaker.php +++ b/php/Mcp/Services/CircuitBreaker.php @@ -294,21 +294,3 @@ namespace Core\Mcp\Services { } } } - -namespace Core\Mcp\Exceptions { - - use RuntimeException; - - final class CircuitOpenException extends RuntimeException - { - public function __construct( - public readonly string $service, - string $message = '', - ) { - parent::__construct($message !== '' ? $message : sprintf( - "Service '%s' is temporarily unavailable. Please try again later.", - $service, - )); - } - } -} diff --git a/php/Mcp/Services/DataRedactor.php b/php/Mcp/Services/DataRedactor.php index 44646b3..150b489 100644 --- a/php/Mcp/Services/DataRedactor.php +++ b/php/Mcp/Services/DataRedactor.php @@ -68,6 +68,10 @@ final class DataRedactor return '[MAX_DEPTH_EXCEEDED]'; } + if (is_object($data)) { + return $this->redactObject($data, $maxDepth - 1); + } + if (is_array($data)) { return $this->redactArray($data, $maxDepth - 1); } @@ -85,6 +89,10 @@ final class DataRedactor return '[...]'; } + if (is_object($data)) { + return $this->summarizeObject($data, $maxDepth - 1); + } + if (is_array($data)) { $result = []; $count = count($data); @@ -127,6 +135,34 @@ final class DataRedactor return $data; } + protected function redactObject(object $data, int $maxDepth): mixed + { + $normalised = $this->normaliseObject($data); + + if (is_object($normalised)) { + return $this->redactObject($normalised, $maxDepth); + } + + if (is_array($normalised)) { + return $this->redactArray($normalised, $maxDepth); + } + + return is_string($normalised) + ? $this->redactString($normalised) + : $normalised; + } + + protected function summarizeObject(object $data, int $maxDepth): mixed + { + $normalised = $this->normaliseObject($data); + + if (is_object($normalised)) { + return $this->summarizeObject($normalised, $maxDepth); + } + + return $this->summarize($normalised, $maxDepth); + } + protected function redactArray(array $data, int $maxDepth): array { $result = []; @@ -212,4 +248,13 @@ final class DataRedactor return substr($value, 0, $visible).'***'.substr($value, -$visible); } + + protected function normaliseObject(object $data): mixed + { + if ($data instanceof \JsonSerializable) { + return $data->jsonSerialize(); + } + + return get_object_vars($data); + } } diff --git a/php/Mcp/Services/McpHealthService.php b/php/Mcp/Services/McpHealthService.php index 4799520..0fa565a 100644 --- a/php/Mcp/Services/McpHealthService.php +++ b/php/Mcp/Services/McpHealthService.php @@ -108,12 +108,15 @@ final class McpHealthService )); } - $command = trim((string) ($connection['command'] ?? '')); + $command = trim($this->resolveEnvVars((string) ($connection['command'] ?? ''))); if ($command === '') { return $this->buildResult(self::STATUS_OFFLINE, 'No command configured'); } - $args = array_map(static fn (mixed $value): string => (string) $value, (array) ($connection['args'] ?? [])); + $args = array_map( + fn (mixed $value): string => $this->resolveEnvVars((string) $value), + (array) ($connection['args'] ?? []), + ); $cwd = $this->resolveEnvVars((string) ($connection['cwd'] ?? getcwd())); $payload = json_encode([ 'jsonrpc' => '2.0', @@ -215,7 +218,8 @@ final class McpHealthService fclose($pipes[1]); fclose($pipes[2]); - $exitCode = $timedOut ? 124 : proc_close($process); + $closeCode = proc_close($process); + $exitCode = $timedOut ? 124 : $closeCode; return [ 'exit_code' => $exitCode, diff --git a/php/Mcp/Services/McpMetricsService.php b/php/Mcp/Services/McpMetricsService.php index 7209600..260a53d 100644 --- a/php/Mcp/Services/McpMetricsService.php +++ b/php/Mcp/Services/McpMetricsService.php @@ -183,7 +183,7 @@ final class McpMetricsService ->select('tool_name', 'error_code') ->selectRaw('COUNT(*) as error_count') ->where('success', false) - ->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString()) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateTimeString()) ->groupBy('tool_name', 'error_code') ->orderByDesc('error_count') ->get() @@ -204,7 +204,7 @@ final class McpMetricsService ->select('tool_name', 'duration_ms') ->whereNotNull('duration_ms') ->where('success', true) - ->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString()) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateTimeString()) ->get() ->groupBy('tool_name'); @@ -267,7 +267,7 @@ final class McpMetricsService ->selectRaw('COUNT(DISTINCT tool_name) as unique_tools') ->selectRaw('SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count') ->whereNotNull('plan_slug') - ->where('created_at', '>=', CarbonImmutable::now()->subDays($days)->startOfDay()->toDateTimeString()) + ->where('created_at', '>=', CarbonImmutable::now()->subDays($days - 1)->startOfDay()->toDateTimeString()) ->groupBy('plan_slug') ->orderByDesc('call_count') ->limit($limit) diff --git a/php/Mcp/Services/OpenApiGenerator.php b/php/Mcp/Services/OpenApiGenerator.php index 357fa5e..2d8845c 100644 --- a/php/Mcp/Services/OpenApiGenerator.php +++ b/php/Mcp/Services/OpenApiGenerator.php @@ -6,6 +6,7 @@ declare(strict_types=1); namespace Core\Mcp\Services; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; final class OpenApiGenerator @@ -42,7 +43,17 @@ final class OpenApiGenerator protected function loadRegistry(): void { $path = resource_path('mcp/registry.yaml'); - $this->registry = file_exists($path) ? (array) Yaml::parseFile($path) : ['servers' => []]; + if (! file_exists($path)) { + $this->registry = ['servers' => []]; + + return; + } + + try { + $this->registry = (array) Yaml::parseFile($path); + } catch (ParseException) { + $this->registry = ['servers' => []]; + } } protected function loadServers(): void @@ -56,9 +67,17 @@ final class OpenApiGenerator $id = (string) $reference['id']; $path = resource_path(sprintf('mcp/servers/%s.yaml', $id)); - $this->servers[$id] = file_exists($path) - ? (array) Yaml::parseFile($path) - : ['id' => $id, 'name' => $id]; + if (! file_exists($path)) { + $this->servers[$id] = ['id' => $id, 'name' => $id]; + + continue; + } + + try { + $this->servers[$id] = (array) Yaml::parseFile($path); + } catch (ParseException) { + $this->servers[$id] = ['id' => $id, 'name' => $id]; + } } } @@ -210,7 +229,7 @@ final class OpenApiGenerator ], ], ], - '/resources/{uri}' => [ + '/resources' => [ 'get' => [ 'tags' => ['Execution'], 'summary' => 'Read a resource', @@ -218,7 +237,7 @@ final class OpenApiGenerator 'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]], 'parameters' => [[ 'name' => 'uri', - 'in' => 'path', + 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string'], ]], diff --git a/php/Mcp/Services/QueryAuditService.php b/php/Mcp/Services/QueryAuditService.php index f3c2621..163d02c 100644 --- a/php/Mcp/Services/QueryAuditService.php +++ b/php/Mcp/Services/QueryAuditService.php @@ -21,10 +21,14 @@ final class QueryAuditService public function isSafe(string $query): bool { - return preg_match( - '/\b(drop|delete|truncate|alter|create|insert|update)\b|(?:exec|system|passthru)\s*\(/i', - $query, - ) !== 1; + $trimmedQuery = ltrim($query); + $startsWithWriteStatement = preg_match( + '/^(?:--[^\n]*\n\s*)*(?:drop|delete|truncate|alter|create|insert|update)\b/i', + $trimmedQuery, + ) === 1; + $callsDangerousFunction = preg_match('/(?:exec|system|passthru)\s*\(/i', $query) === 1; + + return ! $startsWithWriteStatement && ! $callsDangerousFunction; } public function exceedsLimit(array $result, int $limitBytes = 1000000): bool @@ -117,31 +121,59 @@ final class QueryAuditService $this->ensureTableExists(); $resolvedPeriods = $periods === [] ? ['day'] : array_values(array_unique($periods)); - $entries = McpAuditEntry::query()->orderBy('created_at')->get(); $aggregates = []; foreach ($resolvedPeriods as $period) { $resolvedPeriod = $this->resolvePeriod((string) $period); + $aggregates[$resolvedPeriod] = []; + } + + McpAuditEntry::query() + ->orderBy('id') + ->chunkById(250, function (Collection $entries) use (&$aggregates, $resolvedPeriods): void { + foreach ($entries as $entry) { + $timestamp = $this->entryTimestamp($entry); + + foreach ($resolvedPeriods as $resolvedPeriod) { + $bucket = $this->bucketFor($timestamp, $resolvedPeriod); + + if (! isset($aggregates[$resolvedPeriod][$bucket])) { + $aggregates[$resolvedPeriod][$bucket] = [ + 'bucket' => $bucket, + 'total' => 0, + 'safe' => 0, + 'unsafe' => 0, + 'duration_total' => 0, + 'result_count' => 0, + ]; + } + + $aggregates[$resolvedPeriod][$bucket]['total']++; + $aggregates[$resolvedPeriod][$bucket][$entry->is_safe ? 'safe' : 'unsafe']++; + $aggregates[$resolvedPeriod][$bucket]['duration_total'] += (int) ($entry->duration_ms ?? 0); + $aggregates[$resolvedPeriod][$bucket]['result_count'] += (int) ($entry->result_count ?? 0); + } + } + }); + + foreach ($aggregates as $period => $buckets) { + ksort($buckets); + + $aggregates[$period] = array_values(array_map( + static function (array $bucket): array { + $total = max((int) $bucket['total'], 1); - $aggregates[$resolvedPeriod] = $entries->groupBy( - fn (McpAuditEntry $entry): string => $this->bucketFor( - $entry->created_at instanceof CarbonInterface - ? CarbonImmutable::instance($entry->created_at) - : CarbonImmutable::parse((string) ($entry->created_at ?? 'now')), - $resolvedPeriod, - ), - )->map( - static function (Collection $group, string $bucket): array { return [ - 'bucket' => $bucket, - 'total' => $group->count(), - 'safe' => $group->where('is_safe', true)->count(), - 'unsafe' => $group->where('is_safe', false)->count(), - 'average_duration_ms' => (int) round((float) ($group->avg('duration_ms') ?? 0)), - 'result_count' => (int) $group->sum('result_count'), + 'bucket' => (string) $bucket['bucket'], + 'total' => (int) $bucket['total'], + 'safe' => (int) $bucket['safe'], + 'unsafe' => (int) $bucket['unsafe'], + 'average_duration_ms' => (int) round(((int) $bucket['duration_total']) / $total), + 'result_count' => (int) $bucket['result_count'], ]; }, - )->values()->all(); + $buckets, + )); } return $aggregates; @@ -191,6 +223,15 @@ final class QueryAuditService 'day' => $timestamp->format('Y-m-d'), }; } + + private function entryTimestamp(McpAuditEntry $entry): CarbonImmutable + { + if ($entry->created_at instanceof CarbonInterface) { + return CarbonImmutable::instance($entry->created_at); + } + + return CarbonImmutable::parse((string) ($entry->created_at ?? 'now')); + } } class McpAuditEntry extends Model diff --git a/php/Mcp/Services/ToolRateLimiter.php b/php/Mcp/Services/ToolRateLimiter.php index 188708b..b874aa8 100644 --- a/php/Mcp/Services/ToolRateLimiter.php +++ b/php/Mcp/Services/ToolRateLimiter.php @@ -47,12 +47,9 @@ final class ToolRateLimiter } $cacheKey = $this->cacheKey($identifier, $toolName); - $current = (int) Cache::get($cacheKey, 0); $decaySeconds = (int) config('mcp.rate_limiting.decay_seconds', 60); - if ($current === 0) { - Cache::put($cacheKey, 1, $decaySeconds); - + if (Cache::add($cacheKey, 1, $decaySeconds)) { return; } diff --git a/php/Mcp/Tools/Agent/Brain/BrainList.php b/php/Mcp/Tools/Agent/Brain/BrainList.php index fca47a8..c03885c 100644 --- a/php/Mcp/Tools/Agent/Brain/BrainList.php +++ b/php/Mcp/Tools/Agent/Brain/BrainList.php @@ -1,5 +1,7 @@ [--help]", NeedsArg: true, @@ -121,13 +128,13 @@ var coreCommandSpecs = []coreCommandSpec{ Usage: "core pipeline budget [plan|log] [--help]", }, { - Path: "core/pipeline/budget/plan", + Path: corePipelineBudgetPlanPath, Description: "Show the optimal dispatch plan for the current budget", Usage: "core pipeline budget plan [--help]", Next: "Read docs/RFC-AGENT-PIPELINE.md and add budget planning based on dispatch constraints.", }, { - Path: "core/pipeline/budget/log", + Path: corePipelineBudgetLogPath, Description: "Append a dispatch event to the budget journal", Usage: "core pipeline budget log [--help]", Next: "Read docs/RFC-AGENT-PIPELINE.md and add budget event journalling for dispatches.", @@ -145,7 +152,7 @@ var coreCommandSpecs = []coreCommandSpec{ Next: "Read docs/flow/RFC.flow-gather-training-data.md and add merged-PR capture into the journal.", }, { - Path: "core/pipeline/training/stats", + Path: corePipelineTrainingStatsPath, Description: "Summarise captured training journal data", Usage: "core pipeline training stats [--help]", Next: "Read docs/flow/RFC.flow-gather-training-data.md and add training journal summaries.", @@ -173,15 +180,15 @@ func (s *PrepSubsystem) registerCoreCommands() { "core/pipeline/fix": s.cmdCorePipelineFix, "core/pipeline/fix/reviews": s.cmdCorePipelineFixReviews, "core/pipeline/fix/conflicts": s.cmdCorePipelineFixConflicts, - "core/pipeline/fix/format": s.cmdCorePipelineFixFormat, + corePipelineFixFormatPath: s.cmdCorePipelineFixFormat, "core/pipeline/fix/threads": s.cmdCorePipelineFixThreads, "core/pipeline/onboard": s.cmdCorePipelineOnboard, "core/pipeline/budget": s.cmdCorePipelineBudget, - "core/pipeline/budget/plan": s.cmdCorePipelineBudgetPlan, - "core/pipeline/budget/log": s.cmdCorePipelineBudgetLog, + corePipelineBudgetPlanPath: s.cmdCorePipelineBudgetPlan, + corePipelineBudgetLogPath: s.cmdCorePipelineBudgetLog, "core/pipeline/training": s.cmdCorePipelineTraining, "core/pipeline/training/capture": s.cmdCorePipelineTrainingCapture, - "core/pipeline/training/stats": s.cmdCorePipelineTrainingStats, + corePipelineTrainingStatsPath: s.cmdCorePipelineTrainingStats, "core/pipeline/training/export": s.cmdCorePipelineTrainingExport, } @@ -310,7 +317,7 @@ func (s *PrepSubsystem) cmdCorePipelineFixConflicts(options core.Options) core.R } func (s *PrepSubsystem) cmdCorePipelineFixFormat(options core.Options) core.Result { - return runCoreCommandPlaceholder(options, "core/pipeline/fix/format") + return runCoreCommandPlaceholder(options, corePipelineFixFormatPath) } func (s *PrepSubsystem) cmdCorePipelineFixThreads(options core.Options) core.Result { @@ -322,11 +329,11 @@ func (s *PrepSubsystem) cmdCorePipelineOnboard(options core.Options) core.Result } func (s *PrepSubsystem) cmdCorePipelineBudgetPlan(options core.Options) core.Result { - return runCoreCommandPlaceholder(options, "core/pipeline/budget/plan") + return runCoreCommandPlaceholder(options, corePipelineBudgetPlanPath) } func (s *PrepSubsystem) cmdCorePipelineBudgetLog(options core.Options) core.Result { - return runCoreCommandPlaceholder(options, "core/pipeline/budget/log") + return runCoreCommandPlaceholder(options, corePipelineBudgetLogPath) } func (s *PrepSubsystem) cmdCorePipelineTrainingCapture(options core.Options) core.Result { @@ -334,7 +341,7 @@ func (s *PrepSubsystem) cmdCorePipelineTrainingCapture(options core.Options) cor } func (s *PrepSubsystem) cmdCorePipelineTrainingStats(options core.Options) core.Result { - return runCoreCommandPlaceholder(options, "core/pipeline/training/stats") + return runCoreCommandPlaceholder(options, corePipelineTrainingStatsPath) } func (s *PrepSubsystem) cmdCorePipelineTrainingExport(options core.Options) core.Result { diff --git a/pkg/agentic/commit.go b/pkg/agentic/commit.go index dae3a4b..1f3bc88 100644 --- a/pkg/agentic/commit.go +++ b/pkg/agentic/commit.go @@ -59,22 +59,13 @@ func (s *PrepSubsystem) commitWorkspace(ctx context.Context, input CommitInput) return CommitOutput{}, core.E("commitWorkspace", core.Concat("workspace not found: ", input.Workspace), nil) } - result := ReadStatusResult(workspaceDir) - workspaceStatus, ok := workspaceStatusValue(result) - if !ok { - err, _ := result.Value.(error) - if err == nil { - err = core.E("commitWorkspace", "status not found", nil) - } + workspaceStatus, err := commitWorkspaceStatus(workspaceDir) + if err != nil { return CommitOutput{}, err } metaDir := WorkspaceMetaDir(workspaceDir) - if r := fs.EnsureDir(metaDir); !r.OK { - err, _ := r.Value.(error) - if err == nil { - err = core.E("commitWorkspace", "failed to create metadata directory", nil) - } + if err := commitEnsureMetaDir(metaDir); err != nil { return CommitOutput{}, err } @@ -83,46 +74,15 @@ func (s *PrepSubsystem) commitWorkspace(ctx context.Context, input CommitInput) committedAt := time.Now().UTC().Format(time.RFC3339) if existingCommit, ok := readCommitMarker(markerPath); ok && existingCommit.UpdatedAt == workspaceStatus.UpdatedAt && existingCommit.Runs == workspaceStatus.Runs { - return CommitOutput{ - Success: true, - Workspace: input.Workspace, - JournalPath: journalPath, - MarkerPath: markerPath, - CommittedAt: existingCommit.CommittedAt, - Skipped: true, - }, nil + return commitSkippedOutput(input.Workspace, journalPath, markerPath, existingCommit), nil } record := commitWorkspaceRecord(workspaceDir, workspaceStatus, committedAt) - line := core.Concat(core.JSONMarshalString(record), "\n") - - appendHandle := fs.Append(journalPath) - if !appendHandle.OK { - err, _ := appendHandle.Value.(error) - if err == nil { - err = core.E("commitWorkspace", "failed to open journal", nil) - } - return CommitOutput{}, err - } - if writeResult := core.WriteAll(appendHandle.Value, line); !writeResult.OK { - err, _ := writeResult.Value.(error) - if err == nil { - err = core.E("commitWorkspace", "failed to append journal entry", nil) - } + if err := commitAppendJournal(journalPath, record); err != nil { return CommitOutput{}, err } - marker := commitMarker{ - Workspace: WorkspaceName(workspaceDir), - UpdatedAt: workspaceStatus.UpdatedAt, - Runs: workspaceStatus.Runs, - CommittedAt: committedAt, - } - if r := fs.WriteAtomic(markerPath, core.JSONMarshalString(marker)); !r.OK { - err, _ := r.Value.(error) - if err == nil { - err = core.E("commitWorkspace", "failed to write commit marker", nil) - } + if err := commitWriteMarker(markerPath, workspaceDir, workspaceStatus, committedAt); err != nil { return CommitOutput{}, err } @@ -148,6 +108,83 @@ func (s *PrepSubsystem) commitWorkspace(ctx context.Context, input CommitInput) }, nil } +func commitWorkspaceStatus(workspaceDir string) (*WorkspaceStatus, error) { + result := ReadStatusResult(workspaceDir) + workspaceStatus, ok := workspaceStatusValue(result) + if ok { + return workspaceStatus, nil + } + err, _ := result.Value.(error) + if err == nil { + err = core.E("commitWorkspace", "status not found", nil) + } + return nil, err +} + +func commitEnsureMetaDir(metaDir string) error { + if r := fs.EnsureDir(metaDir); r.OK { + return nil + } + err, _ := r.Value.(error) + if err == nil { + err = core.E("commitWorkspace", "failed to create metadata directory", nil) + } + return err +} + +func commitSkippedOutput(workspace, journalPath, markerPath string, existingCommit commitMarker) CommitOutput { + return CommitOutput{ + Success: true, + Workspace: workspace, + JournalPath: journalPath, + MarkerPath: markerPath, + CommittedAt: existingCommit.CommittedAt, + Skipped: true, + } +} + +func commitAppendJournal(journalPath string, record map[string]any) error { + appendHandle := fs.Append(journalPath) + if !appendHandle.OK { + err, _ := appendHandle.Value.(error) + if err == nil { + err = core.E("commitWorkspace", "failed to open journal", nil) + } + return err + } + + line := core.Concat(core.JSONMarshalString(record), "\n") + writeResult := core.WriteAll(appendHandle.Value, line) + if writeResult.OK { + return nil + } + + err, _ := writeResult.Value.(error) + if err == nil { + err = core.E("commitWorkspace", "failed to append journal entry", nil) + } + return err +} + +func commitWriteMarker(markerPath, workspaceDir string, workspaceStatus *WorkspaceStatus, committedAt string) error { + marker := commitMarker{ + Workspace: WorkspaceName(workspaceDir), + UpdatedAt: workspaceStatus.UpdatedAt, + Runs: workspaceStatus.Runs, + CommittedAt: committedAt, + } + + if r := fs.WriteAtomic(markerPath, core.JSONMarshalString(marker)); r.OK { + return nil + } + + err, _ := r.Value.(error) + if err == nil { + err = core.E("commitWorkspace", "failed to write commit marker", nil) + } + return err +} + type commitMarker struct { Workspace string `json:"workspace"` UpdatedAt time.Time `json:"updated_at"` diff --git a/pkg/agentic/content_seo.go b/pkg/agentic/content_seo.go index 725e9ab..40c2221 100644 --- a/pkg/agentic/content_seo.go +++ b/pkg/agentic/content_seo.go @@ -43,6 +43,7 @@ type seoRevisionRecord struct { } const contentSEORevisionGroup = "seo_revisions" +const contentSEOPageIDRequired = "page_id is required" var ( contentSEONow = time.Now @@ -83,7 +84,7 @@ func (s *PrepSubsystem) ScheduleRevision(ctx context.Context, pageID, content st pageID = core.Trim(pageID) if pageID == "" { - return SEORevision{}, core.E("scheduleRevision", "page_id is required", nil) + return SEORevision{}, core.E("scheduleRevision", contentSEOPageIDRequired, nil) } storeInstance, err := s.contentSEOStore() @@ -108,7 +109,7 @@ func (s *PrepSubsystem) ScheduleRevision(ctx context.Context, pageID, content st func (s *PrepSubsystem) GetPendingRevisions(pageID string) ([]SEORevision, error) { pageID = core.Trim(pageID) if pageID == "" { - return nil, core.E("getPendingRevisions", "page_id is required", nil) + return nil, core.E("getPendingRevisions", contentSEOPageIDRequired, nil) } storeInstance, err := s.contentSEOStore() @@ -136,7 +137,7 @@ func (s *PrepSubsystem) OnGooglebotVisit(ctx context.Context, pageID string) err pageID = core.Trim(pageID) if pageID == "" { - return core.E("onGooglebotVisit", "page_id is required", nil) + return core.E("onGooglebotVisit", contentSEOPageIDRequired, nil) } storeInstance, err := s.contentSEOStore() diff --git a/pkg/agentic/fetch_loop.go b/pkg/agentic/fetch_loop.go index dfaea50..8543e35 100644 --- a/pkg/agentic/fetch_loop.go +++ b/pkg/agentic/fetch_loop.go @@ -108,24 +108,32 @@ func (s *PrepSubsystem) fetchRegisteredRepos(ctx context.Context) { func (s *PrepSubsystem) fetchLoopRepoRefs() []fetchRepoRef { seen := map[string]bool{} refs := []fetchRepoRef{} + add := func(org, repo string) { fetchLoopAppendRepoRef(seen, &refs, org, repo) } - add := func(org, repo string) { - orgName, ok := validateName(org) - if !ok { - return - } - repoName, ok := validateName(repo) - if !ok { - return - } - key := core.Concat(orgName, "/", repoName) - if seen[key] { - return - } - seen[key] = true - refs = append(refs, fetchRepoRef{Org: orgName, Repo: repoName}) + s.fetchLoopCollectConfiguredRepoRefs(add) + s.fetchLoopCollectWorkspaceRepoRefs(add) + + return refs +} + +func fetchLoopAppendRepoRef(seen map[string]bool, refs *[]fetchRepoRef, org, repo string) { + orgName, ok := validateName(org) + if !ok { + return } + repoName, ok := validateName(repo) + if !ok { + return + } + key := core.Concat(orgName, "/", repoName) + if seen[key] { + return + } + seen[key] = true + *refs = append(*refs, fetchRepoRef{Org: orgName, Repo: repoName}) +} +func (s *PrepSubsystem) fetchLoopCollectConfiguredRepoRefs(add func(org, repo string)) { if s != nil && s.ServiceRuntime != nil { if result := s.Core().Config().Get("agents.fetch_repos"); result.OK { fetchLoopCollectRepoRefs(result.Value, add) @@ -133,29 +141,33 @@ func (s *PrepSubsystem) fetchLoopRepoRefs() []fetchRepoRef { } for _, path := range s.fetchLoopConfigPaths() { - raw := fetchLoopReadConfig(path) - fetchLoopCollectRepoRefs(raw["repos"], add) - if agents, ok := raw["agents"].(map[string]any); ok { - for _, value := range agents { - agent, ok := value.(map[string]any) - if !ok { - continue - } - fetchLoopCollectRepoRefs(agent["repos"], add) - } - } + fetchLoopCollectConfigRepoRefs(fetchLoopReadConfig(path), add) } +} +func fetchLoopCollectConfigRepoRefs(raw map[string]any, add func(org, repo string)) { + fetchLoopCollectRepoRefs(raw["repos"], add) + + agents, ok := raw["agents"].(map[string]any) + if !ok { + return + } + for _, value := range agents { + agent, ok := value.(map[string]any) + if !ok { + continue + } + fetchLoopCollectRepoRefs(agent["repos"], add) + } +} + +func (s *PrepSubsystem) fetchLoopCollectWorkspaceRepoRefs(add func(org, repo string)) { for _, repoDir := range core.PathGlob(core.JoinPath(WorkspaceRoot(), "*", "*")) { if !fs.IsDir(repoDir) { continue } - org := core.PathBase(core.PathDir(repoDir)) - repo := core.PathBase(repoDir) - add(org, repo) + add(core.PathBase(core.PathDir(repoDir)), core.PathBase(repoDir)) } - - return refs } func (s *PrepSubsystem) fetchLoopConfigPaths() []string { diff --git a/pkg/agentic/fleet_connect.go b/pkg/agentic/fleet_connect.go index 2b04530..eaa3657 100644 --- a/pkg/agentic/fleet_connect.go +++ b/pkg/agentic/fleet_connect.go @@ -25,6 +25,8 @@ var fleetPollInterval = 30 * time.Second var fleetHeartbeatInterval = 60 * time.Second var fleetPollingFailureThreshold = 3 +const fleetPollAction = "agentic.fleet.poll" + var fleetSleep = func(ctx context.Context, delay time.Duration) bool { if delay <= 0 { select { @@ -96,26 +98,14 @@ func (s *PrepSubsystem) Connect(ctx context.Context, options core.Options) core. consecutiveFailures := 0 for ctx.Err() == nil { - if pollingDone != nil { - select { - case <-pollingDone: - pollingDone = nil - pollingCancel = nil - default: - } - } + fleetClearCompletedPollFallback(&pollingCancel, &pollingDone) result := s.connectFleetEventStream(ctx, config) if result.OK { consecutiveFailures = 0 - if pollingCancel != nil { - pollingCancel() - if pollingDone != nil { - <-pollingDone - } - pollingCancel = nil - pollingDone = nil - } + fleetStopPollFallback(pollingCancel, pollingDone) + pollingCancel = nil + pollingDone = nil continue } @@ -128,13 +118,7 @@ func (s *PrepSubsystem) Connect(ctx context.Context, options core.Options) core. fleetRememberState("disconnected", "sse", err.Error()) if consecutiveFailures >= fleetPollingFailureThreshold && pollingCancel == nil { - pollingContext, cancelPolling := context.WithCancel(ctx) - pollingCancel = cancelPolling - pollingDone = make(chan struct{}) - go func() { - defer close(pollingDone) - _ = s.runFleetPollFallback(pollingContext, config) - }() + pollingCancel, pollingDone = s.startFleetPollFallback(ctx, config) } if !fleetSleep(ctx, fleetBackoffDelay(consecutiveFailures)) { @@ -142,21 +126,50 @@ func (s *PrepSubsystem) Connect(ctx context.Context, options core.Options) core. } } - if pollingCancel != nil { - pollingCancel() - if pollingDone != nil { - <-pollingDone - } - } + fleetStopPollFallback(pollingCancel, pollingDone) fleetRememberState("offline", fleetRuntimeSnapshotValue().Transport, "") return core.Result{OK: true} } +func fleetClearCompletedPollFallback(cancel *context.CancelFunc, done *chan struct{}) { + if *done == nil { + return + } + + select { + case <-*done: + *done = nil + *cancel = nil + default: + } +} + +func fleetStopPollFallback(cancel context.CancelFunc, done chan struct{}) { + if cancel == nil { + return + } + + cancel() + if done != nil { + <-done + } +} + +func (s *PrepSubsystem) startFleetPollFallback(ctx context.Context, config fleetClientConfig) (context.CancelFunc, chan struct{}) { + pollingContext, cancelPolling := context.WithCancel(ctx) + pollingDone := make(chan struct{}) + go func() { + defer close(pollingDone) + _ = s.runFleetPollFallback(pollingContext, config) + }() + return cancelPolling, pollingDone +} + // result := subsystem.PollFallback(ctx, core.NewOptions(core.Option{Key: "agent_id", Value: "charon"})) func (s *PrepSubsystem) PollFallback(ctx context.Context, options core.Options) core.Result { config := fleetClientConfigFromOptions(s, options) - if validation := validateFleetClientConfig("agentic.fleet.poll", config, true); !validation.OK { + if validation := validateFleetClientConfig(fleetPollAction, config, true); !validation.OK { return validation } return s.runFleetPollFallback(ctx, config) @@ -397,7 +410,7 @@ func (s *PrepSubsystem) runFleetPollFallback(ctx context.Context, config fleetCl return core.Result{Value: task, OK: true} } } else { - err := commandResultError("agentic.fleet.poll", result) + err := commandResultError(fleetPollAction, result) fleetRememberState("polling", "poll", err.Error()) } @@ -413,14 +426,14 @@ func (s *PrepSubsystem) pollFleetNextTask(ctx context.Context, config fleetClien path := appendQueryParam("/v1/fleet/task/next", "agent_id", config.AgentID) path = appendQuerySlice(path, "capabilities[]", config.Capabilities) - result := s.fleetJSONRequest(ctx, "agentic.fleet.poll", config, http.MethodGet, path, nil) + result := s.fleetJSONRequest(ctx, fleetPollAction, config, http.MethodGet, path, nil) if !result.OK { return result } payload, ok := result.Value.(map[string]any) if !ok { - return core.Result{Value: core.E("agentic.fleet.poll", "invalid fleet polling payload", nil), OK: false} + return core.Result{Value: core.E(fleetPollAction, "invalid fleet polling payload", nil), OK: false} } taskValues := payloadResourceMap(payload, "task") diff --git a/pkg/agentic/fleet_login.go b/pkg/agentic/fleet_login.go index 48ef24e..6ed82ca 100644 --- a/pkg/agentic/fleet_login.go +++ b/pkg/agentic/fleet_login.go @@ -17,6 +17,8 @@ type fleetLoginOutput struct { KeyPath string } +const fleetLoginAction = "agentic.fleet.login" + // result := subsystem.cmdFleetLogin(core.NewOptions(core.Option{Key: "_arg", Value: "123456"})) func (s *PrepSubsystem) cmdFleetLogin(options core.Options) core.Result { code := core.Trim(optionStringValue(options, "code", "pairing_code", "pairing-code", "_arg")) @@ -56,14 +58,14 @@ func (s *PrepSubsystem) cmdFleetLogin(options core.Options) core.Result { func (s *PrepSubsystem) loginWithPairingCode(ctx context.Context, options core.Options) core.Result { code := core.Trim(optionStringValue(options, "code", "pairing_code", "pairing-code", "_arg")) if !fleetPairingCodeValid(code) { - return core.Result{Value: core.E("agentic.fleet.login", "pairing code must be 6 digits", nil), OK: false} + return core.Result{Value: core.E(fleetLoginAction, "pairing code must be 6 digits", nil), OK: false} } config := fleetClientConfig{ APIURL: fleetAPIURLFromOptions(s, options), } - result := s.fleetJSONRequest(ctx, "agentic.fleet.login", config, http.MethodPost, "/v1/device/pair", map[string]any{ + result := s.fleetJSONRequest(ctx, fleetLoginAction, config, http.MethodPost, "/v1/device/pair", map[string]any{ "code": code, }) if !result.OK { @@ -72,12 +74,12 @@ func (s *PrepSubsystem) loginWithPairingCode(ctx context.Context, options core.O payload, ok := result.Value.(map[string]any) if !ok { - return core.Result{Value: core.E("agentic.fleet.login", "invalid fleet login payload", nil), OK: false} + return core.Result{Value: core.E(fleetLoginAction, "invalid fleet login payload", nil), OK: false} } output := parseFleetLoginOutput(payload) if output.AgentAPIKey == "" { - return core.Result{Value: core.E("agentic.fleet.login", "device pairing response did not include an api key", nil), OK: false} + return core.Result{Value: core.E(fleetLoginAction, "device pairing response did not include an api key", nil), OK: false} } output.Success = true @@ -85,11 +87,11 @@ func (s *PrepSubsystem) loginWithPairingCode(ctx context.Context, options core.O if ensureResult := fs.EnsureDir(core.PathDir(output.KeyPath)); !ensureResult.OK { err, _ := ensureResult.Value.(error) - return core.Result{Value: core.E("agentic.fleet.login", "create fleet key directory", err), OK: false} + return core.Result{Value: core.E(fleetLoginAction, "create fleet key directory", err), OK: false} } if writeResult := fs.WriteMode(output.KeyPath, output.AgentAPIKey, 0600); !writeResult.OK { err, _ := writeResult.Value.(error) - return core.Result{Value: core.E("agentic.fleet.login", "write fleet api key", err), OK: false} + return core.Result{Value: core.E(fleetLoginAction, "write fleet api key", err), OK: false} } if s != nil { diff --git a/pkg/agentic/flow.go b/pkg/agentic/flow.go index c479f4c..4e0e4b6 100644 --- a/pkg/agentic/flow.go +++ b/pkg/agentic/flow.go @@ -28,6 +28,8 @@ type flowExecutionSummary struct { StepResults []FlowRunStepOutput } +const flowRunCommandContext = "agentic.cmdRunFlow" + func (s *PrepSubsystem) runFlowExecutionCommand(options core.Options, commandLabel string) core.Result { if optionBoolValue(options, "dry_run", "dry-run") { return s.runFlowCommand(options, commandLabel) @@ -36,7 +38,7 @@ func (s *PrepSubsystem) runFlowExecutionCommand(options core.Options, commandLab flowPath := optionStringValue(options, "_arg", "path", "slug") if flowPath == "" { core.Print(nil, "usage: core-agent %s [--dry-run] [--var=key=value] [--vars='{\"key\":\"value\"}'] [--variables='{\"key\":\"value\"}']", commandLabel) - return core.Result{Value: core.E("agentic.cmdRunFlow", "flow path or slug is required", nil), OK: false} + return core.Result{Value: core.E(flowRunCommandContext, "flow path or slug is required", nil), OK: false} } variables := optionStringMapValue(options, "var", "vars", "variables") @@ -48,7 +50,7 @@ func (s *PrepSubsystem) runFlowExecutionCommand(options core.Options, commandLab document, ok := flowResult.Value.(flowRunDocument) if !ok || !document.Parsed { - err := core.E("agentic.cmdRunFlow", "invalid flow definition", nil) + err := core.E(flowRunCommandContext, "invalid flow definition", nil) core.Print(nil, "error: %v", err) return core.Result{Value: err, OK: false} } @@ -57,7 +59,7 @@ func (s *PrepSubsystem) runFlowExecutionCommand(options core.Options, commandLab if !validation.OK { err, ok := validation.Value.(error) if !ok { - err = core.E("agentic.cmdRunFlow", "invalid flow definition", nil) + err = core.E(flowRunCommandContext, "invalid flow definition", nil) } core.Print(nil, "error: %v", err) return core.Result{Value: err, OK: false} @@ -116,22 +118,22 @@ func (s *PrepSubsystem) validateExecutableFlowStep(index int, step flowDefinitio if core.Trim(step.Cmd) == "" { switch { case core.Trim(step.Flow) != "": - return core.E("agentic.validateExecutableFlowStep", core.Concat("step \"", stepName, "\" cannot execute nested flow references; use flow/preview or convert to cmd"), nil) + return flowStepError(stepName, "cannot execute nested flow references; use flow/preview or convert to cmd") case core.Trim(step.Run) != "": - return core.E("agentic.validateExecutableFlowStep", core.Concat("step \"", stepName, "\" uses legacy run syntax; use cmd and args"), nil) + return flowStepError(stepName, "uses legacy run syntax; use cmd and args") default: - return core.E("agentic.validateExecutableFlowStep", core.Concat("step \"", stepName, "\" must define cmd"), nil) + return flowStepError(stepName, "must define cmd") } } commandResult := s.Core().Command(step.Cmd) if !commandResult.OK { - return core.E("agentic.validateExecutableFlowStep", core.Concat("step \"", stepName, "\" references unknown command: ", step.Cmd), nil) + return flowStepError(stepName, core.Concat("references unknown command: ", step.Cmd)) } command, ok := commandResult.Value.(*core.Command) if !ok || command == nil || command.Action == nil { - return core.E("agentic.validateExecutableFlowStep", core.Concat("step \"", stepName, "\" references a non-executable command: ", step.Cmd), nil) + return flowStepError(stepName, core.Concat("references a non-executable command: ", step.Cmd)) } return nil @@ -199,7 +201,7 @@ func (s *PrepSubsystem) executeFlowStep(index int, step flowDefinitionStep) Flow return stepOutput } - stepOutput.Error = commandResultError("agentic.cmdRunFlow", result).Error() + stepOutput.Error = commandResultError(flowRunCommandContext, result).Error() if stepOutput.ContinueOnError { core.Print(nil, " status: failed (continued)") } else { @@ -225,6 +227,14 @@ func flowStepDisplayName(index int, step flowDefinitionStep) string { return core.Concat("step-", core.Itoa(index)) } +func flowStepError(stepName, message string) error { + return core.E( + "agentic.validateExecutableFlowStep", + core.Concat("step \"", stepName, "\" ", message), + nil, + ) +} + func flowStepCommandLine(step flowDefinitionStep) string { command := core.Trim(step.Cmd) if len(step.Args) == 0 { diff --git a/pkg/agentic/repo_sync.go b/pkg/agentic/repo_sync.go index ae05d2b..bf13e1a 100644 --- a/pkg/agentic/repo_sync.go +++ b/pkg/agentic/repo_sync.go @@ -28,6 +28,11 @@ type RepoSyncCommandOutput struct { Synced []RepoSyncOutput `json:"synced,omitempty"` } +const ( + repoSyncResetAction = "sync.reset" + repoSyncRepoDirContext = "agentic.repoSyncRepoDir" +) + // s.registerRepoSyncSupport() func (s *PrepSubsystem) registerRepoSyncSupport() { if s == nil || s.ServiceRuntime == nil { @@ -47,8 +52,8 @@ func (s *PrepSubsystem) registerRepoSyncSupport() { if !c.Action("sync.fetch").Exists() { c.Action("sync.fetch", s.handleRepoSyncFetch).Description = "Fetch a tracked local repo from origin" } - if !c.Action("sync.reset").Exists() { - c.Action("sync.reset", s.handleRepoSyncReset).Description = "Reset a tracked local repo to origin/" + if !c.Action(repoSyncResetAction).Exists() { + c.Action(repoSyncResetAction, s.handleRepoSyncReset).Description = "Reset a tracked local repo to origin/" } c.RegisterAction(func(coreApp *core.Core, msg core.Message) core.Result { @@ -173,7 +178,7 @@ func (s *PrepSubsystem) runRepoSync(ctx context.Context, target fetchRepoRef, br } if reset { - resetResult := s.Core().Action("sync.reset").Run(ctx, repoSyncOptions(target, resetBranch)) + resetResult := s.Core().Action(repoSyncResetAction).Run(ctx, repoSyncOptions(target, resetBranch)) if !resetResult.OK { return resetResult } @@ -332,16 +337,16 @@ func resultErrorValue(result core.Result) error { func (s *PrepSubsystem) repoSyncRepoDir(target fetchRepoRef) (string, error) { if s == nil || s.ServiceRuntime == nil { - return "", core.E("agentic.repoSyncRepoDir", "prep subsystem is not initialised", nil) + return "", core.E(repoSyncRepoDirContext, "prep subsystem is not initialised", nil) } repoDir := s.localRepoDir(target.Org, target.Repo) if repoDir == "" || !fs.Exists(repoDir) || fs.IsFile(repoDir) { - return "", core.E("agentic.repoSyncRepoDir", "local repo not found", nil) + return "", core.E(repoSyncRepoDirContext, "local repo not found", nil) } if !s.Core().Process().RunIn(context.Background(), repoDir, "git", "rev-parse", "--git-dir").OK { - return "", core.E("agentic.repoSyncRepoDir", "local repo is not a git checkout", nil) + return "", core.E(repoSyncRepoDirContext, "local repo is not a git checkout", nil) } return repoDir, nil } diff --git a/pkg/brain/messaging.go b/pkg/brain/messaging.go index fc98362..0b4d5d1 100644 --- a/pkg/brain/messaging.go +++ b/pkg/brain/messaging.go @@ -86,27 +86,40 @@ func (s *DirectSubsystem) sendMessage(ctx context.Context, _ *mcp.CallToolReques return nil, SendOutput{}, core.E("brain.sendMessage", "to and content are required", nil) } - // "self" target: push via notifications/claude/channel directly. - // Claude Code expects: { content: string, meta: Record } if input.To == "self" { - if s.Core() != nil { - if mcpResult := s.Core().Service("mcp"); mcpResult.OK { - if mcpSvc, ok := mcpResult.Value.(*coremcp.Service); ok { - for session := range mcpSvc.Sessions() { - coremcp.NotifySession(ctx, session, "notifications/claude/channel", map[string]any{ - "content": input.Content, - "meta": map[string]string{ - "from": agentic.AgentName(), - "subject": input.Subject, - }, - }) - } - } - } - } + s.notifySelf(ctx, input) return nil, SendOutput{Success: true, ID: 0, To: "self"}, nil } + return s.sendRemoteMessage(ctx, input) +} + +func (s *DirectSubsystem) notifySelf(ctx context.Context, input SendInput) { + // "self" target: push via notifications/claude/channel directly. + // Claude Code expects: { content: string, meta: Record } + if s.Core() == nil { + return + } + mcpResult := s.Core().Service("mcp") + if !mcpResult.OK { + return + } + mcpSvc, ok := mcpResult.Value.(*coremcp.Service) + if !ok { + return + } + for session := range mcpSvc.Sessions() { + coremcp.NotifySession(ctx, session, "notifications/claude/channel", map[string]any{ + "content": input.Content, + "meta": map[string]string{ + "from": agentic.AgentName(), + "subject": input.Subject, + }, + }) + } +} + +func (s *DirectSubsystem) sendRemoteMessage(ctx context.Context, input SendInput) (*mcp.CallToolResult, SendOutput, error) { result := s.apiCall(ctx, "POST", "/v1/messages/send", map[string]any{ "to": input.To, "from": agentic.AgentName(), diff --git a/pkg/lib/flow/flow.go b/pkg/lib/flow/flow.go index 0a534be..1a80d7c 100644 --- a/pkg/lib/flow/flow.go +++ b/pkg/lib/flow/flow.go @@ -13,6 +13,8 @@ import ( var fs = (&core.Fs{}).NewUnrestricted() +const parseFileContext = "flow.ParseFile" + //go:embed *.md upgrade var embeddedFiles embed.FS @@ -55,14 +57,14 @@ func ParseFile(path string) (Flow, error) { readResult := fs.Read(path) if !readResult.OK { if err, ok := readResult.Value.(error); ok { - return Flow{}, core.E("flow.ParseFile", core.Concat("read ", path), err) + return Flow{}, core.E(parseFileContext, core.Concat("read ", path), err) } - return Flow{}, core.E("flow.ParseFile", core.Concat("read ", path), nil) + return Flow{}, core.E(parseFileContext, core.Concat("read ", path), nil) } content, ok := readResult.Value.(string) if !ok { - return Flow{}, core.E("flow.ParseFile", core.Concat("read ", path), nil) + return Flow{}, core.E(parseFileContext, core.Concat("read ", path), nil) } return Parse(bytes.NewBufferString(content)) diff --git a/plugins/core-go/scripts/smart-commit.sh b/plugins/core-go/scripts/smart-commit.sh index 4f77c12..74daa78 100755 --- a/plugins/core-go/scripts/smart-commit.sh +++ b/plugins/core-go/scripts/smart-commit.sh @@ -26,7 +26,7 @@ done # Get staged changes STAGED_FILES=$(git diff --staged --name-status) -if [ -z "$STAGED_FILES" ]; then +if [[ -z "$STAGED_FILES" ]]; then echo "No staged changes to commit." exit 0 fi @@ -53,20 +53,20 @@ elif git diff --staged | grep -q -E "^\+.*(refactor|restructure)"; then fi # Determine scope from the most common path component -if [ -n "$STAGED_FILE_PATHS" ]; then +if [[ -n "$STAGED_FILE_PATHS" ]]; then # Extract the second component of each path (e.g., 'code' from 'claude/code/file.md') # This is a decent heuristic for module name. # We filter for lines that have a second component. POSSIBLE_SCOPES=$(echo "$STAGED_FILE_PATHS" | grep '/' | cut -d/ -f2) - if [ -n "$POSSIBLE_SCOPES" ]; then + if [[ -n "$POSSIBLE_SCOPES" ]]; then SCOPE=$(echo "$POSSIBLE_SCOPES" | sort | uniq -c | sort -nr | head -n 1 | awk '{print $2}') fi # If no scope is found (e.g., all files are in root), SCOPE remains empty, which is valid. fi # Construct the commit message -if [ -n "$CUSTOM_MESSAGE" ]; then +if [[ -n "$CUSTOM_MESSAGE" ]]; then COMMIT_MESSAGE="$CUSTOM_MESSAGE" else # Auto-generate a descriptive summary @@ -75,8 +75,8 @@ else # This is a simple heuristic that can be greatly expanded. SUMMARY=$(echo "$DIFF_CONTENT" | grep -E -o "(function|class|def) \w+" | head -n 1 | sed -e 's/function //g' -e 's/class //g' -e 's/def //g') - if [ -z "$SUMMARY" ]; then - if [ $(echo "$STAGED_FILE_PATHS" | wc -l) -eq 1 ]; then + if [[ -z "$SUMMARY" ]]; then + if [[ $(echo "$STAGED_FILE_PATHS" | wc -l) -eq 1 ]]; then FIRST_FILE=$(echo "$STAGED_FILE_PATHS" | head -n 1) SUMMARY="update $(basename "$FIRST_FILE")" else @@ -100,7 +100,7 @@ fi # Execute the commit git commit $AMEND_FLAG -m "$(echo -e "$COMMIT_MESSAGE")" -if [ $? -eq 0 ]; then +if [[ $? -eq 0 ]]; then echo "Commit successful." else echo "Commit failed." diff --git a/plugins/core-php/scripts/qa-filter.sh b/plugins/core-php/scripts/qa-filter.sh index 6a83930..18f4fd7 100755 --- a/plugins/core-php/scripts/qa-filter.sh +++ b/plugins/core-php/scripts/qa-filter.sh @@ -27,7 +27,7 @@ SUMMARY=$(echo "$OUTPUT" | grep -E "^(fmt:|lint:|test:|pint:|stan:|=== RESULT == # Also grab specific error lines with file:line references FILE_ERRORS=$(echo "$OUTPUT" | grep -E "^[a-zA-Z0-9_/.-]+\.(go|php):[0-9]+:" | head -10) -if [ -z "$FAILURES" ] && [ "$EXIT_CODE" = "0" ]; then +if [[ -z "$FAILURES" && "$EXIT_CODE" == "0" ]]; then # All passed - show brief confirmation cat << 'EOF' { diff --git a/plugins/core-php/scripts/smart-commit.sh b/plugins/core-php/scripts/smart-commit.sh index 4f77c12..74daa78 100755 --- a/plugins/core-php/scripts/smart-commit.sh +++ b/plugins/core-php/scripts/smart-commit.sh @@ -26,7 +26,7 @@ done # Get staged changes STAGED_FILES=$(git diff --staged --name-status) -if [ -z "$STAGED_FILES" ]; then +if [[ -z "$STAGED_FILES" ]]; then echo "No staged changes to commit." exit 0 fi @@ -53,20 +53,20 @@ elif git diff --staged | grep -q -E "^\+.*(refactor|restructure)"; then fi # Determine scope from the most common path component -if [ -n "$STAGED_FILE_PATHS" ]; then +if [[ -n "$STAGED_FILE_PATHS" ]]; then # Extract the second component of each path (e.g., 'code' from 'claude/code/file.md') # This is a decent heuristic for module name. # We filter for lines that have a second component. POSSIBLE_SCOPES=$(echo "$STAGED_FILE_PATHS" | grep '/' | cut -d/ -f2) - if [ -n "$POSSIBLE_SCOPES" ]; then + if [[ -n "$POSSIBLE_SCOPES" ]]; then SCOPE=$(echo "$POSSIBLE_SCOPES" | sort | uniq -c | sort -nr | head -n 1 | awk '{print $2}') fi # If no scope is found (e.g., all files are in root), SCOPE remains empty, which is valid. fi # Construct the commit message -if [ -n "$CUSTOM_MESSAGE" ]; then +if [[ -n "$CUSTOM_MESSAGE" ]]; then COMMIT_MESSAGE="$CUSTOM_MESSAGE" else # Auto-generate a descriptive summary @@ -75,8 +75,8 @@ else # This is a simple heuristic that can be greatly expanded. SUMMARY=$(echo "$DIFF_CONTENT" | grep -E -o "(function|class|def) \w+" | head -n 1 | sed -e 's/function //g' -e 's/class //g' -e 's/def //g') - if [ -z "$SUMMARY" ]; then - if [ $(echo "$STAGED_FILE_PATHS" | wc -l) -eq 1 ]; then + if [[ -z "$SUMMARY" ]]; then + if [[ $(echo "$STAGED_FILE_PATHS" | wc -l) -eq 1 ]]; then FIRST_FILE=$(echo "$STAGED_FILE_PATHS" | head -n 1) SUMMARY="update $(basename "$FIRST_FILE")" else @@ -100,7 +100,7 @@ fi # Execute the commit git commit $AMEND_FLAG -m "$(echo -e "$COMMIT_MESSAGE")" -if [ $? -eq 0 ]; then +if [[ $? -eq 0 ]]; then echo "Commit successful." else echo "Commit failed." diff --git a/tests/cli/_lib/run.sh b/tests/cli/_lib/run.sh index 9678796..ece5bcc 100644 --- a/tests/cli/_lib/run.sh +++ b/tests/cli/_lib/run.sh @@ -18,6 +18,8 @@ run_capture_stdout() { fi return 1 fi + + return 0 } run_capture_all() { @@ -38,6 +40,8 @@ run_capture_all() { fi return 1 fi + + return 0 } assert_jq() {