UNRELEASED DEVELOPMENT SNAPSHOT · READ-ONLY EXCERPT
bin/li-copilot.py
This is the cited technical source at local revision 275a354, inspected on 22 September 2026. It differs from the public release. This reference publishes only the cited sections, not an installable release or the development branch.
Line numbers refer to the complete recorded file. Uncited lines are omitted. Full-file SHA-256: cfd2cc7b7a80c4930859dc1631de1cdd74a044d3da1f2dd83fff676a95e286f0.
1
#!/usr/bin/env python32
# component: copilot-repository-adapter3
# implements: ADR-0024, ADR-0025, ADR-00264
# intent: .claude/plans/swarming-work/spec.md5
# constraints: stdlib only, no network, preserve project prose, preflight all writes6
# last_intent_review: 2026-09-087
"""Deterministic Copilot adapter; standard library only, no network or shell calls."""8
import argparse9
import hashlib10
import json11
import os12
from pathlib import Path, PurePosixPath13
import re14
import shutil15
import subprocess16
import sys17
import tempfile19
SCHEMA = 120
INVENTORY = ".github/lintel/manifest.json"21
BUNDLE = ".github/lintel"22
PROTOCOL_START = b"<!-- LINTEL:SESSION-PROTOCOL:START -->"23
PROTOCOL_END = b"<!-- LINTEL:SESSION-PROTOCOL:END -->"24
COMPONENTS = ("bin", "lib", "skills", "agents", "templates", "scaffolding/01-foundation", "packs/_default")25
DOCS = ("docs/the-cycle.md", "docs/precedence.md", "docs/compliance.md", "docs/architecture.md",26
"docs/GLOSSARY.md", "docs/spec-kit.md", "docs/getting-started.md", "docs/copilot.md",27
"docs/concepts/planner-as-module.md", "docs/concepts/agent-dispatch-rules.md", "docs/concepts/orientator.md",28
"docs/concepts/swarming-work.md")29
TEXT_SUFFIXES = {".md", ".sh", ".bash", ".py", ".json", ".yaml", ".yml", ".csv", ".tsv", ".txt", ".template", ".base"}30
ATTRIBUTES = (31
".github/lintel/** text=auto eol=lf",32
".github/skills/li-*/** text=auto eol=lf",33
".github/agents/lintel-*.agent.md text eol=lf",34
".github/instructions/lintel-session.instructions.md text eol=lf",… omitted uncited lines …
112
if path.suffix in TEXT_SUFFIXES or not path.suffix:113
data = data.replace(b"\r\n", b"\n")114
return data117
def text_bytes(value: str) -> bytes:118
return value.rstrip().encode("utf-8") + b"\n"121
def generate(source: Path, target: Path) -> tuple[dict[str, bytes], dict[str, bytes], str]:122
local = source == target123
files = {}124
# A deliberately scoped source bundle: never copy .git, user packs, runtime,125
# customer material, host settings, hooks, or this generated bundle recursively.126
if not local:127
for component in COMPONENTS:128
folder = safe_path(source, component)129
if not folder.is_dir():130
raise ValueError(f"Missing source component: {component}")131
for path in sorted(folder.rglob("*")):132
relative = path.relative_to(source).as_posix()133
safe_path(source, relative)134
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc":135
files[f"{BUNDLE}/{relative}"] = source_bytes(path)136
for relative in ("LICENSE",) + DOCS:137
if (source / relative).is_file():138
files[f"{BUNDLE}/{relative}"] = read_file(source, relative)139
bridge = "shims/copilot/COPILOT.md" if (source / "shims/copilot/COPILOT.md").is_file() else "COPILOT.md"140
files[f"{BUNDLE}/COPILOT.md"] = read_file(source, bridge)141
# These are the direct workflow, validation, handoff, policy, audit/path and142
# template dependencies needed by the swarm entry point. Fail generation if143
# the installed source is incomplete instead of deferring failure to dispatch.144
for relative in SWARM_RESOURCES:145
data = read_file(source, relative)146
if not local and files.get(f"{BUNDLE}/{relative}") != data:147
raise ValueError(f"Swarm resource was not bundled: {relative}")148
# File-relative Markdown links work after copying and in clean cloud clones.149
skill_source = "../../.." if local else "../../lintel"150
bridge_skill = "../../../shims/copilot/COPILOT.md" if local else "../../lintel/COPILOT.md"151
bridge_instruction = "../shims/copilot/COPILOT.md" if local else "lintel/COPILOT.md"152
for name, description in WORKFLOWS.items():153
read_file(source, f"skills/{name}/SKILL.md")154
files[f".github/skills/li-{name}/SKILL.md"] = text_bytes(f"""---155
name: li-{name}156
description: {description}157
---159
# Lintel {name}161
Read the [Copilot adapter contract]({bridge_skill}) first, then execute the162
[canonical {name} workflow]({skill_source}/skills/{name}/SKILL.md) for the user's request.163
Resolve source resources relative to that canonical file; write outputs to the working164
repository. Follow the adapter's tool mapping, authorization and verification rules.165
""")166
bridge_agent = "../../shims/copilot/COPILOT.md" if local else "../lintel/COPILOT.md"167
for name, (description, workflow) in AGENTS.items():168
files[f".github/agents/lintel-{name}.agent.md"] = text_bytes(f"""---169
name: lintel-{name}170
description: {description}171
---173
Read the [Copilot adapter contract]({bridge_agent}) and use the174
[Lintel {workflow} skill](../skills/li-{workflow}/SKILL.md).175
Keep the task bounded to the supplied requirements and repository context. Report176
changed files, checks actually run, findings by severity, and unresolved limitations.177
Do not claim independent review if you implemented the same change. If delegation178
is unavailable, label the pass as self-review and retain the human review gate.179
""")180
files[".github/instructions/lintel-session.instructions.md"] = text_bytes(f"""---181
applyTo: "**"182
---184
This repository uses Lintel. Read [the Copilot adapter]({('../' + bridge_instruction)})185
before a multi-step task. Load the repository's AGENTS.md and relevant memory and186
decisions. Keep plans and durable lessons in .claude/; the directory name is shared187
storage, not a requirement to use Claude Code. Use li-plan before substantive work,188
li-build for authorized cards and li-review before delivery. Existing user authorization189
remains valid; ask only for missing scope or a new permission boundary. Lintel instructions190
and reviews complement host permissions and branch rules; they do not enforce them.191
""")192
files[".github/copilot-instructions.md"] = text_bytes(f"""# Lintel repository instructions194
Read [the repository instructions](../AGENTS.md), then the195
[Copilot adapter contract]({bridge_instruction}) for multi-step work.197
Use `/li-welcome` to inspect setup, `/li-plan` to create a spec and build cards,198
`/li-build` to execute authorized cards, and `/li-review` to verify changes.199
Use `/li-cycle` for the complete workflow and `/li-resume` to continue a saved plan.200
If slash discovery is unavailable, ask Copilot to read the corresponding201
`.github/skills/li-<name>/SKILL.md` and carry out its instructions.203
Read project memory and relevant decisions before edits. Verify actual behavior and204
report checks, outcomes and limitations. Keep secrets and customer data out of artifacts.205
Repository instructions do not replace enterprise policy, tool permissions or human review.206
""")207
# Foundation seed files are user-owned immediately; never upgrade or hash them.208
seeds = {209
"AGENTS.md": text_bytes("""# Repository agent instructions211
This repository uses Lintel for planned, reviewable agent sessions. Read212
[CORE-PRINCIPLES.md](CORE-PRINCIPLES.md), recent .claude/memory/lessons.md,213
.claude/memory/working-state.md and relevant .claude/decisions/ before substantive work.214
Keep the current task list in .claude/plans/todo.md. For an initiative, write plan.md,215
spec.md and prompt.md under .claude/plans/<initiative>/ and record validation evidence.216
Follow existing project instructions in CLAUDE.md when present; surface conflicts.217
Customize this file with the project's purpose, ownership, build and test commands.218
"""),219
"CLAUDE.md": text_bytes("""# Repository instructions221
Project context and ownership live in [AGENTS.md](AGENTS.md). The shared session222
protocol below is self-contained and applies to work in this repository.223
Add project-specific commands and motivated deviations outside its marked block.224
"""),225
"CORE-PRINCIPLES.md": read_file(source, "scaffolding/01-foundation/CORE-PRINCIPLES.md"),226
"EVOLUTION.md": read_file(source, "scaffolding/01-foundation/EVOLUTION.md"),227
".claude/lintel-layout.yaml": b"layout_version: 5\n",228
}229
for relative in (".claude/memory/lessons.md", ".claude/memory/working-state.md", ".claude/memory/personas.md", ".claude/plans/todo.md", ".claude/decisions/README.md", ".claude/decisions/TEMPLATE.md"):230
seeds[relative] = read_file(source, "scaffolding/01-foundation/" + relative)231
if local:232
seeds = {} # Lintel already has its foundation; never duplicate it at root.233
return files, seeds, "repository" if local else "vendored"… omitted uncited lines …
249
raise ValueError(f"Invalid inventory hash: {relative}")250
blocks = value.get("blocks", {})251
if not isinstance(blocks, dict):252
raise ValueError("Malformed protocol block inventory")253
for relative, sha in blocks.items():254
if relative not in ("AGENTS.md", "CLAUDE.md") or not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{64}", sha):255
raise ValueError(f"Invalid protocol block inventory: {relative}")256
return value["files"], blocks259
def protocol_updates(source: Path, target: Path, seeds: dict[str, bytes],260
old_blocks: dict[str, str], checking: bool) -> tuple[dict[str, bytes], dict[str, str], list[str]]:261
"""Manage only marked protocol blocks; preserve all surrounding project prose."""262
payload = read_file(source, "scaffolding/01-foundation/SESSION-PROTOCOL.md").decode("utf-8-sig").strip().encode("utf-8")263
block = PROTOCOL_START + b"\n" + payload + b"\n" + PROTOCOL_END264
updates, hashes, errors = {}, {}, []265
for relative in ("AGENTS.md", "CLAUDE.md"):266
path = safe_path(target, relative)267
if path.exists() and not path.is_file():268
errors.append(f"Not a regular protocol file: {relative}")269
continue270
original = path.read_bytes() if path.exists() else seeds[relative]271
starts, ends = original.count(PROTOCOL_START), original.count(PROTOCOL_END)272
if starts == ends == 0:273
if checking:274
errors.append(f"Missing session protocol block: {relative}")275
separator = b"\n" if original.endswith(b"\n") else b"\n\n"276
merged = original + separator + block + b"\n"277
elif starts == ends == 1 and original.index(PROTOCOL_START) < original.index(PROTOCOL_END):278
begin = original.index(PROTOCOL_START)279
end = original.index(PROTOCOL_END) + len(PROTOCOL_END)280
actual = original[begin:end].replace(b"\r\n", b"\n")281
expected = old_blocks.get(relative, digest(block))282
if digest(actual) != expected:283
errors.append(f"Modified session protocol block (preserved): {relative}")284
if checking and actual != block:285
errors.append(f"Outdated session protocol block: {relative}")286
merged = original[:begin] + block + original[end:]287
else:288
errors.append(f"Malformed session protocol markers (preserved): {relative}")289
continue290
updates[relative] = merged291
hashes[relative] = digest(block)292
return updates, hashes, errors… omitted uncited lines …
301
resolved = (target / relative).parent.joinpath(link).resolve()302
if not resolved.is_relative_to(target):303
missing.append(f"Escaping generated link: {relative} -> {link}")304
continue305
key = resolved.relative_to(target).as_posix()306
if key not in files and not resolved.is_file():307
missing.append(f"Missing generated link: {relative} -> {link}")308
return missing311
def atomic_write(path: Path, data: bytes) -> None:312
path.parent.mkdir(parents=True, exist_ok=True)313
fd, tmp = tempfile.mkstemp(prefix=".lintel-", dir=path.parent)314
try:315
with os.fdopen(fd, "wb") as handle:316
handle.write(data)317
os.replace(tmp, path)318
finally:319
if os.path.exists(tmp):320
os.unlink(tmp)323
def runtime_ignore_errors(target: Path, ignore_text: str) -> list[str]:324
"""Check the required rule and, in Git repos, its effective negation behavior."""325
if ".claude/runtime/" not in ignore_text.splitlines():… omitted uncited lines …
328
if git and (target / ".git").exists():329
result = subprocess.run(330
[git, "-c", "core.fsmonitor=false", "-C", str(target), "check-ignore",331
"--no-index", "--quiet", ".claude/runtime/.lintel-ignore-check"],332
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)333
if result.returncode:334
return ["Git does not confirm .claude/runtime/ is ignored; review conflicting ignore rules"]335
return []338
def main() -> None:339
parser = argparse.ArgumentParser(description=__doc__)340
parser.add_argument("command", choices=("init", "check"))341
parser.add_argument("--target", type=Path, default=Path.cwd())342
parser.add_argument("--source", type=Path, default=None)343
args = parser.parse_args()344
target = args.target.resolve()345
# Executable-relative source is stable even after copilot-env sets LINTEL_HOME346
# to project runtime storage. A source override is explicit, never ambient.347
source = (args.source or Path(__file__).resolve().parent.parent).resolve()348
if not target.is_dir():349
raise ValueError(f"Target directory does not exist: {target}")350
files, seeds, mode = generate(source, target)351
old, old_blocks = load_inventory(target)352
errors = []353
block_updates, block_hashes = {}, {}354
if mode == "vendored":355
block_updates, block_hashes, block_errors = protocol_updates(source, target, seeds, old_blocks, args.command == "check")356
errors.extend(block_errors)357
# Preflight this user-owned file too, before any managed content is written.358
ignore = safe_path(target, ".gitignore")359
if ignore.exists() and not ignore.is_file():360
raise ValueError(".gitignore is not a regular file")361
existing_ignore = ignore.read_text(encoding="utf-8") if ignore.exists() else ""362
if args.command == "init" and ".claude/runtime/" in existing_ignore.splitlines():363
errors.extend(runtime_ignore_errors(target, existing_ignore))364
attributes = safe_path(target, ".gitattributes")365
if attributes.exists() and not attributes.is_file():366
raise ValueError(".gitattributes is not a regular file")367
existing_attributes = attributes.read_text(encoding="utf-8") if attributes.exists() else ""368
# Preserve a team's existing instruction file. A path-scoped additive entry369
# carries the Lintel pointer instead; unowned files are never adopted silently.370
entry = ".github/copilot-instructions.md"371
if safe_path(target, entry).exists() and entry not in old:372
files.pop(entry)373
attribute_rules = list(ATTRIBUTES)374
if entry in files:375
attribute_rules.append(".github/copilot-instructions.md text eol=lf")376
for relative in sorted(set(files) | set(old) | set(seeds)):377
path = safe_path(target, relative)378
if path.exists() and not path.is_file():379
errors.append(f"Not a regular file: {relative}")380
continue381
if relative in seeds:382
continue383
actual = digest(path.read_bytes()) if path.is_file() else None384
if relative in old and actual not in (None, old[relative]):385
errors.append(f"Modified managed file (preserved): {relative}")386
elif relative not in old and actual is not None:387
# Identical generated files may be checked into Lintel before inventory388
# creation; accepting exact bytes never overwrites custom content.389
if relative not in files or actual != digest(files[relative]):390
errors.append(f"Unmanaged collision (preserved): {relative}")391
if args.command == "check":392
if relative in files and actual != digest(files[relative]):393
errors.append(f"Missing or outdated managed file: {relative}")394
if relative in old and relative not in files:395
errors.append(f"Obsolete managed file: {relative}")396
if args.command == "check":397
for relative in seeds:398
if not safe_path(target, relative).is_file():399
errors.append(f"Missing foundation file: {relative}")400
if not old:401
errors.append("Copilot inventory is missing; run init")402
errors.extend(runtime_ignore_errors(target, existing_ignore))403
if any(rule not in existing_attributes.splitlines() for rule in attribute_rules):404
errors.append("Missing Copilot .gitattributes rules; run init")405
errors.extend(verify_links({**files, **seeds}, target))406
if errors:407
raise ValueError("\n".join(errors))408
if args.command == "check":409
print(f"Copilot kit verified: {len(files)} managed files; {mode} source; no live-host validation.")410
return411
# Everything above is read-only. Only write after the entire update passes.412
for relative, data in sorted(files.items()):413
path = safe_path(target, relative)414
if not path.exists() or path.read_bytes() != data:415
atomic_write(path, data)416
for relative, data in block_updates.items():417
path = safe_path(target, relative)418
if not path.exists() or path.read_bytes() != data:419
atomic_write(path, data)420
for relative, data in seeds.items():421
path = safe_path(target, relative)422
if not path.exists():423
atomic_write(path, data)424
for relative in sorted(set(old) - set(files)):425
path = safe_path(target, relative)426
if path.exists():427
path.unlink()428
if ".claude/runtime/" not in existing_ignore.splitlines():429
separator = "\n" if existing_ignore.endswith("\n") else "\n\n"430
atomic_write(ignore, (existing_ignore + separator + "# Lintel local session state\n.claude/runtime/\n").encode("utf-8"))431
missing_rules = [rule for rule in attribute_rules if rule not in existing_attributes.splitlines()]432
if missing_rules:433
separator = "\n" if existing_attributes.endswith("\n") else "\n\n"434
atomic_write(attributes, (existing_attributes + separator + "# Lintel portable Copilot kit\n" + "\n".join(missing_rules) + "\n").encode("utf-8"))435
inventory = {"schema_version": SCHEMA, "source_mode": mode, "hooks_installed": False,436
"blocks": block_hashes,437
"files": {relative: digest(data) for relative, data in sorted(files.items())}}438
atomic_write(safe_path(target, INVENTORY), text_bytes(json.dumps(inventory, indent=2, sort_keys=True)))439
print(f"Copilot kit ready: {len(files)} managed files; {mode} source. Commit .github/ and foundation files.")440
print("Start a new Copilot session; inspect /skills and select /li-welcome. No hooks or host permissions were changed.")443
if __name__ == "__main__":444
try:445
main()446
except (ValueError, OSError, json.JSONDecodeError) as error:447
print(f"ERROR: {error}", file=sys.stderr)448
sys.exit(1)