kotonoha 0.4.1 → 0.5.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/README-ja.md +108 -18
  3. package/README.md +155 -23
  4. package/bin/kotonoha.js +64 -12
  5. package/package.json +7 -3
  6. package/skills/obsidian-bases/SKILL.md +140 -0
  7. package/skills/obsidian-bases/assets/templates/cards.base +8 -0
  8. package/skills/obsidian-bases/assets/templates/kanban.base +7 -0
  9. package/skills/obsidian-bases/assets/templates/list.base +8 -0
  10. package/skills/obsidian-bases/assets/templates/table.base +10 -0
  11. package/skills/obsidian-bases/references/execution-contract.json +246 -0
  12. package/skills/obsidian-bases/references/workflow.md +159 -0
  13. package/skills/obsidian-bases/scripts/check.cjs +690 -0
  14. package/skills/obsidian-bases/scripts/package-lock.json +104 -0
  15. package/skills/obsidian-bases/scripts/package.json +18 -0
  16. package/skills/obsidian-bases/scripts/verify.cjs +454 -0
  17. package/skills/obsidian-canvas/SKILL.md +134 -0
  18. package/skills/obsidian-canvas/assets/templates/comparison.canvas +11 -0
  19. package/skills/obsidian-canvas/assets/templates/mind-map.canvas +11 -0
  20. package/skills/obsidian-canvas/assets/templates/note-map.canvas +10 -0
  21. package/skills/obsidian-canvas/assets/templates/workflow.canvas +11 -0
  22. package/skills/obsidian-canvas/references/execution-contract.json +404 -0
  23. package/skills/obsidian-canvas/references/workflow.md +189 -0
  24. package/skills/obsidian-canvas/scripts/canvas.py +1479 -0
  25. package/skills/obsidian-canvas/scripts/canvas.py.lock +143 -0
  26. package/skills/obsidian-canvas/scripts/test_canvas.py +2047 -0
  27. package/skills/obsidian-canvas/scripts/test_canvas.py.lock +143 -0
  28. package/skills/obsidian-vault/SKILL.md +149 -93
  29. package/skills/obsidian-vault/references/workflow.md +61 -4
  30. package/skills/obsidian-vault/scripts/test_vault.py +76 -0
  31. package/skills/obsidian-vault/scripts/vault.py +118 -71
  32. package/skills/tech-writer/SKILL.md +26 -36
  33. package/skills/tech-writer/references/japanese-prose-optimization.md +3 -1
@@ -0,0 +1,1479 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = "==3.12.*"
4
+ # dependencies = ["markdown-it-py>=3,<5", "PyYAML>=6,<7", "jsonschema>=4,<5"]
5
+ # ///
6
+ """Scope-bound Canvas diagnostics and explicitly approved single-file transactions."""
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+ import copy
11
+ import errno
12
+ import fcntl
13
+ import hashlib
14
+ import importlib.util
15
+ import ipaddress
16
+ import json
17
+ import math
18
+ import os
19
+ from pathlib import Path
20
+ import posixpath
21
+ import re
22
+ import stat
23
+ import sys
24
+ from urllib.parse import unquote, urlsplit
25
+
26
+ from jsonschema import Draft202012Validator, ValidationError
27
+
28
+ SKILL = Path(__file__).resolve().parent.parent
29
+ CONTROL_MAX = 1048576
30
+ SAFE = 9007199254740991
31
+ CHECKS = ("actors", "negation", "conditions", "modality", "scope", "causality",
32
+ "quantity_bindings", "terminology", "structure")
33
+ PREFIXES = {"evidence": "[根拠あり] ", "proposal": "[提案] ", "unverified": "[未確認] "}
34
+ COMMON = {"id", "type", "x", "y", "width", "height", "color"}
35
+ EXTRA = {"text": {"text"}, "file": {"file", "subpath"}, "link": {"url"},
36
+ "group": {"label", "background", "backgroundStyle"}}
37
+ EDGE = {"id", "fromNode", "toNode", "fromSide", "toSide", "fromEnd", "toEnd", "color", "label"}
38
+ SEMANTIC = {"text", "file", "subpath", "url", "label", "background",
39
+ "fromNode", "toNode", "fromEnd", "toEnd"}
40
+ VIRTUAL = {"nodes_presence", "edges_presence", "nodes_order", "edges_order"}
41
+
42
+
43
+ class Failure(Exception):
44
+ def __init__(self, code, exit_code=2, *, path=None):
45
+ self.code, self.exit_code = code, exit_code
46
+ self.path = path
47
+ super().__init__(code)
48
+
49
+
50
+ class Number(str):
51
+ pass
52
+
53
+
54
+ def digest(data):
55
+ return hashlib.sha256(data).hexdigest()
56
+
57
+
58
+ def canonical(value):
59
+ return json.dumps(value, ensure_ascii=False, sort_keys=True,
60
+ separators=(",", ":"), allow_nan=False).encode("utf-8")
61
+
62
+
63
+ def strict_json(data, *, numbers=False):
64
+ def pairs(items):
65
+ result = {}
66
+ for key, value in items:
67
+ if key in result:
68
+ raise ValueError("duplicate_key")
69
+ result[key] = value
70
+ return result
71
+
72
+ def constant(_):
73
+ raise ValueError("nonfinite_number")
74
+
75
+ def finite(raw):
76
+ value = float(raw)
77
+ if not math.isfinite(value):
78
+ raise ValueError("nonfinite_number")
79
+ return value
80
+
81
+ text = data.decode("utf-8")
82
+ if text.startswith("\ufeff"):
83
+ raise ValueError("utf8_bom")
84
+ result = json.loads(text, object_pairs_hook=pairs, parse_constant=constant,
85
+ **({"parse_int": Number, "parse_float": Number} if numbers else {"parse_float": finite}))
86
+ if not numbers:
87
+ pending = [result]
88
+ while pending:
89
+ item = pending.pop()
90
+ if isinstance(item, str) and any(0xD800 <= ord(c) <= 0xDFFF for c in item):
91
+ raise ValueError("unicode_scalar")
92
+ if isinstance(item, dict):
93
+ pending.extend(item.keys())
94
+ pending.extend(item.values())
95
+ elif isinstance(item, list):
96
+ pending.extend(item)
97
+ return result
98
+
99
+
100
+ CONTRACT_BYTES = (SKILL / "references/execution-contract.json").read_bytes()
101
+ CONTRACT = strict_json(CONTRACT_BYTES)
102
+
103
+
104
+ def validate(value, definition):
105
+ Draft202012Validator({"$ref": f"#/$defs/{definition}",
106
+ "$defs": CONTRACT["$defs"]}).validate(value)
107
+
108
+
109
+ def issue(code, path=None, entity="document", id=None, field=None, related_ids=None,
110
+ category=None):
111
+ if category is None:
112
+ category = next(k for k, v in CONTRACT["x-diagnostic-codes"].items() if code in v)
113
+ return dict(path=path, category=category, code=code, entity=entity, id=id,
114
+ field=field, related_ids=related_ids or [])
115
+
116
+
117
+ def load_module(path, name):
118
+ try:
119
+ spec = importlib.util.spec_from_file_location(name, path)
120
+ module = importlib.util.module_from_spec(spec)
121
+ spec.loader.exec_module(module)
122
+ return module
123
+ except (OSError, ImportError, AttributeError):
124
+ raise Failure("dependency_missing", 4) from None
125
+
126
+
127
+ def backend():
128
+ module = load_module(SKILL.parent / "obsidian-vault/scripts/vault.py", "canvas_storage")
129
+ if (getattr(module, "STORAGE_API_VERSION", None) != 1
130
+ or not all(callable(getattr(getattr(module, "Vault", None), name, None)) for name in
131
+ ("read_authorized", "stat_authorized", "enumerate_authorized"))
132
+ or not all(callable(getattr(module, name, None)) for name in (
133
+ "commit_bytes", "directory", "identity", "metadata_supported", "write_capabilities",
134
+ "local_filesystem", "diff_bytes", "parse_note", "masked_body", "extract_links"))):
135
+ raise Failure("dependency_missing", 4)
136
+ return module
137
+
138
+
139
+ def gate():
140
+ module = load_module(SKILL.parent / "japanese-prose/scripts/invariants.py", "canvas_gate")
141
+ if (tuple(getattr(module, "SEMANTIC_CHECKS", ())) != CHECKS
142
+ or not callable(getattr(module, "check_bytes", None))):
143
+ raise Failure("dependency_missing", 4)
144
+ return module
145
+
146
+
147
+ def integer(number):
148
+ """Classify the decimal lexeme without exponent-sized allocations or float rounding."""
149
+ if isinstance(number, bool) or not isinstance(number, (Number, int, float)):
150
+ return None, "invalid_type"
151
+ raw = str(number).lower()
152
+ match = re.fullmatch(r"(-?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?", raw)
153
+ if not match:
154
+ return None, "invalid_type"
155
+ sign, whole, fraction, exponent = match.groups()
156
+ fraction = fraction or ""
157
+ digits = (whole + fraction).lstrip("0")
158
+ if not digits:
159
+ return 0, None
160
+ exp = (exponent or "0").lstrip("+")
161
+ magnitude = exp.lstrip("-").lstrip("0") or "0"
162
+ # Only a shift within the input's length can produce a small integer.
163
+ if len(magnitude) > len(str(len(raw) + 32)):
164
+ return None, "invalid_type" if exp.startswith("-") else "integer_range"
165
+ shift = int(exp) - len(fraction)
166
+ if shift < 0:
167
+ if -shift > len(digits) or any(c != "0" for c in digits[shift:]):
168
+ return None, "invalid_type"
169
+ digits = digits[:shift]
170
+ elif len(digits) + shift > 16:
171
+ return None, "integer_range"
172
+ else:
173
+ digits += "0" * shift
174
+ digits = digits.lstrip("0") or "0"
175
+ if len(digits) > 16 or len(digits) == 16 and digits > str(SAFE):
176
+ return None, "integer_range"
177
+ return (-1 if sign else 1) * int(digits), None
178
+
179
+
180
+ def request_json(data):
181
+ holder = [strict_json(data, numbers=True)]
182
+ pending = [(holder, 0)]
183
+ while pending:
184
+ parent, key = pending.pop()
185
+ value = parent[key]
186
+ if isinstance(value, Number):
187
+ value, error = integer(value)
188
+ if error:
189
+ raise ValueError("invalid_input")
190
+ parent[key] = value
191
+ elif isinstance(value, str):
192
+ if any(0xD800 <= ord(c) <= 0xDFFF for c in value):
193
+ raise ValueError("invalid_input")
194
+ elif isinstance(value, dict):
195
+ if any(any(0xD800 <= ord(c) <= 0xDFFF for c in field) for field in value):
196
+ raise ValueError("invalid_input")
197
+ pending.extend((value, field) for field in value)
198
+ elif isinstance(value, list):
199
+ pending.extend((value, index) for index in range(len(value)))
200
+ return holder[0]
201
+
202
+
203
+ def forbidden(path, *, parents=False):
204
+ if (not isinstance(path, str) or not path or path.startswith(("/", "\\"))
205
+ or "\\" in path or any(ord(c) < 32 or ord(c) == 127 for c in path)
206
+ or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", path)):
207
+ return True
208
+ parts = path.split("/")
209
+ return (any(p.lower() in {".git", ".obsidian"} for p in parts)
210
+ or not parents and ".." in parts)
211
+
212
+
213
+ def safe_path(path, *, dot=False):
214
+ if forbidden(path):
215
+ raise Failure("scope_refused")
216
+ normalized = posixpath.normpath(path)
217
+ if normalized == "." and not dot:
218
+ raise Failure("scope_refused")
219
+ return normalized
220
+
221
+
222
+ def valid_url(raw):
223
+ try:
224
+ url = urlsplit(raw)
225
+ host = url.hostname
226
+ if (url.scheme.lower() not in {"http", "https"} or not host
227
+ or "\\" in raw or any(c.isspace() or ord(c) < 32 or ord(c) == 127 for c in raw)):
228
+ return False
229
+ _ = url.port
230
+ try:
231
+ ipaddress.ip_address(host)
232
+ except ValueError:
233
+ ascii_host = host.encode("idna").decode("ascii").removesuffix(".")
234
+ labels = ascii_host.split(".")
235
+ if (len(ascii_host) > 253 or len(labels) == 4 and all(label.isdigit() for label in labels)
236
+ or not all(re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
237
+ for label in labels)):
238
+ return False
239
+ return True
240
+ except (ValueError, UnicodeError):
241
+ return False
242
+
243
+
244
+ def text_links(storage, text):
245
+ masked, _, _ = storage.masked_body(text)
246
+ internal, _, all_raw = storage.extract_links(text, masked, 0)
247
+ remaining = iter(internal)
248
+ next_internal = next(remaining, None)
249
+ records, occupied, position = [], [], 0
250
+ for raw in all_raw:
251
+ start = text.find(raw, position)
252
+ while start >= 0 and masked[start:start + 1] != text[start:start + 1]:
253
+ start = text.find(raw, start + 1)
254
+ position = start + len(raw)
255
+ occupied.append((start, position))
256
+ if next_internal is not None and next_internal["raw"] == raw:
257
+ records.append((start, next_internal))
258
+ next_internal = next(remaining, None)
259
+ continue
260
+ wiki = re.fullmatch(r"!?\[\[(.*?)\]\]", raw)
261
+ if wiki:
262
+ destination = wiki[1].replace("\\|", "|").split("|", 1)[0]
263
+ else:
264
+ children = storage.MARKDOWN.parseInline(raw)[0].children or []
265
+ target = next((token for token in children if token.type in {"link_open", "image"}), None)
266
+ destination = target.attrGet("href" if target.type == "link_open" else "src") if target else ""
267
+ records.append((start, {"raw": raw, "syntax": "external", "destination": destination}))
268
+ for match in re.finditer(r"https?://[^\s<>]+", masked, re.I):
269
+ if not any(start <= match.start() < end for start, end in occupied):
270
+ records.append((match.start(), {"raw": text[match.start():match.end()],
271
+ "syntax": "external", "destination": text[match.start():match.end()]}))
272
+ return [link for _, link in sorted(records, key=lambda item: item[0])]
273
+
274
+
275
+ def contains(group, node):
276
+ return (group["width"] > 0 and group["height"] > 0
277
+ and node["width"] > 0 and node["height"] > 0
278
+ and group["x"] <= node["x"] and group["y"] <= node["y"]
279
+ and group["x"] + group["width"] >= node["x"] + node["width"]
280
+ and group["y"] + group["height"] >= node["y"] + node["height"])
281
+
282
+
283
+ def intersects(a, b):
284
+ return (min(a["width"], a["height"], b["width"], b["height"]) > 0
285
+ and a["x"] < b["x"] + b["width"] and b["x"] < a["x"] + a["width"]
286
+ and a["y"] < b["y"] + b["height"] and b["y"] < a["y"] + a["height"])
287
+
288
+
289
+ def prefix(value):
290
+ if not isinstance(value, str):
291
+ return None
292
+ for category, marker in PREFIXES.items():
293
+ if value.startswith(marker):
294
+ rest = value[len(marker):]
295
+ if not rest.startswith(" ") and not any(rest.startswith(other.rstrip()) for other in PREFIXES.values()):
296
+ return category
297
+ return None
298
+
299
+
300
+ def effective(document):
301
+ nodes = document.get("nodes", [])
302
+ by_id = {n["id"]: n for n in nodes}
303
+ node_positions = {n["id"]: i for i, n in enumerate(nodes)}
304
+ groups = [(i, n) for i, n in enumerate(nodes) if n["type"] == "group"]
305
+ result, carriers = {}, {}
306
+ for entity, objects in (("node", nodes), ("edge", document.get("edges", []))):
307
+ for index, obj in enumerate(objects):
308
+ key = entity, obj["id"]
309
+ field = "text" if obj.get("type") == "text" else "label"
310
+ own = prefix(obj.get(field))
311
+ if own:
312
+ result[key], carriers[key] = own, {(entity, obj["id"], field)}
313
+ continue
314
+ subjects = [obj] if entity == "node" else [by_id[obj["fromNode"]], by_id[obj["toNode"]]]
315
+ choices = [(i, g) for i, g in groups
316
+ if (entity == "edge" or i < index)
317
+ and all(i < node_positions[n["id"]] for n in subjects)
318
+ and all(contains(g, n) and g is not n for n in subjects)]
319
+ inner = [(i, g) for i, g in choices
320
+ if not any(g is not h and contains(g, h) and not contains(h, g)
321
+ for _, h in choices)]
322
+ categories = {prefix(g.get("label")) for _, g in inner}
323
+ result[key] = next(iter(categories)) if len(categories) == 1 else (
324
+ "ambiguous" if categories else None)
325
+ carriers[key] = {("node", g["id"], "label") for _, g in inner}
326
+ return result, carriers
327
+
328
+
329
+ def parse_document(data, path):
330
+ issues = []
331
+ diagnostics = dict(structure="supported", references=[], issues=issues,
332
+ render_check="not_performed")
333
+ try:
334
+ document = strict_json(data, numbers=True)
335
+ except UnicodeDecodeError:
336
+ issues.append(issue("utf8_decode", path))
337
+ diagnostics["structure"] = "unsupported"
338
+ return None, diagnostics
339
+ except (ValueError, RecursionError) as error:
340
+ code = str(error) if str(error) in {"duplicate_key", "nonfinite_number", "utf8_bom"} else "json_syntax"
341
+ issues.append(issue(code, path))
342
+ diagnostics["structure"] = "unsupported" if code == "utf8_bom" else "invalid"
343
+ return None, diagnostics
344
+
345
+ def add(code, entity="document", obj=None, field=None, related=None):
346
+ identifier = obj.get("id") if isinstance(obj, dict) else None
347
+ if not isinstance(identifier, str) or isinstance(identifier, Number):
348
+ identifier = None
349
+ if identifier is not None and any(0xD800 <= ord(c) <= 0xDFFF for c in identifier):
350
+ identifier = None
351
+ if field is not None and any(0xD800 <= ord(c) <= 0xDFFF for c in field):
352
+ field = None
353
+ issues.append(issue(code, path, entity, identifier, field, related))
354
+
355
+ def scalar(value):
356
+ pending = [value]
357
+ while pending:
358
+ item = pending.pop()
359
+ if isinstance(item, str) and any(0xD800 <= ord(c) <= 0xDFFF for c in item):
360
+ return True
361
+ if isinstance(item, list):
362
+ pending.extend(item)
363
+ elif isinstance(item, dict):
364
+ pending.extend(item.keys())
365
+ pending.extend(item.values())
366
+ return False
367
+
368
+ if not isinstance(document, dict):
369
+ add("invalid_type")
370
+ else:
371
+ for name in sorted(document):
372
+ if name not in {"nodes", "edges"}:
373
+ add("unknown_attribute", field=name)
374
+ for collection, entity in (("nodes", "node"), ("edges", "edge")):
375
+ objects = document.get(collection, [])
376
+ if not isinstance(objects, list):
377
+ add("invalid_type", field=collection)
378
+ continue
379
+ seen = set()
380
+ for obj in objects:
381
+ if not isinstance(obj, dict):
382
+ add("invalid_type", entity)
383
+ continue
384
+ raw_kind = obj.get("type")
385
+ kind = raw_kind if isinstance(raw_kind, str) and not isinstance(raw_kind, Number) else None
386
+ unknown_kind = entity == "node" and kind is not None and kind not in EXTRA
387
+ known = COMMON | EXTRA.get(kind, set()) if entity == "node" else EDGE
388
+ required = ({"id", "type", "x", "y", "width", "height"}
389
+ | ({"text"} if kind == "text" else {"file"} if kind == "file"
390
+ else {"url"} if kind == "link" else set())
391
+ if entity == "node" else {"id", "fromNode", "toNode"})
392
+ for field in sorted(required - obj.keys()):
393
+ add("missing_required", entity, obj, field)
394
+ for field in sorted(obj):
395
+ value = obj[field]
396
+ if scalar(value) or scalar(field):
397
+ add("unicode_scalar", entity, obj, field)
398
+ if field not in known:
399
+ if not unknown_kind:
400
+ add("unknown_attribute", entity, obj, field)
401
+ continue
402
+ if field in {"x", "y", "width", "height"}:
403
+ value, error = integer(value)
404
+ if error:
405
+ add(error, entity, obj, field)
406
+ else:
407
+ obj[field] = value
408
+ elif not isinstance(value, str) or isinstance(value, Number):
409
+ add("invalid_type", entity, obj, field)
410
+ elif field == "type" and unknown_kind:
411
+ add("unknown_node_type", entity, obj, field)
412
+ elif field == "color" and not re.fullmatch(r"(?:[1-6]|#[0-9A-Fa-f]{6})", value):
413
+ add("unsupported_color", entity, obj, field)
414
+ elif (field in {"fromSide", "toSide"} and value not in {"top", "bottom", "left", "right"}
415
+ or field in {"fromEnd", "toEnd"} and value not in {"none", "arrow"}
416
+ or field == "backgroundStyle" and value not in {"cover", "ratio", "repeat"}
417
+ or field == "subpath" and not value.startswith("#")):
418
+ add("invalid_enum", entity, obj, field)
419
+ identifier = obj.get("id")
420
+ if isinstance(identifier, str):
421
+ if identifier in seen:
422
+ add("duplicate_id", entity, obj, "id")
423
+ seen.add(identifier)
424
+ nodes = document.get("nodes", [])
425
+ edges = document.get("edges", [])
426
+ if isinstance(nodes, list) and isinstance(edges, list):
427
+ ids = {n.get("id") for n in nodes if isinstance(n, dict) and isinstance(n.get("id"), str)}
428
+ for edge in edges:
429
+ if isinstance(edge, dict):
430
+ for field in ("fromNode", "toNode"):
431
+ if isinstance(edge.get(field), str) and edge[field] not in ids:
432
+ add("missing_endpoint", "edge", edge, field, [edge[field]])
433
+ if scalar(document) and not any(i["code"] == "unicode_scalar" for i in issues):
434
+ add("unicode_scalar")
435
+ diagnostics["structure"] = (
436
+ "unsupported" if any(i["code"] == "unicode_scalar" for i in issues)
437
+ else "invalid" if any(i["category"] == "format" for i in issues)
438
+ else "unsupported" if issues else "supported")
439
+ if diagnostics["structure"] == "supported":
440
+ nodes = document.get("nodes", [])
441
+ for index, node in enumerate(nodes):
442
+ for field in ("height", "width"):
443
+ if node[field] <= 0:
444
+ add("nonpositive_size", "node", node, field)
445
+ if node["type"] != "group":
446
+ for earlier in nodes[:index]:
447
+ if earlier["type"] != "group" and intersects(earlier, node):
448
+ add("overlap", "node", node, related=[earlier["id"]])
449
+ categories, _ = effective(document)
450
+ for (entity, identifier), category in categories.items():
451
+ if category == "ambiguous":
452
+ issues.append(issue("ambiguous_marking", path, entity, identifier))
453
+ if isinstance(document, dict):
454
+ positions = {("document", None): -1}
455
+ for offset, collection in ((0, "nodes"), (1000000000, "edges")):
456
+ objects = document.get(collection, [])
457
+ if isinstance(objects, list):
458
+ for index, obj in enumerate(objects):
459
+ if isinstance(obj, dict):
460
+ identifier = obj.get("id")
461
+ if isinstance(identifier, str):
462
+ positions.setdefault((collection[:-1], identifier), offset + index)
463
+ issues.sort(key=lambda i: (positions.get((i["entity"], i["id"]), -1),
464
+ i["field"] or "", i["code"], i["related_ids"]))
465
+ return document, diagnostics
466
+
467
+
468
+ class Session:
469
+ def __init__(self, request, storage):
470
+ self.request, self.storage = request, storage
471
+ self.vault = storage.Vault(request)
472
+ self.versions, self.paths, self.total = set(), set(), 0
473
+ self.exhausted = False
474
+ self.notes_cache = {}
475
+ self.version_sizes = {}
476
+
477
+ def reserve(self, path, data):
478
+ key = path, digest(data)
479
+ if key in self.versions:
480
+ return
481
+ limits = self.request["limits"]
482
+ if ((limits["max_file_bytes"] is not None and len(data) > limits["max_file_bytes"])
483
+ or limits["max_files"] is not None and path not in self.paths and len(self.paths) >= limits["max_files"]
484
+ or limits["max_total_bytes"] is not None and self.total + len(data) > limits["max_total_bytes"]):
485
+ self.exhausted = True
486
+ raise Failure("limit_exceeded")
487
+ self.versions.add(key)
488
+ self.paths.add(path)
489
+ self.total += len(data)
490
+
491
+ def read(self, path):
492
+ safe_path(path)
493
+ if not self.vault.allowed(path):
494
+ raise Failure("scope_refused")
495
+ limits = self.request["limits"]
496
+ maximum = limits["max_file_bytes"]
497
+ # Reserve-size checks precede reading new bodies. Previously seen versions
498
+ # may be re-read without consuming their budget a second time.
499
+ try:
500
+ metadata = self.vault.stat_authorized(path)
501
+ except self.storage.Failure as error:
502
+ if error.code in {"symlink_not_followed", "outside_scope", "invalid_path", "unsupported_format"}:
503
+ raise Failure("scope_refused") from None
504
+ raise
505
+ known_sizes = [size for (name, _), size in self.version_sizes.items() if name == path]
506
+ new_size = metadata.st_size not in known_sizes
507
+ if new_size and (
508
+ limits["max_files"] is not None and path not in self.paths and len(self.paths) >= limits["max_files"]
509
+ or limits["max_total_bytes"] is not None and self.total + metadata.st_size > limits["max_total_bytes"]):
510
+ self.exhausted = True
511
+ raise Failure("limit_exceeded")
512
+ if limits["max_total_bytes"] is not None:
513
+ available = max(limits["max_total_bytes"] - self.total, max(known_sizes, default=0))
514
+ maximum = min(maximum, available) if maximum is not None else available
515
+ try:
516
+ data, metadata = self.vault.read_authorized(path, maximum)
517
+ except self.storage.Failure as error:
518
+ if error.code == "limit_reached":
519
+ self.exhausted = True
520
+ raise Failure("limit_exceeded") from None
521
+ if error.code in {"symlink_not_followed", "outside_scope", "invalid_path", "unsupported_format"}:
522
+ raise Failure("scope_refused") from None
523
+ raise
524
+ self.reserve(path, data)
525
+ self.version_sizes[(path, digest(data))] = len(data)
526
+ return data, metadata
527
+
528
+ def note(self, path):
529
+ if path not in self.notes_cache:
530
+ data, _ = self.read(path)
531
+ self.notes_cache[path] = self.storage.parse_note(path, data)
532
+ return self.notes_cache[path]
533
+
534
+ def resolve(self, path, fragment):
535
+ if forbidden(path):
536
+ return "forbidden_path", []
537
+ path = posixpath.normpath(path)
538
+ if not self.vault.allowed(path):
539
+ return "outside_scope", []
540
+ try:
541
+ metadata = self.vault.stat_authorized(path)
542
+ if not stat.S_ISREG(metadata.st_mode):
543
+ return "not_regular_file", []
544
+ except FileNotFoundError:
545
+ return "missing_file", []
546
+ except (OSError, self.storage.Failure):
547
+ return "read_failed", []
548
+ candidates = [path]
549
+ note = None
550
+ if path.endswith(".md"):
551
+ try:
552
+ note = self.note(path)
553
+ except Failure:
554
+ return "limit_exceeded", candidates
555
+ except (OSError, ValueError, self.storage.Failure):
556
+ return "target_parse_incomplete", candidates
557
+ if note["fatal"]:
558
+ return "target_parse_incomplete", candidates
559
+ if fragment is None:
560
+ return "file_exists", candidates
561
+ if note is None or not fragment.startswith("#") or fragment.startswith("#^"):
562
+ return "unsupported_subpath", candidates
563
+ heading = fragment[1:]
564
+ if note["headings"].count(heading) > 1:
565
+ return "ambiguous_heading", candidates
566
+ if heading in note["headings"]:
567
+ return "heading_exists", candidates
568
+ if note["unsupported_heading"]:
569
+ return "unsupported_subpath", candidates
570
+ return "missing_heading", candidates
571
+
572
+ def text_reference(self, canvas, link):
573
+ raw, syntax = link["destination"], link["syntax"]
574
+ path, separator, heading = raw.partition("#")
575
+ fragment = "#" + heading if separator else None
576
+ if syntax == "external" or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", raw):
577
+ return ("external_url" if valid_url(raw) else "forbidden_url"), [], fragment
578
+ if syntax == "unsupported" or not path:
579
+ return "unsupported_reference", [], fragment
580
+ if forbidden(path, parents=syntax == "markdown"):
581
+ return "forbidden_path", [], fragment
582
+ if syntax == "markdown":
583
+ try:
584
+ if re.search(r"%(?![0-9A-Fa-f]{2})", path + heading):
585
+ raise ValueError()
586
+ path, heading = unquote(path, errors="strict"), unquote(heading, errors="strict")
587
+ except (ValueError, UnicodeError):
588
+ return "unsupported_reference", [], fragment
589
+ fragment = "#" + heading if separator else None
590
+ if forbidden(path, parents=True):
591
+ return "forbidden_path", [], fragment
592
+ path = posixpath.normpath(posixpath.join(posixpath.dirname(canvas), path))
593
+ if forbidden(path):
594
+ return "forbidden_path", [], fragment
595
+ if not path.endswith(".md"):
596
+ return "unsupported_reference", [], fragment
597
+ else:
598
+ if Path(path).suffix and not path.endswith(".md"):
599
+ return "unsupported_reference", [], fragment
600
+ path = path if path.endswith(".md") else path + ".md"
601
+ if "/" not in path:
602
+ names, _, _, complete = self.vault.enumerate_authorized((".md",))
603
+ candidates = sorted(n for n in names if posixpath.basename(n) == path)
604
+ if len(candidates) > 1:
605
+ return "multiple_notes", candidates, fragment
606
+ whole = ("directory", ".") in self.vault.scopes
607
+ if not whole or not complete:
608
+ return "incomplete_search", candidates, fragment
609
+ if not candidates:
610
+ return "missing_file", [], fragment
611
+ path = candidates[0]
612
+ reason, candidates = self.resolve(path, fragment)
613
+ return reason, candidates, fragment
614
+
615
+ def diagnose(self, data, path):
616
+ document, diagnostics = parse_document(data, path)
617
+ if (diagnostics["structure"] == "invalid" or document is None
618
+ or any(i["code"] == "unicode_scalar" for i in diagnostics["issues"])):
619
+ return document, diagnostics
620
+ for entity, collection in (("node", "nodes"), ("edge", "edges")):
621
+ objects = document.get(collection, [])
622
+ if not isinstance(objects, list):
623
+ continue
624
+ for obj in objects:
625
+ if not isinstance(obj, dict) or not isinstance(obj.get("id"), str):
626
+ continue
627
+ for field in ("file", "background", "url", "text", "label"):
628
+ if not isinstance(obj.get(field), str):
629
+ continue
630
+ if field in {"text", "label"}:
631
+ links = text_links(self.storage, obj[field])
632
+ records = [(link["raw"], link, None) for link in links]
633
+ else:
634
+ records = [(obj[field], None, obj.get("subpath") if field == "file" else None)]
635
+ for occurrence, (raw, link, fragment) in enumerate(records, 1):
636
+ policy = False
637
+ if diagnostics["structure"] != "supported":
638
+ if link:
639
+ _, separator, heading = link["destination"].partition("#")
640
+ fragment = "#" + heading if separator else None
641
+ reason, candidates = "source_parse_incomplete", []
642
+ elif link:
643
+ reason, candidates, fragment = self.text_reference(path, link)
644
+ elif field == "url":
645
+ valid = valid_url(raw)
646
+ reason, candidates = ("external_url" if valid else "forbidden_url"), []
647
+ policy = not valid
648
+ else:
649
+ reason, candidates = self.resolve(raw, fragment)
650
+ policy = reason == "forbidden_path"
651
+ diagnostics["references"].append(dict(
652
+ canvas=path, entity=entity, id=obj["id"], field=field,
653
+ occurrence=occurrence, raw=raw, fragment=fragment,
654
+ classification=CONTRACT["x-reference-reasons"][reason]["classification"],
655
+ reason=reason, candidates=candidates, policy_forbidden=policy))
656
+ return document, diagnostics
657
+
658
+
659
+ def partial(diagnostics):
660
+ return (diagnostics["structure"] != "supported"
661
+ or any(CONTRACT["x-reference-reasons"][r["reason"]]["partial"]
662
+ for r in diagnostics["references"]))
663
+
664
+
665
+ def context(request, command):
666
+ validate(request, "Request")
667
+ if (not os.path.isabs(request["vault_root"]) or ".." in request["vault_root"].split("/")
668
+ or request["workspace"] is not None and (
669
+ not os.path.isabs(request["workspace"]) or ".." in request["workspace"].split("/"))):
670
+ raise Failure("invalid_input", 1)
671
+ if ((command in {"inspect", "read"}) != (request["intent"] == "inspect")
672
+ or command == "layout" and (request["intent"] not in {"create", "edit"} or request["layout"] is None)
673
+ or command != "layout" and request["layout"] is not None
674
+ or command in {"layout", "plan", "apply"} and request["workspace"] is None):
675
+ raise Failure("invalid_input", 1)
676
+ for scope in request["read_scopes"]:
677
+ safe_path(scope["path"], dot=scope["kind"] == "directory")
678
+ if request["write_target"] is not None:
679
+ safe_path(request["write_target"])
680
+ if not request["write_target"].endswith(".canvas"):
681
+ raise Failure("invalid_input", 1)
682
+ seen, set_fields = {}, {}
683
+ for mutation in request["mutations"]:
684
+ entity, identifier, action, fields = (mutation[k] for k in ("entity", "id", "action", "fields"))
685
+ if entity == "document":
686
+ valid = identifier is None and action == "set" and bool(fields) and set(fields) <= VIRTUAL
687
+ else:
688
+ allowed = (COMMON | set().union(*EXTRA.values()) if entity == "node" else EDGE) - {"id", "type"}
689
+ valid = (identifier is not None and (action == "set" and bool(fields) and set(fields) <= allowed
690
+ or action in {"add", "remove"} and not fields))
691
+ key = entity, identifier
692
+ previous = seen.setdefault(key, [])
693
+ disjoint_set = (action == "set" and all(a == "set" for a in previous)
694
+ and not (set_fields.get(key, set()) & set(fields)))
695
+ retype_pair = entity == "node" and previous == ["remove"] and action == "add"
696
+ if not valid or previous and not (retype_pair or disjoint_set):
697
+ raise Failure("invalid_input", 1)
698
+ previous.append(action)
699
+ if action == "set":
700
+ set_fields.setdefault(key, set()).update(fields)
701
+ bindings = [(m["entity"], m["id"]) for m in request["markings"]]
702
+ if len(bindings) != len(set(bindings)) or any(not t.strip() for t in request["protected_terms"]):
703
+ raise Failure("invalid_input", 1)
704
+ if not request["consent"]["model_context"] or (
705
+ command in {"layout", "plan", "apply"} and not request["consent"]["workspace_copies"]):
706
+ raise Failure("consent_required")
707
+
708
+
709
+ def private_metadata(metadata, mode, directory=False):
710
+ if (metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != mode
711
+ or not (stat.S_ISDIR(metadata.st_mode) if directory else stat.S_ISREG(metadata.st_mode))):
712
+ raise Failure("scope_refused")
713
+
714
+
715
+ @contextmanager
716
+ def private_parent(request, path):
717
+ workspace = request["workspace"]
718
+ path = os.fspath(path)
719
+ root = os.path.normpath(request["vault_root"])
720
+ if (not workspace or not os.path.isabs(workspace) or not os.path.isabs(path)
721
+ or ".." in path.split("/") or ".." in workspace.split("/")
722
+ or os.path.commonpath([root, workspace]) in {root, os.path.normpath(workspace)}
723
+ or os.path.commonpath([workspace, path]) != workspace or path == workspace):
724
+ raise Failure("scope_refused")
725
+ storage = backend()
726
+ descriptor = storage.directory(workspace)
727
+ try:
728
+ private_metadata(os.fstat(descriptor), 0o700, True)
729
+ parts = os.path.relpath(path, workspace).split("/")
730
+ for part in parts[:-1]:
731
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
732
+ os.close(descriptor)
733
+ descriptor = child
734
+ private_metadata(os.fstat(descriptor), 0o700, True)
735
+ yield descriptor, parts[-1]
736
+ except OSError as error:
737
+ if error.errno in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}:
738
+ raise Failure("scope_refused") from None
739
+ raise
740
+ finally:
741
+ os.close(descriptor)
742
+
743
+
744
+ def private_read(request, path, maximum=CONTROL_MAX):
745
+ with private_parent(request, path) as (parent, name):
746
+ descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
747
+ with os.fdopen(descriptor, "rb") as stream:
748
+ metadata = os.fstat(stream.fileno())
749
+ private_metadata(metadata, 0o600)
750
+ if maximum is not None and metadata.st_size > maximum:
751
+ raise Failure("limit_exceeded")
752
+ data = stream.read() if maximum is None else stream.read(maximum + 1)
753
+ if maximum is not None and len(data) > maximum:
754
+ raise Failure("limit_exceeded")
755
+ return data
756
+
757
+
758
+ def private_write(request, path, data, *, replace=False):
759
+ with private_parent(request, path) as (parent, name):
760
+ stage = "." + name + ".tmp" if replace else name
761
+ try:
762
+ descriptor = os.open(stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
763
+ 0o600, dir_fd=parent)
764
+ except FileExistsError:
765
+ raise Failure("workspace_conflict", path=str(Path(path).with_name(stage))) from None
766
+ with os.fdopen(descriptor, "wb") as stream:
767
+ os.fchmod(stream.fileno(), 0o600)
768
+ stream.write(data)
769
+ stream.flush()
770
+ os.fsync(stream.fileno())
771
+ if replace:
772
+ os.replace(stage, name, src_dir_fd=parent, dst_dir_fd=parent)
773
+ os.fsync(parent)
774
+
775
+
776
+ def private_mkdir(request, path):
777
+ with private_parent(request, path) as (parent, name):
778
+ os.mkdir(name, 0o700, dir_fd=parent)
779
+ os.chmod(name, 0o700, dir_fd=parent, follow_symlinks=False)
780
+ os.fsync(parent)
781
+
782
+
783
+ def changed_delta(request, before, after):
784
+ mutations = request["mutations"]
785
+ permissions = {}
786
+ for mutation in mutations:
787
+ permissions.setdefault((mutation["entity"], mutation["id"], mutation["action"]), set()).update(mutation["fields"])
788
+ proofread = request["intent"] == "proofread"
789
+ refusal = "protected_change" if proofread else "unrequested_change"
790
+ delta, strings = [], []
791
+ removed_nodes = {n["id"] for n in before.get("nodes", [])} - {n["id"] for n in after.get("nodes", [])}
792
+ if request["intent"] == "create":
793
+ return [], []
794
+ if proofread:
795
+ for mutation in mutations:
796
+ entity = mutation["entity"]
797
+ objects = {o["id"]: o for o in before.get(entity + "s", [])} if entity != "document" else {}
798
+ obj = objects.get(mutation["id"])
799
+ if (obj is None or mutation["action"] != "set"
800
+ or any(not (f == "text" and obj.get("type") == "text"
801
+ or f == "label" and (entity == "edge" or obj.get("type") == "group"))
802
+ or not isinstance(obj.get(f), str) for f in mutation["fields"])):
803
+ raise Failure("protected_change")
804
+ for mutation in mutations:
805
+ if mutation["entity"] != "document":
806
+ collection = mutation["entity"] + "s"
807
+ old = {o["id"]: o for o in before.get(collection, [])}
808
+ new = {o["id"]: o for o in after.get(collection, [])}
809
+ if mutation["action"] in {"set", "remove"} and mutation["id"] not in old:
810
+ raise Failure("invalid_input", 1)
811
+ if mutation["action"] == "add" and mutation["id"] in old:
812
+ if not ((mutation["entity"], mutation["id"], "remove") in permissions
813
+ and mutation["id"] in new and old[mutation["id"]].get("type") != new[mutation["id"]].get("type")):
814
+ raise Failure("invalid_input", 1)
815
+ def change(entity, identifier, action, field=None, dependency=False):
816
+ delta.append(dict(entity=entity, id=identifier, action=action, field=field, dependency=dependency))
817
+ for collection in ("nodes", "edges"):
818
+ old, new = before.get(collection, []), after.get(collection, [])
819
+ entity = collection[:-1]
820
+ old_ids, new_ids = [o["id"] for o in old], [o["id"] for o in new]
821
+ if (collection in before) != (collection in after):
822
+ field = collection + "_presence"
823
+ dependency = (collection not in before and bool(new)
824
+ and all((entity, i, "add") in permissions for i in new_ids))
825
+ if not dependency and field not in permissions.get(("document", None, "set"), set()):
826
+ raise Failure(refusal)
827
+ change("document", None, "set", field, dependency)
828
+ if [i for i in old_ids if i in new_ids] != [i for i in new_ids if i in old_ids]:
829
+ field = collection + "_order"
830
+ if field not in permissions.get(("document", None, "set"), set()):
831
+ raise Failure(refusal)
832
+ change("document", None, "set", field)
833
+ for collection in ("nodes", "edges"):
834
+ entity = collection[:-1]
835
+ old = {o["id"]: o for o in before.get(collection, [])}
836
+ new = {o["id"]: o for o in after.get(collection, [])}
837
+ for identifier, obj in old.items():
838
+ if identifier not in new:
839
+ dependency = entity == "edge" and bool({obj["fromNode"], obj["toNode"]} & removed_nodes)
840
+ if proofread or (entity, identifier, "remove") not in permissions and not dependency:
841
+ raise Failure(refusal)
842
+ change(entity, identifier, "remove", dependency=dependency)
843
+ continue
844
+ final = new[identifier]
845
+ if entity == "node" and obj["type"] != final["type"]:
846
+ if proofread or not all((entity, identifier, a) in permissions for a in ("remove", "add")):
847
+ raise Failure(refusal)
848
+ change(entity, identifier, "remove")
849
+ change(entity, identifier, "add")
850
+ continue
851
+ for field in sorted(obj.keys() | final.keys()):
852
+ if (field in obj) == (field in final) and obj.get(field) == final.get(field):
853
+ continue
854
+ if field not in permissions.get((entity, identifier, "set"), set()):
855
+ raise Failure(refusal)
856
+ if proofread:
857
+ valid = (field == "text" and obj.get("type") == "text"
858
+ or field == "label" and (entity == "edge" or obj.get("type") == "group"))
859
+ if not valid or not isinstance(obj.get(field), str) or not isinstance(final.get(field), str):
860
+ raise Failure("protected_change")
861
+ strings.append((entity, identifier, field, obj[field], final[field]))
862
+ change(entity, identifier, "set", field)
863
+ for identifier in new:
864
+ if identifier not in old:
865
+ if proofread or (entity, identifier, "add") not in permissions:
866
+ raise Failure(refusal)
867
+ change(entity, identifier, "add")
868
+ if proofread and not strings:
869
+ raise Failure("protected_change")
870
+ return delta, strings
871
+
872
+
873
+ def marking_gate(request, before, after, delta):
874
+ old, _ = effective(before)
875
+ current, carriers = effective(after)
876
+ proofread = request["intent"] == "proofread"
877
+ needed = set(current) if request["intent"] == "create" else {
878
+ (d["entity"], d["id"]) for d in delta
879
+ if d["action"] == "add" or d["action"] == "set" and d["field"] in SEMANTIC}
880
+ if proofread:
881
+ needed = set()
882
+ for key in old.keys() & current.keys():
883
+ if old[key] != current[key]:
884
+ if proofread:
885
+ raise Failure("protected_change")
886
+ needed.add(key)
887
+ delta.append(dict(entity=key[0], id=key[1], action="set",
888
+ field="effective_category", dependency=True))
889
+ if proofread:
890
+ for collection in ("nodes", "edges"):
891
+ final = {o["id"]: o for o in after.get(collection, [])}
892
+ for obj in before.get(collection, []):
893
+ field = "text" if obj.get("type") == "text" else "label"
894
+ if prefix(obj.get(field)) != prefix(final[obj["id"]].get(field)):
895
+ raise Failure("protected_change")
896
+ bindings = {(m["entity"], m["id"]): m for m in request["markings"]}
897
+ if any(key not in current for key in bindings):
898
+ raise Failure("invalid_input", 1)
899
+ for key in needed | bindings.keys():
900
+ binding = bindings.get(key)
901
+ if (binding is None or current[key] not in PREFIXES
902
+ or binding["category"] != current[key]
903
+ or (binding["carrier_entity"], binding["carrier_id"], binding["carrier_field"]) not in carriers[key]):
904
+ raise Failure("marking_required")
905
+ positions = {("document", None): -1}
906
+ for offset, collection in ((0, "nodes"), (1000000000, "edges")):
907
+ order = list(dict.fromkeys(o["id"] for o in before.get(collection, []) + after.get(collection, [])))
908
+ positions.update({(collection[:-1], identifier): offset + i for i, identifier in enumerate(order)})
909
+ # Stable sorting retains the adjacent remove/add pair.
910
+ delta.sort(key=lambda d: (positions[(d["entity"], d["id"])],
911
+ d["field"] or ""))
912
+
913
+
914
+ def review_gate(request, before, candidate, review_bytes, strings, storage):
915
+ try:
916
+ review = strict_json(review_bytes)
917
+ validate(review, "Review")
918
+ except (ValueError, ValidationError, UnicodeError, RecursionError):
919
+ raise Failure("invalid_input", 1) from None
920
+ if (review["before_sha256"] != (digest(before) if before is not None else None)
921
+ or review["after_sha256"] != digest(candidate)):
922
+ raise Failure("stale_review")
923
+ if not all(review["diagram_checks"].values()):
924
+ raise Failure("review_rejected")
925
+ if request["intent"] != "proofread":
926
+ if review["semantic_checks"] is not None:
927
+ raise Failure("invalid_input", 1)
928
+ return
929
+ mechanism = gate()
930
+ records = review["semantic_checks"]
931
+ if not isinstance(records, list) or len(records) != len(strings):
932
+ raise Failure("review_rejected")
933
+ indexed = {(r["entity"], r["id"], r["field"]): r for r in records}
934
+ if len(indexed) != len(records) or set(indexed) != {s[:3] for s in strings}:
935
+ raise Failure("review_rejected")
936
+ for entity, identifier, field, original, edited in strings:
937
+ record = indexed[(entity, identifier, field)]
938
+ initial, final = original.encode("utf-8"), edited.encode("utf-8")
939
+ if (record["before_sha256"] != digest(initial) or record["after_sha256"] != digest(final)
940
+ or not all(record["checks"].values())):
941
+ raise Failure("review_rejected")
942
+ converted = {k: record[k] for k in ("before_sha256", "after_sha256", "checks")}
943
+ converted["reviewer"] = review["reviewer"]
944
+ outcome = mechanism.check_bytes(initial, final, request["protected_terms"], converted)
945
+ # Parse as a body, not as a note: text cards have no frontmatter.
946
+ def syntax(text):
947
+ masked, incomplete, comments = storage.masked_body(text)
948
+ _, _, all_raw = storage.extract_links(text, masked, 0)
949
+ lines = text.splitlines(keepends=True)
950
+ headings = ["".join(lines[token.map[0]:token.map[1]])
951
+ for token in storage.MARKDOWN.parse(masked)
952
+ if token.type == "heading_open" and token.map]
953
+ return dict(
954
+ incomplete=incomplete, comments=comments, links=all_raw, headings=headings,
955
+ callouts=re.findall(r"^[ >]*\[![^\]\n]+\][+-]?", text, re.M),
956
+ definitions=re.findall(r"^[ ]{0,3}\[[^\]\n]+\]:[^\n]*(?:\n[ \t]+[^\n]+)*", text, re.M),
957
+ block_ids=re.findall(r"(?<!\S)\^[A-Za-z0-9-]+(?=\s|$)", masked),
958
+ tags=re.findall(r"(?<![\w\\])#[\w/-]+", masked))
959
+ if outcome["status"] != "passed" or syntax(original) != syntax(edited):
960
+ raise Failure("protected_change")
961
+
962
+
963
+ def implementation_hash(proofread):
964
+ paths = [Path(__file__), Path(__file__).with_suffix(".py.lock"), SKILL / "SKILL.md",
965
+ SKILL.parent / "obsidian-vault/scripts/vault.py",
966
+ SKILL.parent / "obsidian-vault/scripts/vault.py.lock"]
967
+ if proofread:
968
+ gate()
969
+ paths.append(SKILL.parent / "japanese-prose/scripts/invariants.py")
970
+ try:
971
+ return digest(canonical({"proofread_gate": proofread,
972
+ "files": {str(p.relative_to(SKILL.parent)): digest(p.read_bytes()) for p in paths}}))
973
+ except OSError:
974
+ raise Failure("dependency_missing", 4) from None
975
+
976
+
977
+ def storage_check(session, parent, target, metadata):
978
+ storage = session.storage
979
+ try:
980
+ storage.write_capabilities()
981
+ storage.local_filesystem(parent)
982
+ if os.path.realpath(f"/proc/self/fd/{parent}") != str(Path(session.vault.root) / posixpath.dirname(target)):
983
+ raise Failure("storage_capability", 4)
984
+ if metadata is not None:
985
+ with os.fdopen(os.open(posixpath.basename(target), os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
986
+ storage.metadata_supported(metadata, stream.fileno())
987
+ if os.readlink(f"/proc/self/fd/{stream.fileno()}") != str(Path(session.vault.root) / target):
988
+ raise Failure("storage_capability", 4)
989
+ except storage.Failure:
990
+ raise Failure("storage_capability", 4) from None
991
+
992
+
993
+ def validate_change(session, before, candidate, review_bytes):
994
+ request, storage = session.request, session.storage
995
+ target = request["write_target"]
996
+ if before is None:
997
+ initial, old_diag = {}, None
998
+ else:
999
+ initial, old_diag = session.diagnose(before, target)
1000
+ if old_diag["structure"] != "supported":
1001
+ raise Failure("source_not_editable")
1002
+ final, diagnostics = session.diagnose(candidate, target)
1003
+ if diagnostics["structure"] != "supported":
1004
+ raise Failure("candidate_not_editable")
1005
+ if any(r["policy_forbidden"] for d in (old_diag, diagnostics) if d for r in d["references"]):
1006
+ raise Failure("forbidden_reference")
1007
+ if before is not None and not request["allow_formatting"]:
1008
+ raise Failure("formatting_consent_required")
1009
+ delta, strings = changed_delta(request, initial, final)
1010
+ marking_gate(request, initial, final, delta)
1011
+ if review_bytes is None:
1012
+ raise Failure("review_required")
1013
+ review_gate(request, before, candidate, review_bytes, strings, storage)
1014
+ if session.exhausted:
1015
+ raise Failure("limit_exceeded")
1016
+ return delta, diagnostics
1017
+
1018
+
1019
+ def operation_hash(plan):
1020
+ return digest(canonical({k: v for k, v in plan.items()
1021
+ if k not in {"operation_id", "artifacts", "stage_target", "state"}}))
1022
+
1023
+
1024
+ ARTIFACT_NAMES = {"before": "before.canvas", "candidate": "candidate.canvas",
1025
+ "review": "semantic-review.json", "diff": "diff.patch",
1026
+ "delta": "delta.json", "diagnostics": "diagnostics.json"}
1027
+ HASH_NAMES = {"before": "before_sha256", "candidate": "after_sha256",
1028
+ "review": "review_sha256", "diff": "diff_sha256",
1029
+ "delta": "delta_sha256", "diagnostics": "diagnostics_sha256"}
1030
+
1031
+
1032
+ def target_read(session):
1033
+ target = safe_path(session.request["write_target"])
1034
+ if not session.vault.allowed(target):
1035
+ raise Failure("scope_refused")
1036
+ try:
1037
+ return session.read(target)
1038
+ except FileNotFoundError:
1039
+ return None, None
1040
+
1041
+
1042
+ def plan_command(session, request_bytes, args, report):
1043
+ request, storage = session.request, session.storage
1044
+ with session.vault.parent(request["write_target"]) as (parent, _):
1045
+ before, metadata = target_read(session)
1046
+ if request["intent"] == "create":
1047
+ if before is not None:
1048
+ raise Failure("target_exists")
1049
+ elif before is None or digest(before) != request["base_sha256"]:
1050
+ raise Failure("target_changed")
1051
+ candidate = private_read(request, args["--candidate"], request["limits"]["max_file_bytes"])
1052
+ if before == candidate:
1053
+ report["data"] = dict(plan_path=None, plan_sha256=None, operation_id=None, no_changes=True)
1054
+ return
1055
+ review = private_read(request, args["--review"]) if "--review" in args else None
1056
+ delta, diagnostics = validate_change(session, before, candidate, review)
1057
+ storage_check(session, parent, request["write_target"], metadata)
1058
+ diff = storage.diff_bytes(before, candidate, request["write_target"])
1059
+ maximum = request["limits"]["max_file_bytes"]
1060
+ if maximum is not None and len(diff) > maximum:
1061
+ raise Failure("limit_exceeded")
1062
+ payloads = dict(before=before, candidate=candidate, review=review, diff=diff,
1063
+ delta=canonical(delta), diagnostics=canonical(diagnostics))
1064
+ for key in ("review", "delta", "diagnostics"):
1065
+ if len(payloads[key]) > CONTROL_MAX:
1066
+ raise Failure("limit_exceeded")
1067
+ plan = dict(schema_version=1, kind=request["intent"], request_sha256=digest(request_bytes),
1068
+ contract_sha256=digest(CONTRACT_BYTES),
1069
+ implementation_sha256=implementation_hash(request["intent"] == "proofread"),
1070
+ vault_identity=storage.identity(os.fstat(session.vault.fd)),
1071
+ parent_identity=storage.identity(os.fstat(parent)), target=request["write_target"],
1072
+ **{HASH_NAMES[k]: digest(v) if v is not None else None for k, v in payloads.items()})
1073
+ operation = operation_hash(plan)
1074
+ directory = Path(request["workspace"]) / operation
1075
+ plan.update(operation_id=operation, state="validated",
1076
+ stage_target=posixpath.join(posixpath.dirname(plan["target"]), f".kotonoha-{operation}.tmp"),
1077
+ artifacts={k: str(directory / name) if payloads[k] is not None else None
1078
+ for k, name in ARTIFACT_NAMES.items()})
1079
+ validate(plan, "Plan")
1080
+ encoded = canonical(plan)
1081
+ if len(encoded) > CONTROL_MAX:
1082
+ raise Failure("limit_exceeded")
1083
+ path = directory / "plan.json"
1084
+ try:
1085
+ private_mkdir(request, directory)
1086
+ except FileExistsError:
1087
+ try:
1088
+ if private_read(request, path) != encoded:
1089
+ raise Failure("workspace_conflict")
1090
+ for key, value in payloads.items():
1091
+ if value is not None and private_read(request, plan["artifacts"][key], maximum if key in {"candidate", "before", "diff"} else CONTROL_MAX) != value:
1092
+ raise Failure("workspace_conflict")
1093
+ for name in ("result.json", "receipt.json"):
1094
+ try:
1095
+ private_read(request, directory / name)
1096
+ except FileNotFoundError:
1097
+ continue
1098
+ raise Failure("workspace_conflict")
1099
+ if set(os.listdir(directory)) != {"plan.json"} | {ARTIFACT_NAMES[k] for k, v in payloads.items() if v is not None}:
1100
+ raise Failure("workspace_conflict")
1101
+ with session.vault.parent(plan["stage_target"]) as (stage_parent, stage_name):
1102
+ try:
1103
+ os.stat(stage_name, dir_fd=stage_parent, follow_symlinks=False)
1104
+ except FileNotFoundError:
1105
+ pass
1106
+ else:
1107
+ raise Failure("workspace_conflict")
1108
+ except (OSError, storage.Failure):
1109
+ raise Failure("workspace_conflict") from None
1110
+ else:
1111
+ for key, value in payloads.items():
1112
+ if value is not None:
1113
+ private_write(request, plan["artifacts"][key], value)
1114
+ private_write(request, path, encoded)
1115
+ report["data"] = dict(plan_path=str(path), plan_sha256=digest(encoded),
1116
+ operation_id=operation, no_changes=False)
1117
+ report["operation_state"] = "validated"
1118
+ if partial(diagnostics):
1119
+ report.update(status="partial", exit_code=3)
1120
+
1121
+
1122
+ def replay_decision(result, receipt, target, *, create=False, stage=False):
1123
+ if result == "corrupt":
1124
+ return "replay_mismatch"
1125
+ if receipt == "valid":
1126
+ return "already_applied" if target == "after" else "replay_mismatch"
1127
+ if result == "saved":
1128
+ return "replay_mismatch"
1129
+ if result in {"pending", "unknown"}:
1130
+ return "prior_outcome_unknown"
1131
+ if receipt == "invalid":
1132
+ return "replay_mismatch"
1133
+ if target != "before":
1134
+ return "target_exists" if create else "target_changed"
1135
+ return "workspace_conflict" if stage else "fresh"
1136
+
1137
+
1138
+ def bound_record(session, path, definition, expected):
1139
+ try:
1140
+ value = strict_json(private_read(session.request, path))
1141
+ validate(value, definition)
1142
+ if any(value.get(k) != v for k, v in expected.items()):
1143
+ return "invalid", None
1144
+ return "valid", value
1145
+ except FileNotFoundError:
1146
+ return "absent", None
1147
+ except (ValueError, ValidationError, OSError, Failure):
1148
+ return "invalid", None
1149
+
1150
+
1151
+ def apply_command(session, request_bytes, args, report):
1152
+ request, storage = session.request, session.storage
1153
+ encoded = private_read(request, args["--plan"])
1154
+ if digest(encoded) != args["--approve-plan-sha256"]:
1155
+ raise Failure("approval_mismatch")
1156
+ try:
1157
+ plan = strict_json(encoded)
1158
+ validate(plan, "Plan")
1159
+ except (ValueError, ValidationError):
1160
+ raise Failure("plan_mismatch") from None
1161
+ directory = Path(request["workspace"]) / plan["operation_id"]
1162
+ expected_artifacts = {k: str(directory / name) if k != "before" or plan["before_sha256"] is not None else None
1163
+ for k, name in ARTIFACT_NAMES.items()}
1164
+ stage_target = posixpath.join(posixpath.dirname(request["write_target"]), f".kotonoha-{plan['operation_id']}.tmp")
1165
+ if (str(directory / "plan.json") != args["--plan"] or plan["artifacts"] != expected_artifacts
1166
+ or plan["request_sha256"] != digest(request_bytes) or plan["contract_sha256"] != digest(CONTRACT_BYTES)
1167
+ or plan["implementation_sha256"] != implementation_hash(request["intent"] == "proofread")
1168
+ or plan["kind"] != request["intent"] or plan["target"] != request["write_target"]
1169
+ or plan["stage_target"] != stage_target or operation_hash(plan) != plan["operation_id"]
1170
+ or plan["before_sha256"] != request["base_sha256"]):
1171
+ raise Failure("plan_mismatch")
1172
+ payloads = {}
1173
+ for key, path in expected_artifacts.items():
1174
+ maximum = request["limits"]["max_file_bytes"] if key in {"before", "candidate", "diff"} else CONTROL_MAX
1175
+ payloads[key] = private_read(request, path, maximum) if path is not None else None
1176
+ if (digest(payloads[key]) if payloads[key] is not None else None) != plan[HASH_NAMES[key]]:
1177
+ raise Failure("plan_mismatch")
1178
+ for key, definition in (("delta", "Delta"), ("diagnostics", "Diagnostics")):
1179
+ try:
1180
+ validate(strict_json(payloads[key]), definition)
1181
+ except (ValueError, ValidationError):
1182
+ raise Failure("plan_mismatch") from None
1183
+ if storage.diff_bytes(payloads["before"], payloads["candidate"], plan["target"]) != payloads["diff"]:
1184
+ raise Failure("plan_mismatch")
1185
+ common = dict(schema_version=1, operation_id=plan["operation_id"], request_sha256=plan["request_sha256"],
1186
+ plan_sha256=digest(encoded), target=plan["target"], after_sha256=plan["after_sha256"])
1187
+ result_status, result = bound_record(session, directory / "result.json", "OperationResult",
1188
+ common | {"before_sha256": plan["before_sha256"]})
1189
+ receipt_status, _ = bound_record(session, directory / "receipt.json", "Receipt", common | {"verified": True})
1190
+ with session.vault.parent(plan["target"]) as (parent, name):
1191
+ if (storage.identity(os.fstat(session.vault.fd)) != plan["vault_identity"]
1192
+ or storage.identity(os.fstat(parent)) != plan["parent_identity"]):
1193
+ raise Failure("plan_mismatch")
1194
+ current, metadata = target_read(session)
1195
+ state = ("after" if current is not None and digest(current) == plan["after_sha256"]
1196
+ else "before" if current == payloads["before"] else "other")
1197
+ stage_name = posixpath.basename(stage_target)
1198
+ try:
1199
+ os.stat(stage_name, dir_fd=parent, follow_symlinks=False)
1200
+ stage = True
1201
+ except FileNotFoundError:
1202
+ stage = False
1203
+ decision = replay_decision("corrupt" if result_status == "invalid" else result["phase"] if result else "absent",
1204
+ receipt_status, state, create=request["intent"] == "create", stage=stage)
1205
+ if decision not in {"fresh", "already_applied"}:
1206
+ raise Failure(decision)
1207
+ if payloads["before"] is not None:
1208
+ session.reserve(plan["target"], payloads["before"])
1209
+ session.reserve(plan["target"], payloads["candidate"])
1210
+ delta, diagnostics = validate_change(session, payloads["before"], payloads["candidate"], payloads["review"])
1211
+ if canonical(delta) != payloads["delta"]:
1212
+ raise Failure("plan_mismatch")
1213
+ data = dict(target=plan["target"], operation_id=plan["operation_id"], after_sha256=plan["after_sha256"],
1214
+ changed=decision == "fresh", verified=True, diagnostics=diagnostics,
1215
+ approved_diagnostics_sha256=plan["diagnostics_sha256"])
1216
+ if decision == "fresh":
1217
+ storage_check(session, parent, plan["target"], metadata)
1218
+ pending = common | dict(before_sha256=plan["before_sha256"], phase="pending", verified=False)
1219
+ private_write(request, directory / "result.json", canonical(pending))
1220
+ progress = {"committed": False, "own_stage": False, "cleanup_failed": False}
1221
+
1222
+ def read_at(descriptor, filename):
1223
+ fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor)
1224
+ with os.fdopen(fd, "rb") as stream:
1225
+ info = os.fstat(stream.fileno())
1226
+ if not stat.S_ISREG(info.st_mode):
1227
+ raise Failure("scope_refused")
1228
+ maximum = request["limits"]["max_file_bytes"]
1229
+ if maximum is not None and info.st_size > maximum:
1230
+ raise Failure("limit_exceeded")
1231
+ value = stream.read() if maximum is None else stream.read(maximum + 1)
1232
+ if maximum is not None and len(value) > maximum:
1233
+ raise Failure("limit_exceeded")
1234
+ session.reserve(plan["target"], value)
1235
+ return value, info
1236
+
1237
+ try:
1238
+ storage.commit_bytes(session.vault, parent, name, stage_name, payloads["before"],
1239
+ payloads["candidate"], metadata, plan["vault_identity"], progress,
1240
+ read_at=read_at, target=plan["target"])
1241
+ private_write(request, directory / "receipt.json", canonical(common | {"verified": True}))
1242
+ private_write(request, directory / "result.json",
1243
+ canonical(pending | {"phase": "saved", "verified": True}), replace=True)
1244
+ except Exception as error:
1245
+ if progress["committed"]:
1246
+ report["data"] = data | {"after_sha256": None, "changed": None, "verified": False}
1247
+ report["write_status"] = "unknown"
1248
+ raise Failure("runtime_error", 1,
1249
+ path=stage_target if progress["cleanup_failed"] else None) from error
1250
+ if progress["cleanup_failed"]:
1251
+ # Keep the pending record and the exact known stage path.
1252
+ # Neither retry nor cleanup may guess ownership after failure.
1253
+ raise Failure("runtime_error", 1, path=stage_target) from error
1254
+ if isinstance(error, Failure):
1255
+ raise
1256
+ if isinstance(error, storage.Failure):
1257
+ if error.code == "target_exists":
1258
+ try:
1259
+ os.stat(stage_name, dir_fd=parent, follow_symlinks=False)
1260
+ except FileNotFoundError:
1261
+ raise Failure("target_exists") from None
1262
+ raise Failure("workspace_conflict", path=stage_target) from None
1263
+ if error.code == "target_changed":
1264
+ raise Failure("target_changed") from None
1265
+ if error.code == "parent_changed":
1266
+ raise Failure("plan_mismatch") from None
1267
+ if error.exit_code == 4:
1268
+ raise Failure("storage_capability", 4) from None
1269
+ raise Failure("runtime_error", 1) from error
1270
+ report.update(data=data, write_status="saved", operation_state="saved")
1271
+ report["processed"] = [plan["target"]]
1272
+ if partial(diagnostics):
1273
+ report.update(status="partial", exit_code=3)
1274
+
1275
+
1276
+ def layout_document(document, layout):
1277
+ final = copy.deepcopy(document)
1278
+ nodes = {n["id"]: n for n in final.get("nodes", [])}
1279
+ selected = []
1280
+ for identifier in layout["node_ids"]:
1281
+ node = nodes.get(identifier)
1282
+ if node is None or node["type"] == "group" or min(node["width"], node["height"]) <= 0:
1283
+ raise Failure("layout_refused")
1284
+ selected.append(node)
1285
+ groups = [n for n in nodes.values() if n["type"] == "group"]
1286
+ if any(intersects(n, g) for n in selected for g in groups):
1287
+ raise Failure("layout_refused")
1288
+ width, height = max(n["width"] for n in selected), max(n["height"] for n in selected)
1289
+ for index, node in enumerate(selected):
1290
+ mode, columns = layout["mode"], layout["columns"]
1291
+ column = index if mode == "horizontal" else 0 if mode == "vertical" else index % columns
1292
+ row = index if mode == "vertical" else 0 if mode == "horizontal" else index // columns
1293
+ node["x"] = layout["origin_x"] + column * (width + layout["gap"])
1294
+ node["y"] = layout["origin_y"] + row * (height + layout["gap"])
1295
+ if any(abs(node[k]) > SAFE for k in ("x", "y")) or any(intersects(node, g) for g in groups):
1296
+ raise Failure("layout_refused")
1297
+ return final
1298
+
1299
+
1300
+ def layout_command(session, request_bytes, args, report):
1301
+ request = session.request
1302
+ candidate = private_read(request, args["--candidate"], request["limits"]["max_file_bytes"])
1303
+ document, diagnostics = parse_document(candidate, request["write_target"])
1304
+ if diagnostics["structure"] != "supported":
1305
+ raise Failure("candidate_not_editable")
1306
+ final = layout_document(document, request["layout"])
1307
+ encoded = (json.dumps(final, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
1308
+ maximum = request["limits"]["max_file_bytes"]
1309
+ if maximum is not None and len(encoded) > maximum:
1310
+ raise Failure("limit_exceeded")
1311
+ directory = Path(request["workspace"]) / ("layout-" + digest(request_bytes + candidate))
1312
+ try:
1313
+ private_mkdir(request, directory)
1314
+ path = directory / "candidate.canvas"
1315
+ private_write(request, path, encoded)
1316
+ except FileExistsError:
1317
+ raise Failure("workspace_conflict") from None
1318
+ _, diagnostics = parse_document(encoded, request["write_target"])
1319
+ report.update(operation_state="proposed", data=dict(
1320
+ candidate_path=str(path), candidate_sha256=digest(encoded), changed=encoded != candidate,
1321
+ issues=diagnostics["issues"], reference_check="not_performed", render_check="not_performed"))
1322
+
1323
+
1324
+ def inspect_command(session, args, report):
1325
+ command = report["command"]
1326
+ if "--path" in args:
1327
+ path = safe_path(args["--path"])
1328
+ if not path.endswith((".canvas", ".md") if command == "read" else (".canvas",)):
1329
+ raise Failure("scope_refused")
1330
+ if not session.vault.allowed(path):
1331
+ raise Failure("scope_refused")
1332
+ paths = [path]
1333
+ else:
1334
+ paths, skipped, _, complete = session.vault.enumerate_authorized((".canvas",))
1335
+ report["unprocessed"] = sorted(skipped)
1336
+ if not complete:
1337
+ report.update(status="partial", exit_code=3)
1338
+ documents = []
1339
+ for path in paths:
1340
+ try:
1341
+ data, _ = session.read(path)
1342
+ except Failure as error:
1343
+ if error.code == "scope_refused":
1344
+ raise
1345
+ if command == "read":
1346
+ raise
1347
+ report["issues"].append(issue(error.code, path, category="runtime"))
1348
+ report["unprocessed"].append(path)
1349
+ report.update(status="partial", exit_code=3)
1350
+ continue
1351
+ except (OSError, session.storage.Failure):
1352
+ if command == "read":
1353
+ raise Failure("read_failed", 1) from None
1354
+ report["issues"].append(issue("read_failed", path, category="runtime"))
1355
+ report["unprocessed"].append(path)
1356
+ report.update(status="partial", exit_code=3)
1357
+ continue
1358
+ diagnostics = session.diagnose(data, path)[1] if path.endswith(".canvas") else None
1359
+ if diagnostics and partial(diagnostics):
1360
+ report.update(status="partial", exit_code=3)
1361
+ report["processed"].append(path)
1362
+ if command == "read":
1363
+ try:
1364
+ content = data.decode("utf-8")
1365
+ content.encode("utf-8")
1366
+ except UnicodeError:
1367
+ content = None
1368
+ report.update(status="partial", exit_code=3)
1369
+ report["data"] = dict(path=path, sha256=digest(data), bytes=len(data),
1370
+ content=content, diagnostics=diagnostics)
1371
+ else:
1372
+ documents.append(dict(path=path, sha256=digest(data), bytes=len(data), diagnostics=diagnostics))
1373
+ if command == "inspect":
1374
+ report["data"] = {"documents": documents}
1375
+ if session.exhausted:
1376
+ report.update(status="partial", exit_code=3)
1377
+ if not any(i["code"] == "limit_exceeded" for i in report["issues"]):
1378
+ report["issues"].append(issue("limit_exceeded", category="runtime"))
1379
+
1380
+
1381
+ def arguments(argv):
1382
+ if not argv or argv[0] not in CONTRACT["x-commands"]:
1383
+ raise Failure("invalid_input", 1)
1384
+ command = argv[0]
1385
+ metadata = CONTRACT["x-commands"][command]
1386
+ allowed = metadata["required"] + metadata["optional"]
1387
+ result = {}
1388
+ for index in range(1, len(argv), 2):
1389
+ if (index + 1 >= len(argv) or argv[index] not in allowed or argv[index] in result
1390
+ or argv[index + 1].startswith("--")):
1391
+ raise Failure("invalid_input", 1)
1392
+ result[argv[index]] = argv[index + 1]
1393
+ if not all(flag in result for flag in metadata["required"]):
1394
+ raise Failure("invalid_input", 1)
1395
+ return command, result
1396
+
1397
+
1398
+ def request_read(path):
1399
+ # Requests are control inputs, not Vault payloads; all parents still refuse symlinks.
1400
+ path = os.path.abspath(path)
1401
+ descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
1402
+ try:
1403
+ parts = Path(path).parts[1:]
1404
+ for part in parts[:-1]:
1405
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
1406
+ os.close(descriptor)
1407
+ descriptor = child
1408
+ fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=descriptor)
1409
+ with os.fdopen(fd, "rb") as stream:
1410
+ metadata = os.fstat(stream.fileno())
1411
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > CONTROL_MAX:
1412
+ raise Failure("invalid_input", 1)
1413
+ data = stream.read(CONTROL_MAX + 1)
1414
+ if len(data) > CONTROL_MAX:
1415
+ raise Failure("invalid_input", 1)
1416
+ return data
1417
+ finally:
1418
+ os.close(descriptor)
1419
+
1420
+
1421
+ def run(argv):
1422
+ command = argv[0] if argv and argv[0] in CONTRACT["x-commands"] else "inspect"
1423
+ report = dict(schema_version=1, command=command, status="completed", exit_code=0,
1424
+ write_status="unsaved" if command in {"plan", "apply"} else "not_requested",
1425
+ operation_state="scoped", request_sha256=None, processed=[], unprocessed=[], issues=[], data=None)
1426
+ session = None
1427
+ try:
1428
+ command, args = arguments(argv)
1429
+ try:
1430
+ data = request_read(args["--request"])
1431
+ except OSError:
1432
+ raise Failure("invalid_input", 1) from None
1433
+ report["request_sha256"] = digest(data)
1434
+ try:
1435
+ request = request_json(data)
1436
+ context(request, command)
1437
+ except (ValueError, ValidationError, UnicodeError, RecursionError):
1438
+ raise Failure("invalid_input", 1) from None
1439
+ storage = backend()
1440
+ if request["intent"] == "proofread":
1441
+ gate()
1442
+ session = Session(request, storage)
1443
+ if command in {"plan", "apply"}:
1444
+ fcntl.flock(session.vault.fd, fcntl.LOCK_EX)
1445
+ (plan_command if command == "plan" else apply_command)(session, data, args, report)
1446
+ elif command == "layout":
1447
+ layout_command(session, data, args, report)
1448
+ else:
1449
+ inspect_command(session, args, report)
1450
+ except Failure as error:
1451
+ report.update(status="failed" if error.exit_code == 1 else "not_performed",
1452
+ exit_code=error.exit_code, operation_state="blocked")
1453
+ report["issues"].append(issue(error.code, error.path))
1454
+ except Exception as error:
1455
+ if session and isinstance(error, session.storage.Failure):
1456
+ code = "storage_capability" if error.exit_code == 4 else "scope_refused"
1457
+ exit_code = 4 if error.exit_code == 4 else 2
1458
+ else:
1459
+ code, exit_code = "runtime_error", 1
1460
+ report.update(status="failed" if exit_code == 1 else "not_performed",
1461
+ exit_code=exit_code, operation_state="blocked")
1462
+ report["issues"].append(issue(code))
1463
+ finally:
1464
+ if session:
1465
+ session.vault.close()
1466
+ report["processed"] = sorted(set(report["processed"]))
1467
+ report["unprocessed"] = sorted(set(report["unprocessed"]))
1468
+ validate(report, "Report")
1469
+ return report
1470
+
1471
+
1472
+ def main():
1473
+ report = run(sys.argv[1:])
1474
+ print(canonical(report).decode("utf-8"))
1475
+ return report["exit_code"]
1476
+
1477
+
1478
+ if __name__ == "__main__":
1479
+ sys.exit(main())