@vibecompass/vibecompass 0.11.1 → 0.12.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 +47 -0
- package/README.md +20 -1
- package/package.json +1 -1
- package/src/cli.js +209 -0
- package/src/docs-review.js +103 -16
- package/src/index.js +3 -0
- package/src/lane-marker.js +39 -1
- package/src/mode-transition.js +265 -0
- package/src/scaffold.js +1 -1
- package/src/serialization.js +26 -0
- package/src/session.js +162 -15
- package/src/status.js +78 -0
- package/src/sync.js +264 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.12.0 - 2026-07-10
|
|
6
|
+
|
|
7
|
+
- New `vibecompass bootstrap --bundle <file>` materializes a complete local
|
|
8
|
+
root from a hosted bootstrap-export bundle (fail-closed hash validation,
|
|
9
|
+
verbatim writes, sync-cursor seeding for local-primary bundles).
|
|
10
|
+
- New `vibecompass sync-adopt [--accept-divergence]` re-baselines a root's
|
|
11
|
+
sync cursor onto the hosted head after a divergence preview (D-215) — the
|
|
12
|
+
recovery path for fresh clones and second devices.
|
|
13
|
+
- New `vibecompass promote-hosted [--resume|--abort]` and
|
|
14
|
+
`vibecompass demote-hosted [--accept-divergence]` run the D-288 two-phase
|
|
15
|
+
hosting-mode cutover: fresh verified baseline, server-side transition
|
|
16
|
+
intent with a completeness report, local flip + promoted-root marker, and
|
|
17
|
+
confirm — resumable after any crash and fully reversible.
|
|
18
|
+
- Promoted roots hard-refuse canonical write commands (override with
|
|
19
|
+
VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1); `vibecompass status` now checks
|
|
20
|
+
the hosted mode and warns on local/hosted mode mismatches.
|
|
21
|
+
- `apply-export` pre-validates every operation before writing and treats
|
|
22
|
+
already-applied operations as converged, so retries after partial failure
|
|
23
|
+
succeed. `docs-review --apply-decision-artifact` is idempotent and
|
|
24
|
+
crash-safe (provenance Source line, marker repair, post-write verification,
|
|
25
|
+
code-enforced append-only) and warns on hosted-only roots.
|
|
26
|
+
|
|
27
|
+
- The missing-sync-token error now explains how to recover: re-export the bound
|
|
28
|
+
env var, persist it in a shell profile, or rotate the token from the hosted
|
|
29
|
+
dashboard (previously a bare "requires VIBECOMPASS_SYNC_TOKEN" with no
|
|
30
|
+
guidance). README documents the persistence recipe.
|
|
31
|
+
- AGENTS.md gains a release checklist, including verifying that
|
|
32
|
+
`vibecompass-mcp`'s dependency range still spans the new version so core
|
|
33
|
+
releases stop stranding the MCP package (its `^0.1.0` pin had drifted 27
|
|
34
|
+
releases behind).
|
|
35
|
+
|
|
36
|
+
## 0.11.2 - 2026-07-06
|
|
37
|
+
|
|
38
|
+
- Complete the remaining Phase 8 start-session overlap warnings: lanes can now
|
|
39
|
+
declare expected architecture doc paths with `--architecture-doc` and
|
|
40
|
+
decision domain files with `--decision-domain-file` / `--decision-domain`;
|
|
41
|
+
active-lane overlap warnings compare normalized path spellings for those
|
|
42
|
+
fields, and same-repo ownership ambiguity now covers one-sided or
|
|
43
|
+
unparseable path claims while treating single-repo unprefixed claims and
|
|
44
|
+
disjoint feature slugs as real non-overlapping ownership.
|
|
45
|
+
- Harden S2 lane-marker follow-ups: mutating session commands revalidate a
|
|
46
|
+
pre-lock marker snapshot after acquiring the memory-root lock, close-session
|
|
47
|
+
reports unrecorded cwd markers that name the closing lane, and marker parsing
|
|
48
|
+
round-trips Windows absolute `memory_root` values with backslashes.
|
|
49
|
+
|
|
3
50
|
## 0.11.1 - 2026-07-06
|
|
4
51
|
|
|
5
52
|
- Harden the per-root serialization lock for symlink-aliased memory roots: root keys now canonicalize through existing parent directories when the memory root itself does not yet exist, and nested lock holders compare held/requested roots by `realpath` before reacquiring the disk lock. This preserves reentrancy when an outer lock creates the root and an inner call reaches it through another spelling.
|
package/README.md
CHANGED
|
@@ -145,7 +145,15 @@ npx -y @vibecompass/vibecompass@latest close-session --root .compass --session a
|
|
|
145
145
|
# close-session and docs-update also print a pre-close staleness set: new
|
|
146
146
|
# decisions since lane start, stale base revisions, newer finalized notes
|
|
147
147
|
# that mention the lane's scope, and claim overlap with other active lanes.
|
|
148
|
-
npx -y @vibecompass/vibecompass@latest start-session
|
|
148
|
+
npx -y @vibecompass/vibecompass@latest start-session \
|
|
149
|
+
--id auth-flow \
|
|
150
|
+
--working-on "Auth flow" \
|
|
151
|
+
--repo app \
|
|
152
|
+
--claim src/app/auth \
|
|
153
|
+
--architecture-doc architecture/product/auth/login.md \
|
|
154
|
+
--decision-domain-file cross-cutting.md \
|
|
155
|
+
--branch feature/auth \
|
|
156
|
+
--worktree
|
|
149
157
|
|
|
150
158
|
# Per-lane runtime isolation (D-282): every lane gets its own port and temp
|
|
151
159
|
# dir at start (recorded in the lane's session.yaml; defaults configurable
|
|
@@ -242,6 +250,17 @@ changed canonical project-memory files and the hosted dashboard should catch
|
|
|
242
250
|
up. Hosted-only projects do not use a local authoritative push; update or
|
|
243
251
|
refresh them through the hosted dashboard/proposal flow.
|
|
244
252
|
|
|
253
|
+
The sync token is read from the environment variable you bind (for example
|
|
254
|
+
`VIBECOMPASS_SYNC_TOKEN`). One-off `export`s do not survive new terminals —
|
|
255
|
+
persist the token in your shell profile (`~/.zshenv` or `~/.bashrc`):
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
echo 'export VIBECOMPASS_SYNC_TOKEN="<your sync token>"' >> ~/.zshenv
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
If the token is lost, rotate it on the hosted dashboard under Setup -> Hosted
|
|
262
|
+
sync and export the new value.
|
|
263
|
+
|
|
245
264
|
### Multiple environments
|
|
246
265
|
|
|
247
266
|
One project can have named sync targets (for example a local dev server and
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -17,8 +17,11 @@ import { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
|
|
|
17
17
|
import { refreshWorkflow } from './refresh-workflow.js';
|
|
18
18
|
import { inspectProjectCompatibility, formatCompatibilityWarnings } from './compatibility.js';
|
|
19
19
|
import { PACKAGE_VERSION } from './version.js';
|
|
20
|
+
import { demoteHosted, promoteHosted } from './mode-transition.js';
|
|
20
21
|
import {
|
|
22
|
+
adoptRemoteHead,
|
|
21
23
|
applyPullExport,
|
|
24
|
+
bootstrapFromBundle,
|
|
22
25
|
pullExportProjectMemory,
|
|
23
26
|
pullPreviewProjectMemory,
|
|
24
27
|
pushProjectMemory,
|
|
@@ -437,6 +440,71 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
437
440
|
return 0;
|
|
438
441
|
}
|
|
439
442
|
|
|
443
|
+
if (parsed.command === 'promote-hosted' || parsed.command === 'demote-hosted') {
|
|
444
|
+
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
445
|
+
const run = parsed.command === 'promote-hosted' ? promoteHosted : demoteHosted;
|
|
446
|
+
const result = await run(parsed.options, {
|
|
447
|
+
cwd: runtime.cwd,
|
|
448
|
+
env: runtime.env,
|
|
449
|
+
runtime,
|
|
450
|
+
});
|
|
451
|
+
io.stdout.write(`Mode transition: ${result.status}\n`);
|
|
452
|
+
if (result.completeness) {
|
|
453
|
+
io.stdout.write(`Carries over: ${result.completeness.carries_over}\n`);
|
|
454
|
+
for (const [kind, total] of Object.entries(result.completeness.documents_by_kind ?? {})) {
|
|
455
|
+
io.stdout.write(` ${kind}: ${total}\n`);
|
|
456
|
+
}
|
|
457
|
+
io.stdout.write('Does not carry over:\n');
|
|
458
|
+
for (const item of result.completeness.does_not_carry_over ?? []) {
|
|
459
|
+
io.stdout.write(` - ${item}\n`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const warning of result.warnings) {
|
|
463
|
+
io.stdout.write(`Note: ${warning}\n`);
|
|
464
|
+
}
|
|
465
|
+
io.stdout.write(`${result.message}\n`);
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (parsed.command === 'sync-adopt') {
|
|
470
|
+
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
471
|
+
const result = await adoptRemoteHead(parsed.options, {
|
|
472
|
+
cwd: runtime.cwd,
|
|
473
|
+
env: runtime.env,
|
|
474
|
+
runtime,
|
|
475
|
+
});
|
|
476
|
+
io.stdout.write(`Sync adopt: ${result.status}\n`);
|
|
477
|
+
io.stdout.write(`Adopted head: ${result.remoteRevisionId}\n`);
|
|
478
|
+
io.stdout.write(`Recorded ${result.manifestPath}\n`);
|
|
479
|
+
for (const warning of result.warnings) {
|
|
480
|
+
io.stdout.write(`Warning: ${warning}\n`);
|
|
481
|
+
}
|
|
482
|
+
io.stdout.write(`${result.message}\n`);
|
|
483
|
+
return 0;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (parsed.command === 'bootstrap') {
|
|
487
|
+
const result = await bootstrapFromBundle(parsed.options, {
|
|
488
|
+
cwd: runtime.cwd,
|
|
489
|
+
env: runtime.env,
|
|
490
|
+
runtime,
|
|
491
|
+
});
|
|
492
|
+
io.stdout.write(`Bootstrap: ${result.status}\n`);
|
|
493
|
+
io.stdout.write(`Root: ${result.rootDir}\n`);
|
|
494
|
+
io.stdout.write(`Documents: ${result.documentCount}\n`);
|
|
495
|
+
if (result.mode) {
|
|
496
|
+
io.stdout.write(`Mode: ${result.mode}\n`);
|
|
497
|
+
}
|
|
498
|
+
if (result.manifestPath) {
|
|
499
|
+
io.stdout.write(`Recorded ${result.manifestPath}\n`);
|
|
500
|
+
}
|
|
501
|
+
for (const warning of result.warnings) {
|
|
502
|
+
io.stdout.write(`Warning: ${warning}\n`);
|
|
503
|
+
}
|
|
504
|
+
io.stdout.write(`${result.message}\n`);
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
440
508
|
if (parsed.command === 'pull-preview') {
|
|
441
509
|
await writeCompatibilityPreflightWarnings(io, parsed.options, runtime);
|
|
442
510
|
const result = await pullPreviewProjectMemory(parsed.options, {
|
|
@@ -708,6 +776,18 @@ export function parseCliArgs(argv) {
|
|
|
708
776
|
return parsePushArgs(rest);
|
|
709
777
|
}
|
|
710
778
|
|
|
779
|
+
if (command === 'promote-hosted' || command === 'demote-hosted') {
|
|
780
|
+
return parseModeTransitionArgs(command, rest);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (command === 'sync-adopt') {
|
|
784
|
+
return parseSyncAdoptArgs(rest);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (command === 'bootstrap') {
|
|
788
|
+
return parseBootstrapArgs(rest);
|
|
789
|
+
}
|
|
790
|
+
|
|
711
791
|
if (command === 'pull-preview') {
|
|
712
792
|
return parsePullPreviewArgs(rest);
|
|
713
793
|
}
|
|
@@ -949,6 +1029,13 @@ function parseStartSessionArgs(argv) {
|
|
|
949
1029
|
case '--claim':
|
|
950
1030
|
parsed.claims = [...(parsed.claims ?? []), value];
|
|
951
1031
|
break;
|
|
1032
|
+
case '--architecture-doc':
|
|
1033
|
+
parsed.architectureDocs = [...(parsed.architectureDocs ?? []), value];
|
|
1034
|
+
break;
|
|
1035
|
+
case '--decision-domain':
|
|
1036
|
+
case '--decision-domain-file':
|
|
1037
|
+
parsed.decisionDomainFiles = [...(parsed.decisionDomainFiles ?? []), value];
|
|
1038
|
+
break;
|
|
952
1039
|
case '--branch':
|
|
953
1040
|
parsed.branch = value;
|
|
954
1041
|
break;
|
|
@@ -1769,6 +1856,122 @@ function parseDocsReviewArgs(argv) {
|
|
|
1769
1856
|
};
|
|
1770
1857
|
}
|
|
1771
1858
|
|
|
1859
|
+
function parseModeTransitionArgs(command, argv) {
|
|
1860
|
+
const parsed = {};
|
|
1861
|
+
|
|
1862
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1863
|
+
const token = argv[index];
|
|
1864
|
+
if (!token.startsWith('--')) {
|
|
1865
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
if (token === '--resume') {
|
|
1869
|
+
parsed.resume = true;
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
if (token === '--abort') {
|
|
1873
|
+
parsed.abort = true;
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
if (token === '--accept-divergence') {
|
|
1877
|
+
parsed.acceptDivergence = true;
|
|
1878
|
+
continue;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
const value = argv[index + 1];
|
|
1882
|
+
if (value === undefined) {
|
|
1883
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1884
|
+
}
|
|
1885
|
+
index += 1;
|
|
1886
|
+
|
|
1887
|
+
switch (token) {
|
|
1888
|
+
case '--root':
|
|
1889
|
+
parsed.rootDir = value;
|
|
1890
|
+
break;
|
|
1891
|
+
case '--sync-target':
|
|
1892
|
+
parsed.syncTarget = value;
|
|
1893
|
+
break;
|
|
1894
|
+
default:
|
|
1895
|
+
throw new Error(`Unknown flag "${token}" for ${command}.`);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
return { command, options: parsed };
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function parseSyncAdoptArgs(argv) {
|
|
1903
|
+
const parsed = {};
|
|
1904
|
+
|
|
1905
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1906
|
+
const token = argv[index];
|
|
1907
|
+
if (!token.startsWith('--')) {
|
|
1908
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
if (token === '--accept-divergence') {
|
|
1912
|
+
parsed.acceptDivergence = true;
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
const value = argv[index + 1];
|
|
1917
|
+
if (value === undefined) {
|
|
1918
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1919
|
+
}
|
|
1920
|
+
index += 1;
|
|
1921
|
+
|
|
1922
|
+
switch (token) {
|
|
1923
|
+
case '--root':
|
|
1924
|
+
parsed.rootDir = value;
|
|
1925
|
+
break;
|
|
1926
|
+
case '--sync-target':
|
|
1927
|
+
parsed.syncTarget = value;
|
|
1928
|
+
break;
|
|
1929
|
+
default:
|
|
1930
|
+
throw new Error(`Unknown flag "${token}" for sync-adopt.`);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return {
|
|
1935
|
+
command: 'sync-adopt',
|
|
1936
|
+
options: parsed,
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function parseBootstrapArgs(argv) {
|
|
1941
|
+
const parsed = {};
|
|
1942
|
+
|
|
1943
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1944
|
+
const token = argv[index];
|
|
1945
|
+
if (!token.startsWith('--')) {
|
|
1946
|
+
throw new Error(`Unexpected argument "${token}".`);
|
|
1947
|
+
}
|
|
1948
|
+
const value = argv[index + 1];
|
|
1949
|
+
if (value === undefined) {
|
|
1950
|
+
throw new Error(`Flag "${token}" requires a value.`);
|
|
1951
|
+
}
|
|
1952
|
+
index += 1;
|
|
1953
|
+
|
|
1954
|
+
switch (token) {
|
|
1955
|
+
case '--root':
|
|
1956
|
+
parsed.rootDir = value;
|
|
1957
|
+
break;
|
|
1958
|
+
case '--bundle':
|
|
1959
|
+
parsed.bundlePath = value;
|
|
1960
|
+
break;
|
|
1961
|
+
case '--sync-target':
|
|
1962
|
+
parsed.syncTarget = value;
|
|
1963
|
+
break;
|
|
1964
|
+
default:
|
|
1965
|
+
throw new Error(`Unknown flag "${token}" for bootstrap.`);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
return {
|
|
1970
|
+
command: 'bootstrap',
|
|
1971
|
+
options: parsed,
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1772
1975
|
function parsePushArgs(argv) {
|
|
1773
1976
|
return {
|
|
1774
1977
|
command: 'push',
|
|
@@ -2176,6 +2379,10 @@ function usageText() {
|
|
|
2176
2379
|
' vibecompass next-decision-id [options]',
|
|
2177
2380
|
' vibecompass sync-agents [options]',
|
|
2178
2381
|
' vibecompass push [options]',
|
|
2382
|
+
' vibecompass bootstrap --bundle <file> [--root <dir>]',
|
|
2383
|
+
' vibecompass sync-adopt [--accept-divergence] [options]',
|
|
2384
|
+
' vibecompass promote-hosted [--resume|--abort] [options]',
|
|
2385
|
+
' vibecompass demote-hosted [--accept-divergence] [options]',
|
|
2179
2386
|
' vibecompass pull-preview [options]',
|
|
2180
2387
|
' vibecompass pull-export [options]',
|
|
2181
2388
|
' vibecompass apply-export [options]',
|
|
@@ -2248,6 +2455,8 @@ function usageText() {
|
|
|
2248
2455
|
' --feature <slug> Repeatable feature slug for the lane',
|
|
2249
2456
|
' --repo <id> Repeatable repo ID for the lane',
|
|
2250
2457
|
' --claim <path> Repeatable path claim for overlap warnings',
|
|
2458
|
+
' --architecture-doc <path> Repeatable architecture doc path the lane expects to edit',
|
|
2459
|
+
' --decision-domain-file <domain.md> Repeatable decision domain file the lane expects to edit (alias: --decision-domain)',
|
|
2251
2460
|
' --branch <name> Opt-in git binding (D-281): create or reuse this branch in every bound --repo; requires at least one --repo',
|
|
2252
2461
|
' --worktree With --branch: provision per-repo worktrees under <workspace>/worktrees/<lane-id>/<repo-id> (D-279) and write the lane marker into the container',
|
|
2253
2462
|
' --date <YYYY-MM-DD> Optional explicit session date',
|
package/src/docs-review.js
CHANGED
|
@@ -434,6 +434,15 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
434
434
|
throw new Error(`Decision artifact ${artifactId} must include a non-empty rationale before it can be appended locally.`);
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
// Idempotency guard: never append the same artifact twice. The marker is
|
|
438
|
+
// the fast path; the file-content check covers a crash between the file
|
|
439
|
+
// write and the marker update.
|
|
440
|
+
if (artifact.local_status === 'applied') {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Decision artifact ${artifactId} is already applied locally as ${artifact.local_decision_id ? `decision ${artifact.local_decision_id}` : 'a decision entry'}. Re-applying would append a duplicate entry.`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
437
446
|
const nextDecisionId = await readNextDecisionId(options.rootDir);
|
|
438
447
|
const targetFilePath = path.join(options.rootDir, targetPath);
|
|
439
448
|
await mkdir(path.dirname(targetFilePath), { recursive: true });
|
|
@@ -446,27 +455,99 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
446
455
|
existingContent = `# ${path.basename(targetPath, '.md')} decisions\n`;
|
|
447
456
|
}
|
|
448
457
|
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
458
|
+
const applyWarnings = [];
|
|
459
|
+
const alreadyInFile = existingContent.includes(
|
|
460
|
+
`hosted docs-review artifact ${artifactId}`,
|
|
461
|
+
);
|
|
462
|
+
let appliedDecisionIdForMarker = nextDecisionId;
|
|
463
|
+
|
|
464
|
+
if (alreadyInFile) {
|
|
465
|
+
// A previous apply wrote the file but crashed before recording the
|
|
466
|
+
// marker. Repair the marker instead of appending a duplicate.
|
|
467
|
+
applyWarnings.push(
|
|
468
|
+
`Artifact ${artifactId} was already present in ${targetPath}; repaired the marker without appending a duplicate entry.`,
|
|
469
|
+
);
|
|
470
|
+
const existingIdMatch = existingContent.match(
|
|
471
|
+
new RegExp(
|
|
472
|
+
`### (D-\\d+)[^\\n]*(?:\\n(?!### ).*)*hosted docs-review artifact ${artifactId.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}`,
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
appliedDecisionIdForMarker = existingIdMatch
|
|
476
|
+
? Number.parseInt(existingIdMatch[1].slice(2), 10)
|
|
477
|
+
: nextDecisionId;
|
|
478
|
+
} else {
|
|
479
|
+
const entry = renderDecisionEntry({
|
|
480
|
+
decisionId: nextDecisionId,
|
|
481
|
+
title,
|
|
482
|
+
decision,
|
|
483
|
+
rationale,
|
|
484
|
+
context,
|
|
485
|
+
artifactId,
|
|
486
|
+
});
|
|
487
|
+
const nextContent = `${existingContent.trimEnd()}\n\n${entry}`;
|
|
488
|
+
// Append-only enforced in code, not just convention: the previous file
|
|
489
|
+
// content must survive verbatim as a prefix of what we write.
|
|
490
|
+
if (!nextContent.startsWith(existingContent.trimEnd())) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`Refusing to apply artifact ${artifactId}: the write would not preserve the existing content of ${targetPath} (decisions are append-only).`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
await writeFile(targetFilePath, nextContent, 'utf8');
|
|
496
|
+
|
|
497
|
+
// Post-write verification: the canonical write must be durable and
|
|
498
|
+
// intact before the marker records the artifact as applied.
|
|
499
|
+
const verification = await readFile(targetFilePath, 'utf8');
|
|
500
|
+
if (verification !== nextContent) {
|
|
501
|
+
throw new Error(
|
|
502
|
+
`Post-write verification failed for ${targetPath}: the file on disk does not match the appended content. The marker was NOT updated; inspect the file and retry.`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Hosted-only roots can apply locally but can never push the result back
|
|
508
|
+
// until the project is demoted to local-primary — say so instead of letting
|
|
509
|
+
// the user discover it at push time.
|
|
510
|
+
try {
|
|
511
|
+
const projectConfig = parseSimpleYaml(
|
|
512
|
+
await readFile(path.join(options.rootDir, 'project.yaml'), 'utf8'),
|
|
513
|
+
{ sourceName: 'project.yaml' },
|
|
514
|
+
);
|
|
515
|
+
if (projectConfig?.mode === 'hosted-only') {
|
|
516
|
+
applyWarnings.push(
|
|
517
|
+
'This is a hosted-only project: the locally applied decision cannot be pushed back to the hosted canonical store. The hosted copy was already recorded when the artifact was accepted; treat this local root as a working copy.',
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
} catch {
|
|
521
|
+
// Unreadable project.yaml is surfaced by other commands; not fatal here.
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let indexResult;
|
|
525
|
+
if (options.refreshIndex) {
|
|
526
|
+
try {
|
|
527
|
+
indexResult = await refreshDecisionIndex(options.rootDir);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
// An index-refresh failure after a verified canonical write must not
|
|
530
|
+
// strand the marker (a retry would duplicate the entry): degrade to a
|
|
531
|
+
// warning and keep going.
|
|
532
|
+
indexResult = {
|
|
533
|
+
refreshed: false,
|
|
534
|
+
warnings: [
|
|
535
|
+
`decisions/INDEX.md refresh failed after the entry was appended: ${error.message}. Run vibecompass refresh-decision-index manually.`,
|
|
536
|
+
],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
indexResult = {
|
|
461
541
|
refreshed: false,
|
|
462
542
|
warnings: [
|
|
463
543
|
'decisions/INDEX.md was not refreshed. Run again with --refresh-index if this root uses the package-generated flat index, or update the project-specific index manually.',
|
|
464
544
|
],
|
|
465
545
|
};
|
|
546
|
+
}
|
|
466
547
|
|
|
467
548
|
const appliedDecisionArtifact = {
|
|
468
549
|
artifact_id: artifactId,
|
|
469
|
-
decision_id:
|
|
550
|
+
decision_id: appliedDecisionIdForMarker,
|
|
470
551
|
target_path: targetPath,
|
|
471
552
|
applied_at: new Date().toISOString(),
|
|
472
553
|
refreshed_index: indexResult.refreshed,
|
|
@@ -478,7 +559,7 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
478
559
|
...current.hosted,
|
|
479
560
|
artifacts: (current.hosted?.artifacts ?? []).map((item) =>
|
|
480
561
|
item?.artifact_id === artifactId
|
|
481
|
-
? { ...item, local_status: 'applied', local_decision_id:
|
|
562
|
+
? { ...item, local_status: 'applied', local_decision_id: appliedDecisionIdForMarker }
|
|
482
563
|
: item,
|
|
483
564
|
),
|
|
484
565
|
},
|
|
@@ -494,8 +575,10 @@ async function applyDecisionArtifactLocked(options) {
|
|
|
494
575
|
hosted: current.hosted ?? null,
|
|
495
576
|
appliedDecisionArtifact,
|
|
496
577
|
reviewPrompt: null,
|
|
497
|
-
warnings: indexResult.warnings,
|
|
498
|
-
message:
|
|
578
|
+
warnings: [...applyWarnings, ...indexResult.warnings],
|
|
579
|
+
message: alreadyInFile
|
|
580
|
+
? `Repaired the marker for decision artifact ${artifactId} (already present in ${targetPath} as D-${String(appliedDecisionIdForMarker).padStart(3, '0')}).`
|
|
581
|
+
: `Applied decision artifact ${artifactId} as D-${String(appliedDecisionIdForMarker).padStart(3, '0')} in ${targetPath}.`,
|
|
499
582
|
};
|
|
500
583
|
}
|
|
501
584
|
|
|
@@ -1972,6 +2055,10 @@ function renderDecisionEntry(options) {
|
|
|
1972
2055
|
`**Decision:** ${options.decision}`,
|
|
1973
2056
|
`**Rationale:** ${options.rationale}`,
|
|
1974
2057
|
...(options.context ? [`**Context:** ${options.context}`] : []),
|
|
2058
|
+
// Provenance makes re-application detectable from the file itself, so a
|
|
2059
|
+
// crash between the file write and the marker update can never cause a
|
|
2060
|
+
// silent duplicate append on retry.
|
|
2061
|
+
...(options.artifactId ? [`**Source:** hosted docs-review artifact ${options.artifactId}`] : []),
|
|
1975
2062
|
'',
|
|
1976
2063
|
].join('\n');
|
|
1977
2064
|
}
|
package/src/index.js
CHANGED
|
@@ -6,8 +6,11 @@ export { getProjectStatus, renderStatusText, toStatusJson } from './status.js';
|
|
|
6
6
|
export { initializeProjectMemory } from './init.js';
|
|
7
7
|
export { preflightDocsReview } from './docs-review.js';
|
|
8
8
|
export { planDocsUpdate, renderDocsUpdatePlan } from './docs-update.js';
|
|
9
|
+
export { demoteHosted, promoteHosted } from './mode-transition.js';
|
|
9
10
|
export {
|
|
11
|
+
adoptRemoteHead,
|
|
10
12
|
applyPullExport,
|
|
13
|
+
bootstrapFromBundle,
|
|
11
14
|
pullExportProjectMemory,
|
|
12
15
|
pullPreviewProjectMemory,
|
|
13
16
|
pushProjectMemory,
|
package/src/lane-marker.js
CHANGED
|
@@ -108,7 +108,7 @@ export async function readLaneMarker(markerPath) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
const memoryRoot = typeof data.memory_root === 'string' ? data.memory_root.trim() : '';
|
|
111
|
-
if (!memoryRoot || !
|
|
111
|
+
if (!memoryRoot || !isAbsolutePathForAnySupportedPlatform(memoryRoot)) {
|
|
112
112
|
throw new Error(
|
|
113
113
|
`Lane marker at ${markerPath} must record memory_root as an absolute path (found ${JSON.stringify(data.memory_root ?? null)}). ` +
|
|
114
114
|
'Recreate the marker with `vibecompass write-lane-marker`, or pass both --root and --session to bypass it.',
|
|
@@ -188,6 +188,40 @@ export async function resolveLaneMarkerContext(options) {
|
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* In-lock revalidation for a marker snapshot captured before waiting on the
|
|
193
|
+
* memory-root lock. If the marker changed while the command waited, fail
|
|
194
|
+
* closed instead of using stale lane/root context for a mutating operation.
|
|
195
|
+
*/
|
|
196
|
+
export async function assertLaneMarkerSnapshotCurrent(markerContext) {
|
|
197
|
+
const snapshot = markerContext?.marker;
|
|
198
|
+
if (!snapshot) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let current;
|
|
203
|
+
try {
|
|
204
|
+
current = await readLaneMarker(snapshot.markerPath);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Lane marker at ${snapshot.markerPath} changed while waiting for the project-memory lock. ` +
|
|
208
|
+
`Could not re-read the original marker (${error instanceof Error ? error.message : String(error)}). ` +
|
|
209
|
+
'Rerun the command so lane selection and root inference are based on the current marker.',
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
current.laneId !== snapshot.laneId ||
|
|
215
|
+
current.memoryRoot !== snapshot.memoryRoot ||
|
|
216
|
+
current.token !== snapshot.token
|
|
217
|
+
) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Lane marker at ${snapshot.markerPath} changed while waiting for the project-memory lock. ` +
|
|
220
|
+
'Rerun the command so lane selection and root inference are based on the current marker.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
191
225
|
/**
|
|
192
226
|
* The D-277 lane selection order, shared by every lane-scoped resolver:
|
|
193
227
|
* explicit --session wins (warning when it contradicts a resolvable marker,
|
|
@@ -329,6 +363,10 @@ async function canonicalizePath(value) {
|
|
|
329
363
|
}
|
|
330
364
|
}
|
|
331
365
|
|
|
366
|
+
function isAbsolutePathForAnySupportedPlatform(value) {
|
|
367
|
+
return path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
|
|
368
|
+
}
|
|
369
|
+
|
|
332
370
|
async function fileExists(filePath) {
|
|
333
371
|
try {
|
|
334
372
|
await access(filePath);
|