@polderlabs/bizar-omp 0.2.0 → 0.3.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/README.md +30 -4
- package/agents/bizar-architect.md +1 -1
- package/agents/bizar-docs.md +1 -1
- package/agents/bizar-implementer.md +1 -1
- package/agents/bizar-planner.md +1 -1
- package/agents/bizar-researcher.md +1 -1
- package/agents/bizar-reviewer.md +1 -1
- package/agents/bizar-security-reviewer.md +1 -1
- package/agents/bizar-verifier.md +1 -1
- package/dist/cli/doctor.d.ts +4 -4
- package/dist/cli/install.js +2 -2
- package/dist/extension.d.ts +17 -0
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +60 -12
- package/dist/extension.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/omp/autonomous-settings.d.ts +0 -5
- package/dist/omp/autonomous-settings.d.ts.map +1 -1
- package/dist/omp/autonomous-settings.js +1 -5
- package/dist/omp/autonomous-settings.js.map +1 -1
- package/dist/omp/compatibility.d.ts +4 -4
- package/dist/omp/compatibility.js +1 -1
- package/dist/omp/config.d.ts.map +1 -1
- package/dist/omp/config.js +2 -1
- package/dist/omp/config.js.map +1 -1
- package/dist/omp/model-role-assignment.d.ts +62 -0
- package/dist/omp/model-role-assignment.d.ts.map +1 -0
- package/dist/omp/model-role-assignment.js +92 -0
- package/dist/omp/model-role-assignment.js.map +1 -0
- package/dist/omp/model-roles.d.ts +11 -3
- package/dist/omp/model-roles.d.ts.map +1 -1
- package/dist/omp/model-roles.js +2 -1
- package/dist/omp/model-roles.js.map +1 -1
- package/docs/compatibility/baseline.json +2 -2
- package/docs/compatibility/phase-0-report.md +1 -1
- package/docs/compatibility/supported-surfaces.json +1 -1
- package/docs/releases/0.3.0.md +9 -0
- package/docs/releases/native-stable.md +1 -1
- package/docs/releases/npm-publishing.md +1 -1
- package/docs/releases/support-matrix.md +1 -1
- package/package.json +2 -1
- package/skills/omp-native-development/SKILL.md +111 -0
- package/skills/omp-native-development/agents/openai.yaml +4 -0
- package/skills/omp-native-development/assets/native-role-pack/agent-names.example.json +8 -0
- package/skills/omp-native-development/assets/native-role-pack/agents/bizar-implementer.md +12 -0
- package/skills/omp-native-development/assets/native-role-pack/agents/bizar-planner.md +12 -0
- package/skills/omp-native-development/assets/native-role-pack/agents/bizar-researcher.md +12 -0
- package/skills/omp-native-development/assets/native-role-pack/agents/bizar-reviewer.md +12 -0
- package/skills/omp-native-development/assets/native-role-pack/agents/bizar-security-reviewer.md +12 -0
- package/skills/omp-native-development/assets/native-role-pack/agents/bizar-verifier.md +12 -0
- package/skills/omp-native-development/assets/native-role-pack/bindings.json +8 -0
- package/skills/omp-native-development/assets/native-role-pack/config.fragment.json +10 -0
- package/skills/omp-native-development/assets/native-role-pack/config.fragment.yml +13 -0
- package/skills/omp-native-development/assets/native-role-probe.ts +37 -0
- package/skills/omp-native-development/assets/tests/acceptance-matrix.json +802 -0
- package/skills/omp-native-development/assets/tests/native-role-contract.test.ts +96 -0
- package/skills/omp-native-development/references/accuracy-and-versioning.md +63 -0
- package/skills/omp-native-development/references/agent-and-model-roles.md +158 -0
- package/skills/omp-native-development/references/bizar-integration-contract.md +100 -0
- package/skills/omp-native-development/references/bundle-validation.json +54 -0
- package/skills/omp-native-development/references/developer-handoff.md +126 -0
- package/skills/omp-native-development/references/execution-and-lifecycle.md +74 -0
- package/skills/omp-native-development/references/extensions-and-packaging.md +82 -0
- package/skills/omp-native-development/references/native-validation-matrix.md +96 -0
- package/skills/omp-native-development/references/official-docs-index.md +174 -0
- package/skills/omp-native-development/references/offline-test-results.txt +43 -0
- package/skills/omp-native-development/references/research-and-test-status.md +31 -0
- package/skills/omp-native-development/references/sessions-sdk-rpc.md +59 -0
- package/skills/omp-native-development/references/settings-providers-security.md +69 -0
- package/skills/omp-native-development/references/source-manifest.json +1359 -0
- package/skills/omp-native-development/references/tools-and-capabilities.md +59 -0
- package/skills/omp-native-development/scripts/audit_role_config.py +167 -0
- package/skills/omp-native-development/scripts/omp_docs.py +252 -0
- package/skills/omp-native-development/scripts/test_tools.py +222 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Tool and subsystem research map
|
|
2
|
+
|
|
3
|
+
Snapshot: `dbf3afad4894bde827d90f965e77b3fe1c5a95e5`. This is a navigation and verification guide, not a claim that every tool has been runtime-qualified.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
1. Use the live contract
|
|
7
|
+
2. Tool families
|
|
8
|
+
3. Cross-cutting subsystems
|
|
9
|
+
4. Extending coverage
|
|
10
|
+
5. Sources
|
|
11
|
+
|
|
12
|
+
## 1. Use the live contract
|
|
13
|
+
|
|
14
|
+
Do not maintain a guessed universal list of tool arguments. Native availability and schemas depend on settings, mode, provider support and discovery. For a tool change, read its pinned `docs/tools/<name>.md`, then follow its named implementation and model-facing prompt. Inspect the active schema and a representative result from the target runtime.
|
|
15
|
+
|
|
16
|
+
For code-mode/eval workflows, identify whether the tool is exposed directly, through a bridge, or as a helper inside a kernel. Intercepting one public tool call does not prove every nested effect is covered. Preserve native argument validation, approvals, artifact conventions and result bookkeeping.
|
|
17
|
+
|
|
18
|
+
## 2. Tool families
|
|
19
|
+
|
|
20
|
+
| Family | Indexed documents | Required investigation |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| File and text | tools/read.md, write.md, edit.md, glob.md, grep.md | Paths, native internal URLs, result truncation, edit semantics and side effects |
|
|
23
|
+
| AST and LSP | tools/ast-grep.md, ast-edit.md, lsp.md; lsp-config.md | Supported edit operations, diagnostics, code actions and write-through behavior |
|
|
24
|
+
| Execution | tools/bash.md, eval.md; bash-tool-runtime.md; python-repl.md | Shell/kernel lifecycle, backgrounding, cancellation, process and filesystem access |
|
|
25
|
+
| Delegation | tools/task.md, hub.md; agent-hub.md; task-agent-discovery.md | Dynamic schemas, role routing, jobs, messaging, revival and structured results |
|
|
26
|
+
| Browser/computer | tools/browser.md, computer.md; computer-use.md | Mode-specific interfaces, session ownership, user state and destructive actions |
|
|
27
|
+
| Research | tools/web_search.md, github.md | Provider configuration, source attribution, result grounding and permissions |
|
|
28
|
+
| Workflow state | tools/todo.md, checkpoint.md, rewind.md, new-context.md | Native state ownership, approval/plan interaction and history semantics |
|
|
29
|
+
| Memory/learning | tools/recall.md, retain.md, reflect.md, learn.md, memory_edit.md, context-notes.md | Backend behavior, scope, persistence, deletion and retrieval boundaries |
|
|
30
|
+
| Skills | tools/manage_skill.md; skills.md | Authored versus managed content, discovery, updates and provenance |
|
|
31
|
+
| Specialized | tools/debug.md, security_scan.md, generate_image.md, tts.md | Actual runtime dependencies and exact target support |
|
|
32
|
+
|
|
33
|
+
The table maps documentation paths, not guaranteed enabled tools. A tool's existence in the repository does not make it active in all user sessions or supported by every model.
|
|
34
|
+
|
|
35
|
+
## 3. Cross-cutting subsystems
|
|
36
|
+
|
|
37
|
+
For MCP, read configuration, transports, lifecycle and tool-authoring documents together. Establish who owns connections, whether children proxy parent tools, how refresh notifications work, and how errors/cancellation cross the bridge. Do not expose an unnecessary Bizar MCP server for functions already implemented in-process.
|
|
38
|
+
|
|
39
|
+
For memory, use the native backend/runtime interface where available. Do not modify another backend's internal database and call that integration. Distinguish workflow evidence, authored skills, learned skills and general memory; they have different provenance and acceptance roles.
|
|
40
|
+
|
|
41
|
+
For compaction, read the native method and artifact contracts before deciding what Bizar must checkpoint. Store authoritative workflow evidence outside expendable prompt summaries, with stable references. Do not disable native compaction merely to avoid implementing resume.
|
|
42
|
+
|
|
43
|
+
For provider integration, use the model configuration and compatibility references plus the exact provider implementation. Tool schemas, image encoding, context accounting, caching and retry safety depend on the transport. Model/endpoint names alone do not establish capabilities.
|
|
44
|
+
|
|
45
|
+
For TUI, follow the exact target's exported components. The main/release split at this pin is particularly relevant to UI imports. Prefer optional presentation that does not control workflow correctness.
|
|
46
|
+
|
|
47
|
+
For native bindings and isolation, follow the specific backend and build contracts. Do not assume Linux, macOS and Windows have identical clone semantics, dependencies or containment. A portable TypeScript wrapper still depends on native runtime behavior.
|
|
48
|
+
|
|
49
|
+
For remote/collaborative use, follow `rpc.md`, `collab.md` and the package-specific references. Keep remote/client capability differences explicit; do not turn a terminal extension into an unauthenticated server by adding an ad hoc HTTP listener.
|
|
50
|
+
|
|
51
|
+
## 4. Extending coverage
|
|
52
|
+
|
|
53
|
+
The docs index covers all enumerated root Markdown pages, including tool-conversation format notes, native architecture, marketplace authoring and internal runtime references. Some feature-specific detail lives in package READMEs, tests, examples or issues. Retrieve those at the same target revision and add their identifiers to the implementation evidence, not to an unsourced prose assertion.
|
|
54
|
+
|
|
55
|
+
When a source is absent, contradictory or unsupported by an accessible callsite, record UNKNOWN and design a focused probe. For genuinely new APIs, put proposed names in a proposal section and never present them as already available native methods.
|
|
56
|
+
|
|
57
|
+
## 5. Sources
|
|
58
|
+
|
|
59
|
+
Use [official-docs-index.md](official-docs-index.md) for immutable per-page URLs and research status. The index was generated from the complete enumerated `docs/` tree at the research commit; supporting Git blob identities are in [source-manifest.json](source-manifest.json).
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Static Bizar role-profile lint, NOT an OMP resolver or availability test.
|
|
3
|
+
|
|
4
|
+
Input: effective JSON from `omp config list --json`, or a nested JSON profile.
|
|
5
|
+
No YAML parsing, environment expansion, credentials, providers or model calls.
|
|
6
|
+
Native resolution/authentication and live-agent tests remain mandatory.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
BUILTINS = {"default", "smol", "slow", "vision", "plan", "commit", "tiny", "task", "advisor"}
|
|
18
|
+
TIERS = {"inherit", "none", "auto", "default", "flex", "scale", "priority"}
|
|
19
|
+
ROLE_ALIAS = re.compile(r"^(?:@|pi/)([^,\r\n]+?)(?::(?:off|minimal|low|medium|high|xhigh|max|auto))?$")
|
|
20
|
+
PLACEHOLDER = re.compile(r"REPLACE_|YOUR_|<[^>]+>", re.I)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_value(config: dict[str, Any], key: str, default: Any) -> Any:
|
|
24
|
+
if key in config:
|
|
25
|
+
value = config[key]
|
|
26
|
+
if isinstance(value, dict) and "type" in value and ("value" in value or value.get("redacted")):
|
|
27
|
+
return value.get("value", default)
|
|
28
|
+
return value
|
|
29
|
+
value: Any = config
|
|
30
|
+
for part in key.split("."):
|
|
31
|
+
if not isinstance(value, dict) or part not in value:
|
|
32
|
+
return default
|
|
33
|
+
value = value[part]
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def referenced_roles(value: str | list[str]) -> list[str]:
|
|
38
|
+
patterns = value if isinstance(value, list) else [value]
|
|
39
|
+
result = []
|
|
40
|
+
for item in patterns:
|
|
41
|
+
for pattern in item.split(","):
|
|
42
|
+
pattern = pattern.strip()
|
|
43
|
+
if pattern == "*" or re.fullmatch(r"\*:(off|minimal|low|medium|high|xhigh|max|auto)", pattern):
|
|
44
|
+
result.append("default")
|
|
45
|
+
elif (match := ROLE_ALIAS.fullmatch(pattern)):
|
|
46
|
+
result.append(match.group(1))
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def audit(config: dict[str, Any], required: list[str], known_agents: list[str] | None) -> dict[str, Any]:
|
|
51
|
+
errors: list[dict[str, str]] = []
|
|
52
|
+
warnings: list[dict[str, str]] = []
|
|
53
|
+
def error(code: str, message: str) -> None:
|
|
54
|
+
errors.append({"code": code, "message": message})
|
|
55
|
+
def warning(code: str, message: str) -> None:
|
|
56
|
+
warnings.append({"code": code, "message": message})
|
|
57
|
+
roles = get_value(config, "modelRoles", {})
|
|
58
|
+
overrides = get_value(config, "task.agentModelOverrides", {})
|
|
59
|
+
tiers = get_value(config, "task.agentServiceTierOverrides", {})
|
|
60
|
+
for label, value in (("modelRoles", roles), ("task.agentModelOverrides", overrides), ("task.agentServiceTierOverrides", tiers)):
|
|
61
|
+
if not isinstance(value, dict):
|
|
62
|
+
error("EXPECTED_RECORD", f"{label} must be a mapping for this profile")
|
|
63
|
+
if errors:
|
|
64
|
+
return {"static_ok": False, "native_resolution_tested": False, "errors": errors, "warnings": warnings}
|
|
65
|
+
if len(roles) > 512:
|
|
66
|
+
error("PROFILE_SIZE_LIMIT", "This static lint supports at most 512 explicitly configured roles")
|
|
67
|
+
return {"static_ok": False, "native_resolution_tested": False, "errors": errors, "warnings": warnings}
|
|
68
|
+
graph: dict[str, list[str]] = {}
|
|
69
|
+
for name, selector in roles.items():
|
|
70
|
+
if not isinstance(name, str) or not name.strip():
|
|
71
|
+
error("INVALID_ROLE_NAME", "Role keys must be nonempty strings")
|
|
72
|
+
continue
|
|
73
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
74
|
+
error("INVALID_ROLE_VALUE", f"Role {name!r} must use a nonempty selector string in this profile")
|
|
75
|
+
continue
|
|
76
|
+
if PLACEHOLDER.search(selector):
|
|
77
|
+
error("PLACEHOLDER", f"Role {name!r} still contains a template placeholder")
|
|
78
|
+
graph[name] = referenced_roles(selector)
|
|
79
|
+
for target in graph[name]:
|
|
80
|
+
if target not in BUILTINS and target not in roles:
|
|
81
|
+
error("UNCONFIGURED_ROLE", f"Role {name!r} references unconfigured custom role {target!r}")
|
|
82
|
+
if selector.startswith("pi/"):
|
|
83
|
+
warning("LEGACY_ALIAS", f"Role {name!r} uses the supported legacy alias prefix; prefer @ for new configuration")
|
|
84
|
+
visiting: set[str] = set()
|
|
85
|
+
visited: set[str] = set()
|
|
86
|
+
reported: set[str] = set()
|
|
87
|
+
def visit(name: str) -> None:
|
|
88
|
+
if name in visiting:
|
|
89
|
+
if name not in reported:
|
|
90
|
+
error("BIZAR_POLICY_CYCLE", f"Explicit role cycle through {name!r}; rejected by Bizar lint, not a claim that OMP always rejects cycles")
|
|
91
|
+
reported.add(name)
|
|
92
|
+
return
|
|
93
|
+
if name in visited:
|
|
94
|
+
return
|
|
95
|
+
visiting.add(name)
|
|
96
|
+
for target in graph.get(name, []):
|
|
97
|
+
if target in graph:
|
|
98
|
+
visit(target)
|
|
99
|
+
visiting.remove(name)
|
|
100
|
+
visited.add(name)
|
|
101
|
+
for name in graph:
|
|
102
|
+
visit(name)
|
|
103
|
+
for role in required:
|
|
104
|
+
if role not in roles and role not in BUILTINS:
|
|
105
|
+
error("MISSING_REQUIRED_ROLE", f"Required role {role!r} has no configured mapping")
|
|
106
|
+
for agent, selector in overrides.items():
|
|
107
|
+
values = [selector] if isinstance(selector, str) else selector
|
|
108
|
+
if not isinstance(values, list) or not values or any(not isinstance(v, str) or not v.strip() for v in values):
|
|
109
|
+
error("INVALID_AGENT_OVERRIDE", f"Agent override {agent!r} must contain nonempty selectors")
|
|
110
|
+
continue
|
|
111
|
+
if known_agents is not None and agent not in known_agents:
|
|
112
|
+
error("UNKNOWN_AGENT", f"Override {agent!r} is absent from the supplied discovered-agent names")
|
|
113
|
+
for target in referenced_roles(values):
|
|
114
|
+
if target not in BUILTINS and target not in roles:
|
|
115
|
+
error("UNCONFIGURED_ROLE", f"Agent override {agent!r} references unconfigured custom role {target!r}")
|
|
116
|
+
if any(PLACEHOLDER.search(v) for v in values):
|
|
117
|
+
error("PLACEHOLDER", f"Agent override {agent!r} contains a template placeholder")
|
|
118
|
+
for agent, tier in tiers.items():
|
|
119
|
+
if not isinstance(tier, str) or tier not in TIERS:
|
|
120
|
+
error("INVALID_SERVICE_TIER", f"Agent {agent!r} has an unsupported service-tier value")
|
|
121
|
+
if known_agents is not None and agent not in known_agents:
|
|
122
|
+
error("UNKNOWN_AGENT", f"Service-tier override {agent!r} is absent from supplied discovered-agent names")
|
|
123
|
+
for key in ("task.agentPrewalk", "task.agentAdvisor"):
|
|
124
|
+
value = get_value(config, key, {})
|
|
125
|
+
if not isinstance(value, dict):
|
|
126
|
+
error("EXPECTED_RECORD", f"{key} must be a mapping")
|
|
127
|
+
continue
|
|
128
|
+
for agent, selector in value.items():
|
|
129
|
+
if not isinstance(selector, str):
|
|
130
|
+
error("INVALID_SWITCH", f"{key}[{agent!r}] must use a quoted on/off/model-selector string")
|
|
131
|
+
elif selector.lower() not in {"on", "off", "true", "false"}:
|
|
132
|
+
for target in referenced_roles(selector):
|
|
133
|
+
if target not in BUILTINS and target not in roles:
|
|
134
|
+
error("UNCONFIGURED_ROLE", f"{key}[{agent!r}] references an unconfigured custom role")
|
|
135
|
+
storage = get_value(config, "modelRoleStorage", "global")
|
|
136
|
+
if not isinstance(storage, str) or storage not in {"global", "project"}:
|
|
137
|
+
error("INVALID_ROLE_STORAGE", "modelRoleStorage must be global or project")
|
|
138
|
+
if known_agents is None:
|
|
139
|
+
warning("AGENT_DISCOVERY_UNVERIFIED", "No native-discovered agent names supplied; exact-name bindings were not checked")
|
|
140
|
+
warning("NATIVE_RESOLUTION_UNVERIFIED", "A static pass proves neither native resolution, credential availability, capability support nor correct live worker selection")
|
|
141
|
+
return {"static_ok": not errors, "native_resolution_tested": False, "builtin_roles_at_pin": sorted(BUILTINS),
|
|
142
|
+
"configured_role_names": sorted(roles), "errors": errors, "warnings": warnings}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> int:
|
|
146
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
147
|
+
p.add_argument("config", type=Path)
|
|
148
|
+
p.add_argument("--required-role", action="append", default=[])
|
|
149
|
+
p.add_argument("--known-agents", type=Path, help="JSON array of exact agent names from actual discovery")
|
|
150
|
+
args = p.parse_args()
|
|
151
|
+
try:
|
|
152
|
+
config = json.loads(args.config.read_text(encoding="utf-8"))
|
|
153
|
+
names = json.loads(args.known_agents.read_text(encoding="utf-8")) if args.known_agents else None
|
|
154
|
+
if not isinstance(config, dict):
|
|
155
|
+
raise ValueError("Expected a JSON object")
|
|
156
|
+
if names is not None and (not isinstance(names, list) or any(not isinstance(n, str) for n in names)):
|
|
157
|
+
raise ValueError("known-agents must be a JSON array of strings")
|
|
158
|
+
report = audit(config, args.required_role, names)
|
|
159
|
+
print(json.dumps(report, indent=2))
|
|
160
|
+
return 0 if report["static_ok"] else 1
|
|
161
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
162
|
+
print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr)
|
|
163
|
+
return 2
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read, fetch and hash-check a commit-pinned OMP documentation corpus.
|
|
3
|
+
|
|
4
|
+
Python 3.10+, standard library only. Never runs downloaded code or changes a
|
|
5
|
+
checkout, OMP configuration, credentials, or the bundled reference manifest.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.request import Request, urlopen
|
|
20
|
+
|
|
21
|
+
REPOSITORY = "can1357/oh-my-pi"
|
|
22
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
23
|
+
DEFAULT_MANIFEST = Path(__file__).resolve().parents[1] / "references/source-manifest.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CorpusError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def git_blob_hash(data: bytes) -> str:
|
|
31
|
+
return hashlib.sha1(b"blob " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def safe_path(root: Path, relative: str) -> Path:
|
|
35
|
+
p = PurePosixPath(relative)
|
|
36
|
+
if not relative or p.is_absolute() or ".." in p.parts or "\\" in relative or ":" in relative or "\0" in relative or p.as_posix() != relative:
|
|
37
|
+
raise CorpusError(f"Unsafe relative path: {relative!r}")
|
|
38
|
+
root = root.expanduser().absolute()
|
|
39
|
+
if root.is_symlink():
|
|
40
|
+
raise CorpusError("Corpus root must not be a symlink")
|
|
41
|
+
target = root.joinpath(*p.parts)
|
|
42
|
+
current = root
|
|
43
|
+
for part in p.parts:
|
|
44
|
+
current = current / part
|
|
45
|
+
if current.is_symlink():
|
|
46
|
+
raise CorpusError(f"Symlink in corpus path: {relative}")
|
|
47
|
+
return target
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_manifest(path: Path) -> dict[str, Any]:
|
|
51
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
if value.get("schema_version") != 1 or value.get("repository") != REPOSITORY:
|
|
53
|
+
raise CorpusError("Unsupported manifest schema or repository")
|
|
54
|
+
if not re.fullmatch(r"[a-f0-9]{40}", value.get("commit", "")):
|
|
55
|
+
raise CorpusError("Manifest commit must be a full lowercase Git SHA")
|
|
56
|
+
entries = value.get("files")
|
|
57
|
+
if not isinstance(entries, list) or not entries:
|
|
58
|
+
raise CorpusError("Manifest contains no files")
|
|
59
|
+
seen: set[str] = set()
|
|
60
|
+
for entry in entries:
|
|
61
|
+
name = entry.get("path", "")
|
|
62
|
+
part = PurePosixPath(name)
|
|
63
|
+
if not name or part.is_absolute() or ".." in part.parts or "\\" in name or ":" in name or "\0" in name or part.as_posix() != name:
|
|
64
|
+
raise CorpusError("Unsafe manifest path")
|
|
65
|
+
if name in seen or not re.fullmatch(r"[a-f0-9]{40}", entry.get("git_blob_sha1", "")):
|
|
66
|
+
raise CorpusError(f"Invalid or duplicate manifest entry: {name}")
|
|
67
|
+
seen.add(name)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def checked_bytes(entry: dict[str, Any], data: bytes) -> bytes:
|
|
72
|
+
if len(data) > MAX_BYTES:
|
|
73
|
+
raise CorpusError(f"File exceeds size limit: {entry['path']}")
|
|
74
|
+
if git_blob_hash(data) != entry["git_blob_sha1"]:
|
|
75
|
+
raise CorpusError(f"Git blob hash mismatch: {entry['path']}")
|
|
76
|
+
data.decode("utf-8") # Reject binary/non-text responses, even for source files.
|
|
77
|
+
return data
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def git(repo: Path, *args: str) -> bytes:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["git", "-C", str(repo.expanduser().absolute()), *args],
|
|
83
|
+
capture_output=True, timeout=30, check=False,
|
|
84
|
+
env={**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"},
|
|
85
|
+
)
|
|
86
|
+
if result.returncode:
|
|
87
|
+
raise CorpusError("Local Git read failed: " + result.stderr.decode("utf-8", "replace")[:400])
|
|
88
|
+
return result.stdout
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fetch_file(manifest: dict[str, Any], entry: dict[str, Any], repo: Path | None) -> bytes:
|
|
92
|
+
if repo is not None:
|
|
93
|
+
spec = f"{manifest['commit']}:{entry['path']}"
|
|
94
|
+
size = int(git(repo, "cat-file", "-s", spec).strip())
|
|
95
|
+
if size > MAX_BYTES:
|
|
96
|
+
raise CorpusError("Git object exceeds size limit")
|
|
97
|
+
return checked_bytes(entry, git(repo, "cat-file", "blob", spec))
|
|
98
|
+
# Derive the URL, never trust an arbitrary URL in a supplied manifest.
|
|
99
|
+
url = f"https://raw.githubusercontent.com/{REPOSITORY}/{manifest['commit']}/{entry['path']}"
|
|
100
|
+
request = Request(url, headers={"User-Agent": "omp-native-development-docs/1.0"})
|
|
101
|
+
with urlopen(request, timeout=20) as response:
|
|
102
|
+
data = response.read(MAX_BYTES + 1)
|
|
103
|
+
return checked_bytes(entry, data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write_new(target: Path, data: bytes) -> None:
|
|
107
|
+
"""Create atomically without overwriting any existing destination."""
|
|
108
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
fd, tmp = tempfile.mkstemp(prefix=".omp-doc-", dir=target.parent)
|
|
110
|
+
try:
|
|
111
|
+
with os.fdopen(fd, "wb") as stream:
|
|
112
|
+
stream.write(data)
|
|
113
|
+
stream.flush()
|
|
114
|
+
os.fsync(stream.fileno())
|
|
115
|
+
os.link(tmp, target) # Fails, rather than overwrites, when target exists.
|
|
116
|
+
finally:
|
|
117
|
+
os.unlink(tmp)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sync(manifest: dict[str, Any], root: Path, repo: Path | None) -> dict[str, Any]:
|
|
121
|
+
fetched = reused = 0
|
|
122
|
+
if repo is not None:
|
|
123
|
+
actual = git(repo, "rev-parse", "--verify", manifest["commit"] + "^{commit}").decode().strip()
|
|
124
|
+
if actual != manifest["commit"]:
|
|
125
|
+
raise CorpusError("Local checkout does not contain the pinned commit")
|
|
126
|
+
for entry in manifest["files"]:
|
|
127
|
+
target = safe_path(root, entry["path"])
|
|
128
|
+
if target.exists():
|
|
129
|
+
checked_bytes(entry, target.read_bytes())
|
|
130
|
+
reused += 1
|
|
131
|
+
else:
|
|
132
|
+
data = fetch_file(manifest, entry, repo)
|
|
133
|
+
# Recheck before creating parents; this is path hygiene, not OS sandboxing.
|
|
134
|
+
target = safe_path(root, entry["path"])
|
|
135
|
+
write_new(target, data)
|
|
136
|
+
fetched += 1
|
|
137
|
+
return {"status": "complete", "commit": manifest["commit"], "fetched": fetched, "reused": reused}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def verify(manifest: dict[str, Any], root: Path) -> dict[str, Any]:
|
|
141
|
+
missing: list[str] = []
|
|
142
|
+
invalid: list[dict[str, str]] = []
|
|
143
|
+
valid = 0
|
|
144
|
+
for entry in manifest["files"]:
|
|
145
|
+
try:
|
|
146
|
+
target = safe_path(root, entry["path"])
|
|
147
|
+
if not target.exists():
|
|
148
|
+
missing.append(entry["path"])
|
|
149
|
+
continue
|
|
150
|
+
checked_bytes(entry, target.read_bytes())
|
|
151
|
+
valid += 1
|
|
152
|
+
except (CorpusError, OSError, UnicodeError) as exc:
|
|
153
|
+
invalid.append({"path": entry["path"], "error": str(exc)})
|
|
154
|
+
return {"complete": not missing and not invalid, "commit": manifest["commit"],
|
|
155
|
+
"expected": len(manifest["files"]), "valid": valid, "missing": missing, "invalid": invalid}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def find_entry(manifest: dict[str, Any], name: str) -> dict[str, Any]:
|
|
159
|
+
for entry in manifest["files"]:
|
|
160
|
+
if entry["path"] == name:
|
|
161
|
+
return entry
|
|
162
|
+
raise CorpusError("Path is not in the pinned manifest")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
167
|
+
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
|
168
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
169
|
+
p = sub.add_parser("sync", help="Fetch exact pinned files; existing files must already match")
|
|
170
|
+
p.add_argument("--out", type=Path, required=True)
|
|
171
|
+
p.add_argument("--repo", type=Path, help="Read Git objects locally instead of using the network")
|
|
172
|
+
p = sub.add_parser("verify", help="Verify all expected files; incomplete corpus exits nonzero")
|
|
173
|
+
p.add_argument("--root", type=Path, required=True)
|
|
174
|
+
p = sub.add_parser("search", help="Literal, case-insensitive search with source line numbers")
|
|
175
|
+
p.add_argument("--root", type=Path, required=True)
|
|
176
|
+
p.add_argument("query")
|
|
177
|
+
p.add_argument("--limit", type=int, default=30)
|
|
178
|
+
p.add_argument("--allow-partial", action="store_true")
|
|
179
|
+
p = sub.add_parser("read", help="Read and hash-check one known file")
|
|
180
|
+
p.add_argument("--root", type=Path, required=True)
|
|
181
|
+
p.add_argument("path")
|
|
182
|
+
p.add_argument("--start", type=int, default=1)
|
|
183
|
+
p.add_argument("--lines", type=int, default=120)
|
|
184
|
+
p = sub.add_parser("drift", help="Compare tracked paths at another local Git commit; never updates this skill")
|
|
185
|
+
p.add_argument("--repo", type=Path, required=True)
|
|
186
|
+
p.add_argument("--ref", default="HEAD", help="HEAD or an explicit 40-character commit SHA")
|
|
187
|
+
sub.add_parser("manifest", help="Print corpus scope and pins")
|
|
188
|
+
args = parser.parse_args(argv)
|
|
189
|
+
try:
|
|
190
|
+
manifest = load_manifest(args.manifest)
|
|
191
|
+
if args.command == "manifest":
|
|
192
|
+
result: Any = manifest
|
|
193
|
+
elif args.command == "sync":
|
|
194
|
+
result = sync(manifest, args.out, args.repo)
|
|
195
|
+
elif args.command == "verify":
|
|
196
|
+
result = verify(manifest, args.root)
|
|
197
|
+
print(json.dumps(result, indent=2))
|
|
198
|
+
return 0 if result["complete"] else 1
|
|
199
|
+
elif args.command == "read":
|
|
200
|
+
if args.start < 1 or not 1 <= args.lines <= 500:
|
|
201
|
+
raise CorpusError("Use start >= 1 and lines between 1 and 500")
|
|
202
|
+
entry = find_entry(manifest, args.path)
|
|
203
|
+
text = checked_bytes(entry, safe_path(args.root, args.path).read_bytes()).decode()
|
|
204
|
+
print(entry["url"] + f"#L{args.start}")
|
|
205
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
206
|
+
if args.start <= number < args.start + args.lines:
|
|
207
|
+
print(f"{number}: {line}")
|
|
208
|
+
return 0
|
|
209
|
+
elif args.command == "search":
|
|
210
|
+
if not args.query.strip() or not 1 <= args.limit <= 100:
|
|
211
|
+
raise CorpusError("Use a nonempty query and a limit between 1 and 100")
|
|
212
|
+
report = verify(manifest, args.root)
|
|
213
|
+
if report["invalid"] or (not report["complete"] and not args.allow_partial):
|
|
214
|
+
raise CorpusError("Corpus is invalid or incomplete; sync it first (or explicitly allow missing files)")
|
|
215
|
+
matches = []
|
|
216
|
+
for entry in manifest["files"]:
|
|
217
|
+
path = safe_path(args.root, entry["path"])
|
|
218
|
+
if not path.exists():
|
|
219
|
+
continue
|
|
220
|
+
for number, line in enumerate(checked_bytes(entry, path.read_bytes()).decode().splitlines(), 1):
|
|
221
|
+
if args.query.casefold() in line.casefold():
|
|
222
|
+
matches.append({"path": entry["path"], "line": number, "text": line[:1000],
|
|
223
|
+
"source": entry["url"] + f"#L{number}"})
|
|
224
|
+
if len(matches) >= args.limit:
|
|
225
|
+
break
|
|
226
|
+
if len(matches) >= args.limit:
|
|
227
|
+
break
|
|
228
|
+
result = {"corpus_complete": report["complete"], "matches": matches, "limit": args.limit}
|
|
229
|
+
else:
|
|
230
|
+
if args.ref != "HEAD" and not re.fullmatch(r"[a-f0-9]{40}", args.ref):
|
|
231
|
+
raise CorpusError("Drift ref must be HEAD or a full lowercase commit SHA")
|
|
232
|
+
commit = git(args.repo, "rev-parse", "--verify", args.ref + "^{commit}").decode().strip()
|
|
233
|
+
differences = []
|
|
234
|
+
for entry in manifest["files"]:
|
|
235
|
+
try:
|
|
236
|
+
actual = git(args.repo, "rev-parse", "--verify", f"{commit}:{entry['path']}").decode().strip()
|
|
237
|
+
except CorpusError:
|
|
238
|
+
actual = None
|
|
239
|
+
if actual != entry["git_blob_sha1"]:
|
|
240
|
+
differences.append({"path": entry["path"], "expected": entry["git_blob_sha1"], "actual": actual})
|
|
241
|
+
result = {"baseline": manifest["commit"], "compared_commit": commit,
|
|
242
|
+
"same_commit": commit == manifest["commit"], "changed_or_missing": differences,
|
|
243
|
+
"warning": "Tracked-path comparison only; inspect newly added APIs/docs separately."}
|
|
244
|
+
print(json.dumps(result, indent=2))
|
|
245
|
+
return 0
|
|
246
|
+
except (CorpusError, OSError, ValueError, UnicodeError, subprocess.SubprocessError) as exc:
|
|
247
|
+
print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr)
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
raise SystemExit(main())
|