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.
- package/CHANGELOG.md +50 -1
- package/README-ja.md +108 -18
- package/README.md +155 -23
- package/bin/kotonoha.js +64 -12
- package/package.json +7 -3
- package/skills/obsidian-bases/SKILL.md +140 -0
- package/skills/obsidian-bases/assets/templates/cards.base +8 -0
- package/skills/obsidian-bases/assets/templates/kanban.base +7 -0
- package/skills/obsidian-bases/assets/templates/list.base +8 -0
- package/skills/obsidian-bases/assets/templates/table.base +10 -0
- package/skills/obsidian-bases/references/execution-contract.json +246 -0
- package/skills/obsidian-bases/references/workflow.md +159 -0
- package/skills/obsidian-bases/scripts/check.cjs +690 -0
- package/skills/obsidian-bases/scripts/package-lock.json +104 -0
- package/skills/obsidian-bases/scripts/package.json +18 -0
- package/skills/obsidian-bases/scripts/verify.cjs +454 -0
- package/skills/obsidian-canvas/SKILL.md +134 -0
- package/skills/obsidian-canvas/assets/templates/comparison.canvas +11 -0
- package/skills/obsidian-canvas/assets/templates/mind-map.canvas +11 -0
- package/skills/obsidian-canvas/assets/templates/note-map.canvas +10 -0
- package/skills/obsidian-canvas/assets/templates/workflow.canvas +11 -0
- package/skills/obsidian-canvas/references/execution-contract.json +404 -0
- package/skills/obsidian-canvas/references/workflow.md +189 -0
- package/skills/obsidian-canvas/scripts/canvas.py +1479 -0
- package/skills/obsidian-canvas/scripts/canvas.py.lock +143 -0
- package/skills/obsidian-canvas/scripts/test_canvas.py +2047 -0
- package/skills/obsidian-canvas/scripts/test_canvas.py.lock +143 -0
- package/skills/obsidian-vault/SKILL.md +149 -93
- package/skills/obsidian-vault/references/workflow.md +61 -4
- package/skills/obsidian-vault/scripts/test_vault.py +76 -0
- package/skills/obsidian-vault/scripts/vault.py +118 -71
- package/skills/tech-writer/SKILL.md +26 -36
- 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(
|
|
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(
|
|
234
|
-
|
|
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
|
|
1236
|
+
committed = False
|
|
1237
|
+
progress = {"committed": False, "own_stage": False, "cleanup_failed": False}
|
|
1146
1238
|
stage_name = PurePosixPath(stage).name
|
|
1147
1239
|
try:
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
report["issues"].append(issue("
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
5
|
-
API
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|