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
@@ -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,
@@ -1,39 +1,17 @@
1
1
  ---
2
2
  name: tech-writer
3
3
  description: >-
4
- Helps structure and polish technical documents: README, design docs/ADRs,
5
- API reference, PR descriptions/commit messages/issue reports, release
6
- notes/CHANGELOG, user manuals/how-to guides, code comments/docstrings,
7
- requirements definitions, system designs, test plans, operations runbooks,
8
- migration plans, security designs/threat models, technical proposals,
9
- Blueprints, White Papers, RFI/RFP procurement documents, Obsidian notes
10
- and templates, and Qiita/Zenn articles. Use for requests
11
- like "write a README", "draft a design doc", "define system requirements",
12
- "write a system design", "create a test plan", "write an operations
13
- runbook", "plan a migration", "create a threat model", "write a technical
14
- proposal", "create a Blueprint", "write a White Paper", "draft an RFI",
15
- "create an RFP", "write this PR description", "clean up my commit
16
- message", "make this how-to guide clearer", "write API docs", "summarize
17
- the release notes", "create an Obsidian note", "write a Zenn article",
18
- "write this up for Qiita", as
19
- well as
20
- Japanese-language equivalents (「READMEを書いて」「設計ドキュメントを作って」
21
- 「要件定義書を作って」「システム設計書を書いて」「テスト計画書を作って」
22
- 「運用設計書を書いて」「移行計画を作って」「脅威モデルを作って」
23
- 「技術提案書を書いて」「Blueprintを作って」「ホワイトペーパーを書いて」
24
- 「RFIを作って」「RFPを作って」「PRの説明文を書いて」
25
- 「コミットメッセージを整えて」「手順書を分かりやすくして」
26
- 「APIドキュメントを整備して」「リリースノートをまとめて」
27
- 「Obsidian用のノートを作って」「Obsidianのテンプレートを作って」
28
- 「Zennの記事を書いて」「Qiitaに投稿する記事を書いて」). Especially useful when
29
- the request is a bare goal without enough context to start (e.g. "I want
30
- to write a README" / "○○を書きたい"), since this skill drives a one
31
- question-at-a-time intake before writing. Supports both Japanese and
32
- English documents, with Japanese as the primary target. Owns document
33
- structure, information completeness, and reader fit; for living Japanese
34
- documents, it invokes kotonoha's original bundled `japanese-prose` skill,
35
- which uses GiNZA for sentence-level naturalness, word choice, rhythm, and
36
- repeated AI-like pattern analysis.
4
+ Structures, writes, reviews, and scores technical documents: README,
5
+ design docs/ADRs, API references, requirements, system designs, test plans,
6
+ operations runbooks, migration plans, security designs/threat models,
7
+ technical proposals, Blueprints, White Papers, RFI/RFP, PR descriptions,
8
+ commit messages, issue reports, release notes/CHANGELOG, manuals/how-to
9
+ guides, comments/docstrings, Obsidian notes/templates, and Qiita/Zenn
10
+ articles. Use when asked to create, improve, review, or score these
11
+ documents in Japanese or English, including a bare goal without context.
12
+ Asks one question at a time before writing. Owns structure, completeness,
13
+ traceability, and reader fit; delegates sentence-level Japanese wording
14
+ to kotonoha's original bundled japanese-prose skill with GiNZA diagnostics.
37
15
  license: MIT
38
16
  argument-hint: "[write|review|score] [doctype] <target file or request>"
39
17
  ---
@@ -58,9 +36,19 @@ YAML frontmatter and a few platform extensions on top — see their doctype
58
36
  reference files. Obsidian uses YAML properties, wikilinks, and callouts;
59
37
  see `references/doctypes/obsidian.md` for core Templates compatibility.
60
38
  For an existing Vault's conventions, related-note suggestions, link diagnosis,
61
- or approval-gated saves, use the sibling `obsidian-vault` skill when available.
39
+ or authorized direct edits or CLI plan-approved saves, use the sibling
40
+ `obsidian-vault` skill when available.
62
41
  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.
42
+ hand Vault writes back to that skill's selected workflow, not bypass its scope
43
+ checks or write into the Vault on its own.
44
+ Preserve the user's working directory when handing off: use a user-specified
45
+ directory, or the current directory at request start if none was specified.
46
+ Resolve the final target there without reinterpreting an explicitly absolute or
47
+ Vault-relative path. Do not replace the target with a copy in session-state
48
+ storage; retain Vault scope checks and separate consent for any auxiliary copies.
49
+ Route standalone `.base` configuration to `obsidian-bases`, not the Markdown
50
+ template or Vault Markdown writer; its host workflow validates without evaluating
51
+ formulas and never mutates source-note properties.
64
52
 
65
53
  ## Division of labor
66
54
 
@@ -323,8 +311,10 @@ Load `japanese-prose` through the host's skill-loading mechanism. When only
323
311
  installed skill files are exposed, resolve the sibling path
324
312
  `<tech-writer-dir>/../japanese-prose/SKILL.md` first, then check
325
313
  `.github/skills/japanese-prose/SKILL.md`,
314
+ `.claude/skills/japanese-prose/SKILL.md`,
326
315
  `.copilot/skills/japanese-prose/SKILL.md`, and
327
- `$HOME/.copilot/skills/japanese-prose/SKILL.md`. Load the discovered
316
+ `$HOME/.copilot/skills/japanese-prose/SKILL.md`, then
317
+ `$HOME/.claude/skills/japanese-prose/SKILL.md`. Load the discovered
328
318
  `SKILL.md` and follow its quick or full workflow. A standard
329
319
  `npx kotonoha install` installs this sibling skill automatically. Treat the
330
320
  pass as unavailable only when someone copied or installed tech-writer without
@@ -50,8 +50,10 @@ Never trade technical precision for conversational phrasing.
50
50
  skill-loading mechanism. If the host exposes only skill files, resolve
51
51
  `<tech-writer-dir>/../japanese-prose/SKILL.md` first, then check
52
52
  `.github/skills/japanese-prose/SKILL.md`,
53
+ `.claude/skills/japanese-prose/SKILL.md`,
53
54
  `.copilot/skills/japanese-prose/SKILL.md`, and
54
- `$HOME/.copilot/skills/japanese-prose/SKILL.md`; expand `$HOME`, load the
55
+ `$HOME/.copilot/skills/japanese-prose/SKILL.md`, then
56
+ `$HOME/.claude/skills/japanese-prose/SKILL.md`; expand `$HOME`, load the
55
57
  discovered skill,
56
58
  and follow its workflow.
57
59
  2. Ask the loaded skill to review and rewrite only the Japanese prose under