kotonoha 0.4.1 → 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 (32) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/README-ja.md +68 -12
  3. package/README.md +100 -17
  4. package/bin/kotonoha.js +4 -1
  5. package/package.json +6 -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 +155 -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 +187 -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 +59 -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 +12 -2
@@ -40,6 +40,7 @@ CHECKS = (
40
40
  "actors", "negation", "conditions", "modality", "scope", "causality",
41
41
  "quantity_bindings", "terminology", "structure",
42
42
  )
43
+ STORAGE_API_VERSION = 1
43
44
 
44
45
 
45
46
  class Failure(Exception):
@@ -166,6 +167,12 @@ class Vault:
166
167
  raise Failure("outside_scope", path)
167
168
  if not path.endswith(".md"):
168
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)
169
176
  with self.parent(path) as (parent, name):
170
177
  try:
171
178
  descriptor = os.open(
@@ -186,7 +193,20 @@ class Vault:
186
193
  raise Failure("limit_reached", path)
187
194
  return data, metadata
188
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
+
189
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):
190
210
  paths, skipped, issues = set(), set(), []
191
211
  enumeration_complete = True
192
212
 
@@ -201,7 +221,7 @@ class Vault:
201
221
  return
202
222
  for name in names:
203
223
  path = name if prefix == "." else prefix + "/" + name
204
- if name in MANAGEMENT:
224
+ if name in MANAGEMENT or not strict_file_scopes and name.lower() in MANAGEMENT:
205
225
  issues.append(issue("excluded_management_directory", path))
206
226
  continue
207
227
  try:
@@ -217,7 +237,7 @@ class Vault:
217
237
  walk(child, path)
218
238
  finally:
219
239
  os.close(child)
220
- elif name.endswith(".md"):
240
+ elif name.endswith(tuple(suffixes)):
221
241
  if stat.S_ISREG(metadata.st_mode):
222
242
  paths.add(path)
223
243
  else:
@@ -230,8 +250,10 @@ class Vault:
230
250
 
231
251
  for kind, path in self.scopes:
232
252
  if kind == "file":
233
- if not path.endswith(".md"):
234
- raise Failure("unsupported_format", path)
253
+ if not path.endswith(tuple(suffixes)):
254
+ if strict_file_scopes:
255
+ raise Failure("unsupported_format", path)
256
+ continue
235
257
  paths.add(path)
236
258
  continue
237
259
  try:
@@ -980,6 +1002,75 @@ def exclusive_create(parent, stage_name, name):
980
1002
  os.link(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
981
1003
 
982
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
+
983
1074
  def apply(request, vault, report, args):
984
1075
  if not request["consent"]["workspace_copies"]:
985
1076
  raise Failure("consent_missing")
@@ -1142,77 +1233,33 @@ def apply(request, vault, report, args):
1142
1233
  pending = dict(report, status="failed", exit_code=1, write_status="unknown",
1143
1234
  issues=[issue("commit_outcome_uncertain", target)])
1144
1235
  replace_private(result_path, canonical(pending))
1145
- committed, own_stage = False, False
1236
+ committed = False
1237
+ progress = {"committed": False, "own_stage": False, "cleanup_failed": False}
1146
1238
  stage_name = PurePosixPath(stage).name
1147
1239
  try:
1148
- try:
1149
- descriptor = os.open(
1150
- stage_name, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
1151
- 0o666 if planned["kind"] == "create" else 0o600, dir_fd=parent,
1152
- )
1153
- except FileExistsError as error:
1154
- raise Failure("target_exists", stage) from error
1155
- own_stage = True
1156
- with os.fdopen(descriptor, "w+b") as stream:
1157
- stream.write(candidate)
1158
- stream.flush()
1159
- if metadata is not None:
1160
- os.fchown(stream.fileno(), metadata.st_uid, metadata.st_gid)
1161
- os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
1162
- os.fsync(stream.fileno())
1163
- current_root = directory(vault.root)
1164
- try:
1165
- if identity(os.fstat(current_root)) != planned["vault_identity"]:
1166
- raise Failure("parent_changed", target)
1167
- finally:
1168
- os.close(current_root)
1169
- with vault.parent(target) as (current_parent, _):
1170
- if identity(os.fstat(current_parent)) != identity(os.fstat(parent)):
1171
- raise Failure("parent_changed", target)
1172
- if before is not None:
1173
- current, current_metadata = read_at(parent, name)
1174
- if digest(current) != planned["before_sha256"]:
1175
- raise Failure("target_changed", target)
1176
- if (identity(current_metadata) != identity(metadata)
1177
- or current_metadata.st_mode != metadata.st_mode
1178
- or current_metadata.st_gid != metadata.st_gid):
1179
- raise Failure("target_changed", target)
1180
- with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as check:
1181
- metadata_supported(os.fstat(check.fileno()), check.fileno())
1182
- os.replace(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent)
1183
- committed, own_stage = True, False
1184
- else:
1185
- try:
1186
- exclusive_create(parent, stage_name, name)
1187
- except FileExistsError as error:
1188
- raise Failure("target_exists", target) from error
1189
- committed = True
1190
- os.fsync(parent)
1191
- verified, _ = read_at(parent, name)
1192
- if digest(verified) != planned["after_sha256"]:
1193
- raise OSError("Post-commit verification failed")
1194
- if own_stage:
1195
- os.unlink(stage_name, dir_fd=parent)
1196
- own_stage = False
1197
- os.fsync(parent)
1198
- replace_private(receipt_path, canonical(expected_receipt))
1199
- report.update(operation_state="saved", write_status="saved")
1200
- report["data"].update(
1201
- after_sha256=digest(verified), changed=True, verified=True,
1202
- )
1203
- report["issues"].append(issue("saved", target))
1204
- replace_private(result_path, canonical(report))
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))
1205
1252
  except (OSError, Failure) as error:
1253
+ committed = progress["committed"]
1206
1254
  report["issues"] = [item for item in report["issues"] if item["code"] != "saved"]
1207
- if own_stage:
1208
- try:
1209
- os.unlink(stage_name, dir_fd=parent)
1210
- except OSError as cleanup_error:
1211
- report["issues"].append(issue("runtime_error", stage))
1212
- if not committed:
1213
- report.update(status="failed", exit_code=1, operation_state="blocked")
1214
- report["issues"].append(issue("write_failed_before_commit", target))
1215
- raise Failure("write_failed_before_commit", target, status="failed", exit_code=1) from cleanup_error
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
1216
1263
  report.update(
1217
1264
  status="failed" if committed or not isinstance(error, Failure) else error.status,
1218
1265
  exit_code=1 if committed or not isinstance(error, Failure) else error.exit_code,
@@ -58,9 +58,19 @@ YAML frontmatter and a few platform extensions on top — see their doctype
58
58
  reference files. Obsidian uses YAML properties, wikilinks, and callouts;
59
59
  see `references/doctypes/obsidian.md` for core Templates compatibility.
60
60
  For an existing Vault's conventions, related-note suggestions, link diagnosis,
61
- or approval-gated saves, use the sibling `obsidian-vault` skill when available.
61
+ or authorized direct edits or CLI plan-approved saves, use the sibling
62
+ `obsidian-vault` skill when available.
62
63
  Tech-writer still owns document structure and the reusable template; it must
63
- not bypass that skill's scope checks or save directly into the Vault.
64
+ hand Vault writes back to that skill's selected workflow, not bypass its scope
65
+ checks or write into the Vault on its own.
66
+ Preserve the user's working directory when handing off: use a user-specified
67
+ directory, or the current directory at request start if none was specified.
68
+ Resolve the final target there without reinterpreting an explicitly absolute or
69
+ Vault-relative path. Do not replace the target with a copy in session-state
70
+ storage; retain Vault scope checks and separate consent for any auxiliary copies.
71
+ Route standalone `.base` configuration to `obsidian-bases`, not the Markdown
72
+ template or Vault Markdown writer; its host workflow validates without evaluating
73
+ formulas and never mutates source-note properties.
64
74
 
65
75
  ## Division of labor
66
76