arkaos 2.41.0 → 2.43.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 CHANGED
@@ -1 +1 @@
1
- 2.41.0
1
+ 2.43.0
package/arka/SKILL.md CHANGED
@@ -188,6 +188,7 @@ violation (squad-routing, arka-supremacy, spec-driven, mandatory-qa).
188
188
  | `/arka status` | System status (version, departments, agents, active projects). Includes **LLM costs (24h)** section: top-line cost + cache hit rate + call count from `core.runtime.llm_cost_telemetry.summarise(period="today")`. Also includes **Enforcement (24h)** section: total calls, block rate, top blocked tools/reasons from `core.governance.enforcement_telemetry.summarise(period="today")` (PR19 v2.41.0). |
189
189
  | `/arka costs [period]` | LLM cost visibility — aggregates telemetry by day/week/month/all, with top expensive sessions. See `arka/skills/costs/SKILL.md`. Shells out to `python -m core.runtime.llm_cost_telemetry_cli <period>`. |
190
190
  | `/arka enforcement [period]` | Enforcement compliance — aggregates flow-marker enforcement telemetry by day/week/month/all. Shows block rate, top blocked tools, top reasons. Shells out to `python -m core.governance.enforcement_telemetry_cli <period>`. |
191
+ | `/arka reorganize [--since-days N]` | Dreaming → Agent reorganizer. Aggregates recent KB pattern/anti-pattern/lesson artifacts (default last 7 days) into a markdown proposal at `~/.arkaos/reorganize-proposals/<date>.md`. **Propose-only** — never modifies agent YAMLs. Sanitizes client identifiers from titles and body excerpts; drops `tags:` field entirely to prevent project-name leaks. Shells out to `python -m core.cognition.reorganizer_cli [--since-days N] [--dry-run]`. |
191
192
  | `/arka standup` | Daily standup (projects, priorities, blockers, updates) |
192
193
  | `/arka monitor` | System health monitoring |
193
194
  | `/arka onboard <path>` | Onboard an existing project into ArkaOS |
@@ -0,0 +1,412 @@
1
+ """Dreaming → Agent reorganizer MVP (PR20 v2.42.0).
2
+
3
+ Propose-only. Scans the KB for pattern/anti-pattern/lesson artifacts
4
+ produced by Dreaming, sanitizes client identifiers, and renders a
5
+ markdown proposal report. Never modifies agent YAMLs — that step is
6
+ left for human review (and PR21 will wire optional auto-PR creation).
7
+
8
+ Reads only. The only write is the proposal markdown file under
9
+ ``output_dir`` (default ``~/.arkaos/reorganize-proposals/``), and only
10
+ when ``dry_run=False``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import tempfile
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+
23
+ _FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n(.*)", re.DOTALL)
24
+ _BODY_EXCERPT_LIMIT = 500
25
+ _PROPOSAL_FILENAME_FMT = "%Y-%m-%d.md"
26
+ _DEFAULT_OUTPUT_DIR = Path.home() / ".arkaos" / "reorganize-proposals"
27
+ _REDACT_TOKEN = "<redacted-client>"
28
+
29
+ # Client-name redaction patterns are loaded from a user-local JSON file —
30
+ # NEVER hard-coded in source. v2.18.0 shipped client names in source and
31
+ # leaked them to the npm registry (see feedback_npm_publish_safety in
32
+ # user memory); v2.42.0 fixes that class of mistake by design.
33
+ #
34
+ # File: ~/.arkaos/redaction-clients.json
35
+ # Shape: {"clients": ["client-a", "client-b", ...]}
36
+ # Missing file or malformed JSON → empty list (no redaction, tags-drop
37
+ # safety net at `_render` is the architectural backstop).
38
+ #
39
+ # Word boundary uses negative lookaround that allows hyphens so
40
+ # `client-a-billing-quirk` redacts the `client-a` segment correctly
41
+ # without false-positives on `client-axfoo`.
42
+ _REDACT_CONFIG_PATH = Path.home() / ".arkaos" / "redaction-clients.json"
43
+
44
+
45
+ def _load_client_patterns() -> tuple[str, ...]:
46
+ """Read the user-local redaction list. Empty tuple on any error."""
47
+ try:
48
+ data = json.loads(_REDACT_CONFIG_PATH.read_text(encoding="utf-8"))
49
+ raw = data.get("clients", [])
50
+ return tuple(str(c).strip().lower() for c in raw if c and isinstance(c, str))
51
+ except (OSError, json.JSONDecodeError, AttributeError):
52
+ return ()
53
+
54
+
55
+ def _build_redact_re(patterns: tuple[str, ...]) -> re.Pattern[str] | None:
56
+ if not patterns:
57
+ return None
58
+ escaped = [re.escape(p) for p in patterns]
59
+ return re.compile(
60
+ r"(?<![a-z0-9])(" + "|".join(escaped) + r")(?![a-z0-9])",
61
+ re.IGNORECASE,
62
+ )
63
+
64
+
65
+ _CLIENT_PATTERNS: tuple[str, ...] = _load_client_patterns()
66
+ _REDACT_RE: re.Pattern[str] | None = _build_redact_re(_CLIENT_PATTERNS)
67
+
68
+ _KNOWN_CATEGORIES = ("pattern", "anti-pattern", "lesson")
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class KbArtifact:
73
+ """One pattern / anti-pattern / lesson surfaced from the KB."""
74
+ path: Path
75
+ category: str
76
+ title: str
77
+ confidence: str
78
+ tags: list[str] = field(default_factory=list)
79
+ first_seen: str = ""
80
+ last_seen: str = ""
81
+ times_used: int = 0
82
+ body_excerpt: str = ""
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ProposalReport:
87
+ """Result of build_proposal — never contains client identifiers."""
88
+ generated_at: str
89
+ since_days: int
90
+ kb_dir: str
91
+ artifact_count: int
92
+ by_category: dict[str, int] = field(default_factory=dict)
93
+ report_markdown: str = ""
94
+ report_path: Path | None = None
95
+
96
+
97
+ def build_proposal(
98
+ kb_dir: Path,
99
+ *,
100
+ since_days: int = 7,
101
+ output_dir: Path | None = None,
102
+ dry_run: bool = False,
103
+ ) -> ProposalReport:
104
+ """Aggregate recent KB artifacts into a propose-only markdown report."""
105
+ kb_dir = Path(kb_dir)
106
+ cutoff = datetime.now(timezone.utc).date() - timedelta(days=max(since_days - 1, 0))
107
+ artifacts = _scan_kb(kb_dir, cutoff)
108
+ by_category = _aggregate_by_category(artifacts)
109
+ generated_at = datetime.now(timezone.utc).isoformat()
110
+ markdown = _render(artifacts, by_category, since_days, kb_dir, generated_at)
111
+ report_path = None if dry_run else _write_report(markdown, output_dir)
112
+ return ProposalReport(
113
+ generated_at=generated_at,
114
+ since_days=since_days,
115
+ kb_dir=str(kb_dir),
116
+ artifact_count=len(artifacts),
117
+ by_category=by_category,
118
+ report_markdown=markdown,
119
+ report_path=report_path,
120
+ )
121
+
122
+
123
+ def _aggregate_by_category(artifacts: list[KbArtifact]) -> dict[str, int]:
124
+ counts: dict[str, int] = {}
125
+ for art in artifacts:
126
+ counts[art.category] = counts.get(art.category, 0) + 1
127
+ return counts
128
+
129
+
130
+ def _write_report(markdown: str, output_dir: Path | None) -> Path:
131
+ """Atomic markdown write to a validated output directory."""
132
+ out = _validate_output_dir(output_dir)
133
+ out.mkdir(parents=True, exist_ok=True)
134
+ report_path = out / datetime.now(timezone.utc).strftime(_PROPOSAL_FILENAME_FMT)
135
+ tmp_path = report_path.with_suffix(f".tmp-{os.getpid()}.md")
136
+ tmp_path.write_text(markdown, encoding="utf-8")
137
+ os.replace(tmp_path, report_path)
138
+ return report_path
139
+
140
+
141
+ def _validate_output_dir(output_dir: Path | None) -> Path:
142
+ """Allowlist the output directory to a safe parent.
143
+
144
+ Without this guard, programmatic callers passing an attacker-controlled
145
+ ``output_dir`` could write the proposal report anywhere the process has
146
+ write access (e.g., ``~/.ssh/``). Even though the CLI does not expose
147
+ ``--output-dir``, the public API does — defence in depth.
148
+
149
+ Allowed roots:
150
+ - ``~/.arkaos`` — the canonical production location
151
+ - the system tempdir — for tests using pytest's ``tmp_path`` and any
152
+ deliberate scratch use; still bounded by OS process privilege
153
+ """
154
+ if output_dir is None:
155
+ return _DEFAULT_OUTPUT_DIR
156
+ resolved = Path(output_dir).expanduser().resolve()
157
+ allowed_roots = (
158
+ (Path.home() / ".arkaos").resolve(),
159
+ Path(tempfile.gettempdir()).resolve(),
160
+ )
161
+ for root in allowed_roots:
162
+ try:
163
+ resolved.relative_to(root)
164
+ return resolved
165
+ except ValueError:
166
+ continue
167
+ raise ValueError(
168
+ "output_dir must be under one of "
169
+ f"{[str(r) for r in allowed_roots]}; got {resolved}"
170
+ )
171
+
172
+
173
+ def _scan_kb(kb_dir: Path, cutoff) -> list[KbArtifact]:
174
+ if not kb_dir.is_dir():
175
+ return []
176
+ out: list[KbArtifact] = []
177
+ for md in sorted(kb_dir.rglob("*.md")):
178
+ category = _category_from_filename(md.name)
179
+ if category is None:
180
+ continue
181
+ try:
182
+ text = md.read_text(encoding="utf-8")
183
+ except OSError:
184
+ continue
185
+ artifact = _parse_artifact(md, text, category)
186
+ if artifact is None:
187
+ continue
188
+ if not _within_window(artifact, cutoff):
189
+ continue
190
+ out.append(artifact)
191
+ return out
192
+
193
+
194
+ def _category_from_filename(name: str) -> str | None:
195
+ lower = name.lower()
196
+ if lower.startswith("anti-pattern-"):
197
+ return "anti-pattern"
198
+ if lower.startswith("pattern-"):
199
+ return "pattern"
200
+ if lower.startswith("lesson-"):
201
+ return "lesson"
202
+ return None
203
+
204
+
205
+ def _parse_artifact(path: Path, text: str, default_category: str) -> KbArtifact | None:
206
+ match = _FRONTMATTER_RE.match(text)
207
+ if not match:
208
+ return None
209
+ fm = _parse_frontmatter(match.group(1))
210
+ if not fm:
211
+ return None
212
+ body = match.group(2)
213
+ category = str(fm.get("category", default_category))
214
+ if category not in _KNOWN_CATEGORIES:
215
+ category = default_category
216
+ raw_title = str(fm.get("title", path.stem))
217
+ excerpt = _redact(_body_excerpt(body))
218
+ times_used = _safe_int(fm.get("times_used"))
219
+ return KbArtifact(
220
+ path=path,
221
+ category=category,
222
+ title=_redact(raw_title),
223
+ confidence=str(fm.get("confidence", "")),
224
+ tags=_parse_list(fm.get("tags")),
225
+ first_seen=str(fm.get("first_seen", "")),
226
+ last_seen=str(fm.get("last_seen", "")),
227
+ times_used=times_used,
228
+ body_excerpt=excerpt,
229
+ )
230
+
231
+
232
+ def _within_window(artifact: KbArtifact, cutoff) -> bool:
233
+ for raw in (artifact.last_seen, artifact.first_seen):
234
+ try:
235
+ seen = datetime.strptime(raw, "%Y-%m-%d").date()
236
+ except (ValueError, TypeError):
237
+ continue
238
+ if seen >= cutoff:
239
+ return True
240
+ return not artifact.first_seen and not artifact.last_seen
241
+
242
+
243
+ def _parse_frontmatter(block: str) -> dict:
244
+ out: dict[str, object] = {}
245
+ current_key: str | None = None
246
+ current_list: list[str] = []
247
+ for raw_line in block.splitlines():
248
+ if not raw_line.strip():
249
+ continue
250
+ if raw_line.startswith(" - "):
251
+ current_list.append(raw_line[4:].strip())
252
+ continue
253
+ if current_key is not None and current_list:
254
+ out[current_key] = list(current_list)
255
+ current_list = []
256
+ current_key = None
257
+ if ":" not in raw_line:
258
+ continue
259
+ key, _, value = raw_line.partition(":")
260
+ key, value = key.strip(), value.strip()
261
+ if value == "":
262
+ current_key = key
263
+ continue
264
+ out[key] = _parse_inline_value(value)
265
+ if current_key is not None and current_list:
266
+ out[current_key] = list(current_list)
267
+ return out
268
+
269
+
270
+ def _parse_inline_value(value: str) -> object:
271
+ if value.startswith("[") and value.endswith("]"):
272
+ inner = value[1:-1].strip()
273
+ if not inner:
274
+ return []
275
+ return [item.strip() for item in inner.split(",")]
276
+ return value
277
+
278
+
279
+ def _parse_list(value: object) -> list[str]:
280
+ if isinstance(value, list):
281
+ return [str(v) for v in value]
282
+ if isinstance(value, str) and value:
283
+ return [value]
284
+ return []
285
+
286
+
287
+ def _safe_int(value: object) -> int:
288
+ try:
289
+ return int(str(value))
290
+ except (TypeError, ValueError):
291
+ return 0
292
+
293
+
294
+ def _redact(text: str) -> str:
295
+ if _REDACT_RE is None:
296
+ return text
297
+ return _REDACT_RE.sub(_REDACT_TOKEN, text)
298
+
299
+
300
+ def _md_escape(text: str) -> str:
301
+ """Escape markdown control characters that would distort a table row.
302
+
303
+ Titles and excerpts come from frontmatter the operator controls, but
304
+ a `|` in a title silently shifts table columns and corrupts the raw-
305
+ artifact table. Escape pipes, newlines, and stray backticks.
306
+ """
307
+ return (
308
+ text.replace("\\", "\\\\")
309
+ .replace("|", "\\|")
310
+ .replace("\n", " ")
311
+ .replace("\r", " ")
312
+ .replace("`", "")
313
+ )
314
+
315
+
316
+ def _body_excerpt(body: str) -> str:
317
+ stripped = body.strip()
318
+ if len(stripped) <= _BODY_EXCERPT_LIMIT:
319
+ return stripped
320
+ return stripped[:_BODY_EXCERPT_LIMIT].rstrip() + "..."
321
+
322
+
323
+ def _render(
324
+ artifacts: list[KbArtifact],
325
+ by_category: dict[str, int],
326
+ since_days: int,
327
+ kb_dir: Path,
328
+ generated_at: str,
329
+ ) -> str:
330
+ today = generated_at.split("T", 1)[0]
331
+ parts = [
332
+ _render_header(today, len(artifacts), since_days),
333
+ _render_summary(by_category, since_days, len(artifacts)),
334
+ ]
335
+ if not artifacts:
336
+ parts.append("\n_(no artifacts in window — nothing to propose)_")
337
+ return "\n".join(parts)
338
+ parts.append(_render_suggested_actions(artifacts))
339
+ parts.append("## Raw artifact list\n\n" + _render_table(artifacts))
340
+ return "\n".join(parts)
341
+
342
+
343
+ def _render_header(today: str, count: int, since_days: int) -> str:
344
+ return (
345
+ f"# ArkaOS Reorganization Proposal — {today}\n"
346
+ "\n"
347
+ f"> Generated by `/arka reorganize` from {count} artifact(s) "
348
+ f"learned in the last {since_days} days.\n"
349
+ "> **Propose-only** — no agent YAML changes have been applied."
350
+ )
351
+
352
+
353
+ def _render_summary(by_category: dict[str, int], since_days: int, count: int) -> str:
354
+ lines = [
355
+ "\n## Summary\n",
356
+ f"- Window: last {since_days} days",
357
+ f"- Artifacts: **{count}**",
358
+ ]
359
+ for cat in _KNOWN_CATEGORIES:
360
+ if cat in by_category:
361
+ lines.append(f"- {cat.capitalize()}s: {by_category[cat]}")
362
+ return "\n".join(lines)
363
+
364
+
365
+ def _render_suggested_actions(artifacts: list[KbArtifact]) -> str:
366
+ lines = ["\n## Suggested actions\n"]
367
+ for cat in _KNOWN_CATEGORIES:
368
+ cat_items = [a for a in artifacts if a.category == cat]
369
+ if not cat_items:
370
+ continue
371
+ lines.append(f"### {cat.capitalize()}s\n")
372
+ for art in cat_items:
373
+ lines.append(_render_artifact_bullet(art, cat))
374
+ return "\n".join(lines)
375
+
376
+
377
+ def _render_artifact_bullet(art: KbArtifact, category: str) -> str:
378
+ # Tags intentionally NOT rendered — see _CLIENT_PATTERNS comment.
379
+ line = (
380
+ f"- **{art.title}** (confidence: {art.confidence or 'n/a'}, "
381
+ f"times_used: {art.times_used})\n"
382
+ f" suggested: review for {_suggest_target(category)}"
383
+ )
384
+ if art.body_excerpt:
385
+ line += f"\n > {art.body_excerpt}"
386
+ return line + "\n"
387
+
388
+
389
+ def _suggest_target(category: str) -> str:
390
+ if category == "pattern":
391
+ return "surfacing in the relevant department squad's context"
392
+ if category == "anti-pattern":
393
+ return "candidate detector in `core/governance/learning_detector.py`"
394
+ if category == "lesson":
395
+ return "Tier 0 review — potential constitution amendment"
396
+ return "manual review"
397
+
398
+
399
+ def _render_table(artifacts: list[KbArtifact]) -> str:
400
+ rows = [
401
+ "| Category | Title | Confidence | first_seen | last_seen | times_used |",
402
+ "|---|---|---|---|---|---|",
403
+ ]
404
+ for art in artifacts:
405
+ rows.append(
406
+ f"| {_md_escape(art.category)} | {_md_escape(art.title)} "
407
+ f"| {_md_escape(art.confidence) or 'n/a'} "
408
+ f"| {_md_escape(art.first_seen) or 'n/a'} "
409
+ f"| {_md_escape(art.last_seen) or 'n/a'} "
410
+ f"| {art.times_used} |"
411
+ )
412
+ return "\n".join(rows)
@@ -0,0 +1,72 @@
1
+ """CLI for the propose-only Dreaming reorganizer (PR20 v2.42.0).
2
+
3
+ Invoked as ``python -m core.cognition.reorganizer_cli [options]``.
4
+ Reads recent KB artifacts (pattern / anti-pattern / lesson files),
5
+ sanitizes client identifiers, and writes a proposal markdown report.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from core.cognition.reorganizer import build_proposal
16
+
17
+ # Default KB location — the Obsidian vault subfolder where Dreaming v2
18
+ # writes pattern/anti-pattern/lesson artifacts. Overridable via env var
19
+ # or --kb-dir for tests and unusual installs.
20
+ _DEFAULT_KB_DIR = (
21
+ Path.home()
22
+ / "Documents" / "Personal" / "Projects"
23
+ / "WizardingCode Internal" / "ArkaOS" / "Knowledge Base"
24
+ )
25
+
26
+
27
+ def _build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="arkaos-reorganize",
30
+ description="Aggregate recent KB artifacts into a propose-only "
31
+ "markdown report. Never modifies agent YAMLs.",
32
+ )
33
+ parser.add_argument(
34
+ "--since-days", type=int, default=7,
35
+ help="Window in days for first_seen/last_seen filter (default: 7).",
36
+ )
37
+ parser.add_argument(
38
+ "--kb-dir", type=Path, default=None,
39
+ help="Override the KB directory to scan (default: ArkaOS vault).",
40
+ )
41
+ parser.add_argument(
42
+ "--dry-run", action="store_true",
43
+ help="Print the report to stdout, do not write to disk.",
44
+ )
45
+ return parser
46
+
47
+
48
+ def main(argv: list[str]) -> int:
49
+ parser = _build_parser()
50
+ args = parser.parse_args(argv[1:])
51
+ kb_dir = args.kb_dir or Path(os.environ.get("ARKAOS_KB_DIR", _DEFAULT_KB_DIR))
52
+
53
+ report = build_proposal(
54
+ kb_dir,
55
+ since_days=args.since_days,
56
+ dry_run=args.dry_run,
57
+ )
58
+
59
+ if args.dry_run:
60
+ print(report.report_markdown)
61
+ return 0
62
+
63
+ print(f"Artifacts: {report.artifact_count}")
64
+ for cat, count in sorted(report.by_category.items()):
65
+ print(f" {cat}: {count}")
66
+ if report.report_path is not None:
67
+ print(f"Report: {report.report_path}")
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ raise SystemExit(main(sys.argv))
@@ -0,0 +1 @@
1
+ """Release pipeline tooling — preflight gate, version helpers."""
@@ -0,0 +1,225 @@
1
+ """Release preflight gate (PR21 v2.43.0).
2
+
3
+ Runs BEFORE the irreversible tag/push/publish steps of the release
4
+ pipeline. Catches expired tokens, version misalignment, and dirty
5
+ git state at minute 0 instead of minute 60.
6
+
7
+ Closes a real-world debt: v2.40.0 release took ~1h because the npm
8
+ token had expired silently and the failure was only discovered
9
+ after merge, tag, GH release. A 2-second check up front would have
10
+ turned that into an immediate STOP-and-rotate.
11
+
12
+ All shell-outs use ``subprocess.run(check=False, capture_output=True,
13
+ timeout=10)`` with argv as a list — no ``shell=True``, no string
14
+ interpolation of user input.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ _DEFAULT_REPO_ROOT = Path.cwd()
26
+ _SUBPROCESS_TIMEOUT = 10
27
+ _NPM_PACKAGE_VERSION_RE = re.compile(r'"version"\s*:\s*"([^"]+)"')
28
+ _PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CheckResult:
33
+ """Single preflight check outcome."""
34
+ name: str
35
+ passed: bool
36
+ reason: str
37
+ remediation: str | None = None
38
+ severity: str = "blocking"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class PreflightReport:
43
+ """Aggregated preflight verdict."""
44
+ all_passed: bool
45
+ results: list[CheckResult] = field(default_factory=list)
46
+ blocking_failures: list[CheckResult] = field(default_factory=list)
47
+ warnings: list[CheckResult] = field(default_factory=list)
48
+
49
+
50
+ def run_preflight(
51
+ *,
52
+ repo_root: Path | None = None,
53
+ expected_npm_user: str | None = None,
54
+ ) -> PreflightReport:
55
+ """Run every preflight check and return an aggregated report."""
56
+ root = repo_root or _DEFAULT_REPO_ROOT
57
+ results = [
58
+ check_version_alignment(repo_root=root),
59
+ check_npm_auth(expected_user=expected_npm_user),
60
+ check_npm_publish_capability(),
61
+ check_gh_auth(),
62
+ check_git_remote(),
63
+ check_git_clean(),
64
+ ]
65
+ return _aggregate(results)
66
+
67
+
68
+ def _load_repo_versions(
69
+ root: Path,
70
+ ) -> tuple[str | None, str | None, str | None] | None:
71
+ """Read VERSION, package.json, pyproject.toml. None if any unreadable."""
72
+ try:
73
+ v = (root / "VERSION").read_text(encoding="utf-8").strip()
74
+ pkg_match = _NPM_PACKAGE_VERSION_RE.search(
75
+ (root / "package.json").read_text(encoding="utf-8"),
76
+ )
77
+ py_match = _PYPROJECT_VERSION_RE.search(
78
+ (root / "pyproject.toml").read_text(encoding="utf-8"),
79
+ )
80
+ except (OSError, AttributeError):
81
+ return None
82
+ pkg_v = pkg_match.group(1) if pkg_match else None
83
+ py_v = py_match.group(1) if py_match else None
84
+ return v, pkg_v, py_v
85
+
86
+
87
+ def check_version_alignment(*, repo_root: Path | None = None) -> CheckResult:
88
+ """Verify VERSION, package.json, pyproject.toml all carry the same version."""
89
+ root = repo_root or _DEFAULT_REPO_ROOT
90
+ versions = _load_repo_versions(root)
91
+ if versions is None:
92
+ return CheckResult(
93
+ name="version-alignment", passed=False,
94
+ reason="could not read one of VERSION / package.json / pyproject.toml",
95
+ remediation="ensure all three files exist at repo root",
96
+ )
97
+ v, pkg_v, py_v = versions
98
+ if v and pkg_v == v and py_v == v:
99
+ return CheckResult(
100
+ name="version-alignment", passed=True,
101
+ reason=f"all three at {v}",
102
+ )
103
+ return CheckResult(
104
+ name="version-alignment", passed=False,
105
+ reason=f"mismatch: VERSION={v} package.json={pkg_v} pyproject={py_v}",
106
+ remediation="align all three files to the same version, then re-run",
107
+ )
108
+
109
+
110
+ def check_npm_auth(*, expected_user: str | None = None) -> CheckResult:
111
+ """Verify `npm whoami` returns a valid user (and matches expected, if set)."""
112
+ out = _run(["npm", "whoami"])
113
+ if out is None:
114
+ return CheckResult(
115
+ name="npm-auth", passed=False,
116
+ reason="npm command not found or timed out",
117
+ remediation="install Node.js / npm and retry",
118
+ )
119
+ if out.returncode != 0:
120
+ return CheckResult(
121
+ name="npm-auth", passed=False,
122
+ reason=f"npm whoami failed: {out.stderr.strip() or 'unknown'}",
123
+ remediation="run `npm login` or rotate token in ~/.npmrc",
124
+ )
125
+ user = out.stdout.strip()
126
+ if expected_user and user != expected_user:
127
+ return CheckResult(
128
+ name="npm-auth", passed=False,
129
+ reason=f"npm authenticated as {user}, expected {expected_user}",
130
+ remediation=f"switch token to {expected_user} or update expected user",
131
+ )
132
+ return CheckResult(
133
+ name="npm-auth", passed=True,
134
+ reason=f"authenticated as {user}",
135
+ )
136
+
137
+
138
+ def check_npm_publish_capability() -> CheckResult:
139
+ """Verify `npm pack --dry-run` succeeds — proves write scope on the package."""
140
+ out = _run(["npm", "pack", "--dry-run"])
141
+ if out is None or out.returncode != 0:
142
+ msg = (out.stderr.strip() if out else "command unavailable") or "unknown"
143
+ return CheckResult(
144
+ name="npm-publish-capability", passed=False,
145
+ reason=f"pack dry-run failed: {msg}",
146
+ remediation="check granular token has 'read and write' on this package",
147
+ )
148
+ return CheckResult(
149
+ name="npm-publish-capability", passed=True,
150
+ reason="pack dry-run OK",
151
+ )
152
+
153
+
154
+ def check_gh_auth() -> CheckResult:
155
+ """Verify `gh auth status` reports a logged-in session."""
156
+ out = _run(["gh", "auth", "status"])
157
+ if out is None:
158
+ return CheckResult(
159
+ name="gh-auth", passed=False,
160
+ reason="gh command not found or timed out",
161
+ remediation="install the GitHub CLI and run `gh auth login`",
162
+ )
163
+ if out.returncode != 0:
164
+ return CheckResult(
165
+ name="gh-auth", passed=False,
166
+ reason="gh not authenticated",
167
+ remediation="run `gh auth login`",
168
+ )
169
+ return CheckResult(name="gh-auth", passed=True, reason="authenticated")
170
+
171
+
172
+ def check_git_remote() -> CheckResult:
173
+ """Verify `git remote get-url origin` returns a real remote URL."""
174
+ out = _run(["git", "remote", "get-url", "origin"])
175
+ if out is None or out.returncode != 0:
176
+ return CheckResult(
177
+ name="git-remote", passed=False,
178
+ reason="no `origin` remote configured",
179
+ remediation="set up the remote with `git remote add origin <url>`",
180
+ )
181
+ return CheckResult(
182
+ name="git-remote", passed=True,
183
+ reason=out.stdout.strip(),
184
+ )
185
+
186
+
187
+ def check_git_clean() -> CheckResult:
188
+ """Flag uncommitted changes as a WARNING (not blocking)."""
189
+ out = _run(["git", "status", "--porcelain"])
190
+ if out is None:
191
+ return CheckResult(
192
+ name="git-clean", passed=False, severity="warning",
193
+ reason="git not available",
194
+ )
195
+ dirty = out.stdout.strip()
196
+ if not dirty:
197
+ return CheckResult(name="git-clean", passed=True, reason="working tree clean")
198
+ file_count = len([line for line in dirty.splitlines() if line.strip()])
199
+ return CheckResult(
200
+ name="git-clean", passed=False, severity="warning",
201
+ reason=f"{file_count} uncommitted file(s)",
202
+ remediation="commit, stash, or proceed deliberately",
203
+ )
204
+
205
+
206
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess | None:
207
+ """subprocess.run with our defaults; returns None on missing exe / timeout."""
208
+ try:
209
+ return subprocess.run(
210
+ cmd, capture_output=True, text=True,
211
+ timeout=_SUBPROCESS_TIMEOUT, check=False,
212
+ )
213
+ except (FileNotFoundError, subprocess.TimeoutExpired):
214
+ return None
215
+
216
+
217
+ def _aggregate(results: list[CheckResult]) -> PreflightReport:
218
+ blocking = [r for r in results if not r.passed and r.severity == "blocking"]
219
+ warnings = [r for r in results if not r.passed and r.severity == "warning"]
220
+ return PreflightReport(
221
+ all_passed=not blocking,
222
+ results=results,
223
+ blocking_failures=blocking,
224
+ warnings=warnings,
225
+ )
@@ -0,0 +1,74 @@
1
+ """CLI entry for the release preflight gate (PR21 v2.43.0).
2
+
3
+ Usage:
4
+ python -m core.release.preflight_cli [--expected-npm-user USER]
5
+
6
+ Exit code: 0 if all blocking checks pass, 1 otherwise. Warnings never
7
+ gate the exit code. Output is markdown so it renders cleanly in both
8
+ terminal and PR comments.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from datetime import datetime, timezone
16
+
17
+ from core.release.preflight import (
18
+ CheckResult,
19
+ PreflightReport,
20
+ run_preflight,
21
+ )
22
+
23
+
24
+ def _glyph(result: CheckResult) -> str:
25
+ if result.passed:
26
+ return "PASS"
27
+ if result.severity == "warning":
28
+ return "WARN"
29
+ return "FAIL"
30
+
31
+
32
+ def _render(report: PreflightReport) -> str:
33
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
34
+ lines = [
35
+ f"# Release Preflight — {today}",
36
+ "",
37
+ "| Check | Status | Notes |",
38
+ "|---|---|---|",
39
+ ]
40
+ for r in report.results:
41
+ notes = r.reason or ""
42
+ if r.remediation and not r.passed:
43
+ notes = f"{notes} | remediation: {r.remediation}"
44
+ lines.append(f"| {r.name} | {_glyph(r)} | {notes} |")
45
+ lines.append("")
46
+ if report.blocking_failures:
47
+ lines.append(f"**BLOCKED — {len(report.blocking_failures)} failure(s).** "
48
+ "Fix each remediation and rerun before tagging/publishing.")
49
+ elif report.warnings:
50
+ lines.append(f"All blocking checks passed ({len(report.warnings)} "
51
+ "warning(s) noted, non-blocking).")
52
+ else:
53
+ lines.append("All checks green. Safe to proceed to tag → release → publish.")
54
+ return "\n".join(lines)
55
+
56
+
57
+ def main(argv: list[str]) -> int:
58
+ parser = argparse.ArgumentParser(
59
+ prog="arkaos-preflight",
60
+ description="Run release preflight checks. Exit 1 on any blocking failure.",
61
+ )
62
+ parser.add_argument(
63
+ "--expected-npm-user", default=None,
64
+ help="Assert that `npm whoami` returns this user (e.g. wizardingcode).",
65
+ )
66
+ args = parser.parse_args(argv[1:])
67
+
68
+ report = run_preflight(expected_npm_user=args.expected_npm_user)
69
+ print(_render(report))
70
+ return 0 if report.all_passed else 1
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main(sys.argv))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.41.0",
3
+ "version": "2.43.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.41.0"
3
+ version = "2.43.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}