arkaos 2.13.0 → 2.15.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/VERSION +1 -1
- package/arka/skills/forge/SKILL.md +649 -0
- package/config/constitution.yaml +8 -0
- package/config/hooks/post-tool-use.sh +43 -0
- package/config/hooks/session-start.sh +24 -0
- package/config/hooks/user-prompt-submit.sh +25 -1
- package/core/forge/__init__.py +104 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/handoff.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/forge/complexity.py +125 -0
- package/core/forge/handoff.py +100 -0
- package/core/forge/persistence.py +308 -0
- package/core/forge/renderer.py +261 -0
- package/core/forge/schema.py +213 -0
- package/core/synapse/__init__.py +2 -2
- package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +4 -2
- package/core/synapse/layers.py +49 -0
- package/core/sync/__init__.py +25 -0
- package/core/sync/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
- package/core/sync/descriptor_syncer.py +166 -0
- package/core/sync/discovery.py +256 -0
- package/core/sync/engine.py +177 -0
- package/core/sync/features/forge.yaml +16 -0
- package/core/sync/features/quality-gate.yaml +15 -0
- package/core/sync/features/spec-gate.yaml +15 -0
- package/core/sync/features/workflow-tiers.yaml +19 -0
- package/core/sync/manifest.py +87 -0
- package/core/sync/mcp_syncer.py +255 -0
- package/core/sync/reporter.py +178 -0
- package/core/sync/schema.py +94 -0
- package/core/sync/settings_syncer.py +121 -0
- package/core/workflow/state_reader.sh +25 -1
- package/departments/ops/skills/update/SKILL.md +69 -0
- package/installer/update.js +14 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Forge schema — Pydantic models and enums for the ArkaOS Intelligent Planning Engine.
|
|
2
|
+
|
|
3
|
+
The Forge analyses incoming requests, scores complexity across five dimensions,
|
|
4
|
+
selects the appropriate execution tier (shallow / standard / deep), and emits a
|
|
5
|
+
structured ForgePlan that downstream agents consume.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Enums
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
class ForgeTier(str, Enum):
|
|
19
|
+
"""Execution tier determined by complexity score."""
|
|
20
|
+
SHALLOW = "shallow"
|
|
21
|
+
STANDARD = "standard"
|
|
22
|
+
DEEP = "deep"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ForgeStatus(str, Enum):
|
|
26
|
+
"""Lifecycle status of a ForgePlan."""
|
|
27
|
+
DRAFT = "draft"
|
|
28
|
+
REVIEWING = "reviewing"
|
|
29
|
+
APPROVED = "approved"
|
|
30
|
+
EXECUTING = "executing"
|
|
31
|
+
COMPLETED = "completed"
|
|
32
|
+
REJECTED = "rejected"
|
|
33
|
+
CANCELLED = "cancelled"
|
|
34
|
+
ARCHIVED = "archived"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExplorerLens(str, Enum):
|
|
38
|
+
"""The analytical perspective used when exploring a plan."""
|
|
39
|
+
PRAGMATIC = "pragmatic" # Focus on fastest viable path
|
|
40
|
+
ARCHITECTURAL = "architectural" # Focus on long-term design health
|
|
41
|
+
CONTRARIAN = "contrarian" # Challenge assumptions, surface risks
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RiskSeverity(str, Enum):
|
|
45
|
+
"""Severity level for identified risks."""
|
|
46
|
+
LOW = "low"
|
|
47
|
+
MEDIUM = "medium"
|
|
48
|
+
HIGH = "high"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ExecutionPathType(str, Enum):
|
|
52
|
+
"""Type of execution artefact that fulfils a plan step."""
|
|
53
|
+
SKILL = "skill"
|
|
54
|
+
WORKFLOW = "workflow"
|
|
55
|
+
ENTERPRISE_WORKFLOW = "enterprise_workflow"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Models
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
class ComplexityDimensions(BaseModel):
|
|
63
|
+
"""Five-axis complexity breakdown, each scored 0–100."""
|
|
64
|
+
|
|
65
|
+
scope: int = Field(default=0, description="Breadth of change across the codebase or system.")
|
|
66
|
+
dependencies: int = Field(default=0, description="Number and criticality of upstream/downstream dependencies.")
|
|
67
|
+
ambiguity: int = Field(default=0, description="How unclear or under-specified the requirements are.")
|
|
68
|
+
risk: int = Field(default=0, description="Potential for breakage, data loss, or security impact.")
|
|
69
|
+
novelty: int = Field(default=0, description="How unlike existing patterns this work is.")
|
|
70
|
+
|
|
71
|
+
@field_validator("scope", "dependencies", "ambiguity", "risk", "novelty", mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def clamp_to_range(cls, v: int) -> int:
|
|
74
|
+
"""Clamp dimension value to [0, 100]."""
|
|
75
|
+
return max(0, min(100, int(v)))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ComplexityScore(BaseModel):
|
|
79
|
+
"""Aggregated complexity result produced by the Complexity Scorer."""
|
|
80
|
+
|
|
81
|
+
score: int = Field(default=0, description="Composite 0–100 score derived from all dimensions.")
|
|
82
|
+
tier: ForgeTier = Field(default=ForgeTier.SHALLOW, description="Execution tier selected based on the composite score.")
|
|
83
|
+
dimensions: ComplexityDimensions = Field(default_factory=ComplexityDimensions, description="Per-dimension breakdown.")
|
|
84
|
+
similar_plans: List[str] = Field(
|
|
85
|
+
default_factory=list,
|
|
86
|
+
description="IDs of previously completed plans with similar profiles.",
|
|
87
|
+
)
|
|
88
|
+
reused_patterns: List[str] = Field(
|
|
89
|
+
default_factory=list,
|
|
90
|
+
description="Named patterns from the ArkaOS pattern library reused in this plan.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Approach & Critic Models
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
class KeyDecision(BaseModel):
|
|
99
|
+
"""A single decision made during exploration."""
|
|
100
|
+
decision: str
|
|
101
|
+
rationale: str = ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PhaseDeliverable(BaseModel):
|
|
105
|
+
"""A deliverable within a plan phase."""
|
|
106
|
+
name: str
|
|
107
|
+
deliverables: list[str] = Field(default_factory=list)
|
|
108
|
+
effort: str = "medium"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ExplorerApproach(BaseModel):
|
|
112
|
+
"""Output from a single explorer agent."""
|
|
113
|
+
explorer: ExplorerLens
|
|
114
|
+
summary: str = ""
|
|
115
|
+
key_decisions: list[KeyDecision] = Field(default_factory=list)
|
|
116
|
+
phases: list[PhaseDeliverable] = Field(default_factory=list)
|
|
117
|
+
estimated_total_effort: str = "medium"
|
|
118
|
+
risks: list[str] = Field(default_factory=list)
|
|
119
|
+
reuses_patterns: list[str] = Field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RejectedElement(BaseModel):
|
|
123
|
+
"""An element rejected by the critic with reason."""
|
|
124
|
+
element: str
|
|
125
|
+
reason: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class IdentifiedRisk(BaseModel):
|
|
129
|
+
"""A risk identified by the critic with mitigation."""
|
|
130
|
+
risk: str
|
|
131
|
+
mitigation: str = ""
|
|
132
|
+
severity: RiskSeverity = RiskSeverity.LOW
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class CriticVerdict(BaseModel):
|
|
136
|
+
"""Synthesis output from the Plan Critic."""
|
|
137
|
+
synthesis: dict[str, list[str]] = Field(default_factory=dict)
|
|
138
|
+
rejected_elements: list[RejectedElement] = Field(default_factory=list)
|
|
139
|
+
risks: list[IdentifiedRisk] = Field(default_factory=list)
|
|
140
|
+
confidence: float = 0.0
|
|
141
|
+
estimated_phases: int = 0
|
|
142
|
+
estimated_departments: list[str] = Field(default_factory=list)
|
|
143
|
+
|
|
144
|
+
def is_valid(self) -> bool:
|
|
145
|
+
"""Check critic rules: must reject >= 1 and identify >= 1 risk."""
|
|
146
|
+
return len(self.rejected_elements) >= 1 and len(self.risks) >= 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# ForgePlan and Context Models
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
class ForgeContext(BaseModel):
|
|
154
|
+
"""Snapshot of repo state when forge was initiated."""
|
|
155
|
+
repo: str
|
|
156
|
+
branch: str
|
|
157
|
+
commit_at_forge: str
|
|
158
|
+
arkaos_version: str
|
|
159
|
+
prompt: str
|
|
160
|
+
context_refreshed: bool = False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PlanPhase(BaseModel):
|
|
164
|
+
"""A single phase in the forge plan."""
|
|
165
|
+
name: str
|
|
166
|
+
department: str
|
|
167
|
+
agents: list[str] = Field(default_factory=list)
|
|
168
|
+
deliverables: list[str] = Field(default_factory=list)
|
|
169
|
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
|
170
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
171
|
+
context_from_forge: dict[str, list[str]] = Field(default_factory=dict)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ExecutionPath(BaseModel):
|
|
175
|
+
"""How the plan will be executed after approval."""
|
|
176
|
+
type: ExecutionPathType = ExecutionPathType.SKILL
|
|
177
|
+
target: str = ""
|
|
178
|
+
departments: list[str] = Field(default_factory=list)
|
|
179
|
+
estimated_commits: int = 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ForgeGovernance(BaseModel):
|
|
183
|
+
"""Governance metadata for a forge plan."""
|
|
184
|
+
constitution_check: str = "pending"
|
|
185
|
+
violations: list[str] = Field(default_factory=list)
|
|
186
|
+
quality_gate_required: bool = True
|
|
187
|
+
branch_strategy: str = ""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class ForgePlan(BaseModel):
|
|
191
|
+
"""Complete forge plan — the primary artifact of The Forge."""
|
|
192
|
+
id: str
|
|
193
|
+
name: str
|
|
194
|
+
created_at: str = ""
|
|
195
|
+
forged_by: str = ""
|
|
196
|
+
version: int = 1
|
|
197
|
+
|
|
198
|
+
context: ForgeContext
|
|
199
|
+
complexity: ComplexityScore = Field(default_factory=ComplexityScore)
|
|
200
|
+
approaches: list[ExplorerApproach] = Field(default_factory=list)
|
|
201
|
+
critic: CriticVerdict = Field(default_factory=CriticVerdict)
|
|
202
|
+
|
|
203
|
+
plan_phases: list[PlanPhase] = Field(default_factory=list)
|
|
204
|
+
goal: str = ""
|
|
205
|
+
execution_path: ExecutionPath = Field(default_factory=ExecutionPath)
|
|
206
|
+
|
|
207
|
+
governance: ForgeGovernance = Field(default_factory=ForgeGovernance)
|
|
208
|
+
|
|
209
|
+
status: ForgeStatus = ForgeStatus.DRAFT
|
|
210
|
+
approved_at: Optional[str] = None
|
|
211
|
+
approved_by: Optional[str] = None
|
|
212
|
+
executed_at: Optional[str] = None
|
|
213
|
+
completion_notes: Optional[str] = None
|
package/core/synapse/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@ and 65% context reduction through intelligent filtering.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from core.synapse.engine import SynapseEngine
|
|
8
|
-
from core.synapse.layers import Layer, LayerResult
|
|
8
|
+
from core.synapse.layers import Layer, LayerResult, ForgeContextLayer
|
|
9
9
|
from core.synapse.cache import LayerCache
|
|
10
10
|
|
|
11
|
-
__all__ = ["SynapseEngine", "Layer", "LayerResult", "LayerCache"]
|
|
11
|
+
__all__ = ["SynapseEngine", "Layer", "LayerResult", "LayerCache", "ForgeContextLayer"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/core/synapse/engine.py
CHANGED
|
@@ -28,7 +28,7 @@ class SynapseResult:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
class SynapseEngine:
|
|
31
|
-
"""
|
|
31
|
+
"""9-layer context injection engine.
|
|
32
32
|
|
|
33
33
|
Computes all registered layers, caches results per TTL,
|
|
34
34
|
filters empty results, and combines into a compact context string.
|
|
@@ -155,7 +155,7 @@ def create_default_engine(
|
|
|
155
155
|
agents_registry: dict[str, dict] | None = None,
|
|
156
156
|
vector_store: Any = None,
|
|
157
157
|
) -> SynapseEngine:
|
|
158
|
-
"""Create a SynapseEngine with all
|
|
158
|
+
"""Create a SynapseEngine with all 9 default layers.
|
|
159
159
|
|
|
160
160
|
Args:
|
|
161
161
|
constitution_compressed: Compressed Constitution string for L0.
|
|
@@ -169,6 +169,7 @@ def create_default_engine(
|
|
|
169
169
|
ConstitutionLayer, DepartmentLayer, AgentLayer,
|
|
170
170
|
ProjectLayer, BranchLayer, CommandHintsLayer,
|
|
171
171
|
QualityGateLayer, TimeLayer, KnowledgeRetrievalLayer,
|
|
172
|
+
ForgeContextLayer,
|
|
172
173
|
)
|
|
173
174
|
|
|
174
175
|
engine = SynapseEngine()
|
|
@@ -184,5 +185,6 @@ def create_default_engine(
|
|
|
184
185
|
engine.register_layer(CommandHintsLayer(commands=commands))
|
|
185
186
|
engine.register_layer(QualityGateLayer())
|
|
186
187
|
engine.register_layer(TimeLayer())
|
|
188
|
+
engine.register_layer(ForgeContextLayer())
|
|
187
189
|
|
|
188
190
|
return engine
|
package/core/synapse/layers.py
CHANGED
|
@@ -525,3 +525,52 @@ class KnowledgeRetrievalLayer(Layer):
|
|
|
525
525
|
layer_id=self.id, tag=tag, content=content,
|
|
526
526
|
tokens_est=total_tokens, compute_ms=ms, cached=False,
|
|
527
527
|
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# --- L8: Forge Context ---
|
|
531
|
+
|
|
532
|
+
class ForgeContextLayer(Layer):
|
|
533
|
+
"""L8: Active forge plan context — decisions, risks, rejected approaches."""
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def id(self) -> str:
|
|
537
|
+
return "L8"
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def name(self) -> str:
|
|
541
|
+
return "ForgeContext"
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def cache_ttl(self) -> int:
|
|
545
|
+
return 0
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def priority(self) -> int:
|
|
549
|
+
return 80
|
|
550
|
+
|
|
551
|
+
def compute(self, ctx: PromptContext) -> LayerResult:
|
|
552
|
+
start = time.time()
|
|
553
|
+
try:
|
|
554
|
+
from core.forge.persistence import get_active_plan
|
|
555
|
+
plan = get_active_plan()
|
|
556
|
+
except Exception:
|
|
557
|
+
plan = None
|
|
558
|
+
if plan is None:
|
|
559
|
+
return LayerResult(layer_id=self.id, tag="", content="", tokens_est=0, compute_ms=0, cached=False)
|
|
560
|
+
tag = f"[forge:{plan.id}]"
|
|
561
|
+
parts = [f"Forge plan: {plan.id} ({plan.status.value})"]
|
|
562
|
+
if plan.critic.confidence > 0:
|
|
563
|
+
decisions = []
|
|
564
|
+
for source, elements in plan.critic.synthesis.items():
|
|
565
|
+
decisions.extend(elements)
|
|
566
|
+
if decisions:
|
|
567
|
+
parts.append(f"Decisions: {'; '.join(decisions[:5])}")
|
|
568
|
+
rejected = [r.element for r in plan.critic.rejected_elements]
|
|
569
|
+
if rejected:
|
|
570
|
+
parts.append(f"Rejected: {'; '.join(rejected[:3])}")
|
|
571
|
+
risks = [r.risk for r in plan.critic.risks]
|
|
572
|
+
if risks:
|
|
573
|
+
parts.append(f"Risks: {'; '.join(risks[:3])}")
|
|
574
|
+
content = " | ".join(parts)
|
|
575
|
+
ms = int((time.time() - start) * 1000)
|
|
576
|
+
return LayerResult(layer_id=self.id, tag=tag, content=content, tokens_est=len(content.split()), compute_ms=ms, cached=False)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""ArkaOS Sync Engine — Hybrid sync for /arka update."""
|
|
2
|
+
|
|
3
|
+
from core.sync.engine import run_sync
|
|
4
|
+
from core.sync.schema import (
|
|
5
|
+
ChangeManifest,
|
|
6
|
+
DescriptorSyncResult,
|
|
7
|
+
FeatureSpec,
|
|
8
|
+
McpSyncResult,
|
|
9
|
+
Project,
|
|
10
|
+
SettingsSyncResult,
|
|
11
|
+
SkillSyncResult,
|
|
12
|
+
SyncReport,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"run_sync",
|
|
17
|
+
"ChangeManifest",
|
|
18
|
+
"DescriptorSyncResult",
|
|
19
|
+
"FeatureSpec",
|
|
20
|
+
"McpSyncResult",
|
|
21
|
+
"Project",
|
|
22
|
+
"SettingsSyncResult",
|
|
23
|
+
"SkillSyncResult",
|
|
24
|
+
"SyncReport",
|
|
25
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Descriptor syncer for the ArkaOS Sync Engine.
|
|
2
|
+
|
|
3
|
+
Syncs project descriptor YAML frontmatter: auto-pauses inactive projects,
|
|
4
|
+
archives missing paths, and updates detected stacks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from core.sync.schema import DescriptorSyncResult, Project
|
|
16
|
+
|
|
17
|
+
_PAUSE_THRESHOLD_DAYS = 30
|
|
18
|
+
_REACTIVATE_THRESHOLD_DAYS = 7
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Public API
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sync_descriptor(project: Project) -> DescriptorSyncResult:
|
|
27
|
+
"""Sync a single project's descriptor file.
|
|
28
|
+
|
|
29
|
+
Reads the descriptor, checks if the project path exists, compares the
|
|
30
|
+
detected stack, checks git activity, and writes any updates back.
|
|
31
|
+
"""
|
|
32
|
+
if not project.descriptor_path:
|
|
33
|
+
return DescriptorSyncResult(path=project.path, status="unchanged")
|
|
34
|
+
|
|
35
|
+
desc_path = Path(project.descriptor_path)
|
|
36
|
+
if not desc_path.exists():
|
|
37
|
+
return DescriptorSyncResult(path=project.path, status="unchanged")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return _do_sync(project)
|
|
41
|
+
except Exception as exc: # noqa: BLE001
|
|
42
|
+
return DescriptorSyncResult(
|
|
43
|
+
path=project.path, status="error", error=str(exc)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _do_sync(project: Project) -> DescriptorSyncResult:
|
|
48
|
+
"""Execute the descriptor sync logic for a single project."""
|
|
49
|
+
desc_path = Path(project.descriptor_path) # type: ignore[arg-type]
|
|
50
|
+
text = desc_path.read_text()
|
|
51
|
+
frontmatter, body = _split_frontmatter(text)
|
|
52
|
+
changes: list[str] = []
|
|
53
|
+
|
|
54
|
+
if not Path(project.path).exists():
|
|
55
|
+
frontmatter["status"] = "archived"
|
|
56
|
+
changes.append("status: archived (path not found)")
|
|
57
|
+
_write_descriptor(desc_path, frontmatter, body)
|
|
58
|
+
return DescriptorSyncResult(
|
|
59
|
+
path=project.path, status="updated", changes=changes
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_check_stack(frontmatter, project.stack, changes)
|
|
63
|
+
_check_activity(frontmatter, project.path, changes)
|
|
64
|
+
|
|
65
|
+
if not changes:
|
|
66
|
+
return DescriptorSyncResult(path=project.path, status="unchanged")
|
|
67
|
+
|
|
68
|
+
_write_descriptor(desc_path, frontmatter, body)
|
|
69
|
+
return DescriptorSyncResult(
|
|
70
|
+
path=project.path, status="updated", changes=changes
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sync_all_descriptors(projects: list[Project]) -> list[DescriptorSyncResult]:
|
|
75
|
+
"""Sync descriptor files for all projects."""
|
|
76
|
+
return [sync_descriptor(p) for p in projects]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Private helpers
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _split_frontmatter(text: str) -> tuple[dict, str]:
|
|
85
|
+
"""Split a markdown file into its YAML frontmatter dict and body string.
|
|
86
|
+
|
|
87
|
+
Expects the file to start with '---' and have a closing '---' marker.
|
|
88
|
+
Returns ({}, full_text) if frontmatter markers are not found.
|
|
89
|
+
"""
|
|
90
|
+
if not text.startswith("---"):
|
|
91
|
+
return {}, text
|
|
92
|
+
|
|
93
|
+
parts = text.split("---", 2)
|
|
94
|
+
if len(parts) < 3:
|
|
95
|
+
return {}, text
|
|
96
|
+
|
|
97
|
+
raw_yaml = parts[1]
|
|
98
|
+
body = parts[2]
|
|
99
|
+
parsed = yaml.safe_load(raw_yaml) or {}
|
|
100
|
+
return parsed, body
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalize_stack_item(item: str) -> str:
|
|
104
|
+
"""Normalize a stack item to lowercase first word for comparison."""
|
|
105
|
+
return item.strip().lower().split()[0]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_stack(
|
|
109
|
+
frontmatter: dict, detected_stack: list[str], changes: list[str]
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Compare frontmatter stack with detected stack and update if different."""
|
|
112
|
+
fm_stack: list[str] = frontmatter.get("stack") or []
|
|
113
|
+
fm_normalized = {_normalize_stack_item(s) for s in fm_stack}
|
|
114
|
+
detected_normalized = {_normalize_stack_item(s) for s in detected_stack}
|
|
115
|
+
|
|
116
|
+
if fm_normalized != detected_normalized and detected_stack:
|
|
117
|
+
frontmatter["stack"] = detected_stack
|
|
118
|
+
changes.append(f"stack updated: {fm_stack} -> {detected_stack}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_activity(
|
|
122
|
+
frontmatter: dict, project_path: str, changes: list[str]
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Check git activity and auto-pause or auto-reactivate the project."""
|
|
125
|
+
days = _get_last_commit_days(project_path)
|
|
126
|
+
if days is None:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
current_status = frontmatter.get("status", "active")
|
|
130
|
+
|
|
131
|
+
if days > _PAUSE_THRESHOLD_DAYS and current_status == "active":
|
|
132
|
+
frontmatter["status"] = "paused"
|
|
133
|
+
changes.append(f"status: active -> paused ({days}d since last commit)")
|
|
134
|
+
elif days < _REACTIVATE_THRESHOLD_DAYS and current_status == "paused":
|
|
135
|
+
frontmatter["status"] = "active"
|
|
136
|
+
changes.append(f"status: paused -> active ({days}d since last commit)")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _get_last_commit_days(project_path: str) -> int | None:
|
|
140
|
+
"""Return days since the last git commit in project_path, or None."""
|
|
141
|
+
try:
|
|
142
|
+
result = subprocess.run(
|
|
143
|
+
["git", "log", "-1", "--format=%ci"],
|
|
144
|
+
cwd=project_path,
|
|
145
|
+
capture_output=True,
|
|
146
|
+
text=True,
|
|
147
|
+
timeout=10,
|
|
148
|
+
)
|
|
149
|
+
raw = result.stdout.strip()
|
|
150
|
+
if not raw:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
commit_dt = datetime.fromisoformat(raw)
|
|
154
|
+
if commit_dt.tzinfo is None:
|
|
155
|
+
commit_dt = commit_dt.replace(tzinfo=timezone.utc)
|
|
156
|
+
|
|
157
|
+
now = datetime.now(tz=timezone.utc)
|
|
158
|
+
return (now - commit_dt).days
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _write_descriptor(desc_path: Path, frontmatter: dict, body: str) -> None:
|
|
164
|
+
"""Write updated frontmatter and preserved body back to the descriptor file."""
|
|
165
|
+
fm_text = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True)
|
|
166
|
+
desc_path.write_text(f"---\n{fm_text}---{body}")
|