open-wiki-spec 0.2.4 → 0.3.1

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 (252) hide show
  1. package/CHANGELOG.md +59 -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 +99 -4
  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 +5 -1
  82. package/dist/core/embedding/embedder.d.ts.map +1 -1
  83. package/dist/core/embedding/embedder.js +78 -7
  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 +52 -5
  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/spec-converter.js +5 -0
  106. package/dist/core/migrate/spec-converter.js.map +1 -1
  107. package/dist/core/migrate/types.d.ts +9 -0
  108. package/dist/core/migrate/types.d.ts.map +1 -1
  109. package/dist/core/parser/frontmatter-parser.d.ts +0 -6
  110. package/dist/core/parser/frontmatter-parser.d.ts.map +1 -1
  111. package/dist/core/parser/frontmatter-parser.js +59 -2
  112. package/dist/core/parser/frontmatter-parser.js.map +1 -1
  113. package/dist/core/parser/requirement-parser.js +4 -0
  114. package/dist/core/parser/requirement-parser.js.map +1 -1
  115. package/dist/core/parser/task-parser.d.ts.map +1 -1
  116. package/dist/core/parser/task-parser.js +19 -0
  117. package/dist/core/parser/task-parser.js.map +1 -1
  118. package/dist/core/parser/wikilink-parser.d.ts.map +1 -1
  119. package/dist/core/parser/wikilink-parser.js +21 -5
  120. package/dist/core/parser/wikilink-parser.js.map +1 -1
  121. package/dist/core/retrieval/classify.d.ts +11 -4
  122. package/dist/core/retrieval/classify.d.ts.map +1 -1
  123. package/dist/core/retrieval/classify.js +76 -14
  124. package/dist/core/retrieval/classify.js.map +1 -1
  125. package/dist/core/retrieval/lexical.d.ts.map +1 -1
  126. package/dist/core/retrieval/lexical.js +31 -12
  127. package/dist/core/retrieval/lexical.js.map +1 -1
  128. package/dist/core/retrieval/retrieve.d.ts.map +1 -1
  129. package/dist/core/retrieval/retrieve.js +45 -5
  130. package/dist/core/retrieval/retrieve.js.map +1 -1
  131. package/dist/core/retrieval/scoring.d.ts.map +1 -1
  132. package/dist/core/retrieval/scoring.js +37 -12
  133. package/dist/core/retrieval/scoring.js.map +1 -1
  134. package/dist/core/schema/change.schema.d.ts.map +1 -1
  135. package/dist/core/schema/change.schema.js +6 -1
  136. package/dist/core/schema/change.schema.js.map +1 -1
  137. package/dist/core/schema/feature.schema.d.ts +1 -1
  138. package/dist/core/schema/feature.schema.d.ts.map +1 -1
  139. package/dist/core/schema/feature.schema.js +1 -0
  140. package/dist/core/schema/feature.schema.js.map +1 -1
  141. package/dist/core/schema/frontmatter.d.ts +15 -0
  142. package/dist/core/schema/frontmatter.d.ts.map +1 -1
  143. package/dist/core/schema/index.d.ts +15 -0
  144. package/dist/core/schema/index.d.ts.map +1 -1
  145. package/dist/core/schema/query.schema.d.ts +15 -0
  146. package/dist/core/schema/query.schema.d.ts.map +1 -1
  147. package/dist/core/schema/query.schema.js +5 -0
  148. package/dist/core/schema/query.schema.js.map +1 -1
  149. package/dist/core/schema/templates.d.ts +0 -4
  150. package/dist/core/schema/templates.d.ts.map +1 -1
  151. package/dist/core/schema/templates.js +25 -7
  152. package/dist/core/schema/templates.js.map +1 -1
  153. package/dist/core/sequencing/analyze.d.ts.map +1 -1
  154. package/dist/core/sequencing/analyze.js +3 -1
  155. package/dist/core/sequencing/analyze.js.map +1 -1
  156. package/dist/core/sequencing/priority-queue.d.ts +9 -3
  157. package/dist/core/sequencing/priority-queue.d.ts.map +1 -1
  158. package/dist/core/sequencing/priority-queue.js +10 -4
  159. package/dist/core/sequencing/priority-queue.js.map +1 -1
  160. package/dist/core/sequencing/stale-detector.d.ts.map +1 -1
  161. package/dist/core/sequencing/stale-detector.js +5 -0
  162. package/dist/core/sequencing/stale-detector.js.map +1 -1
  163. package/dist/core/workflow/apply/apply.d.ts.map +1 -1
  164. package/dist/core/workflow/apply/apply.js +429 -25
  165. package/dist/core/workflow/apply/apply.js.map +1 -1
  166. package/dist/core/workflow/apply/delta-parser.d.ts.map +1 -1
  167. package/dist/core/workflow/apply/delta-parser.js +43 -9
  168. package/dist/core/workflow/apply/delta-parser.js.map +1 -1
  169. package/dist/core/workflow/apply/feature-updater.d.ts.map +1 -1
  170. package/dist/core/workflow/apply/feature-updater.js +27 -11
  171. package/dist/core/workflow/apply/feature-updater.js.map +1 -1
  172. package/dist/core/workflow/apply/types.d.ts +9 -0
  173. package/dist/core/workflow/apply/types.d.ts.map +1 -1
  174. package/dist/core/workflow/continue/continue.d.ts +18 -0
  175. package/dist/core/workflow/continue/continue.d.ts.map +1 -1
  176. package/dist/core/workflow/continue/continue.js +134 -15
  177. package/dist/core/workflow/continue/continue.js.map +1 -1
  178. package/dist/core/workflow/continue/next-action.d.ts.map +1 -1
  179. package/dist/core/workflow/continue/next-action.js +16 -3
  180. package/dist/core/workflow/continue/next-action.js.map +1 -1
  181. package/dist/core/workflow/continue/types.d.ts +12 -0
  182. package/dist/core/workflow/continue/types.d.ts.map +1 -1
  183. package/dist/core/workflow/propose/note-creator.d.ts +2 -2
  184. package/dist/core/workflow/propose/note-creator.d.ts.map +1 -1
  185. package/dist/core/workflow/propose/note-creator.js +89 -12
  186. package/dist/core/workflow/propose/note-creator.js.map +1 -1
  187. package/dist/core/workflow/propose/propose.d.ts.map +1 -1
  188. package/dist/core/workflow/propose/propose.js +189 -47
  189. package/dist/core/workflow/propose/propose.js.map +1 -1
  190. package/dist/core/workflow/propose/query-normalizer.d.ts.map +1 -1
  191. package/dist/core/workflow/propose/query-normalizer.js +37 -5
  192. package/dist/core/workflow/propose/query-normalizer.js.map +1 -1
  193. package/dist/core/workflow/propose/types.d.ts +15 -0
  194. package/dist/core/workflow/propose/types.d.ts.map +1 -1
  195. package/dist/core/workflow/propose/types.js +6 -1
  196. package/dist/core/workflow/propose/types.js.map +1 -1
  197. package/dist/core/workflow/query/query-note-creator.js +4 -0
  198. package/dist/core/workflow/query/query-note-creator.js.map +1 -1
  199. package/dist/core/workflow/verify/coherence.d.ts +10 -0
  200. package/dist/core/workflow/verify/coherence.d.ts.map +1 -1
  201. package/dist/core/workflow/verify/coherence.js +91 -4
  202. package/dist/core/workflow/verify/coherence.js.map +1 -1
  203. package/dist/core/workflow/verify/completeness.d.ts +1 -1
  204. package/dist/core/workflow/verify/completeness.d.ts.map +1 -1
  205. package/dist/core/workflow/verify/completeness.js +53 -10
  206. package/dist/core/workflow/verify/completeness.js.map +1 -1
  207. package/dist/core/workflow/verify/correctness.d.ts +1 -1
  208. package/dist/core/workflow/verify/correctness.d.ts.map +1 -1
  209. package/dist/core/workflow/verify/correctness.js +95 -14
  210. package/dist/core/workflow/verify/correctness.js.map +1 -1
  211. package/dist/core/workflow/verify/vault-integrity.d.ts +11 -0
  212. package/dist/core/workflow/verify/vault-integrity.d.ts.map +1 -1
  213. package/dist/core/workflow/verify/vault-integrity.js +80 -3
  214. package/dist/core/workflow/verify/vault-integrity.js.map +1 -1
  215. package/dist/core/workflow/verify/verify.d.ts +0 -5
  216. package/dist/core/workflow/verify/verify.d.ts.map +1 -1
  217. package/dist/core/workflow/verify/verify.js +54 -3
  218. package/dist/core/workflow/verify/verify.js.map +1 -1
  219. package/dist/index.d.ts +13 -0
  220. package/dist/index.d.ts.map +1 -1
  221. package/dist/index.js +11 -0
  222. package/dist/index.js.map +1 -1
  223. package/dist/types/cli-contracts.d.ts +78 -0
  224. package/dist/types/cli-contracts.d.ts.map +1 -0
  225. package/dist/types/cli-contracts.js +2 -0
  226. package/dist/types/cli-contracts.js.map +1 -0
  227. package/dist/types/error-codes.d.ts +25 -0
  228. package/dist/types/error-codes.d.ts.map +1 -0
  229. package/dist/types/error-codes.js +27 -0
  230. package/dist/types/error-codes.js.map +1 -0
  231. package/dist/types/frontmatter.d.ts +9 -2
  232. package/dist/types/frontmatter.d.ts.map +1 -1
  233. package/dist/types/next-action.d.ts +21 -0
  234. package/dist/types/next-action.d.ts.map +1 -1
  235. package/dist/types/retrieval.d.ts +17 -0
  236. package/dist/types/retrieval.d.ts.map +1 -1
  237. package/dist/types/verify.d.ts +58 -1
  238. package/dist/types/verify.d.ts.map +1 -1
  239. package/dist/types/verify.js +58 -1
  240. package/dist/types/verify.js.map +1 -1
  241. package/dist/utils/conventions.d.ts +9 -0
  242. package/dist/utils/conventions.d.ts.map +1 -0
  243. package/dist/utils/conventions.js +22 -0
  244. package/dist/utils/conventions.js.map +1 -0
  245. package/dist/utils/id-generator.d.ts.map +1 -1
  246. package/dist/utils/id-generator.js +8 -0
  247. package/dist/utils/id-generator.js.map +1 -1
  248. package/dist/utils/normalize.d.ts +7 -4
  249. package/dist/utils/normalize.d.ts.map +1 -1
  250. package/dist/utils/normalize.js +43 -6
  251. package/dist/utils/normalize.js.map +1 -1
  252. package/package.json +3 -2
@@ -21,6 +21,79 @@ function defaultExclusiveCreateFile(filePath, content) {
21
21
  fs.closeSync(fd);
22
22
  }
23
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-')) {
68
+ try {
69
+ if (deps.deleteFile)
70
+ deps.deleteFile(absPath);
71
+ }
72
+ catch (err) {
73
+ failures.push(`failed to remove stale tmp file ${file}: ${err.message}`);
74
+ }
75
+ }
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}`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ catch {
93
+ // wiki dir might not exist yet — ignore
94
+ }
95
+ return failures;
96
+ }
24
97
  function acquireLock(vaultRoot, deps) {
25
98
  const lockPath = join(vaultRoot, 'wiki', LOCK_FILENAME);
26
99
  const lockContent = JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() });
@@ -128,7 +201,13 @@ export function applyChange(options, index, deps) {
128
201
  throw new Error(`Cannot apply change with status "${changeRecord.status}". ` +
129
202
  'Expected "in_progress". Use \'continue\' to advance through the status lifecycle first.');
130
203
  }
131
- // 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
+ }
132
211
  const uncheckedTasks = parsed.tasks.filter((t) => !t.done);
133
212
  if (uncheckedTasks.length > 0) {
134
213
  throw new Error(`Cannot apply: ${uncheckedTasks.length} unchecked task(s) remaining. ` +
@@ -136,11 +215,18 @@ export function applyChange(options, index, deps) {
136
215
  }
137
216
  // 2. Parse Delta Summary
138
217
  const resolveWikilink = (target) => {
139
- // Simple lookup by title
218
+ // Lookup by id first (highest priority), then title, then alias
140
219
  for (const record of index.records.values()) {
141
- 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))
142
229
  return record.id;
143
- }
144
230
  }
145
231
  return undefined;
146
232
  };
@@ -153,6 +239,57 @@ export function applyChange(options, index, deps) {
153
239
  warnings,
154
240
  });
155
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
+ }
156
293
  // Validate delta conflicts
157
294
  const conflictErrors = validateDeltaConflicts(deltaPlan);
158
295
  if (conflictErrors.length > 0) {
@@ -163,10 +300,22 @@ export function applyChange(options, index, deps) {
163
300
  for (const [noteKey] of deltaPlan.byTargetNote) {
164
301
  const noteRecord = index.records.get(noteKey);
165
302
  if (!noteRecord) {
166
- 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.`);
167
317
  continue;
168
318
  }
169
- const noteParsed = deps.parseNote(resolve(options.vaultRoot, noteRecord.path));
170
319
  const reqMap = new Map();
171
320
  for (const req of noteParsed.requirements) {
172
321
  reqMap.set(req.name, req);
@@ -190,6 +339,19 @@ export function applyChange(options, index, deps) {
190
339
  errors: staleReport.staleEntries.map((s) => `STALE: ${s.entry.op} "${s.entry.targetName}" - ${s.reason}`),
191
340
  });
192
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
+ }
193
355
  // === PHASE 1: Validate and compute ===
194
356
  const featureResults = [];
195
357
  const allPostValidations = [];
@@ -231,10 +393,40 @@ export function applyChange(options, index, deps) {
231
393
  const agentEntries = reqEntries.filter((e) => AGENT_DRIVEN_OPS.has(e.op));
232
394
  const allReqEntries = [...mechEntries, ...agentEntries];
233
395
  const resolvedNotePath = resolve(options.vaultRoot, noteRecord.path);
234
- // Read Feature file content for programmatic editing
235
- 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
+ }
236
417
  const result = applyDeltaToFeature(noteKey, resolvedNotePath, reqMap, allReqEntries, featureContent, options.changeId);
237
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
+ }
238
430
  // Collect agent-driven ops (MODIFIED/ADDED need agent to fill content)
239
431
  for (const entry of agentEntries) {
240
432
  pendingAgentOps.push({
@@ -307,10 +499,43 @@ export function applyChange(options, index, deps) {
307
499
  // Uses atomic temp-file pattern: write to .ows-tmp first, then rename all at once.
308
500
  let statusTransitioned = false;
309
501
  const modifiedFiles = [];
310
- // When noAutoTransition is set and there are pending agent ops, skip auto-transition.
311
- // The agent must fill markers first, then run apply again (or verifyApply).
312
- 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
+ }
313
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
+ }
314
539
  // Acquire vault-level lock
315
540
  if (!acquireLock(options.vaultRoot, deps)) {
316
541
  errors.push('Cannot apply: another apply operation is in progress (wiki/.ows-lock exists)');
@@ -335,7 +560,12 @@ export function applyChange(options, index, deps) {
335
560
  const pendingWrites = [];
336
561
  for (const featureResult of featureResults) {
337
562
  if (featureResult.requiresWrite && featureResult.updatedContent) {
338
- 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 });
339
569
  }
340
570
  }
341
571
  // Status transition content — skip if noAutoTransition with pending ops
@@ -346,7 +576,10 @@ export function applyChange(options, index, deps) {
346
576
  pendingWrites.push({ path: resolvedChangePath, content: updatedChangeContent });
347
577
  // Use unique temp suffix to avoid collisions
348
578
  const tmpSuffix = `.ows-tmp-${Date.now()}`;
349
- // 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.
350
583
  const tmpPaths = [];
351
584
  let writeFailed = false;
352
585
  for (const { path, content } of pendingWrites) {
@@ -355,6 +588,16 @@ export function applyChange(options, index, deps) {
355
588
  try {
356
589
  deps.writeFile(tmpPath, content);
357
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
+ }
358
601
  }
359
602
  catch (err) {
360
603
  errors.push(`Failed to write temp file ${tmpPath}: ${err.message}`);
@@ -387,13 +630,15 @@ export function applyChange(options, index, deps) {
387
630
  }
388
631
  }
389
632
  if (backupFailed) {
390
- // 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.
391
635
  for (const { original, backup } of backedUpPaths) {
392
636
  try {
393
637
  deps.moveFile(backup, original);
394
638
  }
395
- catch {
396
- // 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.`);
397
642
  }
398
643
  }
399
644
  // Cleanup temp files
@@ -429,13 +674,14 @@ export function applyChange(options, index, deps) {
429
674
  // Best-effort — swallow errors
430
675
  }
431
676
  }
432
- // Restore all backups
677
+ // Restore all backups. Surface restore failures so users can recover manually.
433
678
  for (const { original, backup } of backedUpPaths) {
434
679
  try {
435
680
  deps.moveFile(backup, original);
436
681
  }
437
- catch {
438
- // Best-effort restore swallow errors
682
+ catch (restoreErr) {
683
+ errors.push(`CRITICAL: Failed to restore backup "${backup}" -> "${original}": ${restoreErr.message}. ` +
684
+ `Manual recovery required.`);
439
685
  }
440
686
  }
441
687
  // Cleanup remaining temp files (the ones not yet renamed)
@@ -445,17 +691,40 @@ export function applyChange(options, index, deps) {
445
691
  cleanupTempFiles(remainingTmps, deps);
446
692
  }
447
693
  else {
448
- // 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.
449
709
  for (const { backup } of backedUpPaths) {
450
710
  try {
451
711
  if (deps.deleteFile) {
452
712
  deps.deleteFile(backup);
453
713
  }
454
714
  }
455
- catch {
456
- // 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.');
457
718
  }
458
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
+ }
459
728
  for (const { path } of pendingWrites) {
460
729
  if (path !== resolvedChangePath) {
461
730
  modifiedFiles.push(path);
@@ -470,6 +739,17 @@ export function applyChange(options, index, deps) {
470
739
  releaseLock(options.vaultRoot, deps);
471
740
  }
472
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
+ }
473
753
  return {
474
754
  changeId: options.changeId,
475
755
  changeName: changeRecord.title,
@@ -531,9 +811,9 @@ export function archiveChange(options, index, deps) {
531
811
  function preValidateEntry(entry, requirements) {
532
812
  switch (entry.op) {
533
813
  case 'ADDED':
534
- return requirements.has(entry.targetName)
535
- ? `Requirement "${entry.targetName}" already exists (ADDED requires non-existence)`
536
- : 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;
537
817
  case 'MODIFIED':
538
818
  return !requirements.has(entry.targetName)
539
819
  ? `Requirement "${entry.targetName}" not found (MODIFIED requires existence)`
@@ -566,6 +846,130 @@ function cleanupTempFiles(tmpPaths, deps) {
566
846
  }
567
847
  }
568
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
+ }
569
973
  function makeResult(options, changeRecord, overrides) {
570
974
  return {
571
975
  changeId: options.changeId,