moflo 4.10.20 → 4.10.21
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/.claude/guidance/shipped/moflo-cli-reference.md +1 -0
- package/.claude/guidance/shipped/moflo-core-guidance.md +1 -0
- package/.claude/guidance/shipped/moflo-skills-reference.md +108 -0
- package/.claude/guidance/shipped/moflo-yaml-reference.md +13 -0
- package/.claude/skills/commune/SKILL.md +140 -0
- package/.claude/skills/divine/SKILL.md +130 -0
- package/.claude/skills/meditate/SKILL.md +122 -0
- package/README.md +39 -3
- package/bin/index-all.mjs +2 -1
- package/bin/index-reference.mjs +221 -0
- package/bin/lib/file-sync.mjs +50 -1
- package/bin/lib/hook-io.mjs +63 -0
- package/bin/lib/index-fingerprint.mjs +0 -0
- package/bin/lib/internal-skills.mjs +16 -0
- package/bin/lib/meditate.mjs +497 -0
- package/bin/lib/pii-scrub.mjs +119 -0
- package/bin/lib/reference-docs.mjs +218 -0
- package/bin/lib/session-continuity.mjs +372 -0
- package/bin/lib/shipped-scripts.json +36 -0
- package/bin/lib/shipped-scripts.mjs +33 -0
- package/bin/lib/yaml-upgrader.mjs +62 -0
- package/bin/meditate-capture.mjs +123 -0
- package/bin/meditate-distill.mjs +121 -0
- package/bin/session-continuity.mjs +206 -0
- package/bin/session-start-launcher.mjs +140 -60
- package/dist/src/cli/config/moflo-config.js +18 -0
- package/dist/src/cli/init/executor.js +11 -17
- package/dist/src/cli/init/moflo-init.js +21 -19
- package/dist/src/cli/init/moflo-yaml-template.js +21 -0
- package/dist/src/cli/init/settings-generator.js +23 -1
- package/dist/src/cli/init/shipped-scripts.js +39 -0
- package/dist/src/cli/memory/bridge-core.js +20 -0
- package/dist/src/cli/memory/bridge-entries.js +8 -2
- package/dist/src/cli/memory/memory-bridge.js +6 -2
- package/dist/src/cli/memory/memory-initializer.js +6 -2
- package/dist/src/cli/services/hook-block-hash.js +9 -1
- package/dist/src/cli/services/hook-wiring.js +38 -0
- package/dist/src/cli/transfer/anonymization/index.js +146 -40
- package/dist/src/cli/transfer/deploy-seraphine.js +1 -1
- package/dist/src/cli/transfer/export.js +2 -2
- package/dist/src/cli/transfer/store/publish.js +1 -1
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
- package/scripts/post-install-bootstrap.mjs +22 -42
- package/dist/src/cli/hooks/llm/index.js +0 -11
- package/dist/src/cli/hooks/llm/llm-hooks.js +0 -382
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the `reference` (library-docs grounding) indexer — issue #1184.
|
|
3
|
+
*
|
|
4
|
+
* Separated from `bin/index-reference.mjs` (the orchestrator) so the dependency
|
|
5
|
+
* discovery, doc resolution, chunking, and entry-shaping logic is unit-testable
|
|
6
|
+
* without a sql.js load or a background embedding spawn — same split as
|
|
7
|
+
* `bin/lib/incremental-write.mjs` / `bin/lib/index-fingerprint.mjs`.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is side-effect-free apart from `fs` READS rooted at an
|
|
10
|
+
* explicit `projectRoot`, and cross-platform: `path.join` only, no shelling out.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join, resolve, sep } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// Bounds — keep one mega-dependency from dominating the index or the embed cost.
|
|
16
|
+
// The byte caps are applied as a character budget (a coarse cost bound; exact
|
|
17
|
+
// byte counting isn't needed) and MAX_CHUNKS_PER_DOC is the hard upper limit.
|
|
18
|
+
export const MAX_README_BYTES = 128 * 1024; // skip the rare giant README
|
|
19
|
+
export const MAX_DTS_BYTES = 256 * 1024; // skip bundled mega-.d.ts (aws-sdk etc.)
|
|
20
|
+
export const MIN_CHUNK_SIZE = 50; // drop trivial fragments
|
|
21
|
+
export const MAX_CHUNK_SIZE = 4000; // fits embedding context comfortably
|
|
22
|
+
export const MAX_CHUNKS_PER_DOC = 40; // hard cap per (package, docType)
|
|
23
|
+
|
|
24
|
+
// Conventional README filenames, checked in order at the package root.
|
|
25
|
+
export const README_NAMES = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README'];
|
|
26
|
+
|
|
27
|
+
// Dependency fields scanned from the consumer's package.json. DIRECT deps only —
|
|
28
|
+
// indexing the transitive node_modules tree would be unbounded and noisy.
|
|
29
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Direct dependency names from the root package.json (sorted, de-duped). Returns
|
|
33
|
+
* [] when there's no package.json or it can't be parsed — never throws.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} projectRoot
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
export function collectDependencyNames(projectRoot) {
|
|
39
|
+
const pkgPath = resolve(projectRoot, 'package.json');
|
|
40
|
+
if (!existsSync(pkgPath)) return [];
|
|
41
|
+
let pkg;
|
|
42
|
+
try { pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); } catch { return []; }
|
|
43
|
+
const names = new Set();
|
|
44
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
45
|
+
const deps = pkg[field];
|
|
46
|
+
if (deps && typeof deps === 'object') {
|
|
47
|
+
for (const name of Object.keys(deps)) names.add(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...names].sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve an installed package to its on-disk docs. Returns null when the
|
|
55
|
+
* package isn't installed (e.g. a skipped optional dep) or has no README and no
|
|
56
|
+
* type defs — a missing dep is silently dropped, never an error. The version is
|
|
57
|
+
* read from the installed `package.json`, which IS the resolved version on disk
|
|
58
|
+
* (format-agnostic across npm/yarn/pnpm/bun — no lockfile parsing needed).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} projectRoot
|
|
61
|
+
* @param {string} name bare or scoped package name (e.g. `@types/node`)
|
|
62
|
+
* @returns {{name:string, version:string, dir:string, readmePath:string|null, typesPath:string|null}|null}
|
|
63
|
+
*/
|
|
64
|
+
export function resolvePackageDocs(projectRoot, name) {
|
|
65
|
+
// Scoped names (@types/node) split into nested dirs; path.join handles the sep.
|
|
66
|
+
const dir = join(projectRoot, 'node_modules', ...name.split('/'));
|
|
67
|
+
const pkgJsonPath = join(dir, 'package.json');
|
|
68
|
+
if (!existsSync(pkgJsonPath)) return null;
|
|
69
|
+
let meta;
|
|
70
|
+
try { meta = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')); } catch { return null; }
|
|
71
|
+
const version = typeof meta.version === 'string' ? meta.version : '0.0.0';
|
|
72
|
+
|
|
73
|
+
// README — first match from a small set of conventional names at the root.
|
|
74
|
+
let readmePath = null;
|
|
75
|
+
for (const candidate of README_NAMES) {
|
|
76
|
+
const p = join(dir, candidate);
|
|
77
|
+
if (existsSync(p)) { readmePath = p; break; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Entry .d.ts — the declared types entry, else a root index.d.ts. Only the
|
|
81
|
+
// entry file: pulling the whole declaration graph is unbounded and noisy.
|
|
82
|
+
let typesPath = null;
|
|
83
|
+
const declared = meta.types || meta.typings;
|
|
84
|
+
if (typeof declared === 'string') {
|
|
85
|
+
const dirAbs = resolve(dir);
|
|
86
|
+
const p = resolve(dir, ...declared.split('/'));
|
|
87
|
+
// Contain within the package dir — a manifest `types` value that traverses
|
|
88
|
+
// out (e.g. "../x.d.ts") must not pull in a file outside the package.
|
|
89
|
+
if (p.endsWith('.d.ts') && (p === dirAbs || p.startsWith(dirAbs + sep)) && existsSync(p)) {
|
|
90
|
+
typesPath = p;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!typesPath) {
|
|
94
|
+
const fallback = join(dir, 'index.d.ts');
|
|
95
|
+
if (existsSync(fallback)) typesPath = fallback;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!readmePath && !typesPath) return null;
|
|
99
|
+
return { name, version, dir, readmePath, typesPath };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split text into size-bounded, line-aware chunks. Prefers blank-line
|
|
104
|
+
* boundaries so a chunk doesn't split mid-paragraph / mid-declaration; an
|
|
105
|
+
* oversized single block (e.g. a long interface) is hard-split by lines.
|
|
106
|
+
* Capped at MAX_CHUNKS_PER_DOC.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} text
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
export function chunkText(text) {
|
|
112
|
+
const normalized = String(text ?? '').replace(/\r\n/g, '\n').trim();
|
|
113
|
+
if (!normalized) return [];
|
|
114
|
+
if (normalized.length <= MAX_CHUNK_SIZE) return [normalized];
|
|
115
|
+
|
|
116
|
+
const chunks = [];
|
|
117
|
+
const blocks = normalized.split(/\n\s*\n/); // paragraph / declaration blocks
|
|
118
|
+
let current = '';
|
|
119
|
+
const atCap = () => chunks.length >= MAX_CHUNKS_PER_DOC;
|
|
120
|
+
const flush = () => {
|
|
121
|
+
const trimmed = current.trim();
|
|
122
|
+
current = '';
|
|
123
|
+
if (trimmed.length >= MIN_CHUNK_SIZE && !atCap()) chunks.push(trimmed);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const block of blocks) {
|
|
127
|
+
if (atCap()) break;
|
|
128
|
+
if (block.length > MAX_CHUNK_SIZE) {
|
|
129
|
+
flush();
|
|
130
|
+
for (const line of block.split('\n')) {
|
|
131
|
+
if (line.length > MAX_CHUNK_SIZE) {
|
|
132
|
+
// A single line longer than the cap (e.g. a minified .d.ts line) —
|
|
133
|
+
// hard-split into cap-sized slices so no chunk exceeds the bound.
|
|
134
|
+
flush();
|
|
135
|
+
for (let off = 0; off < line.length; off += MAX_CHUNK_SIZE) {
|
|
136
|
+
current = line.slice(off, off + MAX_CHUNK_SIZE);
|
|
137
|
+
flush();
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (current.length + line.length + 1 > MAX_CHUNK_SIZE && current.length > 0) flush();
|
|
142
|
+
current += (current ? '\n' : '') + line;
|
|
143
|
+
}
|
|
144
|
+
flush();
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (current.length + block.length + 2 > MAX_CHUNK_SIZE && current.length > 0) flush();
|
|
148
|
+
current += (current ? '\n\n' : '') + block;
|
|
149
|
+
}
|
|
150
|
+
flush();
|
|
151
|
+
return chunks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build chunk entries for one (package, docType) document, wired with the
|
|
156
|
+
* navigation metadata `memory_get_neighbors` reads (parentDoc, prev/next,
|
|
157
|
+
* siblings) so reference chunks are first-class traversable entries. The chunk
|
|
158
|
+
* key embeds `name@version` so a dependency bump orphans the old version's
|
|
159
|
+
* chunks (swept by applyIncrementalChunks) and inserts the new ones.
|
|
160
|
+
*
|
|
161
|
+
* @param {{name:string, version:string}} pkg
|
|
162
|
+
* @param {'readme'|'types'} docType
|
|
163
|
+
* @param {string} rawText
|
|
164
|
+
* @returns {Array<{key:string, content:string, tags:string[], metadata:object}>}
|
|
165
|
+
*/
|
|
166
|
+
export function buildDocEntries(pkg, docType, rawText) {
|
|
167
|
+
const cap = docType === 'readme' ? MAX_README_BYTES : MAX_DTS_BYTES;
|
|
168
|
+
const raw = String(rawText ?? '');
|
|
169
|
+
const text = raw.length > cap ? raw.slice(0, cap) : raw;
|
|
170
|
+
const pieces = chunkText(text);
|
|
171
|
+
if (pieces.length === 0) return [];
|
|
172
|
+
|
|
173
|
+
const pkgKey = `${pkg.name}@${pkg.version}`;
|
|
174
|
+
const parentDoc = `${pkgKey} ${docType}`;
|
|
175
|
+
const keys = pieces.map((_, i) => `reference:${pkgKey}:${docType}:${i}`);
|
|
176
|
+
const label = docType === 'readme' ? 'README' : 'type definitions';
|
|
177
|
+
|
|
178
|
+
return pieces.map((content, i) => ({
|
|
179
|
+
key: keys[i],
|
|
180
|
+
content: `# ${pkg.name}@${pkg.version} — ${label}${pieces.length > 1 ? ` (part ${i + 1}/${pieces.length})` : ''}\n\n${content}`,
|
|
181
|
+
tags: ['reference', 'library-docs', docType, pkg.name],
|
|
182
|
+
metadata: {
|
|
183
|
+
type: 'chunk',
|
|
184
|
+
source: 'node_modules',
|
|
185
|
+
package: pkg.name,
|
|
186
|
+
version: pkg.version,
|
|
187
|
+
docType,
|
|
188
|
+
parentDoc,
|
|
189
|
+
chunkIndex: i,
|
|
190
|
+
totalChunks: pieces.length,
|
|
191
|
+
prevChunk: i > 0 ? keys[i - 1] : null,
|
|
192
|
+
nextChunk: i < pieces.length - 1 ? keys[i + 1] : null,
|
|
193
|
+
siblings: keys,
|
|
194
|
+
chunkTitle: `${pkg.name} ${label}${pieces.length > 1 ? ` (part ${i + 1})` : ''}`,
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve every direct dependency's docs and collect the absolute doc-file paths
|
|
201
|
+
* (for the content-hash gate). Packages without installed docs drop out.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} projectRoot
|
|
204
|
+
* @returns {{packages:Array<object>, docFiles:string[], depCount:number}}
|
|
205
|
+
*/
|
|
206
|
+
export function collectReferenceDocs(projectRoot) {
|
|
207
|
+
const depNames = collectDependencyNames(projectRoot);
|
|
208
|
+
const packages = [];
|
|
209
|
+
const docFiles = [];
|
|
210
|
+
for (const name of depNames) {
|
|
211
|
+
const pkg = resolvePackageDocs(projectRoot, name);
|
|
212
|
+
if (!pkg) continue;
|
|
213
|
+
packages.push(pkg);
|
|
214
|
+
if (pkg.readmePath) docFiles.push(pkg.readmePath);
|
|
215
|
+
if (pkg.typesPath) docFiles.push(pkg.typesPath);
|
|
216
|
+
}
|
|
217
|
+
return { packages, docFiles, depCount: depNames.length };
|
|
218
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passive session-continuity — pure logic + store helpers shared by the capture
|
|
3
|
+
* orchestrator (`bin/session-continuity.mjs`, wired to the Stop hook) and the
|
|
4
|
+
* injection path (`bin/session-start-launcher.mjs`). See issue #1185.
|
|
5
|
+
*
|
|
6
|
+
* DESIGN (owner-signed-off):
|
|
7
|
+
* - CAPTURE is default-on, silent, mechanical-first (no model call): assemble a
|
|
8
|
+
* digest from data we already have — git state, recent `learnings`, the
|
|
9
|
+
* session goal — scrub secrets, persist one record per session.
|
|
10
|
+
* - INJECTION is default-on but RELEVANCE-GATED: at session-start we score
|
|
11
|
+
* recent digests and inject only the single best one IF it clears a
|
|
12
|
+
* threshold (same branch / file overlap / recency / unfinished work). A
|
|
13
|
+
* fresh, unrelated session injects nothing — that's what keeps the feature
|
|
14
|
+
* from going context-negative.
|
|
15
|
+
* - The injected block stores only NON-RECONSTRUCTABLE context (goal,
|
|
16
|
+
* decisions, where-you-stopped) and is framed as a verifiable lead, never
|
|
17
|
+
* ground truth — git/tests are the source of truth the next session checks.
|
|
18
|
+
*
|
|
19
|
+
* STORAGE: a dedicated `.moflo/continuity/` JSON store, NOT `.moflo/moflo.db`.
|
|
20
|
+
* Capture fires on the Stop hook while the daemon is live; the moflo.db writer
|
|
21
|
+
* audit (docs/internal/1054-writer-audit.md) requires cross-process DB writers
|
|
22
|
+
* to route through the daemon chokepoint. A separate JSON store sidesteps that
|
|
23
|
+
* entirely — these digests are operational state, not semantic memory, carry no
|
|
24
|
+
* embeddings, and never belong in the HNSW index. One file per session avoids
|
|
25
|
+
* concurrent-write conflicts between simultaneous sessions.
|
|
26
|
+
*
|
|
27
|
+
* Cross-platform (Rule #1): `spawnSync` with arg arrays (no shell, no
|
|
28
|
+
* `2>/dev/null`), forward-slash path compares (git porcelain emits `/` on every
|
|
29
|
+
* OS), Node fs/path primitives only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawnSync } from 'child_process';
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
34
|
+
import { resolve, join } from 'path';
|
|
35
|
+
import { randomBytes } from 'crypto';
|
|
36
|
+
|
|
37
|
+
// ── Relevance-scoring tunables (exported for tests) ─────────────────────────
|
|
38
|
+
export const MAINLINE_BRANCHES = ['main', 'master', 'develop', 'trunk'];
|
|
39
|
+
export const SCORE = Object.freeze({
|
|
40
|
+
BRANCH_MATCH: 0.5, // feature branch identity is a strong continuation signal
|
|
41
|
+
MAINLINE_BRANCH_MATCH: 0.25, // main/master is weak — everyone's always "on main"
|
|
42
|
+
FILE_OVERLAP_PER_FILE: 0.1,
|
|
43
|
+
FILE_OVERLAP_MAX: 0.3,
|
|
44
|
+
RECENCY_MAX: 0.2,
|
|
45
|
+
RECENCY_HALFLIFE_HOURS: 24,
|
|
46
|
+
UNFINISHED_BONUS: 0.15,
|
|
47
|
+
INJECT_THRESHOLD: 0.5,
|
|
48
|
+
});
|
|
49
|
+
export const DEFAULT_MAX_AGE_HOURS = 72;
|
|
50
|
+
/** Hard cap on the injected block (~250 tokens). Bounds context cost even when
|
|
51
|
+
* a digest does fire. */
|
|
52
|
+
export const INJECT_MAX_CHARS = 1000;
|
|
53
|
+
/** Rotation: keep the most recent N sessions' digests. */
|
|
54
|
+
export const KEEP_DIGESTS = 30;
|
|
55
|
+
|
|
56
|
+
// ── Store helpers (.moflo/continuity/) ──────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function continuityDir(projectRoot) {
|
|
59
|
+
return resolve(projectRoot, '.moflo', 'continuity');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Stable, filesystem-safe filename for a session's digest record. */
|
|
63
|
+
export function digestFileName(key) {
|
|
64
|
+
const safe = String(key).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 120);
|
|
65
|
+
return `${safe || 'session'}.json`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Persist a digest record atomically (temp + rename) so a crash mid-write can
|
|
70
|
+
* never leave a half-written file the injection path would choke on.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} projectRoot
|
|
73
|
+
* @param {string} key - stable per-session key
|
|
74
|
+
* @param {{content: string, metadata: object}} record
|
|
75
|
+
*/
|
|
76
|
+
export function writeDigest(projectRoot, key, record) {
|
|
77
|
+
const dir = continuityDir(projectRoot);
|
|
78
|
+
mkdirSync(dir, { recursive: true });
|
|
79
|
+
const dest = join(dir, digestFileName(key));
|
|
80
|
+
const tmp = `${dest}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(record), 'utf-8');
|
|
82
|
+
renameSync(tmp, dest);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read recent digest records, newest first, capped to `limit`. Tolerates a
|
|
87
|
+
* partially-written / malformed file (skips it) — never throws.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} projectRoot
|
|
90
|
+
* @param {{limit?: number}} [opts]
|
|
91
|
+
* @returns {Array<{content: string, metadata: object}>}
|
|
92
|
+
*/
|
|
93
|
+
export function readDigests(projectRoot, opts = {}) {
|
|
94
|
+
const limit = opts.limit ?? KEEP_DIGESTS;
|
|
95
|
+
const dir = continuityDir(projectRoot);
|
|
96
|
+
let files;
|
|
97
|
+
try {
|
|
98
|
+
files = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
99
|
+
} catch {
|
|
100
|
+
return []; // no store yet
|
|
101
|
+
}
|
|
102
|
+
const withTime = files.map((f) => ({ f, mtime: fileMtime(join(dir, f)) }));
|
|
103
|
+
withTime.sort((a, b) => b.mtime - a.mtime);
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const { f } of withTime.slice(0, limit)) {
|
|
106
|
+
try {
|
|
107
|
+
const rec = JSON.parse(readFileSync(join(dir, f), 'utf-8'));
|
|
108
|
+
if (rec && typeof rec.content === 'string') out.push(rec);
|
|
109
|
+
} catch { /* skip malformed / mid-write */ }
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Delete the oldest digest files beyond `keep` (housekeeping; never fatal). */
|
|
115
|
+
export function rotateDigests(projectRoot, keep = KEEP_DIGESTS) {
|
|
116
|
+
const dir = continuityDir(projectRoot);
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(dir).filter((f) => f.endsWith('.json'));
|
|
120
|
+
} catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (names.length <= keep) return; // common case — no per-file stat sweep needed
|
|
124
|
+
const entries = names.map((f) => ({ f, mtime: fileMtime(join(dir, f)) }));
|
|
125
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
126
|
+
for (const { f } of entries.slice(keep)) {
|
|
127
|
+
try { unlinkSync(join(dir, f)); } catch { /* already gone */ }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── moflo.yaml config (regex read, matching the launcher's no-js-yaml style) ─
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the `session_continuity` block from moflo.yaml without a YAML parser
|
|
135
|
+
* (the launcher and bin scripts avoid the js-yaml dependency for speed). Both
|
|
136
|
+
* flags default ON; injection is still relevance-gated at runtime.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} projectRoot
|
|
139
|
+
* @returns {{capture: boolean, inject: boolean, maxAgeHours: number}}
|
|
140
|
+
*/
|
|
141
|
+
export function readContinuityConfig(projectRoot) {
|
|
142
|
+
const cfg = { capture: true, inject: true, maxAgeHours: DEFAULT_MAX_AGE_HOURS };
|
|
143
|
+
try {
|
|
144
|
+
const yamlPath = resolve(projectRoot, 'moflo.yaml');
|
|
145
|
+
if (!existsSync(yamlPath)) return cfg;
|
|
146
|
+
const text = readFileSync(yamlPath, 'utf-8');
|
|
147
|
+
const capture = text.match(/session_continuity:\s*\n(?:\s+\w+:.*\n)*?\s+capture:\s*(true|false)/);
|
|
148
|
+
const inject = text.match(/session_continuity:\s*\n(?:\s+\w+:.*\n)*?\s+inject:\s*(true|false)/);
|
|
149
|
+
const maxAge = text.match(/session_continuity:\s*\n(?:\s+\w+:.*\n)*?\s+max_age_hours:\s*(\d+)/);
|
|
150
|
+
if (capture) cfg.capture = capture[1] === 'true';
|
|
151
|
+
if (inject) cfg.inject = inject[1] === 'true';
|
|
152
|
+
if (maxAge) cfg.maxAgeHours = Math.max(1, parseInt(maxAge[1], 10));
|
|
153
|
+
} catch {
|
|
154
|
+
// Defaults (on) keep the feature alive if the file is mid-write / malformed.
|
|
155
|
+
}
|
|
156
|
+
return cfg;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Git state (cross-platform, no shell) ────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function git(projectRoot, args) {
|
|
162
|
+
try {
|
|
163
|
+
const r = spawnSync('git', args, {
|
|
164
|
+
cwd: projectRoot,
|
|
165
|
+
encoding: 'utf-8',
|
|
166
|
+
timeout: 2000,
|
|
167
|
+
windowsHide: true,
|
|
168
|
+
});
|
|
169
|
+
if (r.status !== 0 || typeof r.stdout !== 'string') return null;
|
|
170
|
+
return r.stdout.trim();
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Current branch, changed-file paths, last-commit subject and uncommitted count.
|
|
178
|
+
* Returns null-ish fields rather than throwing in a non-git directory.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} projectRoot
|
|
181
|
+
* @returns {{branch: string|null, changedFiles: string[], lastCommit: string|null, uncommittedCount: number}}
|
|
182
|
+
*/
|
|
183
|
+
export function readGitState(projectRoot) {
|
|
184
|
+
const branchRaw = git(projectRoot, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
185
|
+
let branch = null;
|
|
186
|
+
if (branchRaw === 'HEAD') branch = 'detached'; // detached HEAD checkout
|
|
187
|
+
else if (branchRaw) branch = branchRaw; // null when not a git repo
|
|
188
|
+
const status = git(projectRoot, ['status', '--porcelain']);
|
|
189
|
+
const changedFiles = status
|
|
190
|
+
? status
|
|
191
|
+
.split('\n')
|
|
192
|
+
.map((l) => l.slice(3).trim()) // strip the 2-char XY status + space
|
|
193
|
+
.filter(Boolean)
|
|
194
|
+
.map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p)) // renames
|
|
195
|
+
.slice(0, 50)
|
|
196
|
+
: [];
|
|
197
|
+
const lastCommit = git(projectRoot, ['log', '-1', '--pretty=%s']) || null;
|
|
198
|
+
return { branch, changedFiles, lastCommit, uncommittedCount: changedFiles.length };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Opt-out ─────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** Honor a `<private>` opt-out tag anywhere in the supplied text (e.g. the
|
|
204
|
+
* recent transcript). Case-insensitive. */
|
|
205
|
+
export function hasPrivateOptOut(text) {
|
|
206
|
+
return typeof text === 'string' && /<private>/i.test(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Digest assembly (pure) ──────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/** statSync mtime that never throws (file may vanish between readdir and stat). */
|
|
212
|
+
function fileMtime(p) {
|
|
213
|
+
try { return statSync(p).mtimeMs; } catch { return 0; }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function trimList(items, max) {
|
|
217
|
+
return (Array.isArray(items) ? items : []).map((s) => String(s).trim()).filter(Boolean).slice(0, max);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build the digest CONTENT — only non-reconstructable context. Git facts appear
|
|
222
|
+
* as a single "stopped at" anchor line, not as the payload (the next session can
|
|
223
|
+
* re-derive git state for free; it can't re-derive intent or rejected options).
|
|
224
|
+
*
|
|
225
|
+
* @param {{goal?: string, decisions?: string[], openThreads?: string[],
|
|
226
|
+
* gitState?: {branch?: string|null, lastCommit?: string|null, uncommittedCount?: number}}} parts
|
|
227
|
+
* @returns {string} markdown (may be empty if nothing substantive was captured)
|
|
228
|
+
*/
|
|
229
|
+
export function assembleDigestContent(parts = {}) {
|
|
230
|
+
const lines = [];
|
|
231
|
+
const goal = parts.goal && String(parts.goal).trim();
|
|
232
|
+
if (goal) lines.push(`**Goal:** ${goal}`);
|
|
233
|
+
|
|
234
|
+
const decisions = trimList(parts.decisions, 5);
|
|
235
|
+
if (decisions.length) lines.push(`**Decisions this session:** ${decisions.join('; ')}`);
|
|
236
|
+
|
|
237
|
+
const threads = trimList(parts.openThreads, 5);
|
|
238
|
+
if (threads.length) lines.push(`**Open threads:** ${threads.join('; ')}`);
|
|
239
|
+
|
|
240
|
+
const g = parts.gitState || {};
|
|
241
|
+
if (g.branch) {
|
|
242
|
+
const bits = [`on \`${g.branch}\``];
|
|
243
|
+
if (g.lastCommit) bits.push(`last commit: "${g.lastCommit}"`);
|
|
244
|
+
if (g.uncommittedCount) bits.push(`${g.uncommittedCount} uncommitted file(s)`);
|
|
245
|
+
lines.push(`**Stopped at:** ${bits.join(', ')}`);
|
|
246
|
+
}
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Metadata persisted alongside the digest — the scoring signals (never shown).
|
|
252
|
+
*
|
|
253
|
+
* @returns {{branch: string|null, changedFiles: string[], endedAt: number,
|
|
254
|
+
* sessionId: string|null, hadUnfinished: boolean}}
|
|
255
|
+
*/
|
|
256
|
+
export function buildDigestMetadata({ gitState = {}, sessionId = null, openThreads = [], endedAt = Date.now() } = {}) {
|
|
257
|
+
const uncommitted = Number(gitState.uncommittedCount || 0);
|
|
258
|
+
return {
|
|
259
|
+
branch: gitState.branch ?? null,
|
|
260
|
+
changedFiles: Array.isArray(gitState.changedFiles) ? gitState.changedFiles.slice(0, 50) : [],
|
|
261
|
+
endedAt,
|
|
262
|
+
sessionId: sessionId ?? null,
|
|
263
|
+
hadUnfinished: uncommitted > 0 || (Array.isArray(openThreads) && openThreads.length > 0),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Relevance scoring + selection (pure) ────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function metaOf(row) {
|
|
270
|
+
if (!row) return {};
|
|
271
|
+
const raw = row.metadata;
|
|
272
|
+
if (raw && typeof raw === 'object') return raw;
|
|
273
|
+
if (typeof raw === 'string') {
|
|
274
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
275
|
+
}
|
|
276
|
+
return {};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function overlapCount(a, b) {
|
|
280
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || b.length === 0) return 0;
|
|
281
|
+
const set = new Set(a);
|
|
282
|
+
let n = 0;
|
|
283
|
+
for (const x of b) if (set.has(x)) n++;
|
|
284
|
+
return n;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Score how relevant a stored digest is to the session starting now. Higher =
|
|
289
|
+
* more likely a continuation. Returns 0 for digests older than maxAgeHours.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} meta - parsed digest metadata (branch, changedFiles, endedAt, hadUnfinished)
|
|
292
|
+
* @param {{branch: string|null, changedFiles: string[]}} ctx - current session context
|
|
293
|
+
* @param {{now?: number, maxAgeHours?: number}} [opts]
|
|
294
|
+
* @returns {number}
|
|
295
|
+
*/
|
|
296
|
+
export function scoreDigest(meta, ctx, opts = {}) {
|
|
297
|
+
const now = opts.now ?? Date.now();
|
|
298
|
+
const maxAgeHours = opts.maxAgeHours ?? DEFAULT_MAX_AGE_HOURS;
|
|
299
|
+
const endedAt = Number(meta?.endedAt || 0);
|
|
300
|
+
if (!endedAt) return 0;
|
|
301
|
+
// Clamp small negative ages (clock skew / NTP step between capture and this
|
|
302
|
+
// start) to 0 = most-recent, rather than silently dropping a fresh digest.
|
|
303
|
+
const ageHours = Math.max(0, (now - endedAt) / 3_600_000);
|
|
304
|
+
if (ageHours > maxAgeHours) return 0;
|
|
305
|
+
|
|
306
|
+
let score = 0;
|
|
307
|
+
if (meta.branch && ctx.branch && meta.branch === ctx.branch) {
|
|
308
|
+
score += MAINLINE_BRANCHES.includes(meta.branch) ? SCORE.MAINLINE_BRANCH_MATCH : SCORE.BRANCH_MATCH;
|
|
309
|
+
}
|
|
310
|
+
const overlap = overlapCount(meta.changedFiles, ctx.changedFiles);
|
|
311
|
+
if (overlap > 0) score += Math.min(SCORE.FILE_OVERLAP_MAX, overlap * SCORE.FILE_OVERLAP_PER_FILE);
|
|
312
|
+
score += SCORE.RECENCY_MAX * Math.exp(-ageHours / SCORE.RECENCY_HALFLIFE_HOURS);
|
|
313
|
+
if (meta.hadUnfinished) score += SCORE.UNFINISHED_BONUS;
|
|
314
|
+
return score;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Pick the single most relevant digest to inject, or null if none clear the
|
|
319
|
+
* threshold. Skips the row matching `excludeSessionId` (don't replay the very
|
|
320
|
+
* session that just started, if it somehow already wrote a record).
|
|
321
|
+
*
|
|
322
|
+
* @param {Array<{content: string, metadata: any}>} rows
|
|
323
|
+
* @param {{branch: string|null, changedFiles: string[]}} ctx
|
|
324
|
+
* @param {{now?: number, maxAgeHours?: number, excludeSessionId?: string|null}} [opts]
|
|
325
|
+
* @returns {{row: object, meta: object, score: number}|null}
|
|
326
|
+
*/
|
|
327
|
+
export function selectBestDigest(rows, ctx, opts = {}) {
|
|
328
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
329
|
+
let best = null;
|
|
330
|
+
for (const row of rows) {
|
|
331
|
+
const meta = metaOf(row);
|
|
332
|
+
if (opts.excludeSessionId && meta.sessionId && meta.sessionId === opts.excludeSessionId) continue;
|
|
333
|
+
if (!row.content || !String(row.content).trim()) continue;
|
|
334
|
+
const score = scoreDigest(meta, ctx, opts);
|
|
335
|
+
if (score >= SCORE.INJECT_THRESHOLD && (!best || score > best.score)) {
|
|
336
|
+
best = { row, meta, score };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return best;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── Injection formatting (pure) ─────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/** Humanise an elapsed-millis duration into a short phrase. */
|
|
345
|
+
export function relativeTime(endedAt, now = Date.now()) {
|
|
346
|
+
const mins = Math.max(0, Math.round((now - endedAt) / 60_000));
|
|
347
|
+
if (mins < 60) return mins <= 1 ? 'just now' : `${mins} minutes ago`;
|
|
348
|
+
const hours = Math.round(mins / 60);
|
|
349
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
350
|
+
const days = Math.round(hours / 24);
|
|
351
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Format the selected digest as the SessionStart injection block — framed as a
|
|
356
|
+
* verifiable lead (not ground truth) and hard-capped at INJECT_MAX_CHARS.
|
|
357
|
+
*
|
|
358
|
+
* @param {{row: {content: string}, meta: {endedAt?: number}}} selection
|
|
359
|
+
* @param {number} [now]
|
|
360
|
+
* @returns {string}
|
|
361
|
+
*/
|
|
362
|
+
export function formatInjection(selection, now = Date.now()) {
|
|
363
|
+
const content = String(selection?.row?.content || '').trim();
|
|
364
|
+
if (!content) return '';
|
|
365
|
+
const when = selection?.meta?.endedAt ? relativeTime(selection.meta.endedAt, now) : 'recently';
|
|
366
|
+
const header = `📌 Where you left off (${when}) — verify current state before continuing:`;
|
|
367
|
+
const budget = INJECT_MAX_CHARS - header.length - 2;
|
|
368
|
+
if (budget <= 0) return header; // header alone already at the cap (defensive)
|
|
369
|
+
let body = content;
|
|
370
|
+
if (body.length > budget) body = body.slice(0, budget - 1).trimEnd() + '…';
|
|
371
|
+
return `${header}\n${body}`;
|
|
372
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "CANONICAL single source of truth for the moflo scripts + helpers synced into a consumer's .claude/ on init/upgrade/postinstall. Read by bin/session-start-launcher.mjs §3, scripts/post-install-bootstrap.mjs, src/cli/init/executor.ts, and src/cli/init/moflo-init.ts via loadShippedScripts (bin/lib/shipped-scripts.mjs for .mjs, src/cli/init/shipped-scripts.ts for TS). Add a new shipped bin/*.mjs script HERE, once. NOTE: a script that mutates .moflo/moflo.db ALSO needs a tests/system/fixtures/writer-audit-whitelist.json entry — that is an intentional human-classified safety allowlist, not duplication. This file ships via the bin/** glob and auto-syncs to .claude/scripts/lib/ with the rest of bin/lib/.",
|
|
3
|
+
"scriptFiles": [
|
|
4
|
+
"hooks.mjs",
|
|
5
|
+
"session-start-launcher.mjs",
|
|
6
|
+
"index-guidance.mjs",
|
|
7
|
+
"build-embeddings.mjs",
|
|
8
|
+
"generate-code-map.mjs",
|
|
9
|
+
"semantic-search.mjs",
|
|
10
|
+
"index-tests.mjs",
|
|
11
|
+
"index-patterns.mjs",
|
|
12
|
+
"index-reference.mjs",
|
|
13
|
+
"index-all.mjs",
|
|
14
|
+
"setup-project.mjs",
|
|
15
|
+
"run-migrations.mjs",
|
|
16
|
+
"session-continuity.mjs",
|
|
17
|
+
"meditate-capture.mjs",
|
|
18
|
+
"meditate-distill.mjs"
|
|
19
|
+
],
|
|
20
|
+
"binHelperFiles": [
|
|
21
|
+
"gate.cjs",
|
|
22
|
+
"gate-hook.mjs",
|
|
23
|
+
"prompt-hook.mjs",
|
|
24
|
+
"hook-handler.cjs",
|
|
25
|
+
"simplify-classify.cjs"
|
|
26
|
+
],
|
|
27
|
+
"sourceHelperFiles": [
|
|
28
|
+
"auto-memory-hook.mjs",
|
|
29
|
+
"statusline.cjs",
|
|
30
|
+
"intelligence.cjs",
|
|
31
|
+
"subagent-start.cjs",
|
|
32
|
+
"subagent-bootstrap.json",
|
|
33
|
+
"pre-commit",
|
|
34
|
+
"post-commit"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for the canonical shipped-scripts manifest (shipped-scripts.json) —
|
|
3
|
+
* the single source of truth for the scripts + helpers moflo syncs into a
|
|
4
|
+
* consumer's `.claude/`. Replaces the hand-duplicated arrays that previously
|
|
5
|
+
* lived in the launcher, the post-install bootstrap, executor.ts, and
|
|
6
|
+
* moflo-init.ts (issue #1191).
|
|
7
|
+
*
|
|
8
|
+
* `.mjs` consumers (the launcher + the bootstrap) call this with their resolved
|
|
9
|
+
* `bin/lib` directory so they read the FRESHLY-INSTALLED package's list, not a
|
|
10
|
+
* possibly-stale synced copy. The TS twin (`src/cli/init/shipped-scripts.ts`)
|
|
11
|
+
* resolves the same JSON through `findMofloPackageRoot()`.
|
|
12
|
+
*
|
|
13
|
+
* Throws on a missing/unparseable manifest — that means a broken package
|
|
14
|
+
* install (the JSON ships alongside every other bin/lib file). Callers that can
|
|
15
|
+
* tolerate degraded operation wrap this in try/catch (the launcher and bootstrap
|
|
16
|
+
* do); init/upgrade paths let it throw, since they can't proceed past a broken
|
|
17
|
+
* install anyway.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} binLibDir absolute path to the package's `bin/lib` directory
|
|
24
|
+
* @returns {{ scriptFiles: string[], binHelperFiles: string[], sourceHelperFiles: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
export function loadShippedScripts(binLibDir) {
|
|
27
|
+
const manifest = JSON.parse(readFileSync(join(binLibDir, 'shipped-scripts.json'), 'utf-8'));
|
|
28
|
+
return {
|
|
29
|
+
scriptFiles: manifest.scriptFiles ?? [],
|
|
30
|
+
binHelperFiles: manifest.binHelperFiles ?? [],
|
|
31
|
+
sourceHelperFiles: manifest.sourceHelperFiles ?? [],
|
|
32
|
+
};
|
|
33
|
+
}
|