depa-codument 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/package.json +63 -0
  4. package/src/cli/commands/archive.ts +519 -0
  5. package/src/cli/commands/decisions.ts +123 -0
  6. package/src/cli/commands/engineering.ts +105 -0
  7. package/src/cli/commands/init.ts +54 -0
  8. package/src/cli/commands/list.ts +73 -0
  9. package/src/cli/commands/modeling.ts +105 -0
  10. package/src/cli/commands/show.ts +238 -0
  11. package/src/cli/commands/status.ts +140 -0
  12. package/src/cli/commands/upgrade-track.ts +385 -0
  13. package/src/cli/commands/upgrade-workspace.ts +138 -0
  14. package/src/cli/commands/validate.ts +330 -0
  15. package/src/cli/engineering/config.ts +68 -0
  16. package/src/cli/engineering/lint.ts +58 -0
  17. package/src/cli/engineering/merge.ts +172 -0
  18. package/src/cli/engineering/registry.ts +230 -0
  19. package/src/cli/engineering/schema.ts +126 -0
  20. package/src/cli/engineering/validate.ts +286 -0
  21. package/src/cli/index.ts +136 -0
  22. package/src/cli/modeling/config.ts +68 -0
  23. package/src/cli/modeling/lint.ts +58 -0
  24. package/src/cli/modeling/merge.ts +172 -0
  25. package/src/cli/modeling/registry.ts +229 -0
  26. package/src/cli/modeling/schema.ts +160 -0
  27. package/src/cli/modeling/validate.ts +282 -0
  28. package/src/cli/utils/index.ts +941 -0
  29. package/src/cli/utils/install.ts +291 -0
  30. package/src/cli/utils/spec-xml.ts +673 -0
  31. package/src/cli/utils/track-time.ts +75 -0
  32. package/src/cli/utils/vfs.ts +102 -0
  33. package/src/templates/codument/README.md +59 -0
  34. package/src/templates/codument/attractors/product.md +17 -0
  35. package/src/templates/codument/attractors/project.md +10 -0
  36. package/src/templates/codument/backlog/README.md +33 -0
  37. package/src/templates/codument/config/attractor-profiles.xml +31 -0
  38. package/src/templates/codument/config/engineering.xml +22 -0
  39. package/src/templates/codument/config/modeling.xml +22 -0
  40. package/src/templates/codument/config/operation-hooks.xml +55 -0
  41. package/src/templates/codument/memory/README.md +13 -0
  42. package/src/templates/codument/missions/README.md +125 -0
  43. package/src/templates/codument/sop/README.md +14 -0
  44. package/src/templates/codument/std/AGENTS.md +82 -0
  45. package/src/templates/codument/std/attractors/depa-attractor.md +572 -0
  46. package/src/templates/codument/std/attractors/knowledge-tiers.md +128 -0
  47. package/src/templates/codument/std/attractors/model-driven-docs.md +293 -0
  48. package/src/templates/codument/std/attractors/project-memory.md +48 -0
  49. package/src/templates/codument/std/docs-impl-fractal/index.md +110 -0
  50. package/src/templates/codument/std/docs-modeling-fractal/index.md +156 -0
  51. package/src/templates/codument/std/kernel-pointer.md +19 -0
  52. package/src/templates/codument/std/operations/README.md +30 -0
  53. package/src/templates/codument/std/operations/_operation-spec.md +41 -0
  54. package/src/templates/codument/std/operations/archive-mission.md +66 -0
  55. package/src/templates/codument/std/operations/archive-track.md +238 -0
  56. package/src/templates/codument/std/operations/artifact-sync.md +172 -0
  57. package/src/templates/codument/std/operations/discuss-phase.md +214 -0
  58. package/src/templates/codument/std/operations/discuss.md +87 -0
  59. package/src/templates/codument/std/operations/docs-bootstrap.md +148 -0
  60. package/src/templates/codument/std/operations/gap-loop.md +301 -0
  61. package/src/templates/codument/std/operations/impl-mission.md +167 -0
  62. package/src/templates/codument/std/operations/impl-quick.md +79 -0
  63. package/src/templates/codument/std/operations/impl-track.md +537 -0
  64. package/src/templates/codument/std/operations/migrate.md +337 -0
  65. package/src/templates/codument/std/operations/plan-mission.md +230 -0
  66. package/src/templates/codument/std/operations/plan-track-wave.md +231 -0
  67. package/src/templates/codument/std/operations/plan-track.md +579 -0
  68. package/src/templates/codument/std/operations/revise-track.md +136 -0
  69. package/src/templates/codument/std/operations/validate.md +339 -0
  70. package/src/templates/codument/std/operations/verify.md +184 -0
  71. package/src/templates/codument/std/root-agents.md +39 -0
  72. package/src/templates/codument/std/sop/questioning.md +98 -0
  73. package/src/templates/codument/std/sop/tdd.md +26 -0
  74. package/src/templates/codument/std/sop/validation.md +25 -0
  75. package/src/templates/codument/std/sop/wave-exec.md +42 -0
  76. package/src/templates/codument/std/sop/workflow.md +35 -0
  77. package/src/templates/codument/std/spec/behavior-delta.md +36 -0
  78. package/src/templates/codument/std/spec/behavior-registry.md +42 -0
  79. package/src/templates/codument/std/spec/engineering-delta.md +68 -0
  80. package/src/templates/codument/std/spec/engineering-node-schema.md +86 -0
  81. package/src/templates/codument/std/spec/engineering-registry.md +82 -0
  82. package/src/templates/codument/std/spec/flow-notation.md +93 -0
  83. package/src/templates/codument/std/spec/folder-manifest.md +99 -0
  84. package/src/templates/codument/std/spec/mission-xml-spec.md +249 -0
  85. package/src/templates/codument/std/spec/modeling-delta.md +85 -0
  86. package/src/templates/codument/std/spec/modeling-node-schema.md +183 -0
  87. package/src/templates/codument/std/spec/modeling-registry.md +49 -0
  88. package/src/templates/codument/std/spec/track-xml-spec.md +272 -0
  89. package/src/templates/codument/std/spec/xnl-format.md +301 -0
  90. package/src/templates/codument/workflows/README.md +15 -0
  91. package/src/templates/manifest.ts +177 -0
  92. package/src/templates/skills/README.md +38 -0
  93. package/src/templates/skills/codument-archive/SKILL.md +17 -0
  94. package/src/templates/skills/codument-archive-mission/SKILL.md +17 -0
  95. package/src/templates/skills/codument-archive-track/SKILL.md +17 -0
  96. package/src/templates/skills/codument-artifact-sync/SKILL.md +17 -0
  97. package/src/templates/skills/codument-code-quality-score/SKILL.md +67 -0
  98. package/src/templates/skills/codument-decision-tree/SKILL.md +40 -0
  99. package/src/templates/skills/codument-discuss/SKILL.md +17 -0
  100. package/src/templates/skills/codument-discuss-phase/SKILL.md +17 -0
  101. package/src/templates/skills/codument-docs-bootstrap/SKILL.md +17 -0
  102. package/src/templates/skills/codument-gap-loop/SKILL.md +17 -0
  103. package/src/templates/skills/codument-impl-mission/SKILL.md +17 -0
  104. package/src/templates/skills/codument-impl-quick/SKILL.md +17 -0
  105. package/src/templates/skills/codument-impl-track/SKILL.md +17 -0
  106. package/src/templates/skills/codument-implement/SKILL.md +14 -0
  107. package/src/templates/skills/codument-migrate/SKILL.md +17 -0
  108. package/src/templates/skills/codument-modeling-engineering-e2e/SKILL.md +74 -0
  109. package/src/templates/skills/codument-plan-mission/SKILL.md +17 -0
  110. package/src/templates/skills/codument-plan-track/SKILL.md +17 -0
  111. package/src/templates/skills/codument-plan-track-wave/SKILL.md +17 -0
  112. package/src/templates/skills/codument-revise-track/SKILL.md +17 -0
  113. package/src/templates/skills/codument-track/SKILL.md +14 -0
  114. package/src/templates/skills/codument-validate/SKILL.md +17 -0
  115. package/src/templates/skills/codument-verify/SKILL.md +17 -0
  116. package/src/types/text-assets.d.ts +9 -0
  117. package/src/version.ts +1 -0
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bun
2
+ import { listCommand } from './commands/list';
3
+ import { showCommand } from './commands/show';
4
+ import { validateCommand } from './commands/validate';
5
+ import { statusCommand } from './commands/status';
6
+ import { initCommand } from './commands/init';
7
+ import { archiveCommand } from './commands/archive';
8
+ import { upgradeWorkspaceCommand } from './commands/upgrade-workspace';
9
+ import { upgradeTrackCommand } from './commands/upgrade-track';
10
+ import { modelingCommand } from './commands/modeling';
11
+ import { engineeringCommand } from './commands/engineering';
12
+ import { decisionsCommand } from './commands/decisions';
13
+ import { setWorkspaceDir } from './utils';
14
+ import { VERSION } from '../version';
15
+
16
+ function helpText(): string {
17
+ return `
18
+ codument v${VERSION} - Spec-driven development tool for AI coding assistants
19
+
20
+ Usage:
21
+ codument <command> [options]
22
+
23
+ Commands:
24
+ init Initialize Codument in the current project
25
+ upgrade-workspace Upgrade workspace Codument files to latest
26
+ upgrade-track Upgrade a track plan.xml to wave-capable format
27
+ list List active tracks or specs
28
+ show [item] Show details of a track or spec
29
+ validate [item] Validate track or spec format
30
+ archive <id> Archive a completed track
31
+ status Show project status overview
32
+ modeling lint Flag oversized modeling XNL files for fractal split
33
+ modeling validate Validate modeling XNL (syntax + schema + hierarchy)
34
+ engineering lint Flag oversized engineering XNL files for fractal split
35
+ engineering validate Validate engineering XNL (syntax + schema + hierarchy)
36
+ decisions validate Validate track decisions.md metadata
37
+
38
+ Options:
39
+ -h, --help Show this help message
40
+ -v, --version Show version number
41
+ -w, --workspace-dir Set workspace directory (default: current directory)
42
+
43
+ Examples:
44
+ codument init --agent=codex,claude,eidolon,sparrow # Install skills into the target skills dir
45
+ codument init --skills-dir=.claude/skills --force
46
+ codument upgrade-workspace --agent=codex,claude,eidolon,sparrow # Refresh std + selected agent skills
47
+ codument list # List active tracks
48
+ codument show add-user-auth # Show track details
49
+ codument validate # Validate all tracks (track.xml + behavior deltas)
50
+ codument validate add-user-auth # Validate one track
51
+ codument status # Show project status
52
+ `;
53
+ }
54
+
55
+ async function main() {
56
+ const args = process.argv.slice(2);
57
+
58
+ // Parse global options first
59
+ let workspaceDir: string | undefined;
60
+ const filteredArgs: string[] = [];
61
+
62
+ for (let i = 0; i < args.length; i++) {
63
+ if (args[i] === '-w' || args[i] === '--workspace-dir') {
64
+ workspaceDir = args[i + 1];
65
+ i++; // Skip the value
66
+ } else {
67
+ filteredArgs.push(args[i]);
68
+ }
69
+ }
70
+
71
+ // Set workspace directory if provided
72
+ if (workspaceDir) {
73
+ setWorkspaceDir(workspaceDir);
74
+ }
75
+
76
+ if (filteredArgs.length === 0 || filteredArgs[0] === '-h' || filteredArgs[0] === '--help') {
77
+ console.log(helpText());
78
+ process.exit(0);
79
+ }
80
+
81
+ if (filteredArgs[0] === '-v' || filteredArgs[0] === '--version') {
82
+ console.log(`codument v${VERSION}`);
83
+ process.exit(0);
84
+ }
85
+
86
+ const command = filteredArgs[0];
87
+ const commandArgs = filteredArgs.slice(1);
88
+
89
+ try {
90
+ switch (command) {
91
+ case 'init':
92
+ await initCommand(commandArgs);
93
+ break;
94
+ case 'list':
95
+ await listCommand(commandArgs);
96
+ break;
97
+ case 'show':
98
+ await showCommand(commandArgs);
99
+ break;
100
+ case 'validate':
101
+ await validateCommand(commandArgs);
102
+ break;
103
+ case 'archive':
104
+ await archiveCommand(commandArgs);
105
+ break;
106
+ case 'status':
107
+ await statusCommand(commandArgs);
108
+ break;
109
+ case 'upgrade-workspace':
110
+ await upgradeWorkspaceCommand(commandArgs);
111
+ break;
112
+ case 'upgrade-track':
113
+ await upgradeTrackCommand(commandArgs);
114
+ break;
115
+ case 'modeling':
116
+ await modelingCommand(commandArgs);
117
+ break;
118
+ case 'engineering':
119
+ await engineeringCommand(commandArgs);
120
+ break;
121
+ case 'decisions':
122
+ await decisionsCommand(commandArgs);
123
+ break;
124
+ default:
125
+ console.error(`Unknown command: ${command}`);
126
+ console.log(helpText());
127
+ process.exit(1);
128
+ }
129
+ process.exit(0);
130
+ } catch (error) {
131
+ console.error('Error:', error instanceof Error ? error.message : error);
132
+ process.exit(1);
133
+ }
134
+ }
135
+
136
+ main();
@@ -0,0 +1,68 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { CODUMENT_DIR } from '../utils';
4
+ import { DEFAULT_THRESHOLDS, type LintThresholds } from './lint';
5
+ import { DEFAULT_POLICY, type MergePolicy, type ConflictType, type Resolve } from './merge';
6
+
7
+ /**
8
+ * codument/config/modeling.xml gate + settings.
9
+ *
10
+ * Default OFF: when the file is absent or `enabled` is not "true", modeling is
11
+ * disabled and all modeling behavior (track delta, archive merge, lint) is skipped.
12
+ * Lightweight regex read (the file is small and flat); no XML dependency.
13
+ */
14
+
15
+ export interface ModelingConfig {
16
+ enabled: boolean;
17
+ registryDir: string;
18
+ thresholds: LintThresholds;
19
+ mergePolicy: MergePolicy;
20
+ }
21
+
22
+ export function modelingConfigPath(workspaceDir = '.'): string {
23
+ return path.join(workspaceDir, CODUMENT_DIR, 'config', 'modeling.xml');
24
+ }
25
+
26
+ function matchNum(xml: string, re: RegExp): number | undefined {
27
+ const m = re.exec(xml);
28
+ return m ? Number(m[1]) : undefined;
29
+ }
30
+
31
+ const VALID_RESOLVE = new Set<Resolve>(['human', 'ours', 'theirs', 'base']);
32
+
33
+ export function loadModelingConfig(configPath = modelingConfigPath()): ModelingConfig {
34
+ const def: ModelingConfig = {
35
+ enabled: false,
36
+ registryDir: path.join(CODUMENT_DIR, 'modeling'),
37
+ thresholds: { ...DEFAULT_THRESHOLDS },
38
+ mergePolicy: { ...DEFAULT_POLICY },
39
+ };
40
+ if (!fs.existsSync(configPath)) return def;
41
+
42
+ const xml = fs.readFileSync(configPath, 'utf-8');
43
+ const enabled = /<Modeling[^>]*\benabled="true"/.test(xml);
44
+ const maxLines = matchNum(xml, /<Lint[^>]*\bmaxLines="(\d+)"/);
45
+ const maxNodes = matchNum(xml, /<Lint[^>]*\bmaxNodes="(\d+)"/);
46
+
47
+ const mergePolicy: MergePolicy = { ...DEFAULT_POLICY };
48
+ for (const m of xml.matchAll(/<Conflict\s+type="([^"]+)"\s+resolve="([^"]+)"/g)) {
49
+ const type = m[1] as ConflictType;
50
+ const resolve = m[2] as Resolve;
51
+ if (type in mergePolicy && VALID_RESOLVE.has(resolve)) mergePolicy[type] = resolve;
52
+ }
53
+
54
+ return {
55
+ enabled,
56
+ registryDir: def.registryDir,
57
+ thresholds: {
58
+ maxLines: maxLines ?? DEFAULT_THRESHOLDS.maxLines,
59
+ maxNodes: maxNodes ?? DEFAULT_THRESHOLDS.maxNodes,
60
+ },
61
+ mergePolicy,
62
+ };
63
+ }
64
+
65
+ /** True when modeling is enabled in this workspace. */
66
+ export function modelingEnabled(workspaceDir = '.'): boolean {
67
+ return loadModelingConfig(modelingConfigPath(workspaceDir)).enabled;
68
+ }
@@ -0,0 +1,58 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { loadModelingRegistry, isDataElement, readNodeId } from './registry';
4
+
5
+ /**
6
+ * Fractal-split lint: flag oversized modeling XNL files as candidates for
7
+ * same-name-folder split (multi-file). Heuristic, advisory — the actual split is
8
+ * applied by the model per folder-manifest.md. Thresholds are configurable
9
+ * (defaults below; overridable via codument/config/modeling.xml in P4).
10
+ */
11
+
12
+ export interface LintThresholds {
13
+ /** Max lines before a file is a split candidate. */
14
+ maxLines: number;
15
+ /** Max top-level modeling nodes before a file is a split candidate. */
16
+ maxNodes: number;
17
+ }
18
+
19
+ export const DEFAULT_THRESHOLDS: LintThresholds = {
20
+ maxLines: 400,
21
+ maxNodes: 8,
22
+ };
23
+
24
+ export interface LintFinding {
25
+ file: string;
26
+ lines: number;
27
+ nodeCount: number;
28
+ reasons: string[];
29
+ }
30
+
31
+ /** Lint a modeling registry directory for fractal-split candidates. */
32
+ export function lintModelingRegistry(
33
+ dir: string,
34
+ thresholds: LintThresholds = DEFAULT_THRESHOLDS,
35
+ ): LintFinding[] {
36
+ const findings: LintFinding[] = [];
37
+ if (!fs.existsSync(dir)) return findings;
38
+ const registry = loadModelingRegistry(dir);
39
+
40
+ for (const [relFile, nodes] of registry.files) {
41
+ const content = fs.readFileSync(path.join(dir, relFile), 'utf-8');
42
+ const lines = content.split('\n').length;
43
+ const nodeCount = nodes.filter((n) => isDataElement(n) && readNodeId(n)).length;
44
+
45
+ const reasons: string[] = [];
46
+ if (lines > thresholds.maxLines) {
47
+ reasons.push(`${lines} lines > ${thresholds.maxLines}`);
48
+ }
49
+ if (nodeCount > thresholds.maxNodes) {
50
+ reasons.push(`${nodeCount} nodes > ${thresholds.maxNodes}`);
51
+ }
52
+ if (reasons.length > 0) {
53
+ findings.push({ file: relFile, lines, nodeCount, reasons });
54
+ }
55
+ }
56
+
57
+ return findings.sort((a, b) => a.file.localeCompare(b.file));
58
+ }
@@ -0,0 +1,172 @@
1
+ import { diffNodes, applyMutations, parsePath } from 'xnl-core';
2
+ import type { XnlNode, XnlMutation, XnlPath, PathItem } from 'xnl-core';
3
+ import { isDataElement, readNodeId } from './registry';
4
+
5
+ /**
6
+ * Node-level three-way merge for modeling deltas (base + ours + theirs), keyed by
7
+ * namespaced node id. Reuses xnl-core diffNodes/applyMutations. Conservative by
8
+ * default: disjoint changes auto-merge; true conflicts are reported (not silently
9
+ * overwritten) unless a per-type policy overrides. See std/spec/modeling-delta.md.
10
+ */
11
+
12
+ export type ConflictType = 'same-field' | 'delete-modify' | 'add-add';
13
+ export type Resolve = 'human' | 'ours' | 'theirs' | 'base';
14
+ export type MergePolicy = Record<ConflictType, Resolve>;
15
+
16
+ export const DEFAULT_POLICY: MergePolicy = {
17
+ 'same-field': 'human',
18
+ 'delete-modify': 'human',
19
+ 'add-add': 'human',
20
+ };
21
+
22
+ export interface MergeConflict {
23
+ id: string;
24
+ type: ConflictType;
25
+ base?: XnlNode;
26
+ ours?: XnlNode;
27
+ theirs?: XnlNode;
28
+ }
29
+
30
+ export interface MergeResult {
31
+ /** Merged node set keyed by id (excludes unresolved human conflicts). */
32
+ merged: Map<string, XnlNode>;
33
+ conflicts: MergeConflict[];
34
+ }
35
+
36
+ /** Index top-level DataElement nodes by id. */
37
+ export function indexById(nodes: XnlNode[]): Map<string, XnlNode> {
38
+ const m = new Map<string, XnlNode>();
39
+ for (const n of nodes) {
40
+ const id = readNodeId(n);
41
+ if (id && isDataElement(n)) m.set(id, n);
42
+ }
43
+ return m;
44
+ }
45
+
46
+ function clone<T>(v: T): T {
47
+ return JSON.parse(JSON.stringify(v)) as T;
48
+ }
49
+
50
+ function nodeEqual(a: XnlNode | undefined, b: XnlNode | undefined): boolean {
51
+ return JSON.stringify(a) === JSON.stringify(b);
52
+ }
53
+
54
+ function mutationsBetween(from: XnlNode, to: XnlNode): XnlMutation[] {
55
+ return diffNodes(from, to, [], { metadataIdMode: 'identity' });
56
+ }
57
+
58
+ function pathItems(p: string | XnlPath): PathItem[] {
59
+ return Array.isArray(p) ? p : parsePath(p);
60
+ }
61
+
62
+ /** Two paths overlap if one is a prefix of (or equal to) the other. */
63
+ function overlaps(a: PathItem[], b: PathItem[]): boolean {
64
+ const n = Math.min(a.length, b.length);
65
+ for (let i = 0; i < n; i++) {
66
+ if (a[i].type !== b[i].type || a[i].value !== b[i].value) return false; // diverge -> disjoint
67
+ }
68
+ return true; // one is a prefix of the other -> overlap
69
+ }
70
+
71
+ function changesAreDisjoint(oursMut: XnlMutation[], theirsMut: XnlMutation[]): boolean {
72
+ for (const o of oursMut) {
73
+ for (const t of theirsMut) {
74
+ if (overlaps(pathItems(o.path), pathItems(t.path))) return false;
75
+ }
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function resolveConflict(
81
+ conflict: MergeConflict,
82
+ policy: MergePolicy,
83
+ merged: Map<string, XnlNode>,
84
+ conflicts: MergeConflict[],
85
+ ): void {
86
+ const choice = policy[conflict.type] ?? 'human';
87
+ switch (choice) {
88
+ case 'ours':
89
+ if (conflict.ours !== undefined) merged.set(conflict.id, conflict.ours);
90
+ return;
91
+ case 'theirs':
92
+ if (conflict.theirs !== undefined) merged.set(conflict.id, conflict.theirs);
93
+ return;
94
+ case 'base':
95
+ if (conflict.base !== undefined) merged.set(conflict.id, conflict.base);
96
+ return;
97
+ case 'human':
98
+ default:
99
+ conflicts.push(conflict); // report; leave unresolved
100
+ return;
101
+ }
102
+ }
103
+
104
+ export function mergeModeling(
105
+ baseNodes: XnlNode[],
106
+ ourNodes: XnlNode[],
107
+ theirNodes: XnlNode[],
108
+ policy: MergePolicy = DEFAULT_POLICY,
109
+ ): MergeResult {
110
+ const base = indexById(baseNodes);
111
+ const ours = indexById(ourNodes);
112
+ const theirs = indexById(theirNodes);
113
+ const merged = new Map<string, XnlNode>();
114
+ const conflicts: MergeConflict[] = [];
115
+
116
+ const ids = new Set<string>([...base.keys(), ...ours.keys(), ...theirs.keys()]);
117
+
118
+ for (const id of ids) {
119
+ const b = base.get(id);
120
+ const o = ours.get(id);
121
+ const t = theirs.get(id);
122
+
123
+ // Present in all three.
124
+ if (b && o && t) {
125
+ const oChanged = !nodeEqual(b, o);
126
+ const tChanged = !nodeEqual(b, t);
127
+ if (!oChanged && !tChanged) merged.set(id, o);
128
+ else if (oChanged && !tChanged) merged.set(id, o);
129
+ else if (!oChanged && tChanged) merged.set(id, t);
130
+ else if (nodeEqual(o, t)) merged.set(id, o); // same change both sides
131
+ else {
132
+ const oursMut = mutationsBetween(b, o);
133
+ const theirsMut = mutationsBetween(b, t);
134
+ if (changesAreDisjoint(oursMut, theirsMut)) {
135
+ merged.set(id, applyMutations(clone(b), [...oursMut, ...theirsMut], { metadataIdMode: 'identity' }));
136
+ } else {
137
+ resolveConflict({ id, type: 'same-field', base: b, ours: o, theirs: t }, policy, merged, conflicts);
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+
143
+ // Added (not in base).
144
+ if (!b) {
145
+ if (o && !t) merged.set(id, o);
146
+ else if (!o && t) merged.set(id, t);
147
+ else if (o && t) {
148
+ if (nodeEqual(o, t)) merged.set(id, o);
149
+ else resolveConflict({ id, type: 'add-add', ours: o, theirs: t }, policy, merged, conflicts);
150
+ }
151
+ continue;
152
+ }
153
+
154
+ // In base; deleted on at least one side.
155
+ const oDeleted = !o;
156
+ const tDeleted = !t;
157
+ if (oDeleted && tDeleted) continue; // both deleted -> gone
158
+ if (oDeleted && t) {
159
+ // ours deleted; theirs kept/modified
160
+ if (nodeEqual(b, t)) continue; // theirs unchanged -> honor delete
161
+ resolveConflict({ id, type: 'delete-modify', base: b, theirs: t }, policy, merged, conflicts);
162
+ continue;
163
+ }
164
+ if (tDeleted && o) {
165
+ if (nodeEqual(b, o)) continue; // ours unchanged -> honor delete
166
+ resolveConflict({ id, type: 'delete-modify', base: b, ours: o }, policy, merged, conflicts);
167
+ continue;
168
+ }
169
+ }
170
+
171
+ return { merged, conflicts };
172
+ }
@@ -0,0 +1,229 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { parseXnl, stringifyLineBlock, wordToString, XnlParseError } from 'xnl-core';
4
+ import type { XnlNode, DataElementNode, XnlWord } from 'xnl-core';
5
+
6
+ /**
7
+ * codument modeling registry adapter.
8
+ *
9
+ * The registry is a plain, host-git-versioned working tree of `.xnl` files under
10
+ * `codument/modeling/<plane>/<context>/...`. We load/save via xnl-core (parse +
11
+ * lineBlock formatter) and index nodes by their namespaced id. No parallel vcs repo
12
+ * is persisted; node-level 3-way merge (see merge.ts) uses xnl-vfs ephemerally.
13
+ */
14
+
15
+ export interface ModelingNodeRef {
16
+ /** Namespaced id, e.g. `resource.skill_tool` or `domain.resource.skill_tool`. */
17
+ id: string;
18
+ node: DataElementNode;
19
+ /** Path relative to the modeling dir, e.g. `domain/resource/index.xnl`. */
20
+ file: string;
21
+ /** `modeling://<plane>/<context>/<name>` derived from the file path + id. */
22
+ uri: string;
23
+ }
24
+
25
+ export interface ModelingRegistry {
26
+ dir: string;
27
+ /** relPath -> top-level nodes of that file (preserves order for save). */
28
+ files: Map<string, XnlNode[]>;
29
+ /** namespaced id -> ref. */
30
+ index: Map<string, ModelingNodeRef>;
31
+ }
32
+
33
+ export function isDataElement(node: XnlNode | undefined): node is DataElementNode {
34
+ return Boolean(node && typeof node === 'object' && (node as DataElementNode).kind === 'DataElement');
35
+ }
36
+
37
+ /** Read a node's namespaced id from `#word` or `metadata.id`. */
38
+ export function readNodeId(node: XnlNode): string | undefined {
39
+ if (!isDataElement(node)) return undefined;
40
+ const fromWord = wordToString((node as { id?: XnlWord }).id);
41
+ if (fromWord) return fromWord;
42
+ const metaId = node.metadata?.id;
43
+ if (typeof metaId === 'string') return metaId;
44
+ if (metaId && typeof metaId === 'object' && (metaId as XnlWord).kind === 'Word') {
45
+ return wordToString(metaId as XnlWord);
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /** Last segment of a namespaced id (`a.b.name` -> `name`). */
51
+ export function nodeName(id: string): string {
52
+ const parts = id.split('.');
53
+ return parts[parts.length - 1];
54
+ }
55
+
56
+ /**
57
+ * Namespace prefix of a namespaced id (`a.b.name` -> `a.b`, `name` -> ``).
58
+ * The namespace is the id minus its trailing `name` segment; a bare name yields
59
+ * the empty string. Used by id↔path alignment and modeling-uri derivation.
60
+ */
61
+ export function idNamespace(id: string): string {
62
+ const parts = id.split('.');
63
+ return parts.slice(0, -1).join('.');
64
+ }
65
+
66
+ /** Build `modeling://<plane>/<context>/<name>` from a file path and node id. */
67
+ export function modelingUri(relFile: string, id: string): string {
68
+ const segs = relFile.split(path.sep).filter(Boolean);
69
+ const plane = segs[0] ?? 'domain';
70
+ const context = segs[1] ?? plane;
71
+ return `modeling://${plane}/${context}/${nodeName(id)}`;
72
+ }
73
+
74
+ function isHidden(name: string): boolean {
75
+ return name.startsWith('.');
76
+ }
77
+
78
+ function walkXnlFiles(dir: string, base: string, out: string[]): void {
79
+ if (!fs.existsSync(dir)) return;
80
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
81
+ if (isHidden(entry.name)) continue; // skip .node-meta, .tmp, .xnl-vcs, etc.
82
+ const abs = path.join(dir, entry.name);
83
+ if (entry.isDirectory()) {
84
+ walkXnlFiles(abs, base, out);
85
+ } else if (entry.isFile() && entry.name.endsWith('.xnl')) {
86
+ out.push(path.relative(base, abs));
87
+ }
88
+ }
89
+ }
90
+
91
+ /** Load the modeling registry from a working tree directory. */
92
+ export function loadModelingRegistry(dir: string): ModelingRegistry {
93
+ const files = new Map<string, XnlNode[]>();
94
+ const index = new Map<string, ModelingNodeRef>();
95
+ const relFiles: string[] = [];
96
+ walkXnlFiles(dir, dir, relFiles);
97
+ relFiles.sort();
98
+
99
+ for (const relFile of relFiles) {
100
+ const content = fs.readFileSync(path.join(dir, relFile), 'utf-8');
101
+ const nodes = parseXnl(content, { textBlockStyle: true }).nodes;
102
+ files.set(relFile, nodes);
103
+ for (const node of nodes) {
104
+ const id = readNodeId(node);
105
+ if (!id || !isDataElement(node)) continue;
106
+ if (index.has(id)) {
107
+ throw new Error(
108
+ `Duplicate modeling node id '${id}' in '${relFile}' and '${index.get(id)!.file}'`,
109
+ );
110
+ }
111
+ index.set(id, { id, node, file: relFile, uri: modelingUri(relFile, id) });
112
+ }
113
+ }
114
+
115
+ return { dir, files, index };
116
+ }
117
+
118
+ /**
119
+ * A non-fatal load issue collected by {@link loadModelingRegistrySafe}: an XNL
120
+ * parse error (`syntax`) or a cross-file duplicate id (`duplicate-id`). The
121
+ * validate engine maps these onto its own finding shape.
122
+ */
123
+ export interface LoadIssue {
124
+ kind: 'syntax' | 'duplicate-id';
125
+ /** Path relative to the registry dir. */
126
+ file: string;
127
+ /** 1-based line, when known (parse errors carry one). */
128
+ line?: number;
129
+ message: string;
130
+ /** For duplicate ids: the other file that already defined the id. */
131
+ otherFile?: string;
132
+ }
133
+
134
+ export interface SafeRegistryResult {
135
+ registry: ModelingRegistry;
136
+ issues: LoadIssue[];
137
+ }
138
+
139
+ /**
140
+ * Load the modeling registry without throwing. Files that fail XNL parsing are
141
+ * skipped (their error is collected as a `syntax` issue); duplicate ids are
142
+ * collected as `duplicate-id` issues (first definition wins in the index)
143
+ * instead of aborting the load. The non-safe {@link loadModelingRegistry}
144
+ * keeps its throwing contract for existing callers (lint, merge, archive).
145
+ */
146
+ export function loadModelingRegistrySafe(dir: string): SafeRegistryResult {
147
+ const files = new Map<string, XnlNode[]>();
148
+ const index = new Map<string, ModelingNodeRef>();
149
+ const issues: LoadIssue[] = [];
150
+ const relFiles: string[] = [];
151
+ walkXnlFiles(dir, dir, relFiles);
152
+ relFiles.sort();
153
+
154
+ for (const relFile of relFiles) {
155
+ const content = fs.readFileSync(path.join(dir, relFile), 'utf-8');
156
+ let nodes: XnlNode[];
157
+ try {
158
+ nodes = parseXnl(content, { textBlockStyle: true }).nodes;
159
+ } catch (err) {
160
+ if (err instanceof XnlParseError) {
161
+ issues.push({ kind: 'syntax', file: relFile, line: err.line, message: err.message });
162
+ continue;
163
+ }
164
+ throw err;
165
+ }
166
+ files.set(relFile, nodes);
167
+ for (const node of nodes) {
168
+ const id = readNodeId(node);
169
+ if (!id || !isDataElement(node)) continue;
170
+ const existing = index.get(id);
171
+ if (existing) {
172
+ issues.push({
173
+ kind: 'duplicate-id',
174
+ file: relFile,
175
+ otherFile: existing.file,
176
+ message: `Duplicate modeling node id '${id}' in '${relFile}' and '${existing.file}'`,
177
+ });
178
+ continue;
179
+ }
180
+ index.set(id, { id, node, file: relFile, uri: modelingUri(relFile, id) });
181
+ }
182
+ }
183
+
184
+ return { registry: { dir, files, index }, issues };
185
+ }
186
+
187
+ export interface SerializeOptions {
188
+ /**
189
+ * Marker factory for markerless text blocks. Defaults to xnl-core's ULID factory,
190
+ * which preserves XNL text nodes' escape-free property (a markerless `?>` block
191
+ * gets a stable unique marker on first write, then round-trips unchanged).
192
+ * Tests may inject a deterministic factory; production MUST keep the ULID default.
193
+ */
194
+ textMarkerFactory?: () => string;
195
+ /**
196
+ * Block text style (default true for modeling files): render each text element's
197
+ * content on its own indented line(s) between `<tag ?m>` and `</?m>`, so registry
198
+ * files stay readable and diff-friendly. Paired with `parseXnl(.., {textBlockStyle})`.
199
+ */
200
+ textBlockStyle?: boolean;
201
+ }
202
+
203
+ /** Serialize top-level nodes of a file to XNL (lineBlock pretty, block text style). */
204
+ export function serializeModelingFile(nodes: XnlNode[], opts: SerializeOptions = {}): string {
205
+ return nodes.map((n) => stringifyLineBlock(n, { textBlockStyle: true, ...opts })).join('\n\n') + '\n';
206
+ }
207
+
208
+ /** Write one modeling file back to the working tree. */
209
+ export function saveModelingFile(
210
+ dir: string,
211
+ relFile: string,
212
+ nodes: XnlNode[],
213
+ opts: SerializeOptions = {},
214
+ ): void {
215
+ const abs = path.join(dir, relFile);
216
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
217
+ fs.writeFileSync(abs, serializeModelingFile(nodes, opts), 'utf-8');
218
+ }
219
+
220
+ /** Save the whole registry back to its working tree. */
221
+ export function saveModelingRegistry(
222
+ registry: ModelingRegistry,
223
+ targetDir = registry.dir,
224
+ opts: SerializeOptions = {},
225
+ ): void {
226
+ for (const [relFile, nodes] of registry.files) {
227
+ saveModelingFile(targetDir, relFile, nodes, opts);
228
+ }
229
+ }