Source index · Technical reference · Home

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 python3
2# component: copilot-repository-adapter
3# implements: ADR-0024, ADR-0025, ADR-0026
4# intent: .claude/plans/swarming-work/spec.md
5# constraints: stdlib only, no network, preserve project prose, preflight all writes
6# last_intent_review: 2026-09-08
7"""Deterministic Copilot adapter; standard library only, no network or shell calls."""
8import argparse
9import hashlib
10import json
11import os
12from pathlib import Path, PurePosixPath
13import re
14import shutil
15import subprocess
16import sys
17import tempfile
19SCHEMA = 1
20INVENTORY = ".github/lintel/manifest.json"
21BUNDLE = ".github/lintel"
22PROTOCOL_START = b"<!-- LINTEL:SESSION-PROTOCOL:START -->"
23PROTOCOL_END = b"<!-- LINTEL:SESSION-PROTOCOL:END -->"
24COMPONENTS = ("bin", "lib", "skills", "agents", "templates", "scaffolding/01-foundation", "packs/_default")
25DOCS = ("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")
29TEXT_SUFFIXES = {".md", ".sh", ".bash", ".py", ".json", ".yaml", ".yml", ".csv", ".tsv", ".txt", ".template", ".base"}
30ATTRIBUTES = (
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 data
117def text_bytes(value: str) -> bytes:
118 return value.rstrip().encode("utf-8") + b"\n"
121def generate(source: Path, target: Path) -> tuple[dict[str, bytes], dict[str, bytes], str]:
122 local = source == target
123 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 and
142 # template dependencies needed by the swarm entry point. Fail generation if
143 # 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"""---
155name: li-{name}
156description: {description}
157---
159# Lintel {name}
161Read the [Copilot adapter contract]({bridge_skill}) first, then execute the
162[canonical {name} workflow]({skill_source}/skills/{name}/SKILL.md) for the user's request.
163Resolve source resources relative to that canonical file; write outputs to the working
164repository. 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"""---
169name: lintel-{name}
170description: {description}
171---
173Read the [Copilot adapter contract]({bridge_agent}) and use the
174[Lintel {workflow} skill](../skills/li-{workflow}/SKILL.md).
175Keep the task bounded to the supplied requirements and repository context. Report
176changed files, checks actually run, findings by severity, and unresolved limitations.
177Do not claim independent review if you implemented the same change. If delegation
178is 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"""---
181applyTo: "**"
182---
184This repository uses Lintel. Read [the Copilot adapter]({('../' + bridge_instruction)})
185before a multi-step task. Load the repository's AGENTS.md and relevant memory and
186decisions. Keep plans and durable lessons in .claude/; the directory name is shared
187storage, not a requirement to use Claude Code. Use li-plan before substantive work,
188li-build for authorized cards and li-review before delivery. Existing user authorization
189remains valid; ask only for missing scope or a new permission boundary. Lintel instructions
190and reviews complement host permissions and branch rules; they do not enforce them.
191""")
192 files[".github/copilot-instructions.md"] = text_bytes(f"""# Lintel repository instructions
194Read [the repository instructions](../AGENTS.md), then the
195[Copilot adapter contract]({bridge_instruction}) for multi-step work.
197Use `/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.
199Use `/li-cycle` for the complete workflow and `/li-resume` to continue a saved plan.
200If slash discovery is unavailable, ask Copilot to read the corresponding
201`.github/skills/li-<name>/SKILL.md` and carry out its instructions.
203Read project memory and relevant decisions before edits. Verify actual behavior and
204report checks, outcomes and limitations. Keep secrets and customer data out of artifacts.
205Repository 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 instructions
211This repository uses Lintel for planned, reviewable agent sessions. Read
212[CORE-PRINCIPLES.md](CORE-PRINCIPLES.md), recent .claude/memory/lessons.md,
213.claude/memory/working-state.md and relevant .claude/decisions/ before substantive work.
214Keep the current task list in .claude/plans/todo.md. For an initiative, write plan.md,
215spec.md and prompt.md under .claude/plans/<initiative>/ and record validation evidence.
216Follow existing project instructions in CLAUDE.md when present; surface conflicts.
217Customize this file with the project's purpose, ownership, build and test commands.
218"""),
219 "CLAUDE.md": text_bytes("""# Repository instructions
221Project context and ownership live in [AGENTS.md](AGENTS.md). The shared session
222protocol below is self-contained and applies to work in this repository.
223Add 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"], blocks
259def 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_END
264 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 continue
270 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 continue
290 updates[relative] = merged
291 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 continue
305 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 missing
311def 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)
323def 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 []
338def 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_HOME
346 # 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 entry
369 # 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 continue
381 if relative in seeds:
382 continue
383 actual = digest(path.read_bytes()) if path.is_file() else None
384 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 inventory
388 # 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 return
411 # 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.")
443if __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)