@zyaiting/keelson 0.4.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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +101 -0
  3. package/README_CN.md +101 -0
  4. package/bin/keelson.js +15 -0
  5. package/hooks/codebuddy-session.mjs +67 -0
  6. package/hooks/opencode-session.mjs +65 -0
  7. package/hooks/prompt-state.mjs +66 -0
  8. package/hooks/session-start.mjs +94 -0
  9. package/package.json +64 -0
  10. package/registry/models.json +118 -0
  11. package/registry/platforms.json +92 -0
  12. package/skills/keelson/SKILL.md +44 -0
  13. package/skills/keelson/references/build.md +61 -0
  14. package/skills/keelson/references/context.md +34 -0
  15. package/skills/keelson/references/debug.md +46 -0
  16. package/skills/keelson/references/design-lenses.md +78 -0
  17. package/skills/keelson/references/discover.md +70 -0
  18. package/skills/keelson/references/engineer.md +110 -0
  19. package/skills/keelson/references/frontend-delivery.md +38 -0
  20. package/skills/keelson/references/frontend-interaction.md +31 -0
  21. package/skills/keelson/references/frontend-review.md +33 -0
  22. package/skills/keelson/references/frontend-visual.md +31 -0
  23. package/skills/keelson/references/frontend.md +33 -0
  24. package/skills/keelson/references/handoff.md +43 -0
  25. package/skills/keelson/references/harness.md +54 -0
  26. package/skills/keelson/references/interview.md +120 -0
  27. package/skills/keelson/references/land.md +47 -0
  28. package/skills/keelson/references/model.md +29 -0
  29. package/skills/keelson/references/plan.md +106 -0
  30. package/skills/keelson/references/reconcile.md +61 -0
  31. package/skills/keelson/references/shape.md +86 -0
  32. package/skills/keelson/references/verify.md +64 -0
  33. package/skills/keelson/templates/GLOSSARY.md +5 -0
  34. package/skills/keelson/templates/INTENT.md +22 -0
  35. package/skills/keelson/templates/NOW.md +9 -0
  36. package/skills/keelson/templates/README.md +60 -0
  37. package/skills/keelson/templates/ROADMAP.md +12 -0
  38. package/skills/keelson/templates/change-quick.md +16 -0
  39. package/skills/keelson/templates/change.md +32 -0
  40. package/skills/keelson/templates/delta-spec.md +12 -0
  41. package/skills/keelson/templates/handoff.md +27 -0
  42. package/skills/keelson/templates/ledger.md +3 -0
  43. package/skills/keelson/templates/resident-block.md +7 -0
  44. package/skills/keelson/templates/rules-general.md +10 -0
  45. package/skills/keelson/templates/rules-index.md +5 -0
  46. package/skills/keelson/templates/spec.md +14 -0
  47. package/skills/keelson/templates/tasks.md +9 -0
  48. package/skills/keelson/templates/workflow.md +18 -0
  49. package/skills/zh/keelson/SKILL.md +46 -0
  50. package/skills/zh/keelson/references/build.md +61 -0
  51. package/skills/zh/keelson/references/context.md +34 -0
  52. package/skills/zh/keelson/references/debug.md +46 -0
  53. package/skills/zh/keelson/references/design-lenses.md +78 -0
  54. package/skills/zh/keelson/references/discover.md +70 -0
  55. package/skills/zh/keelson/references/engineer.md +110 -0
  56. package/skills/zh/keelson/references/frontend-delivery.md +38 -0
  57. package/skills/zh/keelson/references/frontend-interaction.md +31 -0
  58. package/skills/zh/keelson/references/frontend-review.md +33 -0
  59. package/skills/zh/keelson/references/frontend-visual.md +31 -0
  60. package/skills/zh/keelson/references/frontend.md +33 -0
  61. package/skills/zh/keelson/references/handoff.md +43 -0
  62. package/skills/zh/keelson/references/harness.md +54 -0
  63. package/skills/zh/keelson/references/interview.md +120 -0
  64. package/skills/zh/keelson/references/land.md +47 -0
  65. package/skills/zh/keelson/references/model.md +29 -0
  66. package/skills/zh/keelson/references/plan.md +106 -0
  67. package/skills/zh/keelson/references/reconcile.md +61 -0
  68. package/skills/zh/keelson/references/shape.md +86 -0
  69. package/skills/zh/keelson/references/verify.md +64 -0
  70. package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
  71. package/skills/zh/keelson/templates/INTENT.md +22 -0
  72. package/skills/zh/keelson/templates/NOW.md +9 -0
  73. package/skills/zh/keelson/templates/README.md +60 -0
  74. package/skills/zh/keelson/templates/ROADMAP.md +12 -0
  75. package/skills/zh/keelson/templates/change-quick.md +16 -0
  76. package/skills/zh/keelson/templates/change.md +32 -0
  77. package/skills/zh/keelson/templates/delta-spec.md +12 -0
  78. package/skills/zh/keelson/templates/handoff.md +27 -0
  79. package/skills/zh/keelson/templates/ledger.md +3 -0
  80. package/skills/zh/keelson/templates/resident-block.md +7 -0
  81. package/skills/zh/keelson/templates/rules-general.md +10 -0
  82. package/skills/zh/keelson/templates/rules-index.md +5 -0
  83. package/skills/zh/keelson/templates/spec.md +14 -0
  84. package/skills/zh/keelson/templates/tasks.md +9 -0
  85. package/skills/zh/keelson/templates/workflow.md +18 -0
  86. package/src/cli.js +87 -0
  87. package/src/commands/ablate.js +96 -0
  88. package/src/commands/ask.js +64 -0
  89. package/src/commands/attest.js +71 -0
  90. package/src/commands/check.js +127 -0
  91. package/src/commands/context.js +95 -0
  92. package/src/commands/design.js +63 -0
  93. package/src/commands/doctor.js +157 -0
  94. package/src/commands/focus.js +84 -0
  95. package/src/commands/guide.js +59 -0
  96. package/src/commands/handoff.js +41 -0
  97. package/src/commands/hook.js +23 -0
  98. package/src/commands/impact.js +58 -0
  99. package/src/commands/init.js +289 -0
  100. package/src/commands/land.js +258 -0
  101. package/src/commands/models.js +62 -0
  102. package/src/commands/new.js +70 -0
  103. package/src/commands/platforms.js +39 -0
  104. package/src/commands/retro.js +114 -0
  105. package/src/commands/status.js +115 -0
  106. package/src/commands/uninstall.js +30 -0
  107. package/src/commands/validate.js +117 -0
  108. package/src/lib/args.js +30 -0
  109. package/src/lib/changes.js +114 -0
  110. package/src/lib/check-activity.js +29 -0
  111. package/src/lib/config.js +102 -0
  112. package/src/lib/decisions.js +59 -0
  113. package/src/lib/evidence.js +127 -0
  114. package/src/lib/fs.js +126 -0
  115. package/src/lib/git.js +353 -0
  116. package/src/lib/glob.js +54 -0
  117. package/src/lib/health.js +113 -0
  118. package/src/lib/lifecycle.js +120 -0
  119. package/src/lib/maintenance.js +66 -0
  120. package/src/lib/markdown.js +438 -0
  121. package/src/lib/models.js +195 -0
  122. package/src/lib/out.js +13 -0
  123. package/src/lib/paths.js +82 -0
  124. package/src/lib/rules.js +27 -0
  125. package/src/lib/runtime-path.js +22 -0
  126. package/src/lib/session.js +100 -0
  127. package/src/lib/specs.js +345 -0
  128. package/src/lib/transaction.js +93 -0
  129. package/src/platforms/index.js +3 -0
  130. package/src/platforms/integration.js +384 -0
  131. package/src/platforms/registry.js +46 -0
  132. package/src/platforms/runtime.js +249 -0
@@ -0,0 +1,82 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { exists } from './fs.js';
6
+ import { loadConfig } from './config.js';
7
+ import { runtimeDir } from './runtime-path.js';
8
+
9
+ export const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
10
+ export const KEELSON_DIR = '.keelson';
11
+ export const USER_HOME = path.join(os.homedir(), '.keelson');
12
+
13
+ /** A project root is a directory whose .keelson/ holds config.yaml or INTENT.md. The user-level ~/.keelson never counts. */
14
+ export const isProjectRoot = (dir) => {
15
+ const k = path.join(dir, KEELSON_DIR);
16
+ if (path.resolve(k) === path.resolve(USER_HOME)) return false;
17
+ return exists(path.join(k, 'config.yaml')) || exists(path.join(k, 'INTENT.md'));
18
+ };
19
+
20
+ /** Walk up from cwd to find the project root containing .keelson/. */
21
+ export function findProjectRoot(start = process.cwd()) {
22
+ let dir = path.resolve(start);
23
+ for (;;) {
24
+ if (isProjectRoot(dir)) return dir;
25
+ const parent = path.dirname(dir);
26
+ if (parent === dir) return null;
27
+ dir = parent;
28
+ }
29
+ }
30
+
31
+ export function requireProjectRoot(start) {
32
+ const root = findProjectRoot(start);
33
+ if (!root) throw new Error('no .keelson/ found here or in any parent directory. Run `keelson init` first.');
34
+ return root;
35
+ }
36
+
37
+ /** Resolve writable project data without following links out of the project. */
38
+ export function resolveWithin(root, relative) {
39
+ if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)) throw new Error('project data path must be relative');
40
+ const absolute = path.resolve(root, relative);
41
+ const rel = path.relative(root, absolute);
42
+ if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) throw new Error(`project data path escapes its root: ${relative}`);
43
+ let current = root;
44
+ for (const part of rel.split(path.sep)) {
45
+ current = path.join(current, part);
46
+ // A single lstat also sees dangling links and tolerates an atomic-write
47
+ // temporary file disappearing between directory enumeration and this scan.
48
+ if (fs.lstatSync(current, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error(`project data path must not be a symlink: ${current}`);
49
+ }
50
+ return absolute;
51
+ }
52
+
53
+ /** Resolve every path Keelson uses. `paths.specs` in config.yaml may relocate the contracts. */
54
+ export const projectPaths = (root, cfg = null) => {
55
+ const k = resolveWithin(root, KEELSON_DIR);
56
+ const config = path.join(k, 'config.yaml');
57
+ const c = cfg ?? (exists(config) ? loadConfig(config) : null);
58
+ const specsRel = c?.paths?.specs ?? '.keelson/specs';
59
+ return {
60
+ root,
61
+ keelson: k,
62
+ config,
63
+ readme: path.join(k, 'README.md'),
64
+ intent: path.join(k, 'INTENT.md'),
65
+ now: path.join(k, 'NOW.md'),
66
+ roadmap: path.join(k, 'ROADMAP.md'),
67
+ glossary: path.join(k, 'GLOSSARY.md'),
68
+ workflow: path.join(k, 'workflow.md'),
69
+ skill: path.join(k, 'skill'),
70
+ specs: resolveWithin(root, specsRel),
71
+ specsRel,
72
+ rules: resolveWithin(root, '.keelson/rules'),
73
+ rulesIndex: resolveWithin(root, '.keelson/rules/index.md'),
74
+ changes: resolveWithin(root, '.keelson/changes'),
75
+ archive: resolveWithin(root, '.keelson/changes/archive'),
76
+ hooks: resolveWithin(root, '.keelson/hooks'),
77
+ runtime: runtimeDir(root),
78
+ sessions: path.join(runtimeDir(root), 'sessions'),
79
+ evidence: path.join(runtimeDir(root), 'evidence'),
80
+ legacyLocal: path.join(k, '.local'),
81
+ };
82
+ };
@@ -0,0 +1,27 @@
1
+ import path from 'node:path';
2
+ import { readOr, exists } from './fs.js';
3
+ import { globMatch } from './glob.js';
4
+
5
+ /** rules/index.md lines: "- `src/api/**` → api.md" (also accepts "->" and ":") */
6
+ export function parseRulesIndex(text) {
7
+ const entries = [];
8
+ for (const line of text.split('\n')) {
9
+ const m = line.match(/^\s*[-*]\s+`([^`]+)`\s*(?:→|->|:)\s*`?([^`\s]+)`?\s*(?:[—-]\s*(.*))?$/);
10
+ if (m) entries.push({ glob: m[1].trim(), file: m[2].trim(), note: (m[3] ?? '').trim() });
11
+ }
12
+ return entries;
13
+ }
14
+
15
+ export function matchRules(rulesDir, paths) {
16
+ const index = parseRulesIndex(readOr(path.join(rulesDir, 'index.md')));
17
+ const hits = new Map();
18
+ for (const e of index) {
19
+ const always = e.glob === '**' || e.glob === '*';
20
+ const matched = always || paths.some((p) => globMatch(e.glob, p));
21
+ if (!matched) continue;
22
+ const f = path.join(rulesDir, e.file);
23
+ if (!hits.has(e.file)) hits.set(e.file, { file: e.file, globs: [], exists: exists(f), content: readOr(f) });
24
+ hits.get(e.file).globs.push(e.glob);
25
+ }
26
+ return [...hits.values()];
27
+ }
@@ -0,0 +1,22 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ const resolved = new Map();
7
+
8
+ /** Private state never requires changing the user's ignore rules. */
9
+ export function runtimeDir(root) {
10
+ const key = path.resolve(root);
11
+ if (resolved.has(key)) return resolved.get(key);
12
+ let runtime;
13
+ try {
14
+ const gitPath = execFileSync('git', ['rev-parse', '--git-path', 'keelson-runtime'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
15
+ runtime = path.resolve(root, gitPath);
16
+ } catch {
17
+ const fallback = crypto.createHash('sha256').update(key).digest('hex');
18
+ runtime = path.join(os.homedir(), '.cache', 'keelson', fallback);
19
+ }
20
+ resolved.set(key, runtime);
21
+ return runtime;
22
+ }
@@ -0,0 +1,100 @@
1
+ import crypto from 'node:crypto';
2
+ import { exists, listFiles, readJson, rmrf, withLock, writeJson } from './fs.js';
3
+ import path from 'node:path';
4
+ import { runtimeDir } from './runtime-path.js';
5
+
6
+ const SCHEMA = 1;
7
+
8
+ export const sessionsDir = (root) => path.join(runtimeDir(root), 'sessions');
9
+
10
+ export function resolveSessionIdentity(env = process.env) {
11
+ const sources = [
12
+ ['KEELSON_SESSION_ID', env.KEELSON_SESSION_ID],
13
+ ['CODEX_THREAD_ID', env.CODEX_THREAD_ID],
14
+ // Pi exposes the current session to every shell tool invocation.
15
+ ['PI_SESSION_ID', env.PI_SESSION_ID],
16
+ ];
17
+ for (const [source, value] of sources) {
18
+ const raw = typeof value === 'string' ? value.trim() : '';
19
+ if (!raw) continue;
20
+ const opaque = source === 'KEELSON_SESSION_ID'
21
+ ? raw
22
+ : crypto.createHash('sha256').update(`${source.toLowerCase()}:${raw}`).digest('hex').slice(0, 32);
23
+ const key = crypto.createHash('sha256').update(opaque).digest('hex').slice(0, 24);
24
+ return { key, source };
25
+ }
26
+ return null;
27
+ }
28
+
29
+ export function sessionFile(root, key) {
30
+ return path.join(sessionsDir(root), `${key}.json`);
31
+ }
32
+
33
+ export function readSession(root, env = process.env) {
34
+ const id = resolveSessionIdentity(env);
35
+ if (!id) return { available: false, key: null, source: null, state: null };
36
+ const state = readJson(sessionFile(root, id.key), null);
37
+ return { available: true, key: id.key, source: id.source, state };
38
+ }
39
+
40
+ export function writeSession(root, patch, env = process.env) {
41
+ const id = resolveSessionIdentity(env);
42
+ if (!id) return null;
43
+ const file = sessionFile(root, id.key);
44
+ const next = withLock(file, () => {
45
+ const prev = readJson(file, {}) ?? {};
46
+ const state = {
47
+ schema: SCHEMA,
48
+ change: null,
49
+ createdAt: prev.createdAt ?? new Date().toISOString(),
50
+ ...prev,
51
+ ...patch,
52
+ updatedAt: new Date().toISOString(),
53
+ };
54
+ writeJson(file, state);
55
+ return state;
56
+ });
57
+ return { key: id.key, file, state: next };
58
+ }
59
+
60
+ export function bindSession(root, change, { branch = null, source = 'cli', env = process.env } = {}) {
61
+ return writeSession(root, { change, branch, source }, env);
62
+ }
63
+
64
+ export function touchSession(root, { env = process.env, source = 'cli' } = {}) {
65
+ return writeSession(root, { source }, env);
66
+ }
67
+
68
+ export function clearSession(root, env = process.env) {
69
+ const id = resolveSessionIdentity(env);
70
+ if (!id) return false;
71
+ const file = sessionFile(root, id.key);
72
+ if (!exists(file)) return false;
73
+ rmrf(file);
74
+ return true;
75
+ }
76
+
77
+ export function clearChangeBindings(root, change) {
78
+ const dir = sessionsDir(root);
79
+ let removed = 0;
80
+ for (const file of listFiles(dir)) {
81
+ if (!file.endsWith('.json')) continue;
82
+ const full = path.join(dir, file);
83
+ const state = readJson(full, null);
84
+ if (state?.change !== change) continue;
85
+ rmrf(full);
86
+ removed += 1;
87
+ }
88
+ return removed;
89
+ }
90
+
91
+ export function listSessionStates(root) {
92
+ const dir = sessionsDir(root);
93
+ const out = [];
94
+ for (const file of listFiles(dir)) {
95
+ if (!file.endsWith('.json')) continue;
96
+ const state = readJson(path.join(dir, file), null);
97
+ if (state) out.push({ key: file.replace(/\.json$/, ''), ...state });
98
+ }
99
+ return out.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
100
+ }
@@ -0,0 +1,345 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import crypto from 'node:crypto';
4
+ import { exists, read, readOr, write, walk, rmrf } from './fs.js';
5
+ import { markdownHeadings, parseFrontmatter, parseSpec, renderSpec } from './markdown.js';
6
+ import { resolveWithin } from './paths.js';
7
+
8
+ const lineCount = (text) => String(text ?? '').split('\n').length;
9
+
10
+ const hasText = (lines) => lines.some((line) => line.trim());
11
+ const requirementTitle = (title) => /^Requirement:\s*(.+)$/i.test(title);
12
+
13
+ /**
14
+ * Sharded storage can represent the title, purpose, requirements, and decision
15
+ * bullets. Do not use a byte-for-byte comparison with canonical output here:
16
+ * ordinary specs have harmless choices in blank lines and heading order. We
17
+ * instead reject only source material for which the shard index has no owner.
18
+ */
19
+ function hasUnmanagedSpecContent(capability, spec) {
20
+ const source = spec.source;
21
+ const { body } = parseFrontmatter(source);
22
+ // The shard index owns its own layout frontmatter. Any source frontmatter,
23
+ // including an otherwise empty block, would be discarded by that index.
24
+ if (body !== source || spec.name !== capability) return true;
25
+
26
+ const lines = body.split('\n');
27
+ const headings = markdownHeadings(body);
28
+ const names = headings.filter((heading) => heading.level === 1);
29
+ if (names.length !== 1 || hasText(lines.slice(0, names[0].line))) return true;
30
+
31
+ const h2 = headings.filter((heading) => heading.level === 2);
32
+ let cursor = names[0].line + 1;
33
+ let purposeCount = 0;
34
+ for (const heading of h2) {
35
+ // Text between structural sections is not parsed into any owned field.
36
+ if (hasText(lines.slice(cursor, heading.line))) return true;
37
+ const next = headings.find((candidate) => candidate.line > heading.line && candidate.level <= 2);
38
+ const end = next ? next.line : lines.length;
39
+ const content = lines.slice(heading.line + 1, end);
40
+
41
+ if (/^Purpose$/i.test(heading.title)) {
42
+ if (++purposeCount > 1) return true;
43
+ } else if (requirementTitle(heading.title)) {
44
+ // A legacy requirement's complete body is written into its own shard.
45
+ } else if (/^Requirements$/i.test(heading.title)) {
46
+ const h3 = headings.filter((candidate) => candidate.level === 3
47
+ && candidate.line > heading.line && candidate.line < end);
48
+ if (h3.some((candidate) => !requirementTitle(candidate.title))) return true;
49
+ // Preamble prose under an nested container is not part of any
50
+ // requirement and would disappear when the container becomes a folder.
51
+ if (h3.length && hasText(lines.slice(heading.line + 1, h3[0].line))) return true;
52
+ if (!h3.length && hasText(content)) return true;
53
+ } else if (/^Decisions?$/i.test(heading.title)) {
54
+ // parseSpec owns decision bullets only. Keep comments, prose, headings,
55
+ // and fenced examples in the original document rather than silently
56
+ // converting or dropping them.
57
+ if (content.some((line) => line.trim() && !/^\s*[-*]\s+/.test(line))) return true;
58
+ } else {
59
+ return true;
60
+ }
61
+ cursor = end;
62
+ }
63
+ return hasText(lines.slice(cursor));
64
+ }
65
+ export const slugify = (s) => Array.from(String(s)
66
+ .normalize('NFKD')
67
+ .replace(/\p{Mark}/gu, '')
68
+ .toLocaleLowerCase()
69
+ .replace(/[^\p{Letter}\p{Number}]+/gu, '-')
70
+ .replace(/^-+|-+$/g, ''))
71
+ .slice(0, 60)
72
+ .join('') || 'requirement';
73
+
74
+ export const specFingerprint = (text) => crypto.createHash('sha1').update(text).digest('hex').slice(0, 10);
75
+
76
+ export function capabilityDir(specsDir, capability) {
77
+ return resolveWithin(specsDir, capability);
78
+ }
79
+
80
+ function capabilityStorageRoot(specsDir, capability) {
81
+ const dir = capabilityDir(specsDir, capability);
82
+ if (exists(dir) && fs.lstatSync(dir).isSymbolicLink()) throw new Error(`capability storage root must not be a symlink: ${dir}`);
83
+ return dir;
84
+ }
85
+
86
+ /** Keep sharding metadata from escaping its capability directory. */
87
+ function storagePath(dir, rel) {
88
+ if (typeof rel !== 'string' || !rel || path.isAbsolute(rel)) throw new Error('capability storage path must be relative');
89
+ if (exists(dir) && fs.lstatSync(dir).isSymbolicLink()) throw new Error(`capability storage root must not be a symlink: ${dir}`);
90
+ const absolute = path.resolve(dir, rel);
91
+ const relative = path.relative(dir, absolute);
92
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error(`capability storage path escapes its root: ${rel}`);
93
+ let current = dir;
94
+ for (const part of relative.split(path.sep)) {
95
+ current = path.join(current, part);
96
+ if (exists(current) && fs.lstatSync(current).isSymbolicLink()) throw new Error(`capability storage path must not be a symlink: ${current}`);
97
+ }
98
+ return absolute;
99
+ }
100
+
101
+ const safeStorageRel = (dir, rel) => {
102
+ storagePath(dir, rel);
103
+ return rel;
104
+ };
105
+
106
+ const firstFree = (dir, preferred, fallback) => {
107
+ if (!exists(path.join(dir, preferred))) return preferred;
108
+ if (!exists(path.join(dir, fallback))) return fallback;
109
+ for (let i = 2; ; i++) {
110
+ const dot = fallback.lastIndexOf('.');
111
+ const candidate = dot > 0
112
+ ? `${fallback.slice(0, dot)}-${i}${fallback.slice(dot)}`
113
+ : `${fallback}-${i}`;
114
+ if (!exists(path.join(dir, candidate))) return candidate;
115
+ }
116
+ };
117
+
118
+ export function capabilityStorageOptions(specsDir, capability) {
119
+ const dir = capabilityStorageRoot(specsDir, capability);
120
+ const main = readOr(storagePath(dir, 'spec.md'), '');
121
+ const { data } = parseFrontmatter(main);
122
+ if (data.layout === 'sharded') {
123
+ return {
124
+ requirementsDir: safeStorageRel(dir, data.requirements_dir || 'requirements'),
125
+ // Older Keelson layouts used one decisions_file. New writes migrate that
126
+ // representation to a directory without touching an unmanaged decisions/
127
+ // neighbor that the project may already own.
128
+ decisionsDir: data.decisions_dir
129
+ ? safeStorageRel(dir, data.decisions_dir)
130
+ : firstFree(dir, 'decisions', 'keelson-decisions'),
131
+ decisionsFile: null,
132
+ };
133
+ }
134
+ return {
135
+ requirementsDir: firstFree(dir, 'requirements', 'keelson-requirements'),
136
+ decisionsDir: firstFree(dir, 'decisions', 'keelson-decisions'),
137
+ decisionsFile: null,
138
+ };
139
+ }
140
+
141
+ export function readCapabilitySpec(specsDir, capability) {
142
+ const dir = capabilityStorageRoot(specsDir, capability);
143
+ const mainPath = storagePath(dir, 'spec.md');
144
+ const main = readOr(mainPath, '');
145
+ if (!main) return '';
146
+
147
+ const { data } = parseFrontmatter(main);
148
+ if (data.layout !== 'sharded') return main;
149
+
150
+ const index = parseSpec(main);
151
+ const requirements = [];
152
+ const reqDir = storagePath(dir, data.requirements_dir || 'requirements');
153
+ for (const rel of walk(reqDir)) {
154
+ if (!rel.endsWith('.md')) continue;
155
+ requirements.push(...parseSpec(read(storagePath(reqDir, rel))).requirements);
156
+ }
157
+ const decisions = [];
158
+ if (data.decisions_dir) {
159
+ const decisionsDir = storagePath(dir, data.decisions_dir);
160
+ for (const rel of walk(decisionsDir)) {
161
+ if (!rel.endsWith('.md')) continue;
162
+ decisions.push(...parseSpec(read(storagePath(decisionsDir, rel))).decisions);
163
+ }
164
+ } else {
165
+ const decisionsPath = storagePath(dir, data.decisions_file || 'decisions.md');
166
+ if (exists(decisionsPath)) decisions.push(...parseSpec(read(decisionsPath)).decisions);
167
+ }
168
+ return renderSpec({
169
+ name: capability,
170
+ purpose: index.purpose,
171
+ requirements,
172
+ decisions,
173
+ });
174
+ }
175
+
176
+ function uniqueRequirementFiles(requirements, requirementsDir) {
177
+ const used = new Set();
178
+ return requirements.map((r, i) => {
179
+ let base = slugify(r.name);
180
+ if (used.has(base)) base = `${base}-${i + 1}`;
181
+ used.add(base);
182
+ return {
183
+ rel: `${requirementsDir}/${base}.md`,
184
+ name: r.name,
185
+ text: `# ${r.name}\n\n## Requirement: ${r.name}\n\n${r.body.trim()}\n`,
186
+ };
187
+ });
188
+ }
189
+
190
+ function uniqueDecisionFiles(decisions, decisionsDir) {
191
+ return decisions.map((decision, i) => {
192
+ const digest = crypto.createHash('sha1').update(decision).digest('hex').slice(0, 8);
193
+ const words = decision
194
+ .replace(/^[^:]+:\s*/, '')
195
+ .split(/\s+/)
196
+ .slice(0, 6)
197
+ .join('-');
198
+ const stem = slugify(words) || 'decision';
199
+ return {
200
+ rel: `${decisionsDir}/${String(i + 1).padStart(3, '0')}-${stem}-${digest}.md`,
201
+ text: `# Decision\n\n## Decisions\n\n- ${decision}\n`,
202
+ };
203
+ });
204
+ }
205
+
206
+ function renderIndex(capability, spec, reqFiles, { requirementsDir, decisionsDir, decisionsFile, decisionCount }) {
207
+ const parts = [
208
+ '---',
209
+ 'layout: sharded',
210
+ `requirements_dir: ${requirementsDir}`,
211
+ ...(decisionsDir ? [`decisions_dir: ${decisionsDir}`] : decisionsFile ? [`decisions_file: ${decisionsFile}`] : []),
212
+ '---',
213
+ `# ${capability}`,
214
+ '',
215
+ ];
216
+ if (spec.purpose) parts.push('## Purpose', '', spec.purpose.trim(), '');
217
+ parts.push(`- \`${requirementsDir}/\` — ${reqFiles.length} current requirement file(s); read only relevant files`);
218
+ if (decisionCount) {
219
+ const location = decisionsDir ? `${decisionsDir}/` : decisionsFile;
220
+ parts.push(`- \`${location}\` — ${decisionCount} capability-local durable decision file(s)`);
221
+ }
222
+ parts.push('');
223
+ return parts.join('\n');
224
+ }
225
+
226
+ /**
227
+ * Choose a bounded physical representation for one logical capability contract.
228
+ * Total capability knowledge may grow; individual frequently-read files stay bounded.
229
+ */
230
+ export function planCapabilityStorage(capability, logicalText, budget = 0, {
231
+ requirementsDir = 'requirements',
232
+ decisionsDir = 'decisions',
233
+ decisionsFile = null,
234
+ } = {}) {
235
+ const spec = parseSpec(logicalText);
236
+ const canonical = renderSpec(spec);
237
+ const soft = Number(budget) || 0;
238
+ // A shard index has room only for the parsed ownership model. Keep a single
239
+ // file when source content has no shard owner, but accept harmless canonical
240
+ // layout differences in otherwise fully-modelled legacy/nested documents.
241
+ const mustKeepSingle = hasUnmanagedSpecContent(capability, spec);
242
+ if (!soft || lineCount(canonical) <= soft || mustKeepSingle) {
243
+ return {
244
+ mode: 'single',
245
+ logicalText: canonical,
246
+ files: [{ rel: 'spec.md', text: canonical }],
247
+ hardOver: [],
248
+ };
249
+ }
250
+
251
+ const reqFiles = uniqueRequirementFiles(spec.requirements, requirementsDir);
252
+ const decisionFiles = spec.decisions.length
253
+ ? uniqueDecisionFiles(spec.decisions, decisionsDir)
254
+ : [];
255
+ const indexText = renderIndex(capability, spec, reqFiles, {
256
+ requirementsDir,
257
+ decisionsDir,
258
+ decisionsFile,
259
+ decisionCount: decisionFiles.length,
260
+ });
261
+ const files = [
262
+ { rel: 'spec.md', text: indexText },
263
+ ...reqFiles.map(({ rel, text }) => ({ rel, text })),
264
+ ...decisionFiles,
265
+ ];
266
+ const hardLimit = soft * 2;
267
+ const hardOver = files
268
+ .map((f) => ({ rel: f.rel, lines: lineCount(f.text) }))
269
+ .filter((f) => f.lines > hardLimit);
270
+
271
+ return {
272
+ mode: 'sharded',
273
+ logicalText: canonical,
274
+ files,
275
+ hardLimit,
276
+ hardOver,
277
+ requirementsDir,
278
+ decisionsDir,
279
+ decisionsFile,
280
+ };
281
+ }
282
+
283
+ export function writeCapabilityStorage(specsDir, capability, plan) {
284
+ const dir = capabilityStorageRoot(specsDir, capability);
285
+ const currentMain = readOr(storagePath(dir, 'spec.md'), '');
286
+ const { data } = parseFrontmatter(currentMain);
287
+ if (data.layout === 'sharded') {
288
+ rmrf(storagePath(dir, data.requirements_dir || 'requirements'));
289
+ if (data.decisions_dir) rmrf(storagePath(dir, data.decisions_dir));
290
+ if (data.decisions_file) rmrf(storagePath(dir, data.decisions_file));
291
+ }
292
+ if (plan.mode === 'sharded') {
293
+ rmrf(storagePath(dir, plan.requirementsDir));
294
+ if (plan.decisionsDir) rmrf(storagePath(dir, plan.decisionsDir));
295
+ if (plan.decisionsFile) rmrf(storagePath(dir, plan.decisionsFile));
296
+ }
297
+ for (const file of plan.files) write(storagePath(dir, file.rel), file.text);
298
+ }
299
+
300
+ export function capabilityPhysicalDocs(specsDir, capability) {
301
+ const dir = capabilityStorageRoot(specsDir, capability);
302
+ const out = [];
303
+ const main = readOr(storagePath(dir, 'spec.md'), '');
304
+ if (main) out.push({ rel: 'spec.md', file: storagePath(dir, 'spec.md') });
305
+ const { data } = parseFrontmatter(main);
306
+ if (data.layout !== 'sharded') return out;
307
+ if (data.decisions_dir) {
308
+ const decisionsDir = storagePath(dir, data.decisions_dir);
309
+ for (const rel of walk(decisionsDir)) {
310
+ if (rel.endsWith('.md')) out.push({ rel: `${data.decisions_dir}/${rel}`, file: storagePath(decisionsDir, rel) });
311
+ }
312
+ } else {
313
+ const decisionsRel = data.decisions_file || 'decisions.md';
314
+ const decisions = storagePath(dir, decisionsRel);
315
+ if (exists(decisions)) out.push({ rel: decisionsRel, file: decisions });
316
+ }
317
+ const requirementsRel = data.requirements_dir || 'requirements';
318
+ const reqDir = storagePath(dir, requirementsRel);
319
+ for (const rel of walk(reqDir)) {
320
+ if (rel.endsWith('.md')) out.push({ rel: `${requirementsRel}/${rel}`, file: storagePath(reqDir, rel) });
321
+ }
322
+ return out;
323
+ }
324
+
325
+ export function changeSpecDrift(change, specsDir) {
326
+ const out = [];
327
+ for (const df of change.deltaFiles ?? []) {
328
+ const cap = path.dirname(df).replace(/\\/g, '/');
329
+ if (cap === '.' || cap.includes('<')) continue;
330
+ const deltaPath = path.join(change.dir, 'specs', df);
331
+ const { data } = parseFrontmatter(read(deltaPath));
332
+ if (!data.base) continue;
333
+ const logical = readCapabilitySpec(specsDir, cap);
334
+ const current = logical ? specFingerprint(logical) : 'new';
335
+ if (current !== data.base) {
336
+ out.push({
337
+ capability: cap,
338
+ expected: data.base,
339
+ current,
340
+ detail: `specs/${cap} changed since this delta was written (base ${data.base}, now ${current}); re-read and reconcile the delta`,
341
+ });
342
+ }
343
+ }
344
+ return out;
345
+ }
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { exists, readJson, writeJson } from './fs.js';
4
+ import { runtimeDir } from './runtime-path.js';
5
+ import { resolveWithin } from './paths.js';
6
+
7
+ function targetLocation(root, target) {
8
+ const runtime = runtimeDir(root);
9
+ const absolute = path.resolve(target);
10
+ if (absolute === path.join(runtime, 'sessions')) return { scope: 'runtime', target: 'sessions' };
11
+ const relative = path.relative(root, absolute);
12
+ resolveWithin(root, relative);
13
+ if (relative.split(path.sep)[0] === '.git') throw new Error('transaction target must not overwrite Git metadata');
14
+ return { scope: 'project', target: relative };
15
+ }
16
+
17
+ function resolveEntries(root, dir, journal, { requireBackups = true } = {}) {
18
+ if (!journal || !['prepared', 'committed'].includes(journal.phase) || !Array.isArray(journal.entries)
19
+ || (journal.version !== undefined && journal.version !== 2)) throw new Error('invalid landing journal; preserve it for manual recovery');
20
+ // Validate the entire journal before the first removal. In particular, old
21
+ // absolute paths cannot be trusted after a project directory has moved.
22
+ return journal.entries.map((entry, i) => {
23
+ if (!entry || typeof entry.existed !== 'boolean') throw new Error('invalid landing journal entry');
24
+ let location;
25
+ if (journal.version === 2) {
26
+ location = { scope: entry.scope, target: entry.target };
27
+ if (entry.backup !== String(i)) throw new Error('invalid landing backup path');
28
+ } else {
29
+ if (typeof entry.target !== 'string' || !path.isAbsolute(entry.target)
30
+ || entry.backup !== path.join(dir, String(i))) throw new Error('legacy landing journal paths changed; preserve it for manual recovery');
31
+ location = targetLocation(root, entry.target);
32
+ }
33
+ let target;
34
+ if (location.scope === 'runtime' && location.target === 'sessions') {
35
+ target = resolveWithin(runtimeDir(root), 'sessions');
36
+ } else if (location.scope === 'project') {
37
+ target = resolveWithin(root, location.target);
38
+ const checked = targetLocation(root, target);
39
+ if (checked.scope !== 'project') throw new Error('invalid project transaction target');
40
+ } else throw new Error('invalid landing target scope');
41
+ const backup = resolveWithin(dir, String(i));
42
+ if (requireBackups && entry.existed && !exists(backup)) throw new Error(`missing landing backup: ${backup}; preserve the journal for manual recovery`);
43
+ return { target, backup, existed: entry.existed };
44
+ });
45
+ }
46
+
47
+ function restore(root, dir, journal) {
48
+ const entries = resolveEntries(root, dir, journal);
49
+ for (const entry of entries) {
50
+ fs.rmSync(entry.target, { force: true, recursive: true });
51
+ if (entry.existed) {
52
+ fs.mkdirSync(path.dirname(entry.target), { recursive: true });
53
+ fs.cpSync(entry.backup, entry.target, { recursive: true, preserveTimestamps: true });
54
+ }
55
+ }
56
+ }
57
+
58
+ // Caller holds the project landing lock. An interrupted transaction is restored
59
+ // before another landing can start. Backups are private, never project content.
60
+ export function recoverLanding(root) {
61
+ const dir = path.join(runtimeDir(root), 'landing-transaction');
62
+ const journal = readJson(path.join(dir, 'journal.json'));
63
+ if (!journal) return false;
64
+ resolveEntries(root, dir, journal, { requireBackups: journal.phase !== 'committed' });
65
+ if (journal.phase !== 'committed') restore(root, dir, journal);
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ return journal.phase !== 'committed';
68
+ }
69
+
70
+ export function landingTransaction(root, targets, mutate) {
71
+ const dir = path.join(runtimeDir(root), 'landing-transaction');
72
+ if (exists(path.join(dir, 'journal.json'))) throw new Error('landing journal already exists; recover it before starting another transaction');
73
+ fs.mkdirSync(dir, { recursive: true });
74
+ const entries = [...new Set(targets)].map((target, i) => ({ ...targetLocation(root, target), backup: String(i), existed: exists(target) }));
75
+ const journal = { version: 2, phase: 'prepared', entries };
76
+ try {
77
+ for (const e of resolveEntries(root, dir, journal, { requireBackups: false })) if (e.existed) fs.cpSync(e.target, e.backup, { recursive: true, preserveTimestamps: true });
78
+ writeJson(path.join(dir, 'journal.json'), journal);
79
+ try {
80
+ const result = mutate();
81
+ writeJson(path.join(dir, 'journal.json'), { ...journal, phase: 'committed' });
82
+ return result;
83
+ } catch (error) {
84
+ restore(root, dir, journal);
85
+ writeJson(path.join(dir, 'journal.json'), { ...journal, phase: 'committed' });
86
+ throw error;
87
+ }
88
+ } finally {
89
+ // Preserve backups if restoration itself fails, so the next run can retry.
90
+ const journal = readJson(path.join(dir, 'journal.json'));
91
+ if (!journal || journal.phase === 'committed') fs.rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ }
@@ -0,0 +1,3 @@
1
+ export * from './registry.js';
2
+ export * from './runtime.js';
3
+ export * from './integration.js';