open-wiki-spec 0.2.3 → 0.3.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 (265) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +50 -3
  3. package/dist/cli/commands/apply.d.ts.map +1 -1
  4. package/dist/cli/commands/apply.js +96 -4
  5. package/dist/cli/commands/apply.js.map +1 -1
  6. package/dist/cli/commands/archive.d.ts.map +1 -1
  7. package/dist/cli/commands/archive.js +19 -5
  8. package/dist/cli/commands/archive.js.map +1 -1
  9. package/dist/cli/commands/bulk-archive.d.ts +15 -0
  10. package/dist/cli/commands/bulk-archive.d.ts.map +1 -0
  11. package/dist/cli/commands/bulk-archive.js +97 -0
  12. package/dist/cli/commands/bulk-archive.js.map +1 -0
  13. package/dist/cli/commands/continue.d.ts.map +1 -1
  14. package/dist/cli/commands/continue.js +15 -2
  15. package/dist/cli/commands/continue.js.map +1 -1
  16. package/dist/cli/commands/error-handler.d.ts +1 -5
  17. package/dist/cli/commands/error-handler.d.ts.map +1 -1
  18. package/dist/cli/commands/error-handler.js +48 -3
  19. package/dist/cli/commands/error-handler.js.map +1 -1
  20. package/dist/cli/commands/init.d.ts +0 -3
  21. package/dist/cli/commands/init.d.ts.map +1 -1
  22. package/dist/cli/commands/init.js +22 -4
  23. package/dist/cli/commands/init.js.map +1 -1
  24. package/dist/cli/commands/list.d.ts +2 -17
  25. package/dist/cli/commands/list.d.ts.map +1 -1
  26. package/dist/cli/commands/list.js +15 -3
  27. package/dist/cli/commands/list.js.map +1 -1
  28. package/dist/cli/commands/migrate.d.ts.map +1 -1
  29. package/dist/cli/commands/migrate.js +6 -9
  30. package/dist/cli/commands/migrate.js.map +1 -1
  31. package/dist/cli/commands/propose.d.ts.map +1 -1
  32. package/dist/cli/commands/propose.js +46 -6
  33. package/dist/cli/commands/propose.js.map +1 -1
  34. package/dist/cli/commands/query.d.ts.map +1 -1
  35. package/dist/cli/commands/query.js +101 -5
  36. package/dist/cli/commands/query.js.map +1 -1
  37. package/dist/cli/commands/retrieve.d.ts +7 -0
  38. package/dist/cli/commands/retrieve.d.ts.map +1 -0
  39. package/dist/cli/commands/retrieve.js +70 -0
  40. package/dist/cli/commands/retrieve.js.map +1 -0
  41. package/dist/cli/commands/revert.d.ts +18 -0
  42. package/dist/cli/commands/revert.d.ts.map +1 -0
  43. package/dist/cli/commands/revert.js +109 -0
  44. package/dist/cli/commands/revert.js.map +1 -0
  45. package/dist/cli/commands/status.d.ts +2 -19
  46. package/dist/cli/commands/status.d.ts.map +1 -1
  47. package/dist/cli/commands/status.js +42 -3
  48. package/dist/cli/commands/status.js.map +1 -1
  49. package/dist/cli/commands/verify.d.ts.map +1 -1
  50. package/dist/cli/commands/verify.js +12 -1
  51. package/dist/cli/commands/verify.js.map +1 -1
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +68 -2
  54. package/dist/cli/index.js.map +1 -1
  55. package/dist/cli/init/init-engine.d.ts.map +1 -1
  56. package/dist/cli/init/init-engine.js +19 -6
  57. package/dist/cli/init/init-engine.js.map +1 -1
  58. package/dist/cli/init/meta-files.d.ts +2 -1
  59. package/dist/cli/init/meta-files.d.ts.map +1 -1
  60. package/dist/cli/init/meta-files.js +167 -8
  61. package/dist/cli/init/meta-files.js.map +1 -1
  62. package/dist/cli/init/skill-generator.d.ts +3 -0
  63. package/dist/cli/init/skill-generator.d.ts.map +1 -1
  64. package/dist/cli/init/skill-generator.js +184 -3
  65. package/dist/cli/init/skill-generator.js.map +1 -1
  66. package/dist/cli/json-envelope.d.ts +15 -0
  67. package/dist/cli/json-envelope.d.ts.map +1 -0
  68. package/dist/cli/json-envelope.js +86 -0
  69. package/dist/cli/json-envelope.js.map +1 -0
  70. package/dist/cli/schema-check.d.ts +27 -0
  71. package/dist/cli/schema-check.d.ts.map +1 -0
  72. package/dist/cli/schema-check.js +33 -0
  73. package/dist/cli/schema-check.js.map +1 -0
  74. package/dist/cli/vault-discovery.d.ts.map +1 -1
  75. package/dist/cli/vault-discovery.js +16 -2
  76. package/dist/cli/vault-discovery.js.map +1 -1
  77. package/dist/core/embedding/cache.d.ts +12 -3
  78. package/dist/core/embedding/cache.d.ts.map +1 -1
  79. package/dist/core/embedding/cache.js +42 -5
  80. package/dist/core/embedding/cache.js.map +1 -1
  81. package/dist/core/embedding/embedder.d.ts +18 -0
  82. package/dist/core/embedding/embedder.d.ts.map +1 -1
  83. package/dist/core/embedding/embedder.js +87 -10
  84. package/dist/core/embedding/embedder.js.map +1 -1
  85. package/dist/core/index/build.d.ts.map +1 -1
  86. package/dist/core/index/build.js +54 -6
  87. package/dist/core/index/build.js.map +1 -1
  88. package/dist/core/index/resolve.d.ts.map +1 -1
  89. package/dist/core/index/resolve.js +27 -12
  90. package/dist/core/index/resolve.js.map +1 -1
  91. package/dist/core/index/scan.d.ts +10 -1
  92. package/dist/core/index/scan.d.ts.map +1 -1
  93. package/dist/core/index/scan.js +59 -10
  94. package/dist/core/index/scan.js.map +1 -1
  95. package/dist/core/index/schema-version.d.ts +28 -0
  96. package/dist/core/index/schema-version.d.ts.map +1 -1
  97. package/dist/core/index/schema-version.js +47 -0
  98. package/dist/core/index/schema-version.js.map +1 -1
  99. package/dist/core/migrate/change-converter.d.ts.map +1 -1
  100. package/dist/core/migrate/change-converter.js +59 -11
  101. package/dist/core/migrate/change-converter.js.map +1 -1
  102. package/dist/core/migrate/migrate.d.ts.map +1 -1
  103. package/dist/core/migrate/migrate.js +115 -1
  104. package/dist/core/migrate/migrate.js.map +1 -1
  105. package/dist/core/migrate/sanitize.d.ts +17 -0
  106. package/dist/core/migrate/sanitize.d.ts.map +1 -0
  107. package/dist/core/migrate/sanitize.js +33 -0
  108. package/dist/core/migrate/sanitize.js.map +1 -0
  109. package/dist/core/migrate/spec-converter.d.ts.map +1 -1
  110. package/dist/core/migrate/spec-converter.js +9 -3
  111. package/dist/core/migrate/spec-converter.js.map +1 -1
  112. package/dist/core/migrate/types.d.ts +9 -0
  113. package/dist/core/migrate/types.d.ts.map +1 -1
  114. package/dist/core/parser/frontmatter-parser.d.ts +0 -6
  115. package/dist/core/parser/frontmatter-parser.d.ts.map +1 -1
  116. package/dist/core/parser/frontmatter-parser.js +59 -2
  117. package/dist/core/parser/frontmatter-parser.js.map +1 -1
  118. package/dist/core/parser/note-parser.js +15 -4
  119. package/dist/core/parser/note-parser.js.map +1 -1
  120. package/dist/core/parser/requirement-parser.js +4 -0
  121. package/dist/core/parser/requirement-parser.js.map +1 -1
  122. package/dist/core/parser/task-parser.d.ts.map +1 -1
  123. package/dist/core/parser/task-parser.js +19 -0
  124. package/dist/core/parser/task-parser.js.map +1 -1
  125. package/dist/core/parser/wikilink-parser.d.ts.map +1 -1
  126. package/dist/core/parser/wikilink-parser.js +21 -5
  127. package/dist/core/parser/wikilink-parser.js.map +1 -1
  128. package/dist/core/retrieval/classify.d.ts +11 -4
  129. package/dist/core/retrieval/classify.d.ts.map +1 -1
  130. package/dist/core/retrieval/classify.js +76 -14
  131. package/dist/core/retrieval/classify.js.map +1 -1
  132. package/dist/core/retrieval/lexical.d.ts.map +1 -1
  133. package/dist/core/retrieval/lexical.js +31 -12
  134. package/dist/core/retrieval/lexical.js.map +1 -1
  135. package/dist/core/retrieval/retrieve.d.ts.map +1 -1
  136. package/dist/core/retrieval/retrieve.js +45 -5
  137. package/dist/core/retrieval/retrieve.js.map +1 -1
  138. package/dist/core/retrieval/scoring.d.ts.map +1 -1
  139. package/dist/core/retrieval/scoring.js +37 -12
  140. package/dist/core/retrieval/scoring.js.map +1 -1
  141. package/dist/core/schema/change.schema.d.ts.map +1 -1
  142. package/dist/core/schema/change.schema.js +6 -1
  143. package/dist/core/schema/change.schema.js.map +1 -1
  144. package/dist/core/schema/feature.schema.d.ts +1 -1
  145. package/dist/core/schema/feature.schema.d.ts.map +1 -1
  146. package/dist/core/schema/feature.schema.js +1 -0
  147. package/dist/core/schema/feature.schema.js.map +1 -1
  148. package/dist/core/schema/frontmatter.d.ts +15 -0
  149. package/dist/core/schema/frontmatter.d.ts.map +1 -1
  150. package/dist/core/schema/index.d.ts +15 -0
  151. package/dist/core/schema/index.d.ts.map +1 -1
  152. package/dist/core/schema/query.schema.d.ts +15 -0
  153. package/dist/core/schema/query.schema.d.ts.map +1 -1
  154. package/dist/core/schema/query.schema.js +5 -0
  155. package/dist/core/schema/query.schema.js.map +1 -1
  156. package/dist/core/schema/templates.d.ts +0 -4
  157. package/dist/core/schema/templates.d.ts.map +1 -1
  158. package/dist/core/schema/templates.js +25 -7
  159. package/dist/core/schema/templates.js.map +1 -1
  160. package/dist/core/sequencing/analyze.d.ts.map +1 -1
  161. package/dist/core/sequencing/analyze.js +3 -1
  162. package/dist/core/sequencing/analyze.js.map +1 -1
  163. package/dist/core/sequencing/priority-queue.d.ts +9 -3
  164. package/dist/core/sequencing/priority-queue.d.ts.map +1 -1
  165. package/dist/core/sequencing/priority-queue.js +10 -4
  166. package/dist/core/sequencing/priority-queue.js.map +1 -1
  167. package/dist/core/sequencing/stale-detector.d.ts.map +1 -1
  168. package/dist/core/sequencing/stale-detector.js +5 -0
  169. package/dist/core/sequencing/stale-detector.js.map +1 -1
  170. package/dist/core/workflow/apply/apply.d.ts.map +1 -1
  171. package/dist/core/workflow/apply/apply.js +494 -55
  172. package/dist/core/workflow/apply/apply.js.map +1 -1
  173. package/dist/core/workflow/apply/delta-parser.d.ts.map +1 -1
  174. package/dist/core/workflow/apply/delta-parser.js +43 -9
  175. package/dist/core/workflow/apply/delta-parser.js.map +1 -1
  176. package/dist/core/workflow/apply/feature-updater.d.ts.map +1 -1
  177. package/dist/core/workflow/apply/feature-updater.js +27 -11
  178. package/dist/core/workflow/apply/feature-updater.js.map +1 -1
  179. package/dist/core/workflow/apply/types.d.ts +14 -0
  180. package/dist/core/workflow/apply/types.d.ts.map +1 -1
  181. package/dist/core/workflow/continue/continue.d.ts +18 -0
  182. package/dist/core/workflow/continue/continue.d.ts.map +1 -1
  183. package/dist/core/workflow/continue/continue.js +134 -15
  184. package/dist/core/workflow/continue/continue.js.map +1 -1
  185. package/dist/core/workflow/continue/next-action.d.ts.map +1 -1
  186. package/dist/core/workflow/continue/next-action.js +16 -3
  187. package/dist/core/workflow/continue/next-action.js.map +1 -1
  188. package/dist/core/workflow/continue/types.d.ts +12 -0
  189. package/dist/core/workflow/continue/types.d.ts.map +1 -1
  190. package/dist/core/workflow/propose/note-creator.d.ts +2 -2
  191. package/dist/core/workflow/propose/note-creator.d.ts.map +1 -1
  192. package/dist/core/workflow/propose/note-creator.js +89 -12
  193. package/dist/core/workflow/propose/note-creator.js.map +1 -1
  194. package/dist/core/workflow/propose/propose.d.ts.map +1 -1
  195. package/dist/core/workflow/propose/propose.js +190 -46
  196. package/dist/core/workflow/propose/propose.js.map +1 -1
  197. package/dist/core/workflow/propose/query-normalizer.d.ts.map +1 -1
  198. package/dist/core/workflow/propose/query-normalizer.js +37 -5
  199. package/dist/core/workflow/propose/query-normalizer.js.map +1 -1
  200. package/dist/core/workflow/propose/types.d.ts +15 -0
  201. package/dist/core/workflow/propose/types.d.ts.map +1 -1
  202. package/dist/core/workflow/propose/types.js +6 -1
  203. package/dist/core/workflow/propose/types.js.map +1 -1
  204. package/dist/core/workflow/query/query-note-creator.d.ts +0 -4
  205. package/dist/core/workflow/query/query-note-creator.d.ts.map +1 -1
  206. package/dist/core/workflow/query/query-note-creator.js +27 -37
  207. package/dist/core/workflow/query/query-note-creator.js.map +1 -1
  208. package/dist/core/workflow/verify/coherence.d.ts +10 -0
  209. package/dist/core/workflow/verify/coherence.d.ts.map +1 -1
  210. package/dist/core/workflow/verify/coherence.js +91 -4
  211. package/dist/core/workflow/verify/coherence.js.map +1 -1
  212. package/dist/core/workflow/verify/completeness.d.ts +1 -1
  213. package/dist/core/workflow/verify/completeness.d.ts.map +1 -1
  214. package/dist/core/workflow/verify/completeness.js +53 -10
  215. package/dist/core/workflow/verify/completeness.js.map +1 -1
  216. package/dist/core/workflow/verify/correctness.d.ts +1 -1
  217. package/dist/core/workflow/verify/correctness.d.ts.map +1 -1
  218. package/dist/core/workflow/verify/correctness.js +95 -14
  219. package/dist/core/workflow/verify/correctness.js.map +1 -1
  220. package/dist/core/workflow/verify/vault-integrity.d.ts +11 -0
  221. package/dist/core/workflow/verify/vault-integrity.d.ts.map +1 -1
  222. package/dist/core/workflow/verify/vault-integrity.js +80 -3
  223. package/dist/core/workflow/verify/vault-integrity.js.map +1 -1
  224. package/dist/core/workflow/verify/verify.d.ts +0 -5
  225. package/dist/core/workflow/verify/verify.d.ts.map +1 -1
  226. package/dist/core/workflow/verify/verify.js +54 -3
  227. package/dist/core/workflow/verify/verify.js.map +1 -1
  228. package/dist/index.d.ts +13 -0
  229. package/dist/index.d.ts.map +1 -1
  230. package/dist/index.js +11 -0
  231. package/dist/index.js.map +1 -1
  232. package/dist/types/cli-contracts.d.ts +78 -0
  233. package/dist/types/cli-contracts.d.ts.map +1 -0
  234. package/dist/types/cli-contracts.js +2 -0
  235. package/dist/types/cli-contracts.js.map +1 -0
  236. package/dist/types/error-codes.d.ts +25 -0
  237. package/dist/types/error-codes.d.ts.map +1 -0
  238. package/dist/types/error-codes.js +27 -0
  239. package/dist/types/error-codes.js.map +1 -0
  240. package/dist/types/frontmatter.d.ts +9 -2
  241. package/dist/types/frontmatter.d.ts.map +1 -1
  242. package/dist/types/next-action.d.ts +21 -0
  243. package/dist/types/next-action.d.ts.map +1 -1
  244. package/dist/types/retrieval.d.ts +17 -0
  245. package/dist/types/retrieval.d.ts.map +1 -1
  246. package/dist/types/verify.d.ts +58 -1
  247. package/dist/types/verify.d.ts.map +1 -1
  248. package/dist/types/verify.js +58 -1
  249. package/dist/types/verify.js.map +1 -1
  250. package/dist/utils/conventions.d.ts +9 -0
  251. package/dist/utils/conventions.d.ts.map +1 -0
  252. package/dist/utils/conventions.js +22 -0
  253. package/dist/utils/conventions.js.map +1 -0
  254. package/dist/utils/id-generator.d.ts.map +1 -1
  255. package/dist/utils/id-generator.js +8 -0
  256. package/dist/utils/id-generator.js.map +1 -1
  257. package/dist/utils/normalize.d.ts +7 -4
  258. package/dist/utils/normalize.d.ts.map +1 -1
  259. package/dist/utils/normalize.js +43 -6
  260. package/dist/utils/normalize.js.map +1 -1
  261. package/dist/utils/path-safety.d.ts +12 -1
  262. package/dist/utils/path-safety.d.ts.map +1 -1
  263. package/dist/utils/path-safety.js +54 -3
  264. package/dist/utils/path-safety.js.map +1 -1
  265. package/package.json +3 -2
@@ -1,3 +1,4 @@
1
+ import * as fs from 'node:fs';
1
2
  import { join, basename, resolve } from 'node:path';
2
3
  import { parseDeltaSummary, validateDeltaConflicts } from './delta-parser.js';
3
4
  import { detectStale, computeRequirementHash } from './stale-checker.js';
@@ -7,48 +8,155 @@ const PROGRAMMATIC_OPS = new Set(['RENAMED', 'REMOVED']);
7
8
  const AGENT_DRIVEN_OPS = new Set(['MODIFIED', 'ADDED']);
8
9
  const LOCK_FILENAME = '.ows-lock';
9
10
  const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
10
- function acquireLock(vaultRoot, deps) {
11
- const lockPath = join(vaultRoot, 'wiki', LOCK_FILENAME);
12
- if (deps.fileExists(lockPath)) {
13
- // Check if lock is stale (PID dead or timestamp > 5 minutes old)
14
- try {
15
- const content = deps.readFile(lockPath);
16
- const parsed = JSON.parse(content);
17
- const lockTime = parsed.timestamp ? new Date(parsed.timestamp).getTime() : 0;
18
- const isStaleByTime = Date.now() - lockTime > LOCK_STALE_MS;
19
- let isStaleByPid = false;
20
- if (parsed.pid) {
11
+ /**
12
+ * Default atomic exclusive file create using fs.openSync with O_CREAT|O_EXCL ('wx' flag).
13
+ * Throws with code EEXIST if the file already exists — preventing race conditions.
14
+ */
15
+ function defaultExclusiveCreateFile(filePath, content) {
16
+ const fd = fs.openSync(filePath, 'wx');
17
+ try {
18
+ fs.writeSync(fd, content);
19
+ }
20
+ finally {
21
+ fs.closeSync(fd);
22
+ }
23
+ }
24
+ /**
25
+ * Recover from a prior crash: restore .ows-backup-* files and remove .ows-tmp-* files.
26
+ * Called before acquiring lock to ensure clean state.
27
+ *
28
+ * Returns a list of per-file recovery failures so the caller can decide
29
+ * whether to abort. Previously these were swallowed silently, which could
30
+ * leave the vault in a partially-recovered state where some Feature files
31
+ * were successfully restored from backup but others were not — the next
32
+ * apply would then operate on mixed pre-/post-crash content without the
33
+ * user ever seeing a warning.
34
+ */
35
+ function recoverFromCrash(vaultRoot, deps) {
36
+ const failures = [];
37
+ const wikiDir = join(vaultRoot, 'wiki');
38
+ try {
39
+ const allFiles = fs.readdirSync(wikiDir, { recursive: true });
40
+ // Step 0: Check for a commit marker. If present, ALL renames
41
+ // succeeded in a prior run but the process crashed before cleanup.
42
+ // Forward-recover: delete backups + commit marker, keep new files.
43
+ // If absent AND backups exist, the renames were partial — rollback
44
+ // by restoring all backups.
45
+ const commitMarker = allFiles.find((f) => String(f).includes('.ows-commit-'));
46
+ if (commitMarker) {
47
+ // Forward recovery: all renames completed → delete backups + marker
48
+ for (const relFile of allFiles) {
49
+ const file = String(relFile);
50
+ const absPath = join(wikiDir, file);
51
+ if (file.includes('.ows-backup-') || file.includes('.ows-tmp-') || file.includes('.ows-commit-')) {
52
+ try {
53
+ if (deps.deleteFile)
54
+ deps.deleteFile(absPath);
55
+ }
56
+ catch (err) {
57
+ failures.push(`forward-recovery cleanup failed for ${file}: ${err.message}`);
58
+ }
59
+ }
60
+ }
61
+ return failures;
62
+ }
63
+ // No commit marker → rollback any partial renames
64
+ for (const relFile of allFiles) {
65
+ const file = String(relFile);
66
+ const absPath = join(wikiDir, file);
67
+ if (file.includes('.ows-tmp-')) {
21
68
  try {
22
- // process.kill(pid, 0) throws if process doesn't exist
23
- process.kill(parsed.pid, 0);
69
+ if (deps.deleteFile)
70
+ deps.deleteFile(absPath);
24
71
  }
25
- catch {
26
- isStaleByPid = true;
72
+ catch (err) {
73
+ failures.push(`failed to remove stale tmp file ${file}: ${err.message}`);
27
74
  }
28
75
  }
29
- if (isStaleByTime || isStaleByPid) {
30
- // Force-remove stale lock and retry
31
- if (deps.deleteFile) {
32
- deps.deleteFile(lockPath);
76
+ else if (file.includes('.ows-backup-')) {
77
+ // Rollback: ALWAYS restore backup regardless of whether the
78
+ // original path exists. If the original was overwritten by a
79
+ // partial rename, the backup is the authoritative pre-apply
80
+ // version. This prevents the mixed-state bug where some files
81
+ // have post-apply content and others have pre-apply content.
82
+ const originalPath = absPath.replace(/\.ows-backup-\d+/, '');
83
+ try {
84
+ fs.renameSync(absPath, originalPath);
85
+ }
86
+ catch (err) {
87
+ failures.push(`failed to restore backup ${file}: ${err.message}`);
33
88
  }
34
89
  }
35
- else {
36
- return false;
90
+ }
91
+ }
92
+ catch {
93
+ // wiki dir might not exist yet — ignore
94
+ }
95
+ return failures;
96
+ }
97
+ function acquireLock(vaultRoot, deps) {
98
+ const lockPath = join(vaultRoot, 'wiki', LOCK_FILENAME);
99
+ const lockContent = JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() });
100
+ const exclusiveCreate = deps.exclusiveCreateFile ?? defaultExclusiveCreateFile;
101
+ try {
102
+ // Atomic exclusive create — fails with EEXIST if lock already exists
103
+ exclusiveCreate(lockPath, lockContent);
104
+ return true;
105
+ }
106
+ catch (err) {
107
+ if (err.code !== 'EEXIST') {
108
+ // Unexpected error (permissions, missing directory, etc.)
109
+ return false;
110
+ }
111
+ }
112
+ // Lock file exists — check if stale
113
+ try {
114
+ const content = deps.readFile(lockPath);
115
+ const parsed = JSON.parse(content);
116
+ const lockTime = parsed.timestamp ? new Date(parsed.timestamp).getTime() : 0;
117
+ const isStaleByTime = Date.now() - lockTime > LOCK_STALE_MS;
118
+ let isStaleByPid = false;
119
+ if (parsed.pid) {
120
+ try {
121
+ // process.kill(pid, 0) throws if process doesn't exist
122
+ process.kill(parsed.pid, 0);
123
+ }
124
+ catch {
125
+ isStaleByPid = true;
37
126
  }
38
127
  }
39
- catch {
40
- // If we can't parse the lock, treat as stale and recover
128
+ if (isStaleByTime || isStaleByPid) {
129
+ // Remove stale lock and retry atomically
41
130
  if (deps.deleteFile) {
42
- try {
43
- deps.deleteFile(lockPath);
44
- }
45
- catch { /* swallow */ }
131
+ deps.deleteFile(lockPath);
132
+ }
133
+ try {
134
+ exclusiveCreate(lockPath, lockContent);
135
+ return true;
136
+ }
137
+ catch {
138
+ // Another process grabbed the lock between delete and create
139
+ return false;
46
140
  }
47
141
  }
142
+ return false;
143
+ }
144
+ catch {
145
+ // If we can't parse the lock, treat as stale and recover
146
+ if (deps.deleteFile) {
147
+ try {
148
+ deps.deleteFile(lockPath);
149
+ }
150
+ catch { /* swallow */ }
151
+ }
152
+ try {
153
+ exclusiveCreate(lockPath, lockContent);
154
+ return true;
155
+ }
156
+ catch {
157
+ return false;
158
+ }
48
159
  }
49
- const lockContent = JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() });
50
- deps.writeFile(lockPath, lockContent);
51
- return true;
52
160
  }
53
161
  function releaseLock(vaultRoot, deps) {
54
162
  const lockPath = join(vaultRoot, 'wiki', LOCK_FILENAME);
@@ -93,7 +201,13 @@ export function applyChange(options, index, deps) {
93
201
  throw new Error(`Cannot apply change with status "${changeRecord.status}". ` +
94
202
  'Expected "in_progress". Use \'continue\' to advance through the status lifecycle first.');
95
203
  }
96
- // Check all tasks complete
204
+ // Check all tasks complete. Reject changes with zero tasks — a change with
205
+ // no tasks is an incomplete spec (Tasks is a hard prerequisite per the
206
+ // planned transition), and allowing it would let users apply a Change that
207
+ // was never actually implemented.
208
+ if (parsed.tasks.length === 0) {
209
+ throw new Error('Cannot apply: change has no tasks defined. Add implementation tasks to the ## Tasks section before applying.');
210
+ }
97
211
  const uncheckedTasks = parsed.tasks.filter((t) => !t.done);
98
212
  if (uncheckedTasks.length > 0) {
99
213
  throw new Error(`Cannot apply: ${uncheckedTasks.length} unchecked task(s) remaining. ` +
@@ -101,11 +215,18 @@ export function applyChange(options, index, deps) {
101
215
  }
102
216
  // 2. Parse Delta Summary
103
217
  const resolveWikilink = (target) => {
104
- // Simple lookup by title
218
+ // Lookup by id first (highest priority), then title, then alias
105
219
  for (const record of index.records.values()) {
106
- if (record.title === target || record.id === target) {
220
+ if (record.id === target)
221
+ return record.id;
222
+ }
223
+ for (const record of index.records.values()) {
224
+ if (record.title === target)
225
+ return record.id;
226
+ }
227
+ for (const record of index.records.values()) {
228
+ if (record.aliases.some((a) => a === target))
107
229
  return record.id;
108
- }
109
230
  }
110
231
  return undefined;
111
232
  };
@@ -118,6 +239,57 @@ export function applyChange(options, index, deps) {
118
239
  warnings,
119
240
  });
120
241
  }
242
+ // Hard-fail on any Unparseable line. These warnings are only emitted when
243
+ // a delta line STARTS with ADDED/MODIFIED/REMOVED/RENAMED but fails the
244
+ // full regex (e.g., missing quotes around the requirement name, or the
245
+ // target was written as plain text instead of a `[[wikilink]]`). Such
246
+ // lines are unambiguously intended as delta entries — silently applying
247
+ // only the parseable siblings produces a partial change that is
248
+ // impossible to audit after the fact. Block the whole apply so the user
249
+ // fixes the syntax explicitly.
250
+ const unparseable = deltaPlan.warnings.filter((w) => w.startsWith('Unparseable Delta Summary entry'));
251
+ if (unparseable.length > 0) {
252
+ return makeResult(options, changeRecord, {
253
+ success: false,
254
+ errors: [
255
+ `Delta Summary has ${unparseable.length} unparseable line(s). ` +
256
+ `Fix the syntax — requirement names must be quoted ` +
257
+ `(e.g., \`ADDED requirement "FooBar" to [[Feature: Auth]]\`) ` +
258
+ `and target Features must be wikilinks, not plain text:`,
259
+ ...unparseable.map((u) => ` ${u}`),
260
+ ],
261
+ warnings,
262
+ });
263
+ }
264
+ // Validate that every Delta Summary entry targets a Feature declared in the
265
+ // change's frontmatter (`feature` or `features`). This prevents a Change that
266
+ // claims `features: [A, B]` from accidentally modifying Feature C.
267
+ const declaredFeatureIds = new Set();
268
+ if (changeRecord.feature)
269
+ declaredFeatureIds.add(changeRecord.feature);
270
+ if (changeRecord.features) {
271
+ for (const f of changeRecord.features)
272
+ declaredFeatureIds.add(f);
273
+ }
274
+ if (declaredFeatureIds.size > 0) {
275
+ const undeclaredTargets = [];
276
+ for (const [noteKey] of deltaPlan.byTargetNote) {
277
+ if (!declaredFeatureIds.has(noteKey)) {
278
+ undeclaredTargets.push(noteKey);
279
+ }
280
+ }
281
+ if (undeclaredTargets.length > 0) {
282
+ return makeResult(options, changeRecord, {
283
+ success: false,
284
+ errors: [
285
+ `Delta Summary targets Feature(s) not declared in the change's frontmatter: ${undeclaredTargets.join(', ')}. ` +
286
+ `Declared features: ${Array.from(declaredFeatureIds).join(', ')}. ` +
287
+ `Either add the undeclared targets to the change's "features" field, or fix the Delta Summary to point at the declared Features.`,
288
+ ],
289
+ warnings,
290
+ });
291
+ }
292
+ }
121
293
  // Validate delta conflicts
122
294
  const conflictErrors = validateDeltaConflicts(deltaPlan);
123
295
  if (conflictErrors.length > 0) {
@@ -128,10 +300,22 @@ export function applyChange(options, index, deps) {
128
300
  for (const [noteKey] of deltaPlan.byTargetNote) {
129
301
  const noteRecord = index.records.get(noteKey);
130
302
  if (!noteRecord) {
131
- errors.push(`Target note "${noteKey}" not found in index`);
303
+ errors.push(`Target note "${noteKey}" referenced in Delta Summary does not exist in the vault. ` +
304
+ `Either the Feature was deleted, renamed, or the Change's Delta Summary points to the wrong target. ` +
305
+ `Fix the Delta Summary wikilink or restore the missing note.`);
306
+ continue;
307
+ }
308
+ // Resolve path: absolute paths stay as-is, relative paths resolve against vault root
309
+ const resolvedNotePath = resolve(options.vaultRoot, noteRecord.path);
310
+ let noteParsed;
311
+ try {
312
+ noteParsed = deps.parseNote(resolvedNotePath);
313
+ }
314
+ catch (parseErr) {
315
+ errors.push(`Failed to read target note "${noteKey}" at ${noteRecord.path}: ${parseErr.message}. ` +
316
+ `The file may have been deleted or become unreadable since the index was built.`);
132
317
  continue;
133
318
  }
134
- const noteParsed = deps.parseNote(resolve(options.vaultRoot, noteRecord.path));
135
319
  const reqMap = new Map();
136
320
  for (const req of noteParsed.requirements) {
137
321
  reqMap.set(req.name, req);
@@ -155,6 +339,19 @@ export function applyChange(options, index, deps) {
155
339
  errors: staleReport.staleEntries.map((s) => `STALE: ${s.entry.op} "${s.entry.targetName}" - ${s.reason}`),
156
340
  });
157
341
  }
342
+ // 5b. If --force-stale was used and there actually was a stale report,
343
+ // record a prominent warning per entry so the audit trail isn't silent.
344
+ // Without this, a forced apply looks identical to a clean apply in the
345
+ // resulting ApplyResult — making it impossible for reviewers to tell
346
+ // after the fact that the stale guard was bypassed on purpose.
347
+ if (staleReport.blocked && options.forceStale && staleReport.staleEntries.length > 0) {
348
+ warnings.push(`--force-stale: bypassed ${staleReport.staleEntries.length} stale base check(s). ` +
349
+ 'Applied requirement deltas against a base that has already been modified ' +
350
+ 'by another change. Review the result carefully and re-verify the Feature.');
351
+ for (const s of staleReport.staleEntries) {
352
+ warnings.push(` force-stale: ${s.entry.op} "${s.entry.targetName}" - ${s.reason}`);
353
+ }
354
+ }
158
355
  // === PHASE 1: Validate and compute ===
159
356
  const featureResults = [];
160
357
  const allPostValidations = [];
@@ -196,10 +393,40 @@ export function applyChange(options, index, deps) {
196
393
  const agentEntries = reqEntries.filter((e) => AGENT_DRIVEN_OPS.has(e.op));
197
394
  const allReqEntries = [...mechEntries, ...agentEntries];
198
395
  const resolvedNotePath = resolve(options.vaultRoot, noteRecord.path);
199
- // Read Feature file content for programmatic editing
200
- const featureContent = deps.readFile(resolvedNotePath);
396
+ // Read Feature file content for programmatic editing. The file may
397
+ // have been deleted or replaced between parseNote (earlier in this
398
+ // function) and this read — for example, an Obsidian user moving
399
+ // files while `ows apply` runs. Turn a raw exception into a
400
+ // structured `errors` entry so the caller gets a clean failure
401
+ // report instead of the process aborting mid-apply.
402
+ let featureContent;
403
+ try {
404
+ featureContent = deps.readFile(resolvedNotePath);
405
+ }
406
+ catch (err) {
407
+ const code = err.code;
408
+ const reason = code === 'ENOENT'
409
+ ? 'file no longer exists (was it moved or deleted mid-apply?)'
410
+ : code === 'EACCES'
411
+ ? 'permission denied while reading'
412
+ : err.message;
413
+ errors.push(`Failed to re-read Feature "${noteKey}" at ${noteRecord.path}: ${reason}. ` +
414
+ 'Aborting apply before any writes.');
415
+ continue;
416
+ }
201
417
  const result = applyDeltaToFeature(noteKey, resolvedNotePath, reqMap, allReqEntries, featureContent, options.changeId);
202
418
  featureResults.push(result);
419
+ // Hard-fail on any semantic operation failure within a Feature.
420
+ // Without this, a multi-Feature change (features: [A, B]) could
421
+ // see Feature A succeed and Feature B fail at the operation level
422
+ // (e.g., "## Requirements section not found"), yet Phase 2 would
423
+ // still write A and skip B — producing a partial apply. Promoting
424
+ // operation-level failures to the global errors array ensures the
425
+ // entire apply aborts before any writes.
426
+ const failedOps = result.operations.filter((op) => !op.success);
427
+ for (const op of failedOps) {
428
+ errors.push(`Feature "${noteKey}": ${op.entry.op} "${op.entry.targetName}" failed: ${op.error ?? 'unknown error'}`);
429
+ }
203
430
  // Collect agent-driven ops (MODIFIED/ADDED need agent to fill content)
204
431
  for (const entry of agentEntries) {
205
432
  pendingAgentOps.push({
@@ -272,10 +499,43 @@ export function applyChange(options, index, deps) {
272
499
  // Uses atomic temp-file pattern: write to .ows-tmp first, then rename all at once.
273
500
  let statusTransitioned = false;
274
501
  const modifiedFiles = [];
275
- // When noAutoTransition is set and there are pending agent ops, skip auto-transition.
276
- // The agent must fill markers first, then run apply again (or verifyApply).
277
- const skipTransition = options.noAutoTransition && pendingAgentOps.length > 0;
502
+ // Always skip auto-transition when agent-driven ops remain even if
503
+ // `noAutoTransition` is false. Previously the default behavior was
504
+ // to still flip the Change to `applied` while leaving `MODIFIED/ADDED`
505
+ // markers unfilled in the Feature, which verify later caught via
506
+ // `UNFILLED_APPLY_MARKER`. That verdict was correct but came AFTER
507
+ // the status transition, leaving the Change in a lying state. Skip
508
+ // the transition up front so the Change stays `in_progress` until
509
+ // the agent actually fills the markers and runs apply again.
510
+ const skipTransition = pendingAgentOps.length > 0;
511
+ if (skipTransition && !options.noAutoTransition) {
512
+ warnings.push(`Auto-transition to "applied" blocked: ${pendingAgentOps.length} agent-driven op(s) still need marker fill. ` +
513
+ 'Fill the MODIFIED/ADDED markers in the Feature note, then re-run `ows apply`.');
514
+ }
278
515
  if (!options.dryRun) {
516
+ // Recover from prior crash (restore backups, clean temp files).
517
+ // Failures here are not fatal for tmp-file leftovers, but unrestored
518
+ // backups mean the vault is in a half-restored state — surface them
519
+ // as errors so the user can intervene before we clobber more files.
520
+ const recoveryFailures = recoverFromCrash(options.vaultRoot, deps);
521
+ for (const fail of recoveryFailures) {
522
+ if (fail.startsWith('failed to restore backup')) {
523
+ errors.push(`Pre-apply crash recovery could not restore a backup: ${fail}. ` +
524
+ 'Manually inspect wiki/ for .ows-backup-* files, restore them, then retry.');
525
+ }
526
+ else {
527
+ warnings.push(`Pre-apply crash recovery warning: ${fail}`);
528
+ }
529
+ }
530
+ if (errors.length > 0) {
531
+ return makeResult(options, changeRecord, {
532
+ success: false,
533
+ staleReport,
534
+ featureResults,
535
+ errors,
536
+ warnings,
537
+ });
538
+ }
279
539
  // Acquire vault-level lock
280
540
  if (!acquireLock(options.vaultRoot, deps)) {
281
541
  errors.push('Cannot apply: another apply operation is in progress (wiki/.ows-lock exists)');
@@ -300,7 +560,12 @@ export function applyChange(options, index, deps) {
300
560
  const pendingWrites = [];
301
561
  for (const featureResult of featureResults) {
302
562
  if (featureResult.requiresWrite && featureResult.updatedContent) {
303
- pendingWrites.push({ path: featureResult.featurePath, content: featureResult.updatedContent });
563
+ // Only append Change Log when actually transitioning to applied
564
+ // (skip when --no-auto-transition with pending agent ops)
565
+ const content = skipTransition
566
+ ? featureResult.updatedContent
567
+ : appendChangeLogEntry(featureResult.updatedContent, changeRecord, deltaPlan.byTargetNote.get(featureResult.featureId) ?? []);
568
+ pendingWrites.push({ path: featureResult.featurePath, content });
304
569
  }
305
570
  }
306
571
  // Status transition content — skip if noAutoTransition with pending ops
@@ -311,7 +576,10 @@ export function applyChange(options, index, deps) {
311
576
  pendingWrites.push({ path: resolvedChangePath, content: updatedChangeContent });
312
577
  // Use unique temp suffix to avoid collisions
313
578
  const tmpSuffix = `.ows-tmp-${Date.now()}`;
314
- // Phase 2a: Write all to temp files
579
+ // Phase 2a: Write all to temp files, preserving the original file
580
+ // mode so permissions survive the write (a 0444 read-only vault
581
+ // stays 0444, preserving the user's intent). When copyFileMode is
582
+ // not injected, we fall through to whatever umask produces.
315
583
  const tmpPaths = [];
316
584
  let writeFailed = false;
317
585
  for (const { path, content } of pendingWrites) {
@@ -320,6 +588,16 @@ export function applyChange(options, index, deps) {
320
588
  try {
321
589
  deps.writeFile(tmpPath, content);
322
590
  tmpPaths.push(tmpPath);
591
+ if (deps.copyFileMode) {
592
+ try {
593
+ deps.copyFileMode(path, tmpPath);
594
+ }
595
+ catch {
596
+ // Mode preservation is best-effort — on platforms where
597
+ // chmod is a no-op (Windows without admin) or the source
598
+ // file was already replaced, just use default permissions.
599
+ }
600
+ }
323
601
  }
324
602
  catch (err) {
325
603
  errors.push(`Failed to write temp file ${tmpPath}: ${err.message}`);
@@ -352,13 +630,15 @@ export function applyChange(options, index, deps) {
352
630
  }
353
631
  }
354
632
  if (backupFailed) {
355
- // Restore any backups already made
633
+ // Restore any backups already made. Surface any restore failure
634
+ // so the user knows a backup file is orphaned and the original missing.
356
635
  for (const { original, backup } of backedUpPaths) {
357
636
  try {
358
637
  deps.moveFile(backup, original);
359
638
  }
360
- catch {
361
- // Best-effort restore swallow errors
639
+ catch (restoreErr) {
640
+ errors.push(`CRITICAL: Failed to restore backup "${backup}" -> "${original}": ${restoreErr.message}. ` +
641
+ `Manual recovery required: move the backup file back to the original path.`);
362
642
  }
363
643
  }
364
644
  // Cleanup temp files
@@ -394,13 +674,14 @@ export function applyChange(options, index, deps) {
394
674
  // Best-effort — swallow errors
395
675
  }
396
676
  }
397
- // Restore all backups
677
+ // Restore all backups. Surface restore failures so users can recover manually.
398
678
  for (const { original, backup } of backedUpPaths) {
399
679
  try {
400
680
  deps.moveFile(backup, original);
401
681
  }
402
- catch {
403
- // Best-effort restore swallow errors
682
+ catch (restoreErr) {
683
+ errors.push(`CRITICAL: Failed to restore backup "${backup}" -> "${original}": ${restoreErr.message}. ` +
684
+ `Manual recovery required.`);
404
685
  }
405
686
  }
406
687
  // Cleanup remaining temp files (the ones not yet renamed)
@@ -410,17 +691,40 @@ export function applyChange(options, index, deps) {
410
691
  cleanupTempFiles(remainingTmps, deps);
411
692
  }
412
693
  else {
413
- // All writes succeeded delete backups
694
+ // All renames succeeded. Write a commit marker so that
695
+ // crash recovery knows this is a complete apply — forward
696
+ // recovery (delete backups) instead of rollback (restore
697
+ // backups). The marker is deleted after backup cleanup.
698
+ const commitMarkerPath = join(options.vaultRoot, 'wiki', `.ows-commit-${Date.now()}`);
699
+ try {
700
+ deps.writeFile(commitMarkerPath, `${options.changeId}|${Date.now()}`);
701
+ }
702
+ catch {
703
+ // Commit marker write failure is non-fatal — without it,
704
+ // a crash here would trigger rollback recovery on next run,
705
+ // which is safe (just means the apply would be re-run).
706
+ }
707
+ // All writes succeeded — delete backups. Surface any
708
+ // cleanup failure as a warning.
414
709
  for (const { backup } of backedUpPaths) {
415
710
  try {
416
711
  if (deps.deleteFile) {
417
712
  deps.deleteFile(backup);
418
713
  }
419
714
  }
420
- catch {
421
- // Best-effort cleanup swallow errors
715
+ catch (err) {
716
+ warnings.push(`Orphan backup: apply succeeded but could not delete ${backup} (${err.message}). ` +
717
+ 'Remove the file manually or check wiki/ permissions.');
422
718
  }
423
719
  }
720
+ // Remove commit marker now that backups are cleaned up
721
+ try {
722
+ if (deps.deleteFile)
723
+ deps.deleteFile(commitMarkerPath);
724
+ }
725
+ catch {
726
+ // Non-fatal — marker will be cleaned up on next apply.
727
+ }
424
728
  for (const { path } of pendingWrites) {
425
729
  if (path !== resolvedChangePath) {
426
730
  modifiedFiles.push(path);
@@ -435,6 +739,17 @@ export function applyChange(options, index, deps) {
435
739
  releaseLock(options.vaultRoot, deps);
436
740
  }
437
741
  }
742
+ // Dry-run warning: when dry-run reports success but pendingAgentOps
743
+ // remain, the caller must still fill markers and run a real apply
744
+ // later. Without this note, a CI that runs `--dry-run` then trusts
745
+ // `success: true` would skip the real apply and leave the change
746
+ // stuck in in_progress. Surface it explicitly in warnings so both
747
+ // JSON and human consumers see it.
748
+ if (options.dryRun && pendingAgentOps.length > 0 && errors.length === 0) {
749
+ warnings.push(`Dry-run success is PROVISIONAL: ${pendingAgentOps.length} agent-driven operation(s) ` +
750
+ `still need human/LLM authoring (MODIFIED/ADDED requirement markers). ` +
751
+ 'Real apply cannot skip this step — fill the markers then run `ows apply` without --dry-run.');
752
+ }
438
753
  return {
439
754
  changeId: options.changeId,
440
755
  changeName: changeRecord.title,
@@ -496,9 +811,9 @@ export function archiveChange(options, index, deps) {
496
811
  function preValidateEntry(entry, requirements) {
497
812
  switch (entry.op) {
498
813
  case 'ADDED':
499
- return requirements.has(entry.targetName)
500
- ? `Requirement "${entry.targetName}" already exists (ADDED requires non-existence)`
501
- : null;
814
+ // If requirement already exists (e.g., from a prior --no-auto-transition run),
815
+ // treat as no-op rather than error — the skeleton was already placed.
816
+ return null;
502
817
  case 'MODIFIED':
503
818
  return !requirements.has(entry.targetName)
504
819
  ? `Requirement "${entry.targetName}" not found (MODIFIED requires existence)`
@@ -531,6 +846,130 @@ function cleanupTempFiles(tmpPaths, deps) {
531
846
  }
532
847
  }
533
848
  }
849
+ /**
850
+ * Maximum number of rows kept in a Feature note's Change Log table.
851
+ * Older entries are dropped (still preserved in archived Change notes).
852
+ */
853
+ const CHANGE_LOG_MAX_ROWS = 50;
854
+ /**
855
+ * Trim a Change Log section so only the most recent CHANGE_LOG_MAX_ROWS remain.
856
+ * If rows were dropped, adds a comment marker indicating the trim.
857
+ */
858
+ function trimChangeLogSection(featureContent) {
859
+ const changeLogMatch = featureContent.match(/^## Change Log\s*$/m);
860
+ if (!changeLogMatch)
861
+ return featureContent;
862
+ const sectionStart = changeLogMatch.index + changeLogMatch[0].length;
863
+ const nextHeadingMatch = featureContent.slice(sectionStart).match(/^## /m);
864
+ const sectionEnd = nextHeadingMatch
865
+ ? sectionStart + nextHeadingMatch.index
866
+ : featureContent.length;
867
+ const sectionContent = featureContent.slice(sectionStart, sectionEnd);
868
+ // Find table rows (lines starting with `| ` that are not header/separator)
869
+ const lines = sectionContent.split('\n');
870
+ const rowLines = [];
871
+ let headerDone = false;
872
+ for (let i = 0; i < lines.length; i++) {
873
+ const line = lines[i];
874
+ if (line.match(/^\|[-\s|]+\|\s*$/)) {
875
+ headerDone = true;
876
+ continue;
877
+ }
878
+ if (headerDone && line.startsWith('| ') && line.trim().endsWith('|')) {
879
+ rowLines.push({ line, index: i });
880
+ }
881
+ }
882
+ if (rowLines.length <= CHANGE_LOG_MAX_ROWS)
883
+ return featureContent;
884
+ // Keep the first CHANGE_LOG_MAX_ROWS rows (newest, since we prepend)
885
+ const keepRows = rowLines.slice(0, CHANGE_LOG_MAX_ROWS);
886
+ const dropCount = rowLines.length - CHANGE_LOG_MAX_ROWS;
887
+ const lastKeptIdx = keepRows[keepRows.length - 1].index;
888
+ // Build the trimmed section: up to last kept row + marker + empty line
889
+ const keptSectionLines = lines.slice(0, lastKeptIdx + 1);
890
+ keptSectionLines.push('');
891
+ keptSectionLines.push(`<!-- ${dropCount} older entries trimmed; see wiki/99-archive/ for full history -->`);
892
+ keptSectionLines.push('');
893
+ const newSectionContent = keptSectionLines.join('\n');
894
+ return featureContent.slice(0, sectionStart) + newSectionContent + featureContent.slice(sectionEnd);
895
+ }
896
+ /**
897
+ * Append a Change Log entry row to a Feature note's ## Change Log section.
898
+ * If the section or table header doesn't exist, creates them.
899
+ * Trims older entries when the table grows beyond CHANGE_LOG_MAX_ROWS.
900
+ */
901
+ function appendChangeLogEntry(featureContent, changeRecord, deltaEntries) {
902
+ const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
903
+ const changeLink = `[[${changeRecord.title}]]`;
904
+ const summary = deltaEntries
905
+ .map((e) => {
906
+ if (e.op === 'RENAMED' && e.newName) {
907
+ return `${e.op} [${e.targetType}] ${e.targetName} → ${e.newName}`;
908
+ }
909
+ return `${e.op} [${e.targetType}] ${e.targetName}`;
910
+ })
911
+ .join(', ');
912
+ const newRow = `| ${date} | ${changeLink} | ${summary} |`;
913
+ // Idempotency: if ANY row containing this change's wikilink already
914
+ // exists in the Change Log section, skip the append. Previously the
915
+ // check required both date AND change link to match — which meant a
916
+ // revert on day 1 followed by re-apply on day 2 would insert a
917
+ // duplicate entry. Matching on the change link alone is safe because
918
+ // each Change has a unique title/id, and a single apply should
919
+ // produce at most one log row per Feature.
920
+ const existingChangeLogMatch = featureContent.match(/^## Change Log\s*$/m);
921
+ if (existingChangeLogMatch) {
922
+ const exStart = existingChangeLogMatch.index + existingChangeLogMatch[0].length;
923
+ const exNextHeading = featureContent.slice(exStart).match(/^## /m);
924
+ const exEnd = exNextHeading ? exStart + exNextHeading.index : featureContent.length;
925
+ const exSection = featureContent.slice(exStart, exEnd);
926
+ // Escape the change link for use in a literal regex test
927
+ const escapedLink = changeLink.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
928
+ const dupRegex = new RegExp(`${escapedLink}`, 'm');
929
+ if (dupRegex.test(exSection)) {
930
+ return featureContent; // Change already logged — skip append.
931
+ }
932
+ }
933
+ let updated;
934
+ // Check if ## Change Log section exists
935
+ const changeLogMatch = featureContent.match(/^## Change Log\s*$/m);
936
+ if (changeLogMatch) {
937
+ // Extract the Change Log section content (bounded by next ## heading or EOF)
938
+ const sectionStart = changeLogMatch.index + changeLogMatch[0].length;
939
+ const nextHeadingMatch = featureContent.slice(sectionStart).match(/^## /m);
940
+ const sectionEnd = nextHeadingMatch
941
+ ? sectionStart + nextHeadingMatch.index
942
+ : featureContent.length;
943
+ const sectionContent = featureContent.slice(sectionStart, sectionEnd);
944
+ // Look for table separator ONLY within this section (handle EOF without trailing newline)
945
+ const sepMatch = sectionContent.match(/(\|[-\s|]+\|)\s*(?:\n|$)/);
946
+ if (sepMatch) {
947
+ // Insert after the separator row within the section
948
+ const sepAbsPos = sectionStart + sepMatch.index + sepMatch[0].length;
949
+ updated = featureContent.slice(0, sepAbsPos) + newRow + '\n' + featureContent.slice(sepAbsPos);
950
+ }
951
+ else {
952
+ // Section exists but no table — insert table + row after heading
953
+ const tableBlock = `\n\n| Date | Change | Summary |\n|------|--------|---------|\n${newRow}\n`;
954
+ updated = featureContent.slice(0, sectionStart) + tableBlock + featureContent.slice(sectionStart);
955
+ }
956
+ }
957
+ else {
958
+ // No Change Log section — insert before ## Related Notes (or at end)
959
+ const relatedMatch = featureContent.match(/^## Related Notes\s*$/m);
960
+ const section = `## Change Log\n\n| Date | Change | Summary |\n|------|--------|---------|\n${newRow}\n\n`;
961
+ if (relatedMatch) {
962
+ updated = featureContent.slice(0, relatedMatch.index) + section + featureContent.slice(relatedMatch.index);
963
+ }
964
+ else {
965
+ // Ensure trailing newline before appending
966
+ const base = featureContent.endsWith('\n') ? featureContent : featureContent + '\n';
967
+ updated = base + '\n' + section;
968
+ }
969
+ }
970
+ // Trim older entries to keep the table bounded
971
+ return trimChangeLogSection(updated);
972
+ }
534
973
  function makeResult(options, changeRecord, overrides) {
535
974
  return {
536
975
  changeId: options.changeId,