kotonoha 0.4.0 → 0.4.2

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 (38) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README-ja.md +82 -4
  3. package/README.md +123 -6
  4. package/bin/kotonoha.js +4 -1
  5. package/package.json +8 -3
  6. package/skills/japanese-prose/scripts/invariants.py +24 -6
  7. package/skills/obsidian-bases/SKILL.md +140 -0
  8. package/skills/obsidian-bases/assets/templates/cards.base +8 -0
  9. package/skills/obsidian-bases/assets/templates/kanban.base +7 -0
  10. package/skills/obsidian-bases/assets/templates/list.base +8 -0
  11. package/skills/obsidian-bases/assets/templates/table.base +10 -0
  12. package/skills/obsidian-bases/references/execution-contract.json +246 -0
  13. package/skills/obsidian-bases/references/workflow.md +155 -0
  14. package/skills/obsidian-bases/scripts/check.cjs +690 -0
  15. package/skills/obsidian-bases/scripts/package-lock.json +104 -0
  16. package/skills/obsidian-bases/scripts/package.json +18 -0
  17. package/skills/obsidian-bases/scripts/verify.cjs +454 -0
  18. package/skills/obsidian-canvas/SKILL.md +134 -0
  19. package/skills/obsidian-canvas/assets/templates/comparison.canvas +11 -0
  20. package/skills/obsidian-canvas/assets/templates/mind-map.canvas +11 -0
  21. package/skills/obsidian-canvas/assets/templates/note-map.canvas +10 -0
  22. package/skills/obsidian-canvas/assets/templates/workflow.canvas +11 -0
  23. package/skills/obsidian-canvas/references/execution-contract.json +404 -0
  24. package/skills/obsidian-canvas/references/workflow.md +187 -0
  25. package/skills/obsidian-canvas/scripts/canvas.py +1479 -0
  26. package/skills/obsidian-canvas/scripts/canvas.py.lock +143 -0
  27. package/skills/obsidian-canvas/scripts/test_canvas.py +2047 -0
  28. package/skills/obsidian-canvas/scripts/test_canvas.py.lock +143 -0
  29. package/skills/obsidian-vault/SKILL.md +162 -0
  30. package/skills/obsidian-vault/references/execution-contract.json +479 -0
  31. package/skills/obsidian-vault/references/workflow.md +206 -0
  32. package/skills/obsidian-vault/scripts/test_vault.py +569 -0
  33. package/skills/obsidian-vault/scripts/test_vault.py.lock +143 -0
  34. package/skills/obsidian-vault/scripts/vault.py +1363 -0
  35. package/skills/obsidian-vault/scripts/vault.py.lock +143 -0
  36. package/skills/tech-writer/SKILL.md +26 -7
  37. package/skills/tech-writer/assets/templates/obsidian.md +37 -0
  38. package/skills/tech-writer/references/doctypes/obsidian.md +106 -0
@@ -0,0 +1,1363 @@
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
+ """Diagnose authorized Markdown notes and apply explicitly approved single-note plans."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections import defaultdict
12
+ from contextlib import contextmanager
13
+ from datetime import date
14
+ import difflib
15
+ import fcntl
16
+ import hashlib
17
+ import importlib.util
18
+ import json
19
+ import math
20
+ import os
21
+ from pathlib import Path, PurePosixPath
22
+ import posixpath
23
+ import re
24
+ import stat
25
+ import sys
26
+ from urllib.parse import unquote_to_bytes
27
+
28
+ from jsonschema import Draft202012Validator, ValidationError
29
+ from markdown_it import MarkdownIt
30
+ from markdown_it.helpers import parseLinkDestination, parseLinkLabel, parseLinkTitle
31
+ from markdown_it.rules_inline.state_inline import StateInline
32
+ import yaml
33
+
34
+
35
+ SKILL = Path(__file__).resolve().parent.parent
36
+ CONTRACT_PATH = SKILL / "references" / "execution-contract.json"
37
+ MANAGEMENT = {".git", ".obsidian"}
38
+ MARKDOWN = MarkdownIt("commonmark").enable("table")
39
+ CHECKS = (
40
+ "actors", "negation", "conditions", "modality", "scope", "causality",
41
+ "quantity_bindings", "terminology", "structure",
42
+ )
43
+ STORAGE_API_VERSION = 1
44
+
45
+
46
+ class Failure(Exception):
47
+ def __init__(self, code, path=None, *, status="not_performed", exit_code=2):
48
+ super().__init__(code)
49
+ self.code, self.path = code, path
50
+ self.status, self.exit_code = status, exit_code
51
+
52
+
53
+ def digest(data):
54
+ return hashlib.sha256(data).hexdigest()
55
+
56
+
57
+ def canonical(data):
58
+ return json.dumps(data, ensure_ascii=False, sort_keys=True,
59
+ separators=(",", ":"), allow_nan=False).encode("utf-8")
60
+
61
+
62
+ def strict_json(data):
63
+ def pairs(items):
64
+ result = {}
65
+ for key, value in items:
66
+ if key in result:
67
+ raise ValueError("Duplicate JSON key")
68
+ result[key] = value
69
+ return result
70
+
71
+ def invalid_constant(value):
72
+ raise ValueError("Non-finite JSON number")
73
+
74
+ return json.loads(data, object_pairs_hook=pairs, parse_constant=invalid_constant)
75
+
76
+
77
+ CONTRACT = strict_json(CONTRACT_PATH.read_bytes())
78
+
79
+
80
+ def validate(value, definition):
81
+ Draft202012Validator({
82
+ "$ref": f"#/$defs/{definition}", "$defs": CONTRACT["$defs"],
83
+ }).validate(value)
84
+
85
+
86
+ def issue(code, path=None):
87
+ return {"code": code, "path": path, "message": code}
88
+
89
+
90
+ def relative(path, *, dot=False):
91
+ if not isinstance(path, str) or not path or "\0" in path or path.startswith("/"):
92
+ raise Failure("invalid_path")
93
+ parts = path.split("/")
94
+ if ".." in parts:
95
+ raise Failure("invalid_path", path)
96
+ normalized = posixpath.normpath(path)
97
+ if normalized == "." and not dot:
98
+ raise Failure("invalid_path", path)
99
+ if MANAGEMENT.intersection(parts):
100
+ raise Failure("excluded_management_directory", path)
101
+ return normalized
102
+
103
+
104
+ def directory(path):
105
+ path = os.fspath(path)
106
+ if not os.path.isabs(path) or "\0" in path or ".." in path.split("/"):
107
+ raise Failure("invalid_path")
108
+ descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
109
+ try:
110
+ for part in Path(path).parts[1:]:
111
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
112
+ dir_fd=descriptor)
113
+ os.close(descriptor)
114
+ descriptor = child
115
+ return descriptor
116
+ except (OSError, Failure):
117
+ os.close(descriptor)
118
+ raise
119
+
120
+
121
+ class Vault:
122
+ def __init__(self, request):
123
+ self.root = os.path.normpath(request["vault_root"])
124
+ self.scopes = [
125
+ (scope["kind"], relative(scope["path"], dot=scope["kind"] == "directory"))
126
+ for scope in request["read_scopes"]
127
+ ]
128
+ try:
129
+ self.fd = directory(self.root)
130
+ except OSError as error:
131
+ raise Failure("invalid_request", status="failed", exit_code=1) from error
132
+
133
+ def close(self):
134
+ os.close(self.fd)
135
+
136
+ def allowed(self, path):
137
+ return any(
138
+ path == scope or kind == "directory" and (
139
+ scope == "." or path.startswith(scope + "/")
140
+ ) for kind, scope in self.scopes
141
+ )
142
+
143
+ @contextmanager
144
+ def parent(self, path):
145
+ path = relative(path)
146
+ descriptor = os.dup(self.fd)
147
+ try:
148
+ for part in path.split("/")[:-1]:
149
+ try:
150
+ child = os.open(
151
+ part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
152
+ dir_fd=descriptor,
153
+ )
154
+ except OSError as error:
155
+ if error.errno in (20, 40):
156
+ raise Failure("symlink_not_followed", path) from error
157
+ raise
158
+ os.close(descriptor)
159
+ descriptor = child
160
+ yield descriptor, path.split("/")[-1]
161
+ finally:
162
+ os.close(descriptor)
163
+
164
+ def read(self, path, maximum=None):
165
+ path = relative(path)
166
+ if not self.allowed(path):
167
+ raise Failure("outside_scope", path)
168
+ if not path.endswith(".md"):
169
+ raise Failure("unsupported_format", path)
170
+ return self.read_authorized(path, maximum)
171
+
172
+ def read_authorized(self, path, maximum=None):
173
+ path = relative(path)
174
+ if not self.allowed(path):
175
+ raise Failure("outside_scope", path)
176
+ with self.parent(path) as (parent, name):
177
+ try:
178
+ descriptor = os.open(
179
+ name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent,
180
+ )
181
+ except OSError as error:
182
+ if error.errno == 40:
183
+ raise Failure("symlink_not_followed", path) from error
184
+ raise
185
+ with os.fdopen(descriptor, "rb") as stream:
186
+ metadata = os.fstat(stream.fileno())
187
+ if not stat.S_ISREG(metadata.st_mode):
188
+ raise Failure("unsupported_format", path)
189
+ if maximum is not None and metadata.st_size > maximum:
190
+ raise Failure("limit_reached", path)
191
+ data = stream.read() if maximum is None else stream.read(maximum + 1)
192
+ if maximum is not None and len(data) > maximum:
193
+ raise Failure("limit_reached", path)
194
+ return data, metadata
195
+
196
+ def stat_authorized(self, path):
197
+ path = relative(path)
198
+ if not self.allowed(path):
199
+ raise Failure("outside_scope", path)
200
+ with self.parent(path) as (parent, name):
201
+ metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
202
+ if stat.S_ISLNK(metadata.st_mode):
203
+ raise Failure("symlink_not_followed", path)
204
+ return metadata
205
+
206
+ def enumerate(self):
207
+ return self.enumerate_authorized((".md",), strict_file_scopes=True)
208
+
209
+ def enumerate_authorized(self, suffixes, strict_file_scopes=False):
210
+ paths, skipped, issues = set(), set(), []
211
+ enumeration_complete = True
212
+
213
+ def walk(descriptor, prefix):
214
+ nonlocal enumeration_complete
215
+ try:
216
+ names = sorted(os.listdir(descriptor))
217
+ except OSError:
218
+ skipped.add(prefix)
219
+ issues.append(issue("read_failed", prefix))
220
+ enumeration_complete = False
221
+ return
222
+ for name in names:
223
+ path = name if prefix == "." else prefix + "/" + name
224
+ if name in MANAGEMENT or not strict_file_scopes and name.lower() in MANAGEMENT:
225
+ issues.append(issue("excluded_management_directory", path))
226
+ continue
227
+ try:
228
+ metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
229
+ if stat.S_ISLNK(metadata.st_mode):
230
+ skipped.add(path)
231
+ issues.append(issue("symlink_not_followed", path))
232
+ enumeration_complete = False
233
+ elif stat.S_ISDIR(metadata.st_mode):
234
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
235
+ dir_fd=descriptor)
236
+ try:
237
+ walk(child, path)
238
+ finally:
239
+ os.close(child)
240
+ elif name.endswith(tuple(suffixes)):
241
+ if stat.S_ISREG(metadata.st_mode):
242
+ paths.add(path)
243
+ else:
244
+ skipped.add(path)
245
+ issues.append(issue("unsupported_format", path))
246
+ except OSError:
247
+ skipped.add(path)
248
+ issues.append(issue("read_failed", path))
249
+ enumeration_complete = False
250
+
251
+ for kind, path in self.scopes:
252
+ if kind == "file":
253
+ if not path.endswith(tuple(suffixes)):
254
+ if strict_file_scopes:
255
+ raise Failure("unsupported_format", path)
256
+ continue
257
+ paths.add(path)
258
+ continue
259
+ try:
260
+ if path == ".":
261
+ walk(self.fd, ".")
262
+ else:
263
+ with self.parent(path) as (parent, name):
264
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
265
+ dir_fd=parent)
266
+ try:
267
+ walk(child, path)
268
+ finally:
269
+ os.close(child)
270
+ except (OSError, Failure):
271
+ skipped.add(path)
272
+ issues.append(issue("read_failed", path))
273
+ enumeration_complete = False
274
+ return sorted(paths), skipped, issues, enumeration_complete
275
+
276
+
277
+ def yaml_properties(frontmatter):
278
+ for event in yaml.parse(frontmatter):
279
+ if getattr(event, "anchor", None) or isinstance(event, yaml.AliasEvent):
280
+ raise ValueError("YAML anchors unsupported")
281
+ tag = getattr(event, "tag", None)
282
+ if tag and not tag.startswith("tag:yaml.org,2002:"):
283
+ raise ValueError("Custom YAML tag unsupported")
284
+ node = yaml.compose(frontmatter, Loader=yaml.SafeLoader)
285
+ if node is None:
286
+ return {}
287
+ if not isinstance(node, yaml.MappingNode):
288
+ raise ValueError("Properties must be a mapping")
289
+ keys = [key.value for key, _ in node.value if isinstance(key, yaml.ScalarNode)]
290
+ if len(keys) != len(node.value) or len(set(keys)) != len(keys):
291
+ raise ValueError("Duplicate or complex YAML key")
292
+ values = yaml.safe_load(frontmatter)
293
+
294
+ def scalar(value):
295
+ return (value is None or isinstance(value, (str, bool, int, date))
296
+ or isinstance(value, float) and math.isfinite(value))
297
+
298
+ for key, value in values.items():
299
+ if not isinstance(key, str) or not key:
300
+ raise ValueError("Non-string property key")
301
+ if not scalar(value) and not (
302
+ isinstance(value, list) and all(scalar(item) for item in value)
303
+ ):
304
+ raise ValueError("Nested property unsupported")
305
+ if key in {"aliases", "tags", "cssclasses"} and not (
306
+ isinstance(value, list) and all(isinstance(item, str) for item in value)
307
+ ):
308
+ raise ValueError("Expected string list")
309
+ return values
310
+
311
+
312
+ def masked_body(body):
313
+ chars = list(body)
314
+ fatal = False
315
+ comments = []
316
+
317
+ def hide(start, end, *, inline=False):
318
+ for index in range(start, end):
319
+ if chars[index] not in "\r\n":
320
+ chars[index] = "x" if inline else " "
321
+
322
+ def blocks(text):
323
+ lines = text.splitlines(keepends=True)
324
+ offsets = [0]
325
+ for line in lines:
326
+ offsets.append(offsets[-1] + len(line))
327
+ found, containers, tables = {}, [], []
328
+ for token in MARKDOWN.parse(text):
329
+ if token.type == "inline" and token.map:
330
+ containers.append((offsets[token.map[0]], offsets[token.map[1]]))
331
+ if token.type == "table_open" and token.map:
332
+ tables.append((offsets[token.map[0]], offsets[token.map[1]]))
333
+ if token.type in {"fence", "code_block"} and token.map:
334
+ start, end = token.map
335
+ found[offsets[start]] = (offsets[end], token, lines[start:end])
336
+ return found, containers, tables
337
+
338
+ code, containers, tables = blocks(body)
339
+ index = 0
340
+ while index < len(body):
341
+ if index in code:
342
+ end, token, lines = code[index]
343
+ if token.type == "fence" and not (
344
+ len(lines) > 1 and re.match(
345
+ r"^[ >]*" + re.escape(token.markup[0]) + "{"
346
+ + str(len(token.markup)) + r",}\s*$", lines[-1],
347
+ )
348
+ ):
349
+ fatal = True
350
+ hide(index, end)
351
+ index = end
352
+ elif body[index] == "\\":
353
+ index += 2
354
+ elif body.startswith("%%", index):
355
+ close = body.find("%%", index + 2)
356
+ end = len(body) if close < 0 else close + 2
357
+ fatal |= close < 0
358
+ comments.append(body[index:end])
359
+ hide(index, end, inline=True)
360
+ index = end
361
+ elif body[index] == "`":
362
+ opening = re.match(r"`+", body[index:]).group()
363
+ containing = [end for start, end in containers if start <= index < end]
364
+ newline = body.find("\n", index)
365
+ bound = min(containing) if containing else len(body) if newline < 0 else newline
366
+ bound = min([bound] + [start for start in code if start > index])
367
+ if any(start <= index < end for start, end in tables):
368
+ bound = min(bound, len(body) if newline < 0 else newline)
369
+ for position in range(index + len(opening), bound):
370
+ if body[position] == "|":
371
+ previous = position - 1
372
+ while previous >= 0 and body[previous] == "\\":
373
+ previous -= 1
374
+ if (position - previous - 1) % 2 == 0:
375
+ bound = position
376
+ break
377
+ close = re.search(r"(?<!`)" + re.escape(opening) + r"(?!`)",
378
+ body[index + len(opening):bound])
379
+ if close:
380
+ end = index + len(opening) + close.end()
381
+ hide(index, end, inline=True)
382
+ index = end
383
+ else:
384
+ index += len(opening)
385
+ else:
386
+ index += 1
387
+ for start, (end, token, lines) in blocks("".join(chars))[0].items():
388
+ if token.type == "fence" and not (
389
+ len(lines) > 1 and re.match(
390
+ r"^[ >]*" + re.escape(token.markup[0]) + "{"
391
+ + str(len(token.markup)) + r",}\s*$", lines[-1],
392
+ )
393
+ ):
394
+ fatal = True
395
+ hide(start, end)
396
+ return "".join(chars), fatal, comments
397
+
398
+
399
+ def extract_links(body, masked, line_offset):
400
+ tokens = MARKDOWN.parse(masked)
401
+ blocks = []
402
+ for token in tokens:
403
+ if token.map:
404
+ blocks.append(tuple(token.map))
405
+ links, external, all_raw = [], 0, []
406
+ index = 0
407
+ state = StateInline(body, MARKDOWN, {}, [])
408
+ while index < len(masked):
409
+ if masked[index] == "\\":
410
+ index += 2
411
+ continue
412
+ start = index
413
+ image = masked.startswith("![", start)
414
+ bracket = start + 1 if image else start
415
+ syntax = None
416
+ destination = ""
417
+ if masked.startswith("[[", bracket):
418
+ close = masked.find("]]", bracket + 2)
419
+ newline = masked.find("\n", bracket)
420
+ if close >= 0 and (newline < 0 or close < newline):
421
+ end = close + 2
422
+ syntax = "wiki"
423
+ destination = body[bracket + 2:close].replace("\\|", "|").split("|", 1)[0]
424
+ if "[[" in destination:
425
+ syntax = "unsupported"
426
+ else:
427
+ end = len(masked) if newline < 0 else newline
428
+ syntax = "unsupported"
429
+ elif masked.startswith("[", bracket):
430
+ close = parseLinkLabel(state, bracket, False)
431
+ if close >= 0 and masked.startswith("(", close + 1):
432
+ position = close + 2
433
+ while position < len(body) and body[position].isspace():
434
+ position += 1
435
+ parsed = parseLinkDestination(body, position, len(body))
436
+ if parsed.ok:
437
+ destination = parsed.str
438
+ position = parsed.pos
439
+ while position < len(body) and body[position].isspace():
440
+ position += 1
441
+ if position < len(body) and body[position] in "\"'(":
442
+ title = parseLinkTitle(body, position, len(body))
443
+ if title.ok:
444
+ position = title.pos
445
+ while position < len(body) and body[position].isspace():
446
+ position += 1
447
+ if position < len(body) and body[position] == ")":
448
+ end = position + 1
449
+ syntax = "unsupported" if image else "markdown"
450
+ if syntax is None:
451
+ newline = masked.find("\n", bracket)
452
+ end = len(masked) if newline < 0 else newline
453
+ syntax = "unsupported"
454
+ if syntax is None:
455
+ url = re.match(r"https?://[^\s<>]+", masked[index:])
456
+ if url:
457
+ external += 1
458
+ index += len(url.group())
459
+ else:
460
+ index += 1
461
+ continue
462
+ all_raw.append(body[start:end])
463
+ if re.match(r"^(?:https?|mailto|ftp):", destination, re.I):
464
+ external += 1
465
+ index = end
466
+ continue
467
+ line = body.count("\n", 0, start)
468
+ containing = [(a, b) for a, b in blocks if a <= line < b]
469
+ block_line = min((a for a, _ in containing), default=line)
470
+ links.append({
471
+ "raw": body[start:end], "syntax": syntax, "destination": destination,
472
+ "block_start_line": line_offset + block_line + 1,
473
+ "occurrence": len(links) + 1,
474
+ })
475
+ index = end
476
+ return links, external, all_raw
477
+
478
+
479
+ def parse_note(path, data):
480
+ text = data.decode("utf-8")
481
+ lines = text.splitlines(keepends=True)
482
+ properties, offset, fatal = {}, 0, False
483
+ if lines and lines[0].strip() == "---":
484
+ close = next((i for i in range(1, len(lines))
485
+ if lines[i].strip() in {"---", "..."}), None)
486
+ if close is None:
487
+ fatal = True
488
+ else:
489
+ offset = close + 1
490
+ try:
491
+ properties = yaml_properties("".join(lines[1:close]))
492
+ except (ValueError, yaml.YAMLError):
493
+ fatal = True
494
+ body = "".join(lines[offset:])
495
+ masked, incomplete, comments = masked_body(body)
496
+ fatal |= incomplete
497
+ headings, all_headings, unsupported = [], [], False
498
+ tokens = MARKDOWN.parse(masked)
499
+ for index, token in enumerate(tokens):
500
+ if token.type == "heading_open":
501
+ inline = tokens[index + 1]
502
+ start, end = token.map
503
+ source_lines = body.splitlines()
504
+ raw = source_lines[start]
505
+ marker = re.match(r"^[ \t>]*(?:(?:[-+*]|\d+[.)])[ \t]+)?#{1,6}(?:[ \t]+|$)", raw)
506
+ if marker:
507
+ raw = re.sub(r"[ \t]+#+[ \t]*$", "", raw[marker.end():]).strip(" \t")
508
+ else:
509
+ raw = "\n".join(line.lstrip(" >") for line in source_lines[start:end - 1])
510
+ all_headings.append(raw)
511
+ children = MARKDOWN.parseInline(raw)[0].children or []
512
+ plain = (len(children) == 1 and children[0].type == "text"
513
+ and children[0].content == raw
514
+ and not any(marker in raw for marker in ("[[", "]]", "==", "~~", "%%")))
515
+ if plain:
516
+ headings.append(raw)
517
+ else:
518
+ unsupported = True
519
+ links, external, all_raw = extract_links(body, masked, offset) if not fatal else ([], 0, [])
520
+ return {
521
+ "path": path, "data": data, "sha256": digest(data), "properties": properties,
522
+ "headings": headings, "all_headings": all_headings, "unsupported_heading": unsupported,
523
+ "links": links, "external": external, "fatal": fatal, "comments": comments,
524
+ "all_link_raw": all_raw,
525
+ }
526
+
527
+
528
+ def resolve_link(source, link, notes, vault, coverage):
529
+ raw, syntax = link["destination"], link["syntax"]
530
+ candidates = []
531
+
532
+ def result(classification, reason):
533
+ return {key: link[key] for key in ("raw", "block_start_line", "occurrence")} | {
534
+ "source_path": source, "classification": classification,
535
+ "reason": reason, "candidates": candidates,
536
+ }
537
+
538
+ if syntax == "unsupported" or "^" in raw or not raw:
539
+ return result("indeterminate", "unsupported_syntax")
540
+ path, separator, heading = raw.partition("#")
541
+ if syntax == "markdown":
542
+ extension = PurePosixPath(path).suffix
543
+ if path.startswith(("/", "\\")) or (
544
+ extension and "%" not in extension and extension != ".md"
545
+ ):
546
+ return result("indeterminate", "unsupported_syntax")
547
+ try:
548
+ if re.search(r"%(?![a-fA-F0-9]{2})", path + heading):
549
+ raise ValueError("Invalid encoding")
550
+ path = unquote_to_bytes(path).decode("utf-8")
551
+ heading = unquote_to_bytes(heading).decode("utf-8")
552
+ except (ValueError, UnicodeError):
553
+ return result("indeterminate", "invalid_encoding")
554
+ if not path.endswith(".md") or path.startswith("/") or re.match(r"^[a-zA-Z]+:", path):
555
+ return result("indeterminate", "unsupported_syntax")
556
+ target = posixpath.normpath(posixpath.join(posixpath.dirname(source), path))
557
+ bare = False
558
+ else:
559
+ if path.startswith("/") or re.search(r"\.(?:png|jpe?g|gif|pdf|webp|svg|mp[34]|canvas|base)$", path, re.I):
560
+ return result("indeterminate", "unsupported_syntax")
561
+ bare = bool(path) and "/" not in path
562
+ target = source if not path and separator else path if path.endswith(".md") else path + ".md"
563
+ if target == ".." or target.startswith("../") or "\0" in target:
564
+ return result("indeterminate", "outside_scope")
565
+ try:
566
+ target = relative(target)
567
+ except Failure:
568
+ return result("indeterminate", "outside_scope")
569
+ if bare:
570
+ candidates = sorted(name for name in notes if PurePosixPath(name).name == target)
571
+ if len(candidates) > 1:
572
+ return result("ambiguous", "multiple_notes")
573
+ if not (coverage["whole_vault"] and coverage["enumeration_complete"] and coverage["reads_complete"]):
574
+ return result("indeterminate", "scope_incomplete")
575
+ else:
576
+ if not vault.allowed(target):
577
+ return result("indeterminate", "outside_scope")
578
+ if any(target == name or target.startswith(name + "/") for name in coverage["unprocessed"]):
579
+ return result("indeterminate", "scope_incomplete")
580
+ if target in notes:
581
+ candidates = [target]
582
+ if not candidates:
583
+ return result("unresolved", "note_missing")
584
+ note = notes[candidates[0]]
585
+ if note["fatal"]:
586
+ return result("indeterminate", "target_parse_incomplete")
587
+ if not separator:
588
+ return result("resolved", "note_found")
589
+ count = note["headings"].count(heading)
590
+ if count > 1:
591
+ return result("ambiguous", "multiple_headings")
592
+ if count == 1:
593
+ return result("resolved", "heading_found")
594
+ if note["unsupported_heading"]:
595
+ return result("indeterminate", "unsupported_heading")
596
+ return result("unresolved", "heading_missing")
597
+
598
+
599
+ def property_type(value):
600
+ if value is None:
601
+ return "null"
602
+ if isinstance(value, bool):
603
+ return "boolean"
604
+ if isinstance(value, (int, float)):
605
+ return "number"
606
+ if isinstance(value, date):
607
+ return "date"
608
+ return "list" if isinstance(value, list) else "string"
609
+
610
+
611
+ def observations(notes):
612
+ groups = defaultdict(set)
613
+ for path, note in notes.items():
614
+ groups["name", PurePosixPath(path).stem].add(path)
615
+ groups["folder", str(PurePosixPath(path).parent)].add(path)
616
+ if note["fatal"]:
617
+ continue
618
+ for name, value in note["properties"].items():
619
+ groups["property_name", name].add(path)
620
+ groups["property_type", name + ":" + property_type(value)].add(path)
621
+ for tag in note["properties"].get("tags", []):
622
+ if tag:
623
+ groups["tag", tag].add(path)
624
+ return [
625
+ {"kind": kind, "value": value, "paths": sorted(paths),
626
+ "count": len(paths), "label": "observation"}
627
+ for (kind, value), paths in sorted(groups.items())
628
+ ]
629
+
630
+
631
+ def note_record(note):
632
+ return {"path": note["path"], "sha256": note["sha256"],
633
+ "bytes": len(note["data"]), "heading_texts": note["all_headings"]}
634
+
635
+
636
+ def inspect(request, vault, report):
637
+ paths, skipped, issues, complete = vault.enumerate()
638
+ coverage = report["coverage"]
639
+ coverage["enumeration_complete"] = complete
640
+ coverage["reads_complete"] = not skipped
641
+ report["issues"].extend(issues)
642
+ notes, total = {}, 0
643
+ limits = request["limits"]
644
+ for index, path in enumerate(paths):
645
+ if limits["max_files"] is not None and index >= limits["max_files"]:
646
+ skipped.add(path)
647
+ report["issues"].append(issue("limit_reached", path))
648
+ continue
649
+ maximum = limits["max_file_bytes"]
650
+ if limits["max_total_bytes"] is not None:
651
+ remaining = limits["max_total_bytes"] - total
652
+ maximum = remaining if maximum is None else min(maximum, remaining)
653
+ try:
654
+ data, _ = vault.read(path, maximum)
655
+ total += len(data)
656
+ note = parse_note(path, data)
657
+ notes[path] = note
658
+ coverage["processed"].append(path)
659
+ if note["fatal"]:
660
+ coverage["parses_complete"] = False
661
+ report["issues"].extend([
662
+ issue("source_parse_incomplete", path), issue("unsupported_format", path),
663
+ ])
664
+ except (OSError, UnicodeError, Failure) as error:
665
+ skipped.add(path)
666
+ code = error.code if isinstance(error, Failure) else "unsupported_format" if isinstance(error, UnicodeError) else "read_failed"
667
+ report["issues"].append(issue(code, path))
668
+ coverage["unprocessed"] = sorted(skipped)
669
+ coverage["reads_complete"] &= not skipped
670
+ links = [
671
+ resolve_link(path, link, notes, vault, coverage)
672
+ for path, note in sorted(notes.items()) for link in note["links"]
673
+ ]
674
+ for link in links:
675
+ if link["reason"] == "target_parse_incomplete":
676
+ report["issues"].append(issue("target_parse_incomplete", link["source_path"]))
677
+ declarations = []
678
+ for path in request["declared_rules"]:
679
+ path = relative(path)
680
+ if not vault.allowed(path):
681
+ raise Failure("outside_scope", path)
682
+ if path not in notes:
683
+ raise Failure("read_failed", path, status="failed", exit_code=1)
684
+ declarations.append(note_record(notes[path]))
685
+ report["data"] = {
686
+ "notes": [note_record(note) for _, note in sorted(notes.items())],
687
+ "observations": observations(notes), "declarations": declarations,
688
+ "links": links, "external_url_count": sum(note["external"] for note in notes.values()),
689
+ }
690
+ if skipped or not complete or not coverage["parses_complete"] or any(
691
+ link["classification"] == "indeterminate" for link in links
692
+ ):
693
+ report.update(status="partial", exit_code=3)
694
+
695
+
696
+ def load_gate():
697
+ path = SKILL.parent / "japanese-prose" / "scripts" / "invariants.py"
698
+ if not path.is_file():
699
+ raise Failure("dependency_missing", status="not_performed", exit_code=4)
700
+ spec = importlib.util.spec_from_file_location("kotonoha_invariants", path)
701
+ module = importlib.util.module_from_spec(spec)
702
+ spec.loader.exec_module(module)
703
+ if not callable(getattr(module, "check_bytes", None)) or tuple(module.SEMANTIC_CHECKS) != CHECKS:
704
+ raise Failure("dependency_missing", status="not_performed", exit_code=4)
705
+ return module
706
+
707
+
708
+ def implementation_hash():
709
+ paths = [
710
+ Path(__file__), Path(__file__).with_suffix(".py.lock"),
711
+ SKILL.parent / "japanese-prose" / "scripts" / "invariants.py",
712
+ ]
713
+ try:
714
+ return digest(canonical({path.name: digest(path.read_bytes()) for path in paths}))
715
+ except FileNotFoundError as error:
716
+ raise Failure("dependency_missing", status="not_performed", exit_code=4) from error
717
+
718
+
719
+ def protected_snapshot(note):
720
+ text = note["data"].decode("utf-8")
721
+ return {
722
+ "headings": note["all_headings"],
723
+ "wikilinks": re.findall(r"!?\[\[[^\n]*?\]\]", text),
724
+ "links": note["all_link_raw"],
725
+ "markdown_links": re.findall(r"!?\[[^\]\n]*\]\([^\n]*?\)", text),
726
+ "callouts": re.findall(r"^[ >]*\[![^\]\n]+\][+-]?", text, re.M),
727
+ "comments": note["comments"],
728
+ "definitions": re.findall(r"^[ ]{0,3}\[[^\]\n]+\]:[^\n]*(?:\n[ \t]+[^\n]+)*", text, re.M),
729
+ }
730
+
731
+
732
+ def edit_gate(before, after, terms, review):
733
+ initial, final = parse_note("before.md", before), parse_note("after.md", after)
734
+ if initial["fatal"] or final["fatal"]:
735
+ raise Failure("unsupported_format")
736
+ result = load_gate().check_bytes(before, after, terms, review)
737
+ if protected_snapshot(initial) != protected_snapshot(final):
738
+ raise Failure("gate_blocked")
739
+ if result["status"] == "blocked":
740
+ raise Failure("gate_blocked")
741
+ if result["status"] != "passed":
742
+ raise Failure("semantic_review_required")
743
+
744
+
745
+ def workspace_path(request, path):
746
+ workspace = request["workspace"]
747
+ if not workspace:
748
+ raise Failure("consent_missing")
749
+ workspace = os.path.normpath(workspace)
750
+ root = os.path.normpath(request["vault_root"])
751
+ if not os.path.isabs(workspace) or os.path.commonpath([workspace, root]) == root:
752
+ raise Failure("invalid_path")
753
+ path = os.path.normpath(os.fspath(path))
754
+ if not os.path.isabs(path) or os.path.commonpath([workspace, path]) != workspace:
755
+ raise Failure("outside_scope")
756
+ return workspace, path
757
+
758
+
759
+ def workspace_read(request, path):
760
+ workspace, path = workspace_path(request, path)
761
+ descriptor = directory(workspace)
762
+ try:
763
+ relative_path = os.path.relpath(path, workspace)
764
+ parts = relative(relative_path).split("/")
765
+ for part in parts[:-1]:
766
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
767
+ dir_fd=descriptor)
768
+ os.close(descriptor)
769
+ descriptor = child
770
+ file_descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
771
+ dir_fd=descriptor)
772
+ with os.fdopen(file_descriptor, "rb") as stream:
773
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
774
+ raise Failure("unsupported_format")
775
+ return stream.read()
776
+ finally:
777
+ os.close(descriptor)
778
+
779
+
780
+ def write_private(path, data, *, reuse=False):
781
+ try:
782
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
783
+ except FileExistsError:
784
+ if reuse:
785
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
786
+ with os.fdopen(descriptor, "rb") as stream:
787
+ metadata = os.fstat(stream.fileno())
788
+ if (stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.geteuid()
789
+ and stat.S_IMODE(metadata.st_mode) == 0o600
790
+ and stream.read() == data):
791
+ return
792
+ raise Failure("target_exists") from None
793
+ with os.fdopen(descriptor, "wb") as stream:
794
+ stream.write(data)
795
+ stream.flush()
796
+ os.fsync(stream.fileno())
797
+
798
+
799
+ def replace_private(path, data):
800
+ temporary = path.with_name("." + path.name + ".tmp")
801
+ write_private(temporary, data)
802
+ os.replace(temporary, path)
803
+ descriptor = directory(path.parent)
804
+ try:
805
+ os.fsync(descriptor)
806
+ finally:
807
+ os.close(descriptor)
808
+
809
+
810
+ def identity(metadata):
811
+ return {"device": metadata.st_dev, "inode": metadata.st_ino}
812
+
813
+
814
+ def operation_id(plan, parent_metadata):
815
+ return digest(canonical({
816
+ key: plan[key] for key in (
817
+ "request_sha256", "vault_identity", "target", "before_sha256",
818
+ "after_sha256", "review_sha256", "implementation_sha256",
819
+ )
820
+ } | {"target_parent_identity": identity(parent_metadata)}))
821
+
822
+
823
+ def review_data(data):
824
+ review = strict_json(data)
825
+ if (not isinstance(review, dict)
826
+ or set(review) != {"before_sha256", "after_sha256", "reviewer", "checks"}
827
+ or not isinstance(review["checks"], dict) or set(review["checks"]) != set(CHECKS)):
828
+ raise Failure("semantic_review_required")
829
+ return review
830
+
831
+
832
+ def assert_target(target):
833
+ target = relative(target)
834
+ if not target.endswith(".md"):
835
+ raise Failure("invalid_path", target)
836
+ return target
837
+
838
+
839
+ def metadata_supported(metadata, descriptor=None):
840
+ if (metadata.st_uid != os.geteuid() or metadata.st_nlink != 1
841
+ or metadata.st_mode & 0o7000
842
+ or metadata.st_gid not in {os.getegid(), *os.getgroups()}):
843
+ raise Failure("capability_missing", exit_code=4)
844
+ if descriptor is not None:
845
+ try:
846
+ attributes = os.listxattr(descriptor)
847
+ except OSError as error:
848
+ raise Failure("capability_missing", exit_code=4) from error
849
+ if attributes:
850
+ raise Failure("capability_missing", exit_code=4)
851
+
852
+
853
+ def write_capabilities():
854
+ if (not all(hasattr(os, name) for name in ("replace", "link", "fsync", "listxattr"))
855
+ or os.open not in os.supports_dir_fd or os.link not in os.supports_dir_fd):
856
+ raise Failure("capability_missing", exit_code=4)
857
+
858
+
859
+ def maximum_note_bytes(request):
860
+ limits = [value for value in (
861
+ request["limits"]["max_file_bytes"], request["limits"]["max_total_bytes"],
862
+ ) if value is not None]
863
+ return min(limits) if limits else None
864
+
865
+
866
+ def diff_bytes(before, candidate, target):
867
+ lines = difflib.unified_diff(
868
+ (before or b"").decode("utf-8").splitlines(keepends=True),
869
+ candidate.decode("utf-8").splitlines(keepends=True),
870
+ fromfile=target if before is not None else "/dev/null", tofile=target,
871
+ )
872
+ return "".join(
873
+ line if line.endswith(("\n", "\r")) else line + "\n\\n"
874
+ for line in lines
875
+ ).encode("utf-8")
876
+
877
+
878
+ def local_filesystem(descriptor):
879
+ try:
880
+ location = os.readlink(f"/proc/self/fd/{descriptor}")
881
+ matches = []
882
+ for line in Path("/proc/self/mountinfo").read_text().splitlines():
883
+ fields = line.split()
884
+ mount = re.sub(
885
+ r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), fields[4],
886
+ )
887
+ if mount == "/" or location == mount or location.startswith(mount + "/"):
888
+ matches.append((len(mount), fields[fields.index("-") + 1]))
889
+ filesystem = max(matches)[1] if matches else None
890
+ except (OSError, ValueError, IndexError) as error:
891
+ raise Failure("capability_missing", exit_code=4) from error
892
+ if filesystem not in {"ext2", "ext3", "ext4", "xfs", "btrfs", "tmpfs", "overlay", "zfs", "f2fs"}:
893
+ raise Failure("capability_missing", exit_code=4)
894
+
895
+
896
+ def plan(request, vault, report, args):
897
+ try:
898
+ validate(request, "PlanRequest")
899
+ except ValidationError as error:
900
+ raise Failure("invalid_request") from error
901
+ target = assert_target(request["write_target"])
902
+ if not request["consent"]["workspace_copies"]:
903
+ raise Failure("consent_missing")
904
+ workspace, _ = workspace_path(request, request["workspace"])
905
+ descriptor = directory(workspace)
906
+ os.close(descriptor)
907
+ candidate = workspace_read(request, args.candidate)
908
+ candidate.decode("utf-8")
909
+ review_bytes = None
910
+ before = None
911
+ with vault.parent(target) as (parent, name):
912
+ existing = None
913
+ try:
914
+ existing = os.stat(name, dir_fd=parent, follow_symlinks=False)
915
+ except FileNotFoundError:
916
+ pass
917
+ if request["intent"] == "proofread":
918
+ before, metadata = vault.read(target, maximum_note_bytes(request))
919
+ if digest(before) != request["base_sha256"]:
920
+ raise Failure("target_changed", target)
921
+ if before == candidate:
922
+ report["data"] = {"plan_path": None, "plan_sha256": None,
923
+ "operation_id": None, "gate_status": "passed",
924
+ "no_changes": True}
925
+ return
926
+ metadata_supported(metadata)
927
+ with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
928
+ metadata_supported(os.fstat(stream.fileno()), stream.fileno())
929
+ elif existing is not None:
930
+ raise Failure("target_exists", target)
931
+ write_capabilities()
932
+ local_filesystem(parent)
933
+ maximum = maximum_note_bytes(request)
934
+ if maximum is not None and len(candidate) > maximum:
935
+ raise Failure("limit_reached", target)
936
+ if not (SKILL.parent / "tech-writer" / "SKILL.md").is_file():
937
+ raise Failure("dependency_missing", exit_code=4)
938
+ load_gate()
939
+ review_bytes = workspace_read(request, args.review) if args.review else None
940
+ review = review_data(review_bytes) if review_bytes is not None else None
941
+ if before is not None:
942
+ edit_gate(before, candidate, request["protected_terms"], review)
943
+ else:
944
+ if parse_note(target, candidate)["fatal"]:
945
+ raise Failure("unsupported_format", target)
946
+ if request["deliverable"] == "note" and re.search(r"\{\{[^}]*\}\}", candidate.decode("utf-8")):
947
+ raise Failure("gate_blocked", target)
948
+ planned = {
949
+ "schema_version": 1, "request_sha256": report["request_sha256"],
950
+ "contract_sha256": digest(CONTRACT_PATH.read_bytes()),
951
+ "implementation_sha256": implementation_hash(),
952
+ "vault_identity": identity(os.fstat(vault.fd)), "target": target,
953
+ "kind": "edit" if before is not None else "create",
954
+ "before_sha256": digest(before) if before is not None else None,
955
+ "after_sha256": digest(candidate),
956
+ "review_sha256": digest(review_bytes) if review_bytes is not None else None,
957
+ "state": "validated", "protected_terms": request["protected_terms"],
958
+ }
959
+ planned["operation_id"] = operation_id(planned, os.fstat(parent))
960
+ operation = Path(workspace) / planned["operation_id"]
961
+ try:
962
+ operation.mkdir(mode=0o700)
963
+ except FileExistsError:
964
+ pass
965
+ descriptor = directory(operation)
966
+ try:
967
+ metadata = os.fstat(descriptor)
968
+ if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
969
+ raise Failure("capability_missing", exit_code=4)
970
+ finally:
971
+ os.close(descriptor)
972
+ planned["stage_target"] = posixpath.join(
973
+ posixpath.dirname(target), ".kotonoha-" + planned["operation_id"] + ".tmp",
974
+ )
975
+ diff = diff_bytes(before, candidate, target)
976
+ planned["diff_sha256"] = digest(diff)
977
+ contents = {"candidate": candidate, "diff": diff}
978
+ if before is not None:
979
+ contents["before"] = before
980
+ if review_bytes is not None:
981
+ contents["review"] = review_bytes
982
+ filenames = {"candidate": "candidate.md", "before": "before.md",
983
+ "diff": "diff.patch", "review": "semantic-review.json"}
984
+ planned["artifacts"] = {
985
+ key: str(operation / filename) if key in contents else None
986
+ for key, filename in filenames.items()
987
+ }
988
+ for key, data in contents.items():
989
+ write_private(operation / filenames[key], data, reuse=True)
990
+ validate(planned, "Plan")
991
+ plan_bytes = canonical(planned)
992
+ write_private(operation / "plan.json", plan_bytes, reuse=True)
993
+ report.update(operation_state="validated", data={
994
+ "plan_path": str(operation / "plan.json"), "plan_sha256": digest(plan_bytes),
995
+ "operation_id": planned["operation_id"],
996
+ "gate_status": "passed" if before is not None else "not_applicable",
997
+ "no_changes": False,
998
+ })
999
+
1000
+
1001
+ def exclusive_create(parent, stage_name, name):
1002
+ os.link(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
1003
+
1004
+
1005
+ def commit_bytes(vault, parent, name, stage_name, before, candidate, metadata,
1006
+ vault_identity, progress, *, read_at, target):
1007
+ """Commit validated bytes; retain commit progress even when verification fails."""
1008
+ progress.update(committed=False, own_stage=False, cleanup_failed=False)
1009
+ try:
1010
+ try:
1011
+ descriptor = os.open(
1012
+ stage_name, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
1013
+ 0o666 if before is None else 0o600, dir_fd=parent,
1014
+ )
1015
+ except FileExistsError as error:
1016
+ raise Failure("target_exists") from error
1017
+ progress["own_stage"] = True
1018
+ with os.fdopen(descriptor, "w+b") as stream:
1019
+ stream.write(candidate)
1020
+ stream.flush()
1021
+ if metadata is not None:
1022
+ os.fchown(stream.fileno(), metadata.st_uid, metadata.st_gid)
1023
+ os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
1024
+ os.fsync(stream.fileno())
1025
+ current_root = directory(vault.root)
1026
+ try:
1027
+ if identity(os.fstat(current_root)) != vault_identity:
1028
+ raise Failure("parent_changed")
1029
+ finally:
1030
+ os.close(current_root)
1031
+ with vault.parent(target) as (current_parent, _):
1032
+ if identity(os.fstat(current_parent)) != identity(os.fstat(parent)):
1033
+ raise Failure("parent_changed", target)
1034
+ if before is not None:
1035
+ current, current_metadata = read_at(parent, name)
1036
+ if digest(current) != digest(before):
1037
+ raise Failure("target_changed", target)
1038
+ if (identity(current_metadata) != identity(metadata)
1039
+ or current_metadata.st_mode != metadata.st_mode
1040
+ or current_metadata.st_gid != metadata.st_gid):
1041
+ raise Failure("target_changed", target)
1042
+ with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as check:
1043
+ metadata_supported(os.fstat(check.fileno()), check.fileno())
1044
+ os.replace(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent)
1045
+ progress.update(committed=True, own_stage=False)
1046
+ else:
1047
+ try:
1048
+ exclusive_create(parent, stage_name, name)
1049
+ except FileExistsError as error:
1050
+ raise Failure("target_exists", target) from error
1051
+ progress["committed"] = True
1052
+ os.fsync(parent)
1053
+ verified, _ = read_at(parent, name)
1054
+ if digest(verified) != digest(candidate):
1055
+ raise OSError("Post-commit verification failed")
1056
+ if progress["own_stage"]:
1057
+ os.unlink(stage_name, dir_fd=parent)
1058
+ progress["own_stage"] = False
1059
+ os.fsync(parent)
1060
+ return verified
1061
+ finally:
1062
+ original_error = sys.exc_info()[1]
1063
+ if progress["own_stage"]:
1064
+ try:
1065
+ os.unlink(stage_name, dir_fd=parent)
1066
+ except OSError:
1067
+ progress["cleanup_failed"] = True
1068
+ if original_error is None:
1069
+ raise
1070
+ else:
1071
+ progress["own_stage"] = False
1072
+
1073
+
1074
+ def apply(request, vault, report, args):
1075
+ if not request["consent"]["workspace_copies"]:
1076
+ raise Failure("consent_missing")
1077
+ plan_bytes = workspace_read(request, args.plan)
1078
+ if digest(plan_bytes) != args.approve_plan_sha256:
1079
+ raise Failure("plan_not_approved")
1080
+ planned = strict_json(plan_bytes)
1081
+ validate(planned, "Plan")
1082
+ if planned["state"] != "validated":
1083
+ raise Failure("gate_blocked")
1084
+ if planned["request_sha256"] != report["request_sha256"]:
1085
+ raise Failure("request_changed")
1086
+ if planned["contract_sha256"] != digest(CONTRACT_PATH.read_bytes()):
1087
+ raise Failure("implementation_changed")
1088
+ if planned["implementation_sha256"] != implementation_hash():
1089
+ raise Failure("implementation_changed")
1090
+ if planned["vault_identity"] != identity(os.fstat(vault.fd)):
1091
+ raise Failure("parent_changed")
1092
+ if planned["protected_terms"] != request["protected_terms"]:
1093
+ raise Failure("request_changed")
1094
+ target = assert_target(planned["target"])
1095
+ if (target != request["write_target"]
1096
+ or planned["kind"] != ("edit" if request["intent"] == "proofread" else "create")):
1097
+ raise Failure("request_changed", target)
1098
+ if planned["kind"] == "edit" and request["base_sha256"] != planned["before_sha256"]:
1099
+ raise Failure("request_changed", target)
1100
+ workspace, _ = workspace_path(request, args.plan)
1101
+ operation = Path(workspace) / planned["operation_id"]
1102
+ if os.path.normpath(args.plan) != str(operation / "plan.json"):
1103
+ raise Failure("invalid_path")
1104
+ expected_artifacts = {
1105
+ "candidate": operation / "candidate.md", "before": operation / "before.md",
1106
+ "review": operation / "semantic-review.json", "diff": operation / "diff.patch",
1107
+ }
1108
+ loaded = {}
1109
+ for key, path in planned["artifacts"].items():
1110
+ if path is not None:
1111
+ if path != str(expected_artifacts[key]):
1112
+ raise Failure("invalid_path")
1113
+ loaded[key] = workspace_read(request, path)
1114
+ candidate = loaded["candidate"]
1115
+ before = loaded.get("before")
1116
+ review_bytes = loaded.get("review")
1117
+ if digest(candidate) != planned["after_sha256"]:
1118
+ raise Failure("candidate_changed")
1119
+ if digest(loaded["diff"]) != planned["diff_sha256"]:
1120
+ raise Failure("candidate_changed")
1121
+ if (digest(before) if before is not None else None) != planned["before_sha256"]:
1122
+ raise Failure("target_changed")
1123
+ if (digest(review_bytes) if review_bytes is not None else None) != planned["review_sha256"]:
1124
+ raise Failure("semantic_review_required")
1125
+ if before is not None:
1126
+ if not vault.allowed(target):
1127
+ raise Failure("outside_scope", target)
1128
+ edit_gate(before, candidate, request["protected_terms"],
1129
+ review_data(review_bytes) if review_bytes is not None else None)
1130
+ elif (parse_note(target, candidate)["fatal"] or
1131
+ request["deliverable"] == "note" and re.search(r"\{\{[^}]*\}\}", candidate.decode("utf-8"))):
1132
+ raise Failure("gate_blocked", target)
1133
+ stage = posixpath.join(
1134
+ posixpath.dirname(target), ".kotonoha-" + planned["operation_id"] + ".tmp",
1135
+ )
1136
+ if planned["stage_target"] != stage:
1137
+ raise Failure("invalid_path", stage)
1138
+ report["operation_state"] = "approved"
1139
+ report["data"] = {
1140
+ "target": target, "after_sha256": None, "operation_id": planned["operation_id"],
1141
+ "changed": False, "verified": False,
1142
+ }
1143
+
1144
+ def read_at(parent, name):
1145
+ try:
1146
+ descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
1147
+ except OSError as error:
1148
+ if error.errno == 40:
1149
+ raise Failure("symlink_not_followed", target) from error
1150
+ raise
1151
+ with os.fdopen(descriptor, "rb") as stream:
1152
+ metadata = os.fstat(stream.fileno())
1153
+ if not stat.S_ISREG(metadata.st_mode):
1154
+ raise Failure("unsupported_format", target)
1155
+ maximum = maximum_note_bytes(request)
1156
+ if maximum is not None and metadata.st_size > maximum:
1157
+ raise Failure("limit_reached", target)
1158
+ data = stream.read() if maximum is None else stream.read(maximum + 1)
1159
+ if maximum is not None and len(data) > maximum:
1160
+ raise Failure("limit_reached", target)
1161
+ return data, metadata
1162
+
1163
+ with vault.parent(target) as (parent, name):
1164
+ local_filesystem(parent)
1165
+ if operation_id(planned, os.fstat(parent)) != planned["operation_id"]:
1166
+ raise Failure("parent_changed", target)
1167
+ expected_receipt = {
1168
+ "schema_version": 1, "operation_id": planned["operation_id"],
1169
+ "plan_sha256": digest(plan_bytes), "request_sha256": report["request_sha256"],
1170
+ "target": target, "after_sha256": digest(candidate), "verified": True,
1171
+ }
1172
+ receipt_path = operation / "receipt.json"
1173
+ try:
1174
+ receipt_bytes = workspace_read(request, receipt_path)
1175
+ except FileNotFoundError:
1176
+ receipt_bytes = None
1177
+ if receipt_bytes is not None:
1178
+ try:
1179
+ receipt = strict_json(receipt_bytes)
1180
+ validate(receipt, "Receipt")
1181
+ except (ValueError, ValidationError) as error:
1182
+ raise Failure("commit_outcome_uncertain", target) from error
1183
+ if receipt != expected_receipt:
1184
+ raise Failure("commit_outcome_uncertain", target)
1185
+ if not vault.allowed(target):
1186
+ raise Failure("outside_scope", target)
1187
+ try:
1188
+ current, _ = read_at(parent, name)
1189
+ except FileNotFoundError as error:
1190
+ raise Failure("target_changed", target) from error
1191
+ if digest(current) != planned["after_sha256"]:
1192
+ raise Failure("target_changed", target)
1193
+ report.update(operation_state="saved", write_status="saved")
1194
+ report["data"].update(after_sha256=digest(current), changed=False, verified=True)
1195
+ report["issues"].append(issue("already_applied", target))
1196
+ return
1197
+ result_path = operation / "result.json"
1198
+ try:
1199
+ previous = strict_json(workspace_read(request, result_path))
1200
+ except FileNotFoundError:
1201
+ previous = None
1202
+ if previous is not None:
1203
+ validate(previous, "Report")
1204
+ if previous["write_status"] in {"unknown", "saved"}:
1205
+ raise Failure("commit_outcome_uncertain", target)
1206
+ try:
1207
+ current_metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
1208
+ except FileNotFoundError:
1209
+ current_metadata = None
1210
+ metadata = None
1211
+ if planned["kind"] == "create" and current_metadata is not None:
1212
+ if vault.allowed(target) and stat.S_ISREG(current_metadata.st_mode):
1213
+ current, _ = read_at(parent, name)
1214
+ if digest(current) == planned["after_sha256"]:
1215
+ raise Failure("commit_outcome_uncertain", target)
1216
+ raise Failure("target_exists", target)
1217
+ if planned["kind"] == "edit":
1218
+ if current_metadata is None:
1219
+ raise Failure("target_changed", target)
1220
+ current, metadata = vault.read(target, maximum_note_bytes(request))
1221
+ if digest(current) == planned["after_sha256"]:
1222
+ raise Failure("commit_outcome_uncertain", target)
1223
+ if digest(current) != planned["before_sha256"]:
1224
+ raise Failure("target_changed", target)
1225
+ with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
1226
+ metadata_supported(os.fstat(stream.fileno()), stream.fileno())
1227
+ try:
1228
+ os.stat(PurePosixPath(stage).name, dir_fd=parent, follow_symlinks=False)
1229
+ except FileNotFoundError:
1230
+ pass
1231
+ else:
1232
+ raise Failure("target_exists", stage)
1233
+ pending = dict(report, status="failed", exit_code=1, write_status="unknown",
1234
+ issues=[issue("commit_outcome_uncertain", target)])
1235
+ replace_private(result_path, canonical(pending))
1236
+ committed = False
1237
+ progress = {"committed": False, "own_stage": False, "cleanup_failed": False}
1238
+ stage_name = PurePosixPath(stage).name
1239
+ try:
1240
+ verified = commit_bytes(
1241
+ vault, parent, name, stage_name, before, candidate, metadata,
1242
+ planned["vault_identity"], progress, read_at=read_at, target=target,
1243
+ )
1244
+ committed = progress["committed"]
1245
+ replace_private(receipt_path, canonical(expected_receipt))
1246
+ report.update(operation_state="saved", write_status="saved")
1247
+ report["data"].update(
1248
+ after_sha256=digest(verified), changed=True, verified=True,
1249
+ )
1250
+ report["issues"].append(issue("saved", target))
1251
+ replace_private(result_path, canonical(report))
1252
+ except (OSError, Failure) as error:
1253
+ committed = progress["committed"]
1254
+ report["issues"] = [item for item in report["issues"] if item["code"] != "saved"]
1255
+ if progress["cleanup_failed"]:
1256
+ report["issues"].append(issue("runtime_error", stage))
1257
+ if not committed:
1258
+ report.update(status="failed", exit_code=1, operation_state="blocked")
1259
+ report["issues"].append(issue("write_failed_before_commit", target))
1260
+ raise Failure(
1261
+ "write_failed_before_commit", target, status="failed", exit_code=1,
1262
+ ) from error
1263
+ report.update(
1264
+ status="failed" if committed or not isinstance(error, Failure) else error.status,
1265
+ exit_code=1 if committed or not isinstance(error, Failure) else error.exit_code,
1266
+ operation_state="blocked", write_status="unknown" if committed else "unsaved",
1267
+ )
1268
+ report["data"].update(changed=None if committed else False, verified=False,
1269
+ after_sha256=None)
1270
+ code = "commit_outcome_uncertain" if committed else error.code if isinstance(error, Failure) else "write_failed_before_commit"
1271
+ report["issues"].append(issue(code, target))
1272
+ try:
1273
+ replace_private(result_path, canonical(report))
1274
+ except (OSError, Failure):
1275
+ report["issues"].append(issue("runtime_error", str(result_path)))
1276
+ return
1277
+
1278
+
1279
+ class Parser(argparse.ArgumentParser):
1280
+ def error(self, message):
1281
+ raise Failure("invalid_request", status="failed", exit_code=1)
1282
+
1283
+
1284
+ def arguments(argv):
1285
+ parser = Parser(description=__doc__)
1286
+ commands = parser.add_subparsers(dest="command", required=True, parser_class=Parser)
1287
+ for name, command in CONTRACT["x-commands"].items():
1288
+ child = commands.add_parser(name)
1289
+ for flag in command["required"]:
1290
+ child.add_argument(flag, required=True)
1291
+ for flag in command["optional"]:
1292
+ child.add_argument(flag)
1293
+ return parser.parse_args(argv)
1294
+
1295
+
1296
+ def run(argv):
1297
+ command = argv[0] if argv and argv[0] in CONTRACT["x-commands"] else "inspect"
1298
+ report = {
1299
+ "schema_version": 1, "command": command, "status": "completed", "exit_code": 0,
1300
+ "write_status": "unsaved" if command in {"plan", "apply"} else "not_requested",
1301
+ "operation_state": "scoped", "request_sha256": None,
1302
+ "coverage": {"whole_vault": False, "enumeration_complete": True,
1303
+ "reads_complete": True, "parses_complete": True,
1304
+ "processed": [], "unprocessed": []},
1305
+ "issues": [], "data": None,
1306
+ }
1307
+ vault = None
1308
+ try:
1309
+ args = arguments(argv)
1310
+ request_bytes = Path(args.request).read_bytes()
1311
+ report["request_sha256"] = digest(request_bytes)
1312
+ request = strict_json(request_bytes)
1313
+ validate(request, "Request")
1314
+ if not request["consent"]["model_context"]:
1315
+ raise Failure("consent_missing")
1316
+ if sys.platform != "linux" or not hasattr(os, "O_NOFOLLOW"):
1317
+ raise Failure("capability_missing", exit_code=4)
1318
+ vault = Vault(request)
1319
+ report["coverage"]["whole_vault"] = ("directory", ".") in vault.scopes
1320
+ if command == "inspect":
1321
+ inspect(request, vault, report)
1322
+ elif command == "read":
1323
+ data, _ = vault.read(args.path, maximum_note_bytes(request))
1324
+ report["coverage"]["processed"] = [relative(args.path)]
1325
+ report["data"] = {"path": relative(args.path), "sha256": digest(data),
1326
+ "bytes": len(data), "content": data.decode("utf-8")}
1327
+ else:
1328
+ if command == "apply":
1329
+ write_capabilities()
1330
+ fcntl.flock(vault.fd, fcntl.LOCK_EX)
1331
+ try:
1332
+ if command == "plan":
1333
+ plan(request, vault, report, args)
1334
+ else:
1335
+ apply(request, vault, report, args)
1336
+ finally:
1337
+ fcntl.flock(vault.fd, fcntl.LOCK_UN)
1338
+ except Failure as error:
1339
+ report.update(status=error.status, exit_code=error.exit_code,
1340
+ operation_state="blocked")
1341
+ report["issues"].append(issue(error.code, error.path))
1342
+ except (ValueError, UnicodeError, ValidationError, OSError) as error:
1343
+ code = ("unsupported_format" if isinstance(error, UnicodeError)
1344
+ else "read_failed" if command == "read" and isinstance(error, OSError)
1345
+ else "invalid_request" if isinstance(error, (ValueError, ValidationError))
1346
+ else "runtime_error")
1347
+ report.update(status="failed", exit_code=1, operation_state="blocked")
1348
+ report["issues"].append(issue(code))
1349
+ finally:
1350
+ if vault is not None:
1351
+ vault.close()
1352
+ validate(report, "Report")
1353
+ return report
1354
+
1355
+
1356
+ def main():
1357
+ report = run(sys.argv[1:])
1358
+ print(json.dumps(report, ensure_ascii=False, allow_nan=False))
1359
+ return report["exit_code"]
1360
+
1361
+
1362
+ if __name__ == "__main__":
1363
+ sys.exit(main())