@vibecompass/vibecompass 0.11.2 → 0.13.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 +57 -0
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/cli.js +200 -0
- package/src/docs-review.js +131 -22
- package/src/docs-update.js +15 -3
- package/src/index.js +8 -1
- package/src/mode-transition.js +265 -0
- package/src/project-memory.js +422 -0
- package/src/scaffold.js +7 -5
- package/src/serialization.js +26 -0
- package/src/status.js +78 -0
- package/src/sync.js +264 -13
- package/src/workflow.js +3 -0
- package/src/workflows/registry.js +1 -1
package/src/project-memory.js
CHANGED
|
@@ -5,6 +5,15 @@ import { sha256Text } from './hash.js';
|
|
|
5
5
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
6
6
|
|
|
7
7
|
const PROJECT_FILE = 'project.yaml';
|
|
8
|
+
// Shared soft size budget for architecture docs (docs-review apply and the
|
|
9
|
+
// docs-update affected-doc advisory read the same threshold).
|
|
10
|
+
export const ARCHITECTURE_DOC_SOFT_SIZE_LIMIT_BYTES = 12000;
|
|
11
|
+
const KNOWN_ARCHITECTURE_CONTENT_MODES = new Set(['chronological-ledger']);
|
|
12
|
+
const ISO_DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/;
|
|
13
|
+
const HEADING_CHRONOLOGY_CUES = /\b(audit deepening|updated|fixes|fixed|shipped|done|completed|activation gate)\b/i;
|
|
14
|
+
// ATX heading start: 1-6 hashes followed by space, tab, or end of line
|
|
15
|
+
// (CommonMark permits bare `#`..`######`); deliberately not broad \s.
|
|
16
|
+
const ATX_HEADING_START = /^#{1,6}([ \t]|$)/;
|
|
8
17
|
const NON_CANONICAL_DECISION_FILES = new Set(['EXAMPLE.md', 'INDEX.md', 'README.md']);
|
|
9
18
|
const NON_CANONICAL_SESSION_FILES = new Set(['wip.md', 'handoff.md', 'README.md']);
|
|
10
19
|
const NON_CANONICAL_ARCHITECTURE_FILES = new Set(['README.md']);
|
|
@@ -462,6 +471,24 @@ function parseArchitectureDocument(options) {
|
|
|
462
471
|
);
|
|
463
472
|
}
|
|
464
473
|
|
|
474
|
+
// Only an absent content_mode key means current-state; any explicit value
|
|
475
|
+
// that is not the exact string "chronological-ledger" warns (D-292).
|
|
476
|
+
let contentMode = null;
|
|
477
|
+
if (Object.hasOwn(data, 'content_mode')) {
|
|
478
|
+
const rawContentMode = data.content_mode;
|
|
479
|
+
contentMode = typeof rawContentMode === 'string' ? rawContentMode.trim() : null;
|
|
480
|
+
if (!contentMode || !KNOWN_ARCHITECTURE_CONTENT_MODES.has(contentMode)) {
|
|
481
|
+
const rendered = typeof rawContentMode === 'string' ? `"${rawContentMode}"` : JSON.stringify(rawContentMode) ?? 'undefined';
|
|
482
|
+
warnings.push(
|
|
483
|
+
createWarning(
|
|
484
|
+
'architecture-unknown-content-mode',
|
|
485
|
+
`Unknown architecture content_mode ${rendered}; the recognized value is "chronological-ledger" (absence means current-state).`,
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
contentMode = null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
465
492
|
const sections = extractLevelTwoSections(frontmatter.body);
|
|
466
493
|
for (const section of RECOMMENDED_ARCHITECTURE_SECTIONS) {
|
|
467
494
|
if (!sections.has(section.toLowerCase())) {
|
|
@@ -508,6 +535,21 @@ function parseArchitectureDocument(options) {
|
|
|
508
535
|
);
|
|
509
536
|
}
|
|
510
537
|
|
|
538
|
+
// D-292: architecture docs are current-state contracts; one aggregated
|
|
539
|
+
// advisory per doc, suppressed only by content_mode: chronological-ledger.
|
|
540
|
+
if (contentMode !== 'chronological-ledger') {
|
|
541
|
+
const changelogLines = detectChangelogShapedLines(frontmatter.body);
|
|
542
|
+
if (changelogLines.length > 0) {
|
|
543
|
+
const example = changelogLines[0].length > 80 ? `${changelogLines[0].slice(0, 77)}...` : changelogLines[0];
|
|
544
|
+
warnings.push(
|
|
545
|
+
createWarning(
|
|
546
|
+
'architecture-changelog-smell',
|
|
547
|
+
`Architecture doc contains ${changelogLines.length} changelog-shaped line(s) (e.g. "${example}"). Architecture docs are current-state contracts (D-292): fold the change into the existing sections, keep the durable plan and rollout state in the doc, and move work/ship chronology to the session note — decisions record accepted choices (D-293). Intentionally dated ledgers/reports can declare content_mode: chronological-ledger.`,
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
511
553
|
return {
|
|
512
554
|
kind: 'architecture',
|
|
513
555
|
extracted: {
|
|
@@ -515,6 +557,7 @@ function parseArchitectureDocument(options) {
|
|
|
515
557
|
feature: typeof data.feature === 'string' ? data.feature : null,
|
|
516
558
|
component: typeof data.component === 'string' ? data.component : null,
|
|
517
559
|
status: typeof data.status === 'string' ? data.status : null,
|
|
560
|
+
content_mode: contentMode,
|
|
518
561
|
repo_ids: repoIds,
|
|
519
562
|
},
|
|
520
563
|
warnings,
|
|
@@ -717,6 +760,385 @@ function extractLevelTwoSections(content) {
|
|
|
717
760
|
return sections;
|
|
718
761
|
}
|
|
719
762
|
|
|
763
|
+
// D-292 changelog-shape detector, shared by the scanner and the docs-review
|
|
764
|
+
// apply validator. Signals are deliberately conjunctive and line-local so
|
|
765
|
+
// legitimate content never trips it: bare dates, Track/Phase plan headings,
|
|
766
|
+
// lane/session vocabulary, completion words in prose, and strikethrough alone
|
|
767
|
+
// must not trigger. Frontmatter is expected to be stripped by the caller;
|
|
768
|
+
// fenced code, inline code, and HTML comments are masked here because dates
|
|
769
|
+
// inside artifact names (migration filenames, example blocks) are legitimate.
|
|
770
|
+
export function detectChangelogShapedLines(body) {
|
|
771
|
+
// Block-aware masking approximating CommonMark, deliberately short of a
|
|
772
|
+
// full tokenizer. Modeled exactly: fenced code (character + run length,
|
|
773
|
+
// whitespace-only closers within 3 columns relative to the containing
|
|
774
|
+
// block), 4+ column indented code with tab-stop arithmetic (never
|
|
775
|
+
// interrupting an open paragraph), block-level HTML comments, ATX headings
|
|
776
|
+
// interrupting paragraphs, single-level list containers
|
|
777
|
+
// (marker-width/padding-derived content indent, lazy item-paragraph
|
|
778
|
+
// continuation until a blank or block interrupter, container exit with
|
|
779
|
+
// line reprocessing, list-contained fences and comments, and the
|
|
780
|
+
// paragraph-interruption rule: only non-empty bullets or ordered markers
|
|
781
|
+
// with start value 1 interrupt), and source-ordered escape-aware inline
|
|
782
|
+
// masking of code spans and comments. Known approximations, guarded by the
|
|
783
|
+
// checked-in nine-doc calibration fixture matrix plus the separately rerun
|
|
784
|
+
// three-root live calibration: nested containers, setext headings,
|
|
785
|
+
// thematic-break paragraph boundaries, and lazy blockquote continuation.
|
|
786
|
+
const hits = [];
|
|
787
|
+
// fence/htmlComment carry `container` when opened inside a list item;
|
|
788
|
+
// `container` alone means an active list item with no open child block.
|
|
789
|
+
// `container.paragraphOpen` tracks whether the item has an open paragraph
|
|
790
|
+
// eligible for lazy continuation by under-indented lines.
|
|
791
|
+
let fence = null;
|
|
792
|
+
let htmlComment = null;
|
|
793
|
+
let container = null;
|
|
794
|
+
let paragraph = [];
|
|
795
|
+
|
|
796
|
+
const evaluateLeadIn = (maskedLine, rawLine) => {
|
|
797
|
+
const trimmed = maskedLine.trim();
|
|
798
|
+
if (trimmed === '') {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Blockquoted lines are quoted examples, not the doc's own lead-ins.
|
|
802
|
+
const isListItem = /^([-*+]|\d+[.)])\s/.test(trimmed);
|
|
803
|
+
const isBlockquote = trimmed.startsWith('>');
|
|
804
|
+
if (
|
|
805
|
+
!isListItem &&
|
|
806
|
+
!isBlockquote &&
|
|
807
|
+
trimmed.endsWith(':') &&
|
|
808
|
+
trimmed.length <= 160 &&
|
|
809
|
+
ISO_DATE_PATTERN.test(trimmed) &&
|
|
810
|
+
/\bupdated\b/i.test(trimmed)
|
|
811
|
+
) {
|
|
812
|
+
hits.push(rawLine.trim());
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const flushParagraph = () => {
|
|
817
|
+
if (paragraph.length === 0) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const maskedLines = maskInlineConstructs(paragraph.map((entry) => entry.raw).join('\n')).split('\n');
|
|
821
|
+
for (let index = 0; index < paragraph.length; index += 1) {
|
|
822
|
+
evaluateLeadIn(maskedLines[index], paragraph[index].raw);
|
|
823
|
+
}
|
|
824
|
+
paragraph = [];
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const openContainerBlock = (rest) => {
|
|
828
|
+
const opener = /^(`{3,}|~{3,})(.*)$/.exec(rest);
|
|
829
|
+
if (opener && !(opener[1][0] === '`' && opener[2].includes('`'))) {
|
|
830
|
+
fence = { char: opener[1][0], length: opener[1].length, container };
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
if (rest.startsWith('<!--') && !rest.slice(4).includes('-->')) {
|
|
834
|
+
htmlComment = { container };
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
return false;
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// Lines at valid root position (columns 0-3) that start a new block and
|
|
841
|
+
// therefore end lazy paragraph continuation. Any valid list marker —
|
|
842
|
+
// empty or not, of any type — closes the current item here: marker type
|
|
843
|
+
// only decides whether the reprocessed line joins the old list or starts
|
|
844
|
+
// a new one. The empty/start-number paragraph-interruption limits apply
|
|
845
|
+
// only where a document-level paragraph itself is open.
|
|
846
|
+
const interruptsLazyContinuation = (content) => {
|
|
847
|
+
if (ATX_HEADING_START.test(content) || content.startsWith('>') || content.startsWith('<!--')) {
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
const opener = /^(`{3,}|~{3,})(.*)$/.exec(content);
|
|
851
|
+
if (opener && !(opener[1][0] === '`' && opener[2].includes('`'))) {
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
return /^([-*+]|\d{1,9}[.)])([ \t]+\S|[ \t]*$)/.test(content);
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// Whether a container content line is item PARAGRAPH text (eligible for
|
|
858
|
+
// lazy continuation) as opposed to a non-persistent child block: an ATX
|
|
859
|
+
// heading (with content or bare), blockquote, complete one-line HTML
|
|
860
|
+
// comment, or nested list marker occupies the line without leaving a
|
|
861
|
+
// paragraph open.
|
|
862
|
+
const startsContainerParagraph = (rest) => {
|
|
863
|
+
if (ATX_HEADING_START.test(rest) || rest.startsWith('>') || rest.startsWith('<!--')) {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
return !/^([-*+]|\d{1,9}[.)])([ \t]+\S|[ \t]*$)/.test(rest);
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
// Normalize CRLF and lone CR at ingress: the exported helper's contract is
|
|
870
|
+
// "frontmatter-stripped body text", and end-anchored block predicates
|
|
871
|
+
// (bare ATX, fence closers, bare markers) break on trailing \r.
|
|
872
|
+
const lines = (body ?? '').split(/\r\n?|\n/);
|
|
873
|
+
let index = 0;
|
|
874
|
+
while (index < lines.length) {
|
|
875
|
+
const rawLine = lines[index];
|
|
876
|
+
const blank = rawLine.trim() === '';
|
|
877
|
+
const indent = expandedIndentColumns(rawLine);
|
|
878
|
+
|
|
879
|
+
if (fence) {
|
|
880
|
+
if (blank) {
|
|
881
|
+
index += 1;
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (fence.container && indent < fence.container.contentIndent) {
|
|
885
|
+
// Container exit ends the child fence with its enclosing item; the
|
|
886
|
+
// exiting line is reprocessed at document level.
|
|
887
|
+
fence = null;
|
|
888
|
+
container = null;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const baseIndent = fence.container ? fence.container.contentIndent : 0;
|
|
892
|
+
const closer = /^[ \t]*(`{3,}|~{3,})[ \t]*$/.exec(rawLine);
|
|
893
|
+
if (
|
|
894
|
+
closer &&
|
|
895
|
+
closer[1][0] === fence.char &&
|
|
896
|
+
closer[1].length >= fence.length &&
|
|
897
|
+
indent - baseIndent <= 3
|
|
898
|
+
) {
|
|
899
|
+
fence = null;
|
|
900
|
+
}
|
|
901
|
+
index += 1;
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (htmlComment) {
|
|
906
|
+
if (!blank && htmlComment.container && indent < htmlComment.container.contentIndent) {
|
|
907
|
+
htmlComment = null;
|
|
908
|
+
container = null;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (rawLine.includes('-->')) {
|
|
912
|
+
htmlComment = null;
|
|
913
|
+
}
|
|
914
|
+
index += 1;
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (container) {
|
|
919
|
+
if (blank) {
|
|
920
|
+
container.paragraphOpen = false;
|
|
921
|
+
index += 1;
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (indent < container.contentIndent) {
|
|
925
|
+
// A lazy continuation of the item's open paragraph stays inside the
|
|
926
|
+
// item (item text is non-signal); a valid root block start (columns
|
|
927
|
+
// 0-3 only — at 4+ columns nothing can start a root block, and
|
|
928
|
+
// indented code cannot interrupt the open paragraph) exits the
|
|
929
|
+
// container and is reprocessed at document level.
|
|
930
|
+
if (
|
|
931
|
+
container.paragraphOpen &&
|
|
932
|
+
(indent >= 4 || !interruptsLazyContinuation(rawLine.trimStart()))
|
|
933
|
+
) {
|
|
934
|
+
index += 1;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
container = null;
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
// Container content: only child fences/comments matter for masking;
|
|
941
|
+
// item text (including nested lists, an out-of-scope approximation)
|
|
942
|
+
// never produces signals itself. 4+ relative columns is indented code
|
|
943
|
+
// within the item, not a fence opener. Non-persistent child blocks
|
|
944
|
+
// (headings, one-line comments, blockquotes, nested markers) close the
|
|
945
|
+
// item paragraph rather than opening one.
|
|
946
|
+
if (indent - container.contentIndent <= 3) {
|
|
947
|
+
const rest = rawLine.trimStart();
|
|
948
|
+
container.paragraphOpen = openContainerBlock(rest) ? false : startsContainerParagraph(rest);
|
|
949
|
+
}
|
|
950
|
+
index += 1;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const content = rawLine.trimStart();
|
|
955
|
+
|
|
956
|
+
if (blank) {
|
|
957
|
+
flushParagraph();
|
|
958
|
+
index += 1;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (indent >= 4) {
|
|
962
|
+
// Indented code cannot interrupt an open paragraph (CommonMark): an
|
|
963
|
+
// indented line after prose is paragraph continuation text.
|
|
964
|
+
if (paragraph.length > 0) {
|
|
965
|
+
paragraph.push({ raw: rawLine });
|
|
966
|
+
}
|
|
967
|
+
index += 1;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const listMarker = /^([-*+]|\d{1,9}[.)])(?:([ \t]+)(.*)|[ \t]*)$/.exec(content);
|
|
972
|
+
if (listMarker) {
|
|
973
|
+
const rest = listMarker[3] ?? '';
|
|
974
|
+
const orderedDigits = /^(\d{1,9})[.)]$/.exec(listMarker[1]);
|
|
975
|
+
// Interrupting an open paragraph requires a non-empty item, and for
|
|
976
|
+
// ordered lists a start VALUE of 1 (so `01.` may interrupt while `2.`
|
|
977
|
+
// may not).
|
|
978
|
+
const canInterrupt =
|
|
979
|
+
rest.trim() !== '' && (!orderedDigits || Number.parseInt(orderedDigits[1], 10) === 1);
|
|
980
|
+
if (paragraph.length > 0 && !canInterrupt) {
|
|
981
|
+
paragraph.push({ raw: rawLine });
|
|
982
|
+
index += 1;
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
flushParagraph();
|
|
986
|
+
const markerWidth = listMarker[1].length;
|
|
987
|
+
let paddingColumns = 0;
|
|
988
|
+
if (listMarker[2]) {
|
|
989
|
+
let column = indent + markerWidth;
|
|
990
|
+
for (const character of listMarker[2]) {
|
|
991
|
+
column += character === '\t' ? 4 - (column % 4) : 1;
|
|
992
|
+
}
|
|
993
|
+
paddingColumns = column - indent - markerWidth;
|
|
994
|
+
}
|
|
995
|
+
const contentIndent =
|
|
996
|
+
rest === '' || paddingColumns === 0 || paddingColumns > 4
|
|
997
|
+
? indent + markerWidth + 1
|
|
998
|
+
: indent + markerWidth + paddingColumns;
|
|
999
|
+
container = { contentIndent, paragraphOpen: false };
|
|
1000
|
+
if (rest !== '') {
|
|
1001
|
+
// Residual logical indentation of the marker line's own content: with
|
|
1002
|
+
// more than four padding columns, one column is list padding and the
|
|
1003
|
+
// remainder is a child INDENTED CODE block (CommonMark), so no item
|
|
1004
|
+
// paragraph opens and the text is never a fence/comment/heading.
|
|
1005
|
+
const restRelativeIndent = indent + markerWidth + paddingColumns - contentIndent;
|
|
1006
|
+
if (restRelativeIndent >= 4) {
|
|
1007
|
+
container.paragraphOpen = false;
|
|
1008
|
+
} else {
|
|
1009
|
+
container.paragraphOpen = openContainerBlock(rest) ? false : startsContainerParagraph(rest);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
index += 1;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const opener = /^(`{3,}|~{3,})(.*)$/.exec(content);
|
|
1017
|
+
if (opener && !(opener[1][0] === '`' && opener[2].includes('`'))) {
|
|
1018
|
+
flushParagraph();
|
|
1019
|
+
fence = { char: opener[1][0], length: opener[1].length, container: null };
|
|
1020
|
+
index += 1;
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (content.startsWith('<!--')) {
|
|
1025
|
+
flushParagraph();
|
|
1026
|
+
if (!content.slice(4).includes('-->')) {
|
|
1027
|
+
htmlComment = { container: null };
|
|
1028
|
+
}
|
|
1029
|
+
index += 1;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (ATX_HEADING_START.test(content)) {
|
|
1034
|
+
flushParagraph();
|
|
1035
|
+
if (/^#{2,3}\s/.test(content)) {
|
|
1036
|
+
const masked = maskInlineConstructs(content);
|
|
1037
|
+
if (ISO_DATE_PATTERN.test(masked) && HEADING_CHRONOLOGY_CUES.test(masked)) {
|
|
1038
|
+
hits.push(rawLine.trim());
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
index += 1;
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
paragraph.push({ raw: rawLine });
|
|
1046
|
+
index += 1;
|
|
1047
|
+
}
|
|
1048
|
+
flushParagraph();
|
|
1049
|
+
|
|
1050
|
+
return hits;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Source-ordered inline masking: scans left to right so the first valid
|
|
1054
|
+
// construct wins (a comment containing a backtick masks before that backtick
|
|
1055
|
+
// can pair with later code), backslash-escaped backticks are not code
|
|
1056
|
+
// delimiters, an unmatched backtick run or unterminated `<!--` is literal
|
|
1057
|
+
// text, and closer runs inside an open code span count regardless of
|
|
1058
|
+
// backslashes (escapes are literal inside code). Masked spans preserve
|
|
1059
|
+
// newlines so paragraph line mapping stays exact.
|
|
1060
|
+
function maskInlineConstructs(text) {
|
|
1061
|
+
let result = '';
|
|
1062
|
+
let position = 0;
|
|
1063
|
+
|
|
1064
|
+
while (position < text.length) {
|
|
1065
|
+
const character = text[position];
|
|
1066
|
+
|
|
1067
|
+
if (character === '\\' && position + 1 < text.length) {
|
|
1068
|
+
result += text.slice(position, position + 2);
|
|
1069
|
+
position += 2;
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (character === '`') {
|
|
1074
|
+
let runEnd = position;
|
|
1075
|
+
while (runEnd < text.length && text[runEnd] === '`') {
|
|
1076
|
+
runEnd += 1;
|
|
1077
|
+
}
|
|
1078
|
+
const runLength = runEnd - position;
|
|
1079
|
+
let closerStart = -1;
|
|
1080
|
+
let scan = runEnd;
|
|
1081
|
+
while (scan < text.length) {
|
|
1082
|
+
if (text[scan] === '`') {
|
|
1083
|
+
let scanEnd = scan;
|
|
1084
|
+
while (scanEnd < text.length && text[scanEnd] === '`') {
|
|
1085
|
+
scanEnd += 1;
|
|
1086
|
+
}
|
|
1087
|
+
if (scanEnd - scan === runLength) {
|
|
1088
|
+
closerStart = scan;
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
scan = scanEnd;
|
|
1092
|
+
} else {
|
|
1093
|
+
scan += 1;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (closerStart !== -1) {
|
|
1097
|
+
const spanEnd = closerStart + runLength;
|
|
1098
|
+
result += text.slice(position, spanEnd).replace(/[^\n]/g, ' ');
|
|
1099
|
+
position = spanEnd;
|
|
1100
|
+
} else {
|
|
1101
|
+
result += text.slice(position, runEnd);
|
|
1102
|
+
position = runEnd;
|
|
1103
|
+
}
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (text.startsWith('<!--', position)) {
|
|
1108
|
+
const commentEnd = text.indexOf('-->', position + 4);
|
|
1109
|
+
if (commentEnd !== -1) {
|
|
1110
|
+
const spanEnd = commentEnd + 3;
|
|
1111
|
+
result += text.slice(position, spanEnd).replace(/[^\n]/g, ' ');
|
|
1112
|
+
position = spanEnd;
|
|
1113
|
+
} else {
|
|
1114
|
+
result += '<!--';
|
|
1115
|
+
position += 4;
|
|
1116
|
+
}
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
result += character;
|
|
1121
|
+
position += 1;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return result;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// CommonMark column arithmetic: a tab advances to the next 4-column stop.
|
|
1128
|
+
function expandedIndentColumns(line) {
|
|
1129
|
+
let columns = 0;
|
|
1130
|
+
for (const character of line) {
|
|
1131
|
+
if (character === ' ') {
|
|
1132
|
+
columns += 1;
|
|
1133
|
+
} else if (character === '\t') {
|
|
1134
|
+
columns += 4 - (columns % 4);
|
|
1135
|
+
} else {
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
return columns;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
720
1142
|
function extractSessionTitle(content, filenameMatch) {
|
|
721
1143
|
const h1WithNumber = content.match(/^#\s+Session\s+—\s+\d{4}-\d{2}-\d{2}-\d+\s+—\s+(.+)$/m);
|
|
722
1144
|
if (h1WithNumber) {
|
package/src/scaffold.js
CHANGED
|
@@ -215,7 +215,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
|
|
|
215
215
|
|
|
216
216
|
## Canonical files
|
|
217
217
|
- \`${rootRelativePath}/project.yaml\` — machine-oriented project metadata
|
|
218
|
-
- \`${rootRelativePath}/architecture/\` — canonical architecture docs
|
|
218
|
+
- \`${rootRelativePath}/architecture/\` — canonical architecture docs; the mutable current-state layer, rewritten in place as contracts change (D-292)
|
|
219
219
|
- \`${rootRelativePath}/decisions/\` — append-only decision log for this project-memory root
|
|
220
220
|
- \`${rootRelativePath}/sessions/\` — finalized session notes
|
|
221
221
|
|
|
@@ -240,6 +240,7 @@ This workspace uses VibeCompass project memory rooted at \`${rootRelativePath}\`
|
|
|
240
240
|
- Every lane gets a per-lane runtime assignment at start (D-282): a lane port and temp dir recorded in \`session.yaml\` and exported with \`eval "$(vibecompass lane-env)"\` (includes conventional \`PORT\`/\`TMPDIR\` aliases), so parallel lanes never fight over dev-server ports or temp paths. Defaults are configurable under \`project.yaml\` \`runtime:\`; \`close-session\` removes the lane temp dir under guards.
|
|
241
241
|
- Finalized sessions are append-only notes named \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; multiple sessions on the same day increment \`N\`.
|
|
242
242
|
- Decisions remain append-only and independent from session notes. A session note may reference decisions, but the decision entry in \`${rootRelativePath}/decisions/\` is the durable decision record for this root.
|
|
243
|
+
- Architecture docs are the opposite of the append-only surfaces (D-292): they are current-state contracts, rewritten in place. Fold changes into the existing sections and keep the durable plan, unresolved next steps, and material rollout state in the doc. Work/ship chronology belongs in finalized session notes; decision entries record accepted choices and rationale; lane scratch holds only transient execution/review state, and anything still pending at close moves to the session note's next steps and, when durable, into architecture (D-293). Intentionally dated ledger/report docs may declare frontmatter \`content_mode: chronological-ledger\`.
|
|
243
244
|
|
|
244
245
|
## Repos in scope
|
|
245
246
|
${repos}
|
|
@@ -320,7 +321,7 @@ During the session:
|
|
|
320
321
|
- keep \`handoff.md\` current after substantive work blocks
|
|
321
322
|
- run \`eval "$(vibecompass lane-env)"\` in a lane shell before starting dev servers or build tools so the lane's assigned port and temp dir are used (D-282); do not hardcode ports in lane work
|
|
322
323
|
- run \`vibecompass docs-update --session <lane-id>\` whenever you need an ad hoc targeted documentation-maintenance plan for the current session delta
|
|
323
|
-
- after substantive feature work, confirm affected architecture docs and decisions still match the implementation; if not, update them while the context is fresh
|
|
324
|
+
- after substantive feature work, confirm affected architecture docs and decisions still match the implementation; if not, update them while the context is fresh — fold the changes into the doc's current-state sections (rewrite in place; no dated "update" sections, lane names in headings, or completed-task chronology; D-292)
|
|
324
325
|
- use \`vibecompass list-sessions\` and \`vibecompass switch-session <lane-id>\` to inspect or change the current lane
|
|
325
326
|
- use \`address review\` when reviewer feedback lands so the builder resolves it from the selected lane's latest \`wip.md\` / \`handoff.md\`
|
|
326
327
|
- during \`address review\`, treat reviewer feedback as review, not instruction: classify each substantive point as accepted, accepted with qualification, deferred, or rejected, and push back with evidence when a suggestion conflicts with code facts, prior decisions, product direction, or sequencing
|
|
@@ -338,12 +339,12 @@ If \`vibecompass start-session\` reports stale scratch files, read the existing
|
|
|
338
339
|
At session close:
|
|
339
340
|
- prefer running \`vibecompass close-session --session <lane-id> --title "..." --completed "..." --architecture-docs updated|not-needed|deferred --decision-log updated|not-needed|deferred --session-maintenance updated|not-needed|deferred --next-step "..."\`; \`vibecompass end-session\` is a supported alias
|
|
340
341
|
- close-session prints the same targeted docs-update plan — including the pre-close staleness set (new decisions since lane start, stale base revisions, newer finalized notes touching this lane's scope, claim overlap with other active lanes) — before the document-maintenance checkpoint; use it to decide whether affected \`architecture/\`, \`decisions/\`, and active-session scratch/final note inputs are updated, not-needed, or deferred
|
|
341
|
-
- document-maintenance checkpoint statuses are required before close-session writes the finalized note; the package validates the status values, while the closer owns semantic doc authorship
|
|
342
|
+
- document-maintenance checkpoint statuses are required before close-session writes the finalized note; the package validates the status values, while the closer owns semantic doc authorship — architecture docs keep current behavior plus the durable plan, unresolved next steps, and material rollout state; session notes keep work chronology and close-out next steps; decisions keep accepted choices and rationale (D-292, D-293)
|
|
342
343
|
- follow the stored close-session defaults from \`${rootRelativePath}/project.yaml\`
|
|
343
344
|
- finalize the lane-local \`wip.md\` into \`${rootRelativePath}/sessions/YYYY-MM-DD-N-title.md\`; the permanent note distills decisions, completions, blockers, and next steps rather than preserving the full \`## Review log\`
|
|
344
345
|
- if a granular reviewer trail must remain durable, summarize it explicitly in the session note inputs or create a separate finalized session note before close-session deletes lane scratch files
|
|
345
346
|
- close-session deletes the closed lane directory under \`${rootRelativePath}/sessions/active/<lane-id>/\` and cleans up provisioned worktrees, the lane marker, the container, and the lane temp dir when they are safely removable; leftover pieces come with printed guidance
|
|
346
|
-
- refresh any affected architecture/decision docs
|
|
347
|
+
- refresh any affected architecture/decision docs by folding changes into their current-state contract prose — do not append session chronology; distill anything still pending from lane scratch into the session note's next steps and, when durable, into the docs (D-292, D-293)
|
|
347
348
|
- for local-primary roots with hosted sync configured, run \`vibecompass push --root ${rootRelativePath}\` after canonical docs/session files are finalized when the hosted dashboard should reflect the session; pass \`--sync-target <name>\` when using a non-default or named target
|
|
348
349
|
- for hosted-only projects, there is no local authoritative push; confirm hosted dashboard/proposal/Understanding state was updated or record the refresh/apply work as deferred
|
|
349
350
|
|
|
@@ -523,6 +524,7 @@ architecture/<domain-slug>/<feature-slug>/<component>.md
|
|
|
523
524
|
- Retrieval guidance should say when to consult the doc, what it does not cover, and which related docs or decisions matter.
|
|
524
525
|
- Details should distinguish confirmed behavior from known gaps or open follow-up.
|
|
525
526
|
- Involved files should use concrete \`repo:path\` references that match the affected implementation surface.
|
|
527
|
+
- Docs are mutable current-state contracts (D-292): fold updates into the existing sections by rewriting them in place, keeping the durable plan, unresolved next steps, and material rollout state in the doc. Dated session headings, lane names, and completed-task chronology belong in session notes; decision entries record accepted choices and rationale (D-293). Docs whose artifact itself is dated (migration ledgers, incident/upstream reports) may declare frontmatter \`content_mode: chronological-ledger\` to suppress the changelog-shape advisory.
|
|
526
528
|
|
|
527
529
|
Each component doc is canonical. This README is only a convenience guide.
|
|
528
530
|
`;
|
|
@@ -635,7 +637,7 @@ function renderWorkflowDefaults(workflow) {
|
|
|
635
637
|
const lines = [
|
|
636
638
|
`- reviewer handback: ${describeReviewerHandback(workflow)}`,
|
|
637
639
|
'- close-session: require document-maintenance checkpoint statuses for architecture docs, decision log, and session handoff/scratch',
|
|
638
|
-
'- close-session: refresh any relevant architecture docs before finalizing the session',
|
|
640
|
+
'- close-session: refresh any relevant architecture docs before finalizing the session, folding changes into their current-state contract prose (D-292)',
|
|
639
641
|
'- close-session: refresh any relevant decision files before finalizing the session',
|
|
640
642
|
];
|
|
641
643
|
|
package/src/serialization.js
CHANGED
|
@@ -46,7 +46,33 @@ export function memoryRootLockPath(rootDir) {
|
|
|
46
46
|
* Runs `fn` while holding the serialization lock for `rootDir`. Reentrant:
|
|
47
47
|
* a nested call from inside the lock holder runs immediately.
|
|
48
48
|
*/
|
|
49
|
+
// Commands that stay allowed on a promoted root: the transition flows
|
|
50
|
+
// themselves, plus recovery tooling that must be able to repair state.
|
|
51
|
+
const PROMOTED_ROOT_ALLOWED_COMMANDS = new Set([
|
|
52
|
+
'promote-hosted',
|
|
53
|
+
'demote-hosted',
|
|
54
|
+
'bootstrap',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
async function assertNotPromotedRoot(rootDir, label) {
|
|
58
|
+
if (PROMOTED_ROOT_ALLOWED_COMMANDS.has(label)) return;
|
|
59
|
+
if (process.env.VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES === '1') return;
|
|
60
|
+
const markerPath = path.join(rootDir, 'state', 'promoted-root.json');
|
|
61
|
+
let marker;
|
|
62
|
+
try {
|
|
63
|
+
marker = JSON.parse(await readFile(markerPath, 'utf8'));
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new Error(
|
|
68
|
+
`This root was promoted to hosted-only${marker?.promoted_at ? ` on ${marker.promoted_at}` : ''}: the hosted app is canonical and local writes here can never sync back (D-288). `
|
|
69
|
+
+ 'Make changes on the hosted dashboard, run vibecompass demote-hosted to make this root canonical again, '
|
|
70
|
+
+ 'or set VIBECOMPASS_ALLOW_PROMOTED_ROOT_WRITES=1 to override deliberately.',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
75
|
+
await assertNotPromotedRoot(rootDir, label);
|
|
50
76
|
const key = await canonicalizeMemoryRootKey(rootDir);
|
|
51
77
|
const held = heldRoots.getStore();
|
|
52
78
|
if (await findHeldMemoryRootKey(held, key)) {
|
package/src/status.js
CHANGED
|
@@ -4,6 +4,7 @@ import { inspectProjectCompatibility } from './compatibility.js';
|
|
|
4
4
|
import { sha256Text } from './hash.js';
|
|
5
5
|
import { resolveLaneMarkerContext } from './lane-marker.js';
|
|
6
6
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
7
|
+
import { resolveSyncBinding } from './sync-binding.js';
|
|
7
8
|
import { inferToolingRootForMemoryRoot, listProjectSessions } from './session.js';
|
|
8
9
|
import { syncAgentInstructionFiles } from './generators/agent-files/index.js';
|
|
9
10
|
import { scanProjectMemory } from './project-memory.js';
|
|
@@ -30,12 +31,18 @@ export async function getProjectStatus(options = {}) {
|
|
|
30
31
|
readAgentFileStatus(rootDir, toolingRootDir),
|
|
31
32
|
readProjectMemoryStatus(rootDir),
|
|
32
33
|
]);
|
|
34
|
+
const hostedSync = await readHostedModeCheck(
|
|
35
|
+
compatibility.projectFilePath,
|
|
36
|
+
project.mode ?? null,
|
|
37
|
+
options,
|
|
38
|
+
);
|
|
33
39
|
|
|
34
40
|
return {
|
|
35
41
|
generatedAt: new Date().toISOString(),
|
|
36
42
|
rootDir,
|
|
37
43
|
toolingRootDir,
|
|
38
44
|
project,
|
|
45
|
+
hostedSync,
|
|
39
46
|
compatibility: {
|
|
40
47
|
cliPackageVersion: compatibility.cliPackageVersion,
|
|
41
48
|
package: compatibility.package,
|
|
@@ -111,6 +118,13 @@ export function renderStatusText(status) {
|
|
|
111
118
|
`Generated: ${status.generatedAt}`,
|
|
112
119
|
`Project: ${status.project.name ?? 'Unknown'}`,
|
|
113
120
|
`Mode: ${status.project.mode ?? 'Unknown'}`,
|
|
121
|
+
...(status.hostedSync?.status === 'ok' && status.hostedSync.mismatch
|
|
122
|
+
? [
|
|
123
|
+
`WARNING: mode mismatch — local project.yaml says "${status.hostedSync.localMode}" but the hosted project is "${status.hostedSync.hostedMode}". The two mode records are unreconciled: sync and dashboard actions will refuse until they agree. Do not hand-edit mode; a supported promote/demote flow flips both records together.`,
|
|
124
|
+
]
|
|
125
|
+
: status.hostedSync?.status === 'unreachable'
|
|
126
|
+
? [`Hosted mode check: unreachable (${status.hostedSync.detail}).`]
|
|
127
|
+
: []),
|
|
114
128
|
`Root: ${status.rootDir}`,
|
|
115
129
|
`Repos: ${status.project.repos.length > 0 ? status.project.repos.map((repo) => repo.id).join(', ') : 'None recorded'}`,
|
|
116
130
|
`Active lanes: ${formatActiveLanes(status.sessions)}`,
|
|
@@ -162,6 +176,7 @@ export function toStatusJson(status) {
|
|
|
162
176
|
return {
|
|
163
177
|
generatedAt: status.generatedAt,
|
|
164
178
|
rootDir: status.rootDir,
|
|
179
|
+
hostedSync: status.hostedSync ?? null,
|
|
165
180
|
toolingRootDir: status.toolingRootDir,
|
|
166
181
|
project: status.project,
|
|
167
182
|
compatibility: status.compatibility,
|
|
@@ -173,6 +188,69 @@ export function toStatusJson(status) {
|
|
|
173
188
|
};
|
|
174
189
|
}
|
|
175
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Mode-mismatch diagnostic (plan 3.8): compares the local project.yaml mode
|
|
193
|
+
* against the hosted project's mode via the side-effect-free sync status
|
|
194
|
+
* endpoint. A hand-edited mode previously wedged a project with opaque 409s
|
|
195
|
+
* on both sides and no diagnostic anywhere. Network problems degrade to an
|
|
196
|
+
* "unreachable" note — status stays offline-safe.
|
|
197
|
+
*/
|
|
198
|
+
async function readHostedModeCheck(projectFilePath, localMode, options = {}) {
|
|
199
|
+
let binding;
|
|
200
|
+
try {
|
|
201
|
+
const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|
|
202
|
+
sourceName: projectFilePath,
|
|
203
|
+
});
|
|
204
|
+
binding = resolveSyncBinding(config, null);
|
|
205
|
+
} catch {
|
|
206
|
+
return { status: 'not-configured' };
|
|
207
|
+
}
|
|
208
|
+
if (!binding) return { status: 'not-configured' };
|
|
209
|
+
|
|
210
|
+
const env = options.env ?? process.env;
|
|
211
|
+
const credential = typeof env[binding.credentialEnvVar] === 'string'
|
|
212
|
+
? env[binding.credentialEnvVar].trim()
|
|
213
|
+
: '';
|
|
214
|
+
if (!credential) {
|
|
215
|
+
return { status: 'no-credential', credentialEnvVar: binding.credentialEnvVar };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
219
|
+
if (typeof fetchImpl !== 'function') return { status: 'not-configured' };
|
|
220
|
+
|
|
221
|
+
const baseUrl = binding.apiUrl.endsWith('/') ? binding.apiUrl : `${binding.apiUrl}/`;
|
|
222
|
+
const endpoint = new URL(
|
|
223
|
+
`api/sync/projects/${encodeURIComponent(binding.projectId)}/status`,
|
|
224
|
+
baseUrl,
|
|
225
|
+
);
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
228
|
+
try {
|
|
229
|
+
const response = await fetchImpl(endpoint.href, {
|
|
230
|
+
headers: {
|
|
231
|
+
accept: 'application/json',
|
|
232
|
+
authorization: `Bearer ${credential}`,
|
|
233
|
+
},
|
|
234
|
+
signal: controller.signal,
|
|
235
|
+
});
|
|
236
|
+
if (!response.ok) {
|
|
237
|
+
return { status: 'unreachable', detail: `HTTP ${response.status}` };
|
|
238
|
+
}
|
|
239
|
+
const data = typeof response.json === 'function' ? await response.json() : {};
|
|
240
|
+
const hostedMode = typeof data.mode === 'string' ? data.mode : null;
|
|
241
|
+
return {
|
|
242
|
+
status: 'ok',
|
|
243
|
+
hostedMode,
|
|
244
|
+
localMode,
|
|
245
|
+
mismatch: Boolean(hostedMode && localMode && hostedMode !== localMode),
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
return { status: 'unreachable', detail: error?.message ?? 'network error' };
|
|
249
|
+
} finally {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
176
254
|
async function readProjectSummary(projectFilePath) {
|
|
177
255
|
try {
|
|
178
256
|
const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
|