@ijfw/memory-server 1.3.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 (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,235 @@
1
+ // --- project discovery for `ijfw import <tool> --all` ---
2
+ //
3
+ // Given a source memory store (claude-mem SQLite), this module answers:
4
+ // "Which projects does the store reference, and where do they live on disk?"
5
+ //
6
+ // Sources consulted:
7
+ // 1. claude-mem's own `project` column (authoritative for what to import)
8
+ // 2. ~/.claude/projects/ (Claude Code's project directory -- path-encoded)
9
+ // 3. Common dev parents: ~/dev, ~/Code, ~/projects, ~/repos, ~/work, ~/src
10
+ //
11
+ // Output shape (from buildProjectPlan):
12
+ // {
13
+ // source: '/path/to/claude-mem.db',
14
+ // matched: [{ project, path, entryCount, confidence, evidence }],
15
+ // ambiguous: [{ project, candidates: [{path, confidence, evidence}], entryCount }],
16
+ // unmatched: [{ project, entryCount }],
17
+ // }
18
+
19
+ import { existsSync, readdirSync, statSync } from 'node:fs';
20
+ import { join, basename } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+
23
+ const DEV_PARENTS = ['dev', 'Code', 'code', 'projects', 'repos', 'work', 'src'];
24
+
25
+ // Decode Claude Code's path-encoded project directory name back to an absolute
26
+ // path. Example: "-Users-seandonahoe-dev-pip" -> "/Users/seandonahoe/dev/pip".
27
+ // Encoding replaces `/` with `-`. Leading `-` becomes leading `/`.
28
+ // Caveat: directories with literal `-` in their name become ambiguous on
29
+ // decode; we verify by checking whether the decoded path exists.
30
+ export function decodeClaudeProjectDir(name) {
31
+ if (!name || typeof name !== 'string') return null;
32
+ // Leading `-` -> leading `/`. Other `-` -> `/`.
33
+ // We return the most likely decoding (all `-` as `/`). Caller verifies.
34
+ const decoded = '/' + name.replace(/^-+/, '').replace(/-/g, '/');
35
+ return decoded;
36
+ }
37
+
38
+ // Returns a flat list of absolute project paths Claude Code has worked in.
39
+ // Only includes paths that still exist on disk.
40
+ export function discoverKnownProjectPaths({ home = homedir() } = {}) {
41
+ const projectsDir = join(home, '.claude', 'projects');
42
+ if (!existsSync(projectsDir)) return [];
43
+ let entries;
44
+ try { entries = readdirSync(projectsDir); } catch { return []; }
45
+ const paths = [];
46
+ for (const name of entries) {
47
+ const decoded = decodeClaudeProjectDir(name);
48
+ if (!decoded) continue;
49
+ try {
50
+ if (existsSync(decoded) && statSync(decoded).isDirectory()) {
51
+ paths.push(decoded);
52
+ }
53
+ } catch { /* skip */ }
54
+ }
55
+ return paths;
56
+ }
57
+
58
+ // Returns paths like ~/dev/*, ~/Code/*, etc. -- immediate children of common
59
+ // dev parents that exist on disk. Helps catch projects Claude Code hasn't
60
+ // touched yet.
61
+ export function discoverDevParentPaths({ home = homedir() } = {}) {
62
+ const paths = [];
63
+ for (const parent of DEV_PARENTS) {
64
+ const dir = join(home, parent);
65
+ if (!existsSync(dir)) continue;
66
+ let entries;
67
+ try { entries = readdirSync(dir); } catch { continue; }
68
+ for (const name of entries) {
69
+ if (name.startsWith('.')) continue;
70
+ const abs = join(dir, name);
71
+ try {
72
+ if (statSync(abs).isDirectory()) paths.push(abs);
73
+ } catch { /* skip */ }
74
+ }
75
+ }
76
+ return paths;
77
+ }
78
+
79
+ // Score how well a claude-mem project name matches a disk path.
80
+ // Returns { confidence: 0..1, evidence: string } or null.
81
+ // 1.0 -- exact basename match
82
+ // 0.9 -- normalized match (lowercase, strip punctuation)
83
+ // 0.8 -- normalized prefix/suffix (claude-mem name starts or ends with path basename)
84
+ // 0.7 -- contains match (one name contains the other, len >= 4)
85
+ // null -- no match
86
+ function scoreMatch(projectName, diskPath) {
87
+ if (!projectName || !diskPath) return null;
88
+ const pn = String(projectName);
89
+ const dn = basename(diskPath);
90
+
91
+ if (pn === dn) return { confidence: 1.0, evidence: 'exact basename match' };
92
+
93
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
94
+ const pnN = norm(pn);
95
+ const dnN = norm(dn);
96
+ if (!pnN || !dnN) return null;
97
+ if (pnN === dnN) return { confidence: 0.9, evidence: 'normalized match' };
98
+
99
+ if (pnN.startsWith(dnN) || dnN.startsWith(pnN) ||
100
+ pnN.endsWith(dnN) || dnN.endsWith(pnN)) {
101
+ if (Math.min(pnN.length, dnN.length) >= 4) {
102
+ return { confidence: 0.8, evidence: 'normalized prefix/suffix' };
103
+ }
104
+ }
105
+
106
+ // Also handle cases where claude-mem stores a full path as project
107
+ // (some claude-mem versions do this).
108
+ if (pn.includes('/') && pn.endsWith('/' + dn)) {
109
+ return { confidence: 1.0, evidence: 'path suffix match' };
110
+ }
111
+
112
+ if (pnN.length >= 4 && dnN.length >= 4) {
113
+ if (pnN.includes(dnN) || dnN.includes(pnN)) {
114
+ return { confidence: 0.7, evidence: 'substring match' };
115
+ }
116
+ }
117
+
118
+ return null;
119
+ }
120
+
121
+ // Build a candidate list of disk paths for a given claude-mem project name.
122
+ // Sorted by confidence descending.
123
+ export function matchProjectToPaths(projectName, knownPaths) {
124
+ const matches = [];
125
+ for (const p of knownPaths) {
126
+ const score = scoreMatch(projectName, p);
127
+ if (score) matches.push({ path: p, ...score });
128
+ }
129
+ matches.sort((a, b) => b.confidence - a.confidence);
130
+ return matches;
131
+ }
132
+
133
+ // Read distinct project values from claude-mem. Also returns entry counts.
134
+ // Returns Map<projectName, count>.
135
+ export async function readClaudeMemProjects(dbPath) {
136
+ let sqliteMod;
137
+ try {
138
+ sqliteMod = await import('node:sqlite');
139
+ } catch {
140
+ throw new Error(
141
+ 'claude-mem discovery needs Node 22.5+ for built-in SQLite.'
142
+ );
143
+ }
144
+ const { DatabaseSync } = sqliteMod;
145
+ const db = new DatabaseSync(dbPath, { readOnly: true });
146
+ try {
147
+ // Check that the project column exists -- schema varies across versions.
148
+ const cols = db.prepare('PRAGMA table_info(observations)').all();
149
+ const hasProject = cols.some((c) => c.name === 'project');
150
+ if (!hasProject) {
151
+ // No project column -- everything goes to a single "unknown" bucket.
152
+ const total = db.prepare('SELECT COUNT(*) as c FROM observations').get().c;
153
+ return new Map([[null, total]]);
154
+ }
155
+ const rows = db
156
+ .prepare(`SELECT COALESCE(project, '') as project, COUNT(*) as n
157
+ FROM observations
158
+ GROUP BY project
159
+ ORDER BY n DESC`)
160
+ .all();
161
+ const map = new Map();
162
+ for (const r of rows) {
163
+ const key = r.project ? String(r.project) : null;
164
+ map.set(key, r.n);
165
+ }
166
+ return map;
167
+ } finally {
168
+ db.close();
169
+ }
170
+ }
171
+
172
+ // Top-level planner. Returns the full plan for --all mode.
173
+ export async function buildProjectPlan({ dbPath, home = homedir() } = {}) {
174
+ const projects = await readClaudeMemProjects(dbPath);
175
+ const knownPaths = [
176
+ ...new Set([
177
+ ...discoverKnownProjectPaths({ home }),
178
+ ...discoverDevParentPaths({ home }),
179
+ ]),
180
+ ];
181
+
182
+ const matched = [];
183
+ const ambiguous = [];
184
+ const unmatched = [];
185
+
186
+ for (const [projectName, count] of projects.entries()) {
187
+ if (!projectName) {
188
+ unmatched.push({ project: '(no project tag)', entryCount: count });
189
+ continue;
190
+ }
191
+ const candidates = matchProjectToPaths(projectName, knownPaths);
192
+ if (candidates.length === 0) {
193
+ unmatched.push({ project: projectName, entryCount: count });
194
+ continue;
195
+ }
196
+ const top = candidates[0];
197
+ const highConfidence = candidates.filter((c) => c.confidence >= 0.9);
198
+ // Single high-confidence match = auto-matched. Two or more high-confidence
199
+ // matches = ambiguous, ask the user.
200
+ if (highConfidence.length === 1) {
201
+ matched.push({
202
+ project: projectName,
203
+ path: top.path,
204
+ entryCount: count,
205
+ confidence: top.confidence,
206
+ evidence: top.evidence,
207
+ });
208
+ } else if (highConfidence.length > 1) {
209
+ ambiguous.push({
210
+ project: projectName,
211
+ entryCount: count,
212
+ candidates: highConfidence,
213
+ });
214
+ } else {
215
+ // Only low-confidence matches -- treat as ambiguous so user can decide
216
+ // or route to global archive.
217
+ ambiguous.push({
218
+ project: projectName,
219
+ entryCount: count,
220
+ candidates: candidates.slice(0, 3),
221
+ });
222
+ }
223
+ }
224
+
225
+ return {
226
+ source: dbPath,
227
+ matched,
228
+ ambiguous,
229
+ unmatched,
230
+ totalEntries: [...projects.values()].reduce((a, b) => a + b, 0),
231
+ };
232
+ }
233
+
234
+ // For tests.
235
+ export const _internal = { scoreMatch, decodeClaudeProjectDir };
@@ -0,0 +1,107 @@
1
+ // --- RTK importer (scaffold) ---
2
+ //
3
+ // RTK is a third-party Claude Code context-trimming / memory plugin
4
+ // (referenced in prior IJFW handoffs alongside claude-mem). The exact npm
5
+ // name + on-disk layout are pinned in .planning/phase12/IMPORTER-SCHEMAS.md
6
+ // after Phase 12 schema-research. This scaffold parses the two plausible
7
+ // layouts (single JSON doc and per-file dir) so fixture-based tests run
8
+ // green while the real format is nailed down.
9
+
10
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { join, extname } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ import { makeEntry } from './common.js';
14
+
15
+ export const NAME = 'rtk';
16
+
17
+ export function detect({ home = homedir(), path = null } = {}) {
18
+ const candidates = path ? [path] : [
19
+ join(home, '.rtk'),
20
+ join(home, '.claude', 'rtk'),
21
+ join(home, '.config', 'rtk'),
22
+ ];
23
+ for (const p of candidates) {
24
+ if (existsSync(p)) {
25
+ const stats = statSync(p);
26
+ return { found: true, path: p, isDir: stats.isDirectory() };
27
+ }
28
+ }
29
+ return { found: false, path: null };
30
+ }
31
+
32
+ export function* readSource(path) {
33
+ if (!path || !existsSync(path)) return;
34
+
35
+ const stat = statSync(path);
36
+ if (stat.isFile() && path.endsWith('.json')) {
37
+ try {
38
+ const doc = JSON.parse(readFileSync(path, 'utf8'));
39
+ yield* flattenRtk(doc);
40
+ } catch { /* skip malformed root doc */ }
41
+ return;
42
+ }
43
+
44
+ if (stat.isDirectory()) {
45
+ const index = join(path, 'index.json');
46
+ if (existsSync(index)) {
47
+ try {
48
+ const doc = JSON.parse(readFileSync(index, 'utf8'));
49
+ yield* flattenRtk(doc);
50
+ } catch { /* skip */ }
51
+ return;
52
+ }
53
+ for (const name of readdirSync(path)) {
54
+ if (extname(name) !== '.json') continue;
55
+ try {
56
+ const doc = JSON.parse(readFileSync(join(path, name), 'utf8'));
57
+ yield* flattenRtk(doc);
58
+ } catch { /* skip */ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // RTK docs often wrap entries under `entries`, `memories`, or `contexts`.
64
+ function* flattenRtk(doc) {
65
+ if (!doc) return;
66
+ if (Array.isArray(doc)) { for (const r of doc) yield r; return; }
67
+ if (typeof doc !== 'object') return;
68
+ for (const key of ['entries', 'memories', 'contexts', 'items']) {
69
+ if (Array.isArray(doc[key])) { for (const r of doc[key]) yield r; return; }
70
+ }
71
+ yield doc;
72
+ }
73
+
74
+ export function normalize(record) {
75
+ if (!record || typeof record !== 'object') return null;
76
+
77
+ const content =
78
+ record.content ||
79
+ record.text ||
80
+ record.body ||
81
+ record.context ||
82
+ '';
83
+ if (!content) return null;
84
+
85
+ const rawType = record.type || record.category || record.kind || 'observation';
86
+ const mapped = mapType(rawType);
87
+ if (!mapped) return null;
88
+
89
+ return makeEntry({
90
+ type: mapped,
91
+ content,
92
+ summary: record.title || record.label || record.summary || null,
93
+ why: record.rationale || record.why || null,
94
+ how_to_apply: record.guidance || record.how_to_apply || null,
95
+ tags: Array.isArray(record.tags) ? record.tags.map(String) : [],
96
+ source: NAME,
97
+ });
98
+ }
99
+
100
+ function mapType(raw) {
101
+ const t = String(raw).toLowerCase();
102
+ if (t.includes('decision')) return 'decision';
103
+ if (t.includes('pattern')) return 'pattern';
104
+ if (t.includes('preference')) return 'preference';
105
+ if (t.includes('handoff')) return 'handoff';
106
+ return 'observation';
107
+ }
@@ -0,0 +1,221 @@
1
+ // --- Intent router (W2.1 / A1) ---
2
+ // Deterministic keyword → skill dispatch. Runs in UserPromptSubmit hook
3
+ // before the vague-prompt check. When a recognized intent hits, emits a
4
+ // positive-framed nudge that tells the agent which IJFW skill/tool to use.
5
+ //
6
+ // This is the "brainstorm auto-fires workflow" moment: we don't leave the
7
+ // matching to the LLM; we match deterministically and surface the choice.
8
+ //
9
+ // Policy: high precision over recall. False positives are expensive (wrong
10
+ // skill fired), false negatives cheap (agent just picks normally).
11
+
12
+ const INTENTS = [
13
+ {
14
+ intent: 'brainstorm',
15
+ skill: 'ijfw-workflow',
16
+ priority: 8, // High: primary workflow entry point, should beat most other intents
17
+ // Bare "build" is too vague; "build X" + "brainstorm" + "let's design"
18
+ // are the high-signal ones.
19
+ patterns: [
20
+ /\bbrainstorm(?:\s|ing)\b/i,
21
+ /\blet'?s\s+(?:think|design|plan|figure)\b/i,
22
+ /\b(?:new project|starting a project|greenfield)\b/i,
23
+ /\bhelp me (?:build|design|plan|figure)\b/i,
24
+ // Non-software project triggers
25
+ /\b(build|create|design|launch|start|develop|write|outline)\s+(a|an|the|my|our)\s+(?!pr\b|commit\b|branch\b|tag\b|comment\b|variable\b|file\b|line\b|test\b|function\b|class\b|method\b)\w{3,}/i,
26
+ /\b(?:build|create|design|launch|plan|make|start)\s+(?:a|an|the|my|our)\s+(?:landing page|website|app|dashboard|campaign|book|course|brand|product|platform|service)\b/i,
27
+ /\b(from scratch|greenfield|new\s+(?:project|venture|initiative|business|idea))\b/i,
28
+ /\b(strategy for|roadmap for|plan for|outline for)\b/i,
29
+ /\b(social media|content marketing|email sequence|sales funnel|launch plan)\b/i,
30
+ ],
31
+ nudge: "Looks like you want to plan a new project. Try: /ijfw-workflow (quick mode for rapid exploration, deep mode for full project planning).",
32
+ },
33
+ {
34
+ intent: 'project-scale',
35
+ skill: 'ijfw-workflow',
36
+ priority: 7, // Just below brainstorm -- scale detection is a fallback for non-keyword matches
37
+ patterns: [], // no keyword patterns -- uses check() instead
38
+ check: (prompt) => {
39
+ const words = prompt.split(/\s+/).length;
40
+ const hasMultipleDeliverables = (prompt.match(/\band\b/gi) || []).length >= 2;
41
+ const hasTimeline = /\b(by|before|deadline|launch|next\s+(week|month)|this\s+(week|month))\b/i.test(prompt);
42
+ const hasBudget = /\$\d|budget|resource|team of/i.test(prompt);
43
+ const hasScope = /\b(full|complete|entire|end.to.end|comprehensive)\b/i.test(prompt);
44
+ return words > 40 && (hasMultipleDeliverables || hasTimeline || hasBudget || hasScope);
45
+ },
46
+ nudge: 'This sounds like a project. Want me to brainstorm it with you? I\'ll ask a few questions, do some research, and come back with recommendations.',
47
+ },
48
+ {
49
+ intent: 'ship',
50
+ skill: 'ijfw-commit',
51
+ priority: 5,
52
+ patterns: [
53
+ /\b(?:ship it|let'?s ship|ready to commit|commit this|push this)\b/i,
54
+ /\bmake a commit\b/i,
55
+ /\b(?:create|open) (?:a )?PR\b/i,
56
+ ],
57
+ nudge: "Looks like you want to commit and ship your changes. Try: /ijfw-commit for a terse conventional commit, then open a PR following your git conventions.",
58
+ },
59
+ {
60
+ intent: 'review',
61
+ skill: 'ijfw-review',
62
+ priority: 5,
63
+ patterns: [
64
+ /\b(?:code review|review (?:the|this|my) (?:code|diff|PR|change))/i,
65
+ /\breview PR\b/i,
66
+ ],
67
+ nudge: "Looks like you want to review code or a diff. Try: /ijfw-review for concise, actionable findings.",
68
+ },
69
+ {
70
+ intent: 'remember',
71
+ skill: 'ijfw_memory_store',
72
+ priority: 5,
73
+ patterns: [
74
+ /\b(?:remember (?:this|that)|store (?:this|that)|save (?:this|that) (?:for (?:later|next time)|to memory))\b/i,
75
+ /\b(?:this is|that'?s) important (?:to remember|for (?:later|next time))\b/i,
76
+ /\b(?:note to self|save for later)\b/i,
77
+ ],
78
+ nudge: "Looks like you want to save something to memory. Try: ijfw_memory_store with a type (decision, observation, pattern, handoff, or preference) and a brief summary.",
79
+ },
80
+ {
81
+ intent: 'recall',
82
+ skill: 'ijfw_memory_recall',
83
+ priority: 5,
84
+ patterns: [
85
+ /\b(?:what did we|what (?:did|have) I|do you remember)\b/i,
86
+ /\b(?:recall|pull up|look up) (?:from|in) memory\b/i,
87
+ /\bshow me what you remember\b/i,
88
+ ],
89
+ nudge: "Looks like you want to recall something from memory. Try: ijfw_memory_recall or ijfw_memory_search to pull up what was stored.",
90
+ },
91
+ {
92
+ intent: 'cross-research',
93
+ skill: '/cross-research',
94
+ priority: 10,
95
+ patterns: [
96
+ /\bcross[- ]?research(?:\s|ing)?\b/i,
97
+ /\blet'?s cross[- ]?research\b/i,
98
+ /\bdig into .+ from multiple angles\b/i,
99
+ /\bmulti[- ]?angle research\b/i,
100
+ /\bresearch (?:this|that) from multiple angles\b/i,
101
+ ],
102
+ nudge: "Looks like you want to research a topic from multiple angles. Try: /cross-research (auto-detects target; no args needed). Phase A fans out to Codex and Gemini in parallel, Phase B synthesizes results -- all via background bash.",
103
+ },
104
+ {
105
+ intent: 'cross-critique',
106
+ skill: '/cross-critique',
107
+ priority: 10,
108
+ patterns: [
109
+ /\bcross[- ]?critique(?:\s|ing)?\b/i,
110
+ /\blet'?s cross[- ]?critique\b/i,
111
+ /\bstress[- ]?test this claim\b/i,
112
+ /\badversarial (?:critique|review)\b/i,
113
+ /\bchallenge this from every angle\b/i,
114
+ /\battack this from all sides\b/i,
115
+ ],
116
+ nudge: "Looks like you want to stress-test a claim or plan from multiple angles. Try: /cross-critique (auto-detects target). Codex covers technical weaknesses, Gemini covers strategic weaknesses, and a fresh Claude instance covers UX and adoption -- all auto-fired via background bash.",
117
+ },
118
+ {
119
+ intent: 'critique',
120
+ skill: 'ijfw-critique',
121
+ priority: 1,
122
+ patterns: [
123
+ /\b(?:should I|what if|is this (?:right|correct|the best))\b/i,
124
+ /\b(?:critique|poke holes|challenge this)\b/i,
125
+ /\b(?:counter[- ]?argument|devil'?s advocate)\b/i,
126
+ ],
127
+ nudge: "Looks like you want to pressure-test an idea or plan. Try: /ijfw-critique -- steelmans the current approach, then surfaces 2-3 concrete counter-arguments with the conditions that trigger each.",
128
+ },
129
+ {
130
+ intent: 'cross-audit',
131
+ skill: '/cross-audit',
132
+ priority: 10,
133
+ patterns: [
134
+ /\bcross[- ]?audit(?:\s|ing)?\b/i,
135
+ /\b(?:get|need)\s+(?:a\s+)?second opinion\b/i,
136
+ /\b(?:have|ask)\s+(?:codex|gemini|opencode|aider|copilot)\s+(?:to\s+)?(?:review|audit|check)\b/i,
137
+ /\bsecond[- ]model (?:review|opinion|audit)\b/i,
138
+ /\b(?:peer|adversarial)[- ]?(?:review|audit)\b/i,
139
+ ],
140
+ nudge: "Looks like you want a second-model audit of your code. Try: /cross-audit (no args = auto-picks staged and recent-change files; --with <id> to target a specific auditor). Writes .ijfw/cross-audit/request.md and auto-fires the auditor via background bash.",
141
+ },
142
+ {
143
+ intent: 'handoff',
144
+ skill: 'ijfw-handoff',
145
+ priority: 5,
146
+ patterns: [
147
+ /\b(?:session (?:handoff|summary)|wrapping up|end of session)\b/i,
148
+ /\bcontext (?:is )?getting full\b/i,
149
+ ],
150
+ nudge: "Looks like you want to wrap up this session cleanly. Try: /ijfw-handoff -- writes a structured handoff with decisions made, next steps, and open questions.",
151
+ },
152
+ {
153
+ intent: 'mode-brutal',
154
+ skill: 'ijfw-core',
155
+ priority: 5,
156
+ patterns: [
157
+ /\b(?:brutal mode|be brutal|caveman mode|ultra[- ]?terse)\b/i,
158
+ ],
159
+ nudge: "Looks like you want maximum terseness. Try: /ijfw-mode brutal -- code and single-sentence answers only, no narration unless you ask.",
160
+ },
161
+ ];
162
+
163
+ function adaptProjectScaleNudge(prompt) {
164
+ const p = prompt.toLowerCase();
165
+ let verb;
166
+ if (/\b(app|api|dashboard|backend|frontend|service|platform|software|saas|microservice)\b/.test(p)) {
167
+ verb = 'brainstorm the architecture';
168
+ } else if (/\b(book|outline|chapter|manuscript|write)\b/.test(p)) {
169
+ verb = 'outline the structure';
170
+ } else if (/\b(campaign|marketing|social|email sequence|sales funnel|launch plan|content)\b/.test(p)) {
171
+ verb = 'plan the strategy';
172
+ } else if (/\b(design|landing page|ui|ux|brand|visual)\b/.test(p)) {
173
+ verb = 'explore the design';
174
+ } else {
175
+ verb = 'map out the approach';
176
+ }
177
+ return `This sounds like a project. Want me to ${verb} with you? I'll ask a few questions, do some research, and come back with recommendations.`;
178
+ }
179
+
180
+ export function detectIntent(prompt) {
181
+ if (typeof prompt !== 'string' || !prompt) return null;
182
+ // Skip if user explicitly bypasses (leading * or `ijfw off`).
183
+ if (/^\s*\*/.test(prompt)) return null;
184
+ if (/\bijfw off\b/i.test(prompt)) return null;
185
+
186
+ // Collect ALL matching entries with the longest matching pattern length.
187
+ const matches = [];
188
+ for (const entry of INTENTS) {
189
+ // check() function takes precedence over patterns for entries that declare it.
190
+ if (typeof entry.check === 'function') {
191
+ if (entry.check(prompt)) {
192
+ matches.push({ entry, matchLen: 0 });
193
+ }
194
+ continue;
195
+ }
196
+ for (const re of entry.patterns) {
197
+ const m = prompt.match(re);
198
+ if (m) {
199
+ matches.push({ entry, matchLen: m[0].length });
200
+ break; // one match per entry is enough
201
+ }
202
+ }
203
+ }
204
+
205
+ if (matches.length === 0) return null;
206
+
207
+ // Sort: (a) priority DESC, (b) matchLen DESC, (c) INTENTS array order ASC (stable via index).
208
+ matches.sort((a, b) => {
209
+ const pd = (b.entry.priority ?? 0) - (a.entry.priority ?? 0);
210
+ if (pd !== 0) return pd;
211
+ const ld = b.matchLen - a.matchLen;
212
+ if (ld !== 0) return ld;
213
+ return INTENTS.indexOf(a.entry) - INTENTS.indexOf(b.entry);
214
+ });
215
+
216
+ const winner = matches[0].entry;
217
+ const nudge = winner.intent === 'project-scale'
218
+ ? adaptProjectScaleNudge(prompt)
219
+ : winner.nudge;
220
+ return { intent: winner.intent, skill: winner.skill, nudge };
221
+ }