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.
- package/CHANGELOG.md +54 -0
- package/README.md +50 -3
- package/dist/cli/commands/apply.d.ts.map +1 -1
- package/dist/cli/commands/apply.js +96 -4
- package/dist/cli/commands/apply.js.map +1 -1
- package/dist/cli/commands/archive.d.ts.map +1 -1
- package/dist/cli/commands/archive.js +19 -5
- package/dist/cli/commands/archive.js.map +1 -1
- package/dist/cli/commands/bulk-archive.d.ts +15 -0
- package/dist/cli/commands/bulk-archive.d.ts.map +1 -0
- package/dist/cli/commands/bulk-archive.js +97 -0
- package/dist/cli/commands/bulk-archive.js.map +1 -0
- package/dist/cli/commands/continue.d.ts.map +1 -1
- package/dist/cli/commands/continue.js +15 -2
- package/dist/cli/commands/continue.js.map +1 -1
- package/dist/cli/commands/error-handler.d.ts +1 -5
- package/dist/cli/commands/error-handler.d.ts.map +1 -1
- package/dist/cli/commands/error-handler.js +48 -3
- package/dist/cli/commands/error-handler.js.map +1 -1
- package/dist/cli/commands/init.d.ts +0 -3
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +22 -4
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/list.d.ts +2 -17
- package/dist/cli/commands/list.d.ts.map +1 -1
- package/dist/cli/commands/list.js +15 -3
- package/dist/cli/commands/list.js.map +1 -1
- package/dist/cli/commands/migrate.d.ts.map +1 -1
- package/dist/cli/commands/migrate.js +6 -9
- package/dist/cli/commands/migrate.js.map +1 -1
- package/dist/cli/commands/propose.d.ts.map +1 -1
- package/dist/cli/commands/propose.js +46 -6
- package/dist/cli/commands/propose.js.map +1 -1
- package/dist/cli/commands/query.d.ts.map +1 -1
- package/dist/cli/commands/query.js +101 -5
- package/dist/cli/commands/query.js.map +1 -1
- package/dist/cli/commands/retrieve.d.ts +7 -0
- package/dist/cli/commands/retrieve.d.ts.map +1 -0
- package/dist/cli/commands/retrieve.js +70 -0
- package/dist/cli/commands/retrieve.js.map +1 -0
- package/dist/cli/commands/revert.d.ts +18 -0
- package/dist/cli/commands/revert.d.ts.map +1 -0
- package/dist/cli/commands/revert.js +109 -0
- package/dist/cli/commands/revert.js.map +1 -0
- package/dist/cli/commands/status.d.ts +2 -19
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +42 -3
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/verify.d.ts.map +1 -1
- package/dist/cli/commands/verify.js +12 -1
- package/dist/cli/commands/verify.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +68 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/init-engine.d.ts.map +1 -1
- package/dist/cli/init/init-engine.js +19 -6
- package/dist/cli/init/init-engine.js.map +1 -1
- package/dist/cli/init/meta-files.d.ts +2 -1
- package/dist/cli/init/meta-files.d.ts.map +1 -1
- package/dist/cli/init/meta-files.js +167 -8
- package/dist/cli/init/meta-files.js.map +1 -1
- package/dist/cli/init/skill-generator.d.ts +3 -0
- package/dist/cli/init/skill-generator.d.ts.map +1 -1
- package/dist/cli/init/skill-generator.js +184 -3
- package/dist/cli/init/skill-generator.js.map +1 -1
- package/dist/cli/json-envelope.d.ts +15 -0
- package/dist/cli/json-envelope.d.ts.map +1 -0
- package/dist/cli/json-envelope.js +86 -0
- package/dist/cli/json-envelope.js.map +1 -0
- package/dist/cli/schema-check.d.ts +27 -0
- package/dist/cli/schema-check.d.ts.map +1 -0
- package/dist/cli/schema-check.js +33 -0
- package/dist/cli/schema-check.js.map +1 -0
- package/dist/cli/vault-discovery.d.ts.map +1 -1
- package/dist/cli/vault-discovery.js +16 -2
- package/dist/cli/vault-discovery.js.map +1 -1
- package/dist/core/embedding/cache.d.ts +12 -3
- package/dist/core/embedding/cache.d.ts.map +1 -1
- package/dist/core/embedding/cache.js +42 -5
- package/dist/core/embedding/cache.js.map +1 -1
- package/dist/core/embedding/embedder.d.ts +18 -0
- package/dist/core/embedding/embedder.d.ts.map +1 -1
- package/dist/core/embedding/embedder.js +87 -10
- package/dist/core/embedding/embedder.js.map +1 -1
- package/dist/core/index/build.d.ts.map +1 -1
- package/dist/core/index/build.js +54 -6
- package/dist/core/index/build.js.map +1 -1
- package/dist/core/index/resolve.d.ts.map +1 -1
- package/dist/core/index/resolve.js +27 -12
- package/dist/core/index/resolve.js.map +1 -1
- package/dist/core/index/scan.d.ts +10 -1
- package/dist/core/index/scan.d.ts.map +1 -1
- package/dist/core/index/scan.js +59 -10
- package/dist/core/index/scan.js.map +1 -1
- package/dist/core/index/schema-version.d.ts +28 -0
- package/dist/core/index/schema-version.d.ts.map +1 -1
- package/dist/core/index/schema-version.js +47 -0
- package/dist/core/index/schema-version.js.map +1 -1
- package/dist/core/migrate/change-converter.d.ts.map +1 -1
- package/dist/core/migrate/change-converter.js +59 -11
- package/dist/core/migrate/change-converter.js.map +1 -1
- package/dist/core/migrate/migrate.d.ts.map +1 -1
- package/dist/core/migrate/migrate.js +115 -1
- package/dist/core/migrate/migrate.js.map +1 -1
- package/dist/core/migrate/sanitize.d.ts +17 -0
- package/dist/core/migrate/sanitize.d.ts.map +1 -0
- package/dist/core/migrate/sanitize.js +33 -0
- package/dist/core/migrate/sanitize.js.map +1 -0
- package/dist/core/migrate/spec-converter.d.ts.map +1 -1
- package/dist/core/migrate/spec-converter.js +9 -3
- package/dist/core/migrate/spec-converter.js.map +1 -1
- package/dist/core/migrate/types.d.ts +9 -0
- package/dist/core/migrate/types.d.ts.map +1 -1
- package/dist/core/parser/frontmatter-parser.d.ts +0 -6
- package/dist/core/parser/frontmatter-parser.d.ts.map +1 -1
- package/dist/core/parser/frontmatter-parser.js +59 -2
- package/dist/core/parser/frontmatter-parser.js.map +1 -1
- package/dist/core/parser/note-parser.js +15 -4
- package/dist/core/parser/note-parser.js.map +1 -1
- package/dist/core/parser/requirement-parser.js +4 -0
- package/dist/core/parser/requirement-parser.js.map +1 -1
- package/dist/core/parser/task-parser.d.ts.map +1 -1
- package/dist/core/parser/task-parser.js +19 -0
- package/dist/core/parser/task-parser.js.map +1 -1
- package/dist/core/parser/wikilink-parser.d.ts.map +1 -1
- package/dist/core/parser/wikilink-parser.js +21 -5
- package/dist/core/parser/wikilink-parser.js.map +1 -1
- package/dist/core/retrieval/classify.d.ts +11 -4
- package/dist/core/retrieval/classify.d.ts.map +1 -1
- package/dist/core/retrieval/classify.js +76 -14
- package/dist/core/retrieval/classify.js.map +1 -1
- package/dist/core/retrieval/lexical.d.ts.map +1 -1
- package/dist/core/retrieval/lexical.js +31 -12
- package/dist/core/retrieval/lexical.js.map +1 -1
- package/dist/core/retrieval/retrieve.d.ts.map +1 -1
- package/dist/core/retrieval/retrieve.js +45 -5
- package/dist/core/retrieval/retrieve.js.map +1 -1
- package/dist/core/retrieval/scoring.d.ts.map +1 -1
- package/dist/core/retrieval/scoring.js +37 -12
- package/dist/core/retrieval/scoring.js.map +1 -1
- package/dist/core/schema/change.schema.d.ts.map +1 -1
- package/dist/core/schema/change.schema.js +6 -1
- package/dist/core/schema/change.schema.js.map +1 -1
- package/dist/core/schema/feature.schema.d.ts +1 -1
- package/dist/core/schema/feature.schema.d.ts.map +1 -1
- package/dist/core/schema/feature.schema.js +1 -0
- package/dist/core/schema/feature.schema.js.map +1 -1
- package/dist/core/schema/frontmatter.d.ts +15 -0
- package/dist/core/schema/frontmatter.d.ts.map +1 -1
- package/dist/core/schema/index.d.ts +15 -0
- package/dist/core/schema/index.d.ts.map +1 -1
- package/dist/core/schema/query.schema.d.ts +15 -0
- package/dist/core/schema/query.schema.d.ts.map +1 -1
- package/dist/core/schema/query.schema.js +5 -0
- package/dist/core/schema/query.schema.js.map +1 -1
- package/dist/core/schema/templates.d.ts +0 -4
- package/dist/core/schema/templates.d.ts.map +1 -1
- package/dist/core/schema/templates.js +25 -7
- package/dist/core/schema/templates.js.map +1 -1
- package/dist/core/sequencing/analyze.d.ts.map +1 -1
- package/dist/core/sequencing/analyze.js +3 -1
- package/dist/core/sequencing/analyze.js.map +1 -1
- package/dist/core/sequencing/priority-queue.d.ts +9 -3
- package/dist/core/sequencing/priority-queue.d.ts.map +1 -1
- package/dist/core/sequencing/priority-queue.js +10 -4
- package/dist/core/sequencing/priority-queue.js.map +1 -1
- package/dist/core/sequencing/stale-detector.d.ts.map +1 -1
- package/dist/core/sequencing/stale-detector.js +5 -0
- package/dist/core/sequencing/stale-detector.js.map +1 -1
- package/dist/core/workflow/apply/apply.d.ts.map +1 -1
- package/dist/core/workflow/apply/apply.js +494 -55
- package/dist/core/workflow/apply/apply.js.map +1 -1
- package/dist/core/workflow/apply/delta-parser.d.ts.map +1 -1
- package/dist/core/workflow/apply/delta-parser.js +43 -9
- package/dist/core/workflow/apply/delta-parser.js.map +1 -1
- package/dist/core/workflow/apply/feature-updater.d.ts.map +1 -1
- package/dist/core/workflow/apply/feature-updater.js +27 -11
- package/dist/core/workflow/apply/feature-updater.js.map +1 -1
- package/dist/core/workflow/apply/types.d.ts +14 -0
- package/dist/core/workflow/apply/types.d.ts.map +1 -1
- package/dist/core/workflow/continue/continue.d.ts +18 -0
- package/dist/core/workflow/continue/continue.d.ts.map +1 -1
- package/dist/core/workflow/continue/continue.js +134 -15
- package/dist/core/workflow/continue/continue.js.map +1 -1
- package/dist/core/workflow/continue/next-action.d.ts.map +1 -1
- package/dist/core/workflow/continue/next-action.js +16 -3
- package/dist/core/workflow/continue/next-action.js.map +1 -1
- package/dist/core/workflow/continue/types.d.ts +12 -0
- package/dist/core/workflow/continue/types.d.ts.map +1 -1
- package/dist/core/workflow/propose/note-creator.d.ts +2 -2
- package/dist/core/workflow/propose/note-creator.d.ts.map +1 -1
- package/dist/core/workflow/propose/note-creator.js +89 -12
- package/dist/core/workflow/propose/note-creator.js.map +1 -1
- package/dist/core/workflow/propose/propose.d.ts.map +1 -1
- package/dist/core/workflow/propose/propose.js +190 -46
- package/dist/core/workflow/propose/propose.js.map +1 -1
- package/dist/core/workflow/propose/query-normalizer.d.ts.map +1 -1
- package/dist/core/workflow/propose/query-normalizer.js +37 -5
- package/dist/core/workflow/propose/query-normalizer.js.map +1 -1
- package/dist/core/workflow/propose/types.d.ts +15 -0
- package/dist/core/workflow/propose/types.d.ts.map +1 -1
- package/dist/core/workflow/propose/types.js +6 -1
- package/dist/core/workflow/propose/types.js.map +1 -1
- package/dist/core/workflow/query/query-note-creator.d.ts +0 -4
- package/dist/core/workflow/query/query-note-creator.d.ts.map +1 -1
- package/dist/core/workflow/query/query-note-creator.js +27 -37
- package/dist/core/workflow/query/query-note-creator.js.map +1 -1
- package/dist/core/workflow/verify/coherence.d.ts +10 -0
- package/dist/core/workflow/verify/coherence.d.ts.map +1 -1
- package/dist/core/workflow/verify/coherence.js +91 -4
- package/dist/core/workflow/verify/coherence.js.map +1 -1
- package/dist/core/workflow/verify/completeness.d.ts +1 -1
- package/dist/core/workflow/verify/completeness.d.ts.map +1 -1
- package/dist/core/workflow/verify/completeness.js +53 -10
- package/dist/core/workflow/verify/completeness.js.map +1 -1
- package/dist/core/workflow/verify/correctness.d.ts +1 -1
- package/dist/core/workflow/verify/correctness.d.ts.map +1 -1
- package/dist/core/workflow/verify/correctness.js +95 -14
- package/dist/core/workflow/verify/correctness.js.map +1 -1
- package/dist/core/workflow/verify/vault-integrity.d.ts +11 -0
- package/dist/core/workflow/verify/vault-integrity.d.ts.map +1 -1
- package/dist/core/workflow/verify/vault-integrity.js +80 -3
- package/dist/core/workflow/verify/vault-integrity.js.map +1 -1
- package/dist/core/workflow/verify/verify.d.ts +0 -5
- package/dist/core/workflow/verify/verify.d.ts.map +1 -1
- package/dist/core/workflow/verify/verify.js +54 -3
- package/dist/core/workflow/verify/verify.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/types/cli-contracts.d.ts +78 -0
- package/dist/types/cli-contracts.d.ts.map +1 -0
- package/dist/types/cli-contracts.js +2 -0
- package/dist/types/cli-contracts.js.map +1 -0
- package/dist/types/error-codes.d.ts +25 -0
- package/dist/types/error-codes.d.ts.map +1 -0
- package/dist/types/error-codes.js +27 -0
- package/dist/types/error-codes.js.map +1 -0
- package/dist/types/frontmatter.d.ts +9 -2
- package/dist/types/frontmatter.d.ts.map +1 -1
- package/dist/types/next-action.d.ts +21 -0
- package/dist/types/next-action.d.ts.map +1 -1
- package/dist/types/retrieval.d.ts +17 -0
- package/dist/types/retrieval.d.ts.map +1 -1
- package/dist/types/verify.d.ts +58 -1
- package/dist/types/verify.d.ts.map +1 -1
- package/dist/types/verify.js +58 -1
- package/dist/types/verify.js.map +1 -1
- package/dist/utils/conventions.d.ts +9 -0
- package/dist/utils/conventions.d.ts.map +1 -0
- package/dist/utils/conventions.js +22 -0
- package/dist/utils/conventions.js.map +1 -0
- package/dist/utils/id-generator.d.ts.map +1 -1
- package/dist/utils/id-generator.js +8 -0
- package/dist/utils/id-generator.js.map +1 -1
- package/dist/utils/normalize.d.ts +7 -4
- package/dist/utils/normalize.d.ts.map +1 -1
- package/dist/utils/normalize.js +43 -6
- package/dist/utils/normalize.js.map +1 -1
- package/dist/utils/path-safety.d.ts +12 -1
- package/dist/utils/path-safety.d.ts.map +1 -1
- package/dist/utils/path-safety.js +54 -3
- package/dist/utils/path-safety.js.map +1 -1
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
69
|
+
if (deps.deleteFile)
|
|
70
|
+
deps.deleteFile(absPath);
|
|
24
71
|
}
|
|
25
|
-
catch {
|
|
26
|
-
|
|
72
|
+
catch (err) {
|
|
73
|
+
failures.push(`failed to remove stale tmp file ${file}: ${err.message}`);
|
|
27
74
|
}
|
|
28
75
|
}
|
|
29
|
-
if (
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
40
|
-
//
|
|
128
|
+
if (isStaleByTime || isStaleByPid) {
|
|
129
|
+
// Remove stale lock and retry atomically
|
|
41
130
|
if (deps.deleteFile) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
218
|
+
// Lookup by id first (highest priority), then title, then alias
|
|
105
219
|
for (const record of index.records.values()) {
|
|
106
|
-
if (record.
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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,
|