@bradygaster/squad-sdk 0.8.17 → 0.8.18

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 (64) hide show
  1. package/dist/adapter/client.d.ts.map +1 -1
  2. package/dist/adapter/client.js +2 -0
  3. package/dist/adapter/client.js.map +1 -1
  4. package/dist/config/init.d.ts +43 -2
  5. package/dist/config/init.d.ts.map +1 -1
  6. package/dist/config/init.js +389 -46
  7. package/dist/config/init.js.map +1 -1
  8. package/dist/index.d.ts +8 -13
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -9
  11. package/dist/index.js.map +1 -1
  12. package/dist/ralph/index.js +5 -5
  13. package/dist/ralph/index.js.map +1 -1
  14. package/dist/remote/bridge.d.ts +2 -0
  15. package/dist/remote/bridge.d.ts.map +1 -1
  16. package/dist/remote/bridge.js +34 -4
  17. package/dist/remote/bridge.js.map +1 -1
  18. package/dist/resolution.d.ts +13 -0
  19. package/dist/resolution.d.ts.map +1 -1
  20. package/dist/resolution.js +9 -1
  21. package/dist/resolution.js.map +1 -1
  22. package/dist/sharing/consult.d.ts +226 -0
  23. package/dist/sharing/consult.d.ts.map +1 -0
  24. package/dist/sharing/consult.js +818 -0
  25. package/dist/sharing/consult.js.map +1 -0
  26. package/dist/sharing/index.d.ts +2 -1
  27. package/dist/sharing/index.d.ts.map +1 -1
  28. package/dist/sharing/index.js +2 -1
  29. package/dist/sharing/index.js.map +1 -1
  30. package/package.json +4 -2
  31. package/templates/casting-history.json +4 -0
  32. package/templates/casting-policy.json +35 -0
  33. package/templates/casting-registry.json +3 -0
  34. package/templates/ceremonies.md +41 -0
  35. package/templates/charter.md +53 -0
  36. package/templates/constraint-tracking.md +38 -0
  37. package/templates/copilot-instructions.md +46 -0
  38. package/templates/history.md +10 -0
  39. package/templates/identity/now.md +9 -0
  40. package/templates/identity/wisdom.md +15 -0
  41. package/templates/mcp-config.md +98 -0
  42. package/templates/multi-agent-format.md +28 -0
  43. package/templates/orchestration-log.md +27 -0
  44. package/templates/plugin-marketplace.md +49 -0
  45. package/templates/raw-agent-output.md +37 -0
  46. package/templates/roster.md +60 -0
  47. package/templates/routing.md +54 -0
  48. package/templates/run-output.md +50 -0
  49. package/templates/scribe-charter.md +119 -0
  50. package/templates/skill.md +24 -0
  51. package/templates/skills/project-conventions/SKILL.md +56 -0
  52. package/templates/squad.agent.md +1146 -0
  53. package/templates/workflows/squad-ci.yml +24 -0
  54. package/templates/workflows/squad-docs.yml +50 -0
  55. package/templates/workflows/squad-heartbeat.yml +316 -0
  56. package/templates/workflows/squad-insider-release.yml +61 -0
  57. package/templates/workflows/squad-issue-assign.yml +161 -0
  58. package/templates/workflows/squad-label-enforce.yml +181 -0
  59. package/templates/workflows/squad-main-guard.yml +129 -0
  60. package/templates/workflows/squad-preview.yml +55 -0
  61. package/templates/workflows/squad-promote.yml +121 -0
  62. package/templates/workflows/squad-release.yml +77 -0
  63. package/templates/workflows/squad-triage.yml +260 -0
  64. package/templates/workflows/sync-squad-labels.yml +169 -0
@@ -0,0 +1,818 @@
1
+ /**
2
+ * Consult mode SDK — setup, extraction, license detection, learning classification.
3
+ *
4
+ * This module provides the complete SDK surface for consult mode:
5
+ *
6
+ * High-level operations (mirror CLI commands):
7
+ * - setupConsultMode(): Initialize consult mode in a project
8
+ * - extractLearnings(): Extract learnings from a consult session
9
+ *
10
+ * Low-level utilities (used by high-level operations):
11
+ * - detectLicense(): Identify project license type (permissive/copyleft/unknown)
12
+ * - logConsultation(): Write/append consultation log entries
13
+ * - mergeToPersonalSquad(): Merge generic learnings to personal squad
14
+ *
15
+ * @module sharing/consult
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { execSync } from 'node:child_process';
21
+ import { resolveGlobalSquadPath } from '../resolution.js';
22
+ // ============================================================================
23
+ // Typed Errors
24
+ // ============================================================================
25
+ /**
26
+ * Thrown when setupConsultMode is called but no personal squad exists.
27
+ * Consumers can catch this specifically to prompt users to run `squad init --global`.
28
+ */
29
+ export class PersonalSquadNotFoundError extends Error {
30
+ constructor() {
31
+ super('No personal squad found. Run `squad init --global` first to create your personal squad.');
32
+ this.name = 'PersonalSquadNotFoundError';
33
+ }
34
+ }
35
+ /**
36
+ * Error thrown when extraction is disabled for a consult session.
37
+ * Consumers can catch this specifically to suggest using --force.
38
+ */
39
+ export class ExtractionDisabledError extends Error {
40
+ constructor() {
41
+ super('Extraction is disabled for this consult session.\n' +
42
+ 'This was configured in your personal squad settings.\n' +
43
+ 'Use --force to override.');
44
+ this.name = 'ExtractionDisabledError';
45
+ }
46
+ }
47
+ // ============================================================================
48
+ // Consult Mode Agent File
49
+ // ============================================================================
50
+ /**
51
+ * Consult mode preamble to inject after frontmatter in squad.agent.md.
52
+ * This tells Squad it's in consult mode and should skip Init Mode.
53
+ */
54
+ const CONSULT_MODE_PREAMBLE = `
55
+ <!-- consult-mode: true -->
56
+
57
+ ## ⚡ Consult Mode Active
58
+
59
+ This project is in **consult mode**. Your personal squad has been copied into \`.squad/\` for this session.
60
+
61
+ **Key differences from normal mode:**
62
+ - **Skip Init Mode** — The team already exists (copied from your personal squad)
63
+ - **Isolated changes** — All changes stay local until you run \`squad extract\`
64
+ - **Invisible to project** — Both \`.squad/\` and this agent file are in \`.git/info/exclude\`
65
+
66
+ **When done:** Run \`squad extract\` to review learnings and merge generic ones back to your personal squad.
67
+
68
+ ---
69
+
70
+ `;
71
+ /**
72
+ * Get the full squad.agent.md template path.
73
+ * Looks in the SDK package's templates directory.
74
+ */
75
+ function getSquadAgentTemplatePath() {
76
+ // Use fileURLToPath for cross-platform compatibility (handles Windows drive letters, URL encoding)
77
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
78
+ // Try relative to this file (in dist/)
79
+ const distPath = path.resolve(currentDir, '../../templates/squad.agent.md');
80
+ if (fs.existsSync(distPath)) {
81
+ return distPath;
82
+ }
83
+ // Try relative to package root
84
+ const pkgPath = path.resolve(currentDir, '../../../templates/squad.agent.md');
85
+ if (fs.existsSync(pkgPath)) {
86
+ return pkgPath;
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Get the git remote URL for a repository.
92
+ * Converts SSH URLs to HTTPS format for display.
93
+ */
94
+ function getGitRemoteUrl(projectRoot) {
95
+ try {
96
+ const remoteUrl = execSync('git remote get-url origin', {
97
+ cwd: projectRoot,
98
+ encoding: 'utf-8',
99
+ stdio: ['pipe', 'pipe', 'pipe'],
100
+ }).trim();
101
+ // Convert SSH URL to HTTPS for readability
102
+ // git@github.com:owner/repo.git → https://github.com/owner/repo
103
+ if (remoteUrl.startsWith('git@')) {
104
+ const match = remoteUrl.match(/git@([^:]+):(.+?)(\.git)?$/);
105
+ if (match) {
106
+ return `https://${match[1]}/${match[2]}`;
107
+ }
108
+ }
109
+ // Remove .git suffix if present
110
+ return remoteUrl.replace(/\.git$/, '');
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /**
117
+ * Generate squad.agent.md for consult mode.
118
+ * Uses the full template with consult mode preamble injected.
119
+ */
120
+ function getConsultAgentContent(projectName) {
121
+ const templatePath = getSquadAgentTemplatePath();
122
+ if (templatePath && fs.existsSync(templatePath)) {
123
+ const template = fs.readFileSync(templatePath, 'utf-8');
124
+ // Find the end of frontmatter (second ---)
125
+ const frontmatterEnd = template.indexOf('---', template.indexOf('---') + 3);
126
+ if (frontmatterEnd !== -1) {
127
+ const insertPoint = frontmatterEnd + 3;
128
+ const before = template.slice(0, insertPoint);
129
+ const after = template.slice(insertPoint);
130
+ // Update description in frontmatter for consult mode
131
+ const updatedBefore = before.replace(/description:\s*"[^"]*"/, `description: "Your AI team. Consulting on ${projectName} using your personal squad."`);
132
+ return updatedBefore + '\n' + CONSULT_MODE_PREAMBLE + after;
133
+ }
134
+ // Fallback: prepend preamble
135
+ return template + '\n' + CONSULT_MODE_PREAMBLE;
136
+ }
137
+ // Fallback: minimal agent if template not found
138
+ return `---
139
+ name: Squad
140
+ description: "Your AI team. Consulting on ${projectName} using your personal squad."
141
+ ---
142
+
143
+ ${CONSULT_MODE_PREAMBLE}
144
+
145
+ You are **Squad (Consultant)** — working on **${projectName}** using a copy of your personal squad.
146
+
147
+ ### Available Context (local copy in .squad/)
148
+
149
+ - **Team:** \`.squad/team.md\` for roster and roles
150
+ - **Routing:** \`.squad/routing.md\` for task routing rules
151
+ - **Decisions:** \`.squad/decisions.md\` for your established patterns
152
+ - **Skills:** \`.squad/skills/\` for reusable capabilities
153
+ - **Agents:** \`.squad/agents/\` for your squad agents
154
+
155
+ Work as you would with your personal squad, but in this external codebase.
156
+ `;
157
+ }
158
+ // ============================================================================
159
+ // Scribe Charter Patching for Consult Mode
160
+ // ============================================================================
161
+ /**
162
+ * Consult mode instructions to append to Scribe charter.
163
+ * This enables Scribe to classify decisions as generic or project-specific.
164
+ */
165
+ const CONSULT_MODE_SCRIBE_PATCH = `
166
+
167
+ ---
168
+
169
+ ## Consult Mode Extraction
170
+
171
+ **This squad is in consult mode.** When merging decisions from the inbox, also classify each decision:
172
+
173
+ ### Classification
174
+
175
+ For each decision in \`.squad/decisions/inbox/\`:
176
+
177
+ 1. **Generic** (applies to any project) → Copy to \`.squad/extract/\` with the same filename
178
+ - Signals: "always use", "never use", "prefer X over Y", "best practice", coding standards, patterns that work anywhere
179
+ - These will be extracted to the personal squad via \`squad extract\`
180
+
181
+ 2. **Project-specific** (only applies here) → Keep in local \`decisions.md\` only
182
+ - Signals: Contains file paths from this project, references "this project/codebase/repo", mentions project-specific config/APIs/schemas
183
+
184
+ Generic decisions go to BOTH \`.squad/decisions.md\` (for this session) AND \`.squad/extract/\` (for later extraction).
185
+
186
+ ### Extract Directory
187
+
188
+ \`\`\`
189
+ .squad/extract/ # Generic learnings staged for personal squad
190
+ ├── decision-1.md # Ready for extraction
191
+ └── pattern-auth.md # Ready for extraction
192
+ \`\`\`
193
+
194
+ Run \`squad extract\` to review and merge these to your personal squad.
195
+ `;
196
+ /**
197
+ * Patch the Scribe charter in the copied squad with consult mode instructions.
198
+ */
199
+ function patchScribeCharterForConsultMode(squadDir) {
200
+ const charterPath = path.join(squadDir, 'agents', 'scribe', 'charter.md');
201
+ if (!fs.existsSync(charterPath)) {
202
+ // No scribe charter to patch — skip silently
203
+ return;
204
+ }
205
+ const existing = fs.readFileSync(charterPath, 'utf-8');
206
+ // Don't patch if already patched
207
+ if (existing.includes('Consult Mode Extraction')) {
208
+ return;
209
+ }
210
+ fs.appendFileSync(charterPath, CONSULT_MODE_SCRIBE_PATCH);
211
+ }
212
+ /**
213
+ * List files recursively in a directory.
214
+ */
215
+ function listFilesInDir(dir, basePath = '') {
216
+ if (!fs.existsSync(dir))
217
+ return [];
218
+ const files = [];
219
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
220
+ for (const entry of entries) {
221
+ const relativePath = basePath ? path.join(basePath, entry.name) : entry.name;
222
+ if (entry.isDirectory()) {
223
+ files.push(...listFilesInDir(path.join(dir, entry.name), relativePath));
224
+ }
225
+ else {
226
+ files.push(relativePath);
227
+ }
228
+ }
229
+ return files;
230
+ }
231
+ /**
232
+ * Get the personal squad root path.
233
+ * Returns {globalSquadPath}/.squad/
234
+ */
235
+ export function getPersonalSquadRoot() {
236
+ return path.resolve(resolveGlobalSquadPath(), '.squad');
237
+ }
238
+ /**
239
+ * Resolve the git exclude path using git rev-parse (handles worktrees/submodules).
240
+ *
241
+ * @param cwd - Working directory inside the git repo
242
+ * @throws Error if not a git repository
243
+ */
244
+ export function resolveGitExcludePath(cwd) {
245
+ try {
246
+ return execSync('git rev-parse --git-path info/exclude', {
247
+ cwd,
248
+ encoding: 'utf-8',
249
+ }).trim();
250
+ }
251
+ catch {
252
+ throw new Error('Not a git repository. Consult mode requires git.');
253
+ }
254
+ }
255
+ /**
256
+ * Set up consult mode in a project.
257
+ *
258
+ * Creates .squad/ with consult: true, pointing to your personal squad.
259
+ * Creates .github/agents/squad.agent.md for `gh copilot --agent squad` support.
260
+ * Both are hidden via .git/info/exclude (never committed).
261
+ *
262
+ * @param options - Setup options
263
+ * @returns Setup result with paths and metadata
264
+ * @throws Error if not a git repo, personal squad missing, or already squadified
265
+ */
266
+ export async function setupConsultMode(options = {}) {
267
+ const projectRoot = options.projectRoot || process.cwd();
268
+ const personalSquadRoot = options.personalSquadRoot || getPersonalSquadRoot();
269
+ const dryRun = options.dryRun ?? false;
270
+ const squadDir = path.resolve(projectRoot, '.squad');
271
+ const projectName = options.projectName || path.basename(projectRoot);
272
+ const agentFile = path.resolve(projectRoot, '.github', 'agents', 'squad.agent.md');
273
+ // Check if we're in a git repository (handle worktrees/submodules where .git is a file)
274
+ const gitPath = path.resolve(projectRoot, '.git');
275
+ if (!fs.existsSync(gitPath)) {
276
+ throw new Error('Not a git repository. Consult mode requires git.');
277
+ }
278
+ // Resolve exclude path via git rev-parse (handles worktrees/submodules)
279
+ // Normalize to absolute path in case it's relative
280
+ const gitExclude = (() => {
281
+ const excludePath = resolveGitExcludePath(projectRoot);
282
+ return path.isAbsolute(excludePath) ? excludePath : path.resolve(projectRoot, excludePath);
283
+ })();
284
+ // Check if personal squad exists
285
+ if (!fs.existsSync(personalSquadRoot)) {
286
+ throw new PersonalSquadNotFoundError();
287
+ }
288
+ // Read source squad's config to inherit extractionDisabled setting
289
+ // Option takes precedence, then fall back to source config
290
+ let extractionDisabled = options.extractionDisabled ?? false;
291
+ const sourceConfigPath = path.join(personalSquadRoot, 'config.json');
292
+ if (fs.existsSync(sourceConfigPath)) {
293
+ try {
294
+ const sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, 'utf-8'));
295
+ // Inherit from source unless explicitly overridden in options
296
+ if (options.extractionDisabled === undefined && sourceConfig.extractionDisabled) {
297
+ extractionDisabled = true;
298
+ }
299
+ }
300
+ catch {
301
+ // Ignore malformed config
302
+ }
303
+ }
304
+ // Check if project already has .squad/
305
+ if (fs.existsSync(squadDir)) {
306
+ throw new Error('This project already has a .squad/ directory. Cannot use consult mode on squadified projects.');
307
+ }
308
+ // List files in personal squad (for dry run preview or later count)
309
+ const sourceFiles = listFilesInDir(personalSquadRoot);
310
+ if (!dryRun) {
311
+ // Copy personal squad contents into project's .squad/
312
+ // This isolates changes during the consult session
313
+ fs.cpSync(personalSquadRoot, squadDir, { recursive: true });
314
+ // Write/overwrite config.json with consult: true
315
+ // Include SquadDirConfig fields so loadDirConfig() can read it
316
+ // Note: version must be numeric for loadDirConfig() compatibility
317
+ const config = {
318
+ version: 1,
319
+ teamRoot: personalSquadRoot,
320
+ consult: true,
321
+ sourceSquad: personalSquadRoot,
322
+ projectName,
323
+ createdAt: new Date().toISOString(),
324
+ extractionDisabled,
325
+ };
326
+ fs.writeFileSync(path.join(squadDir, 'config.json'), JSON.stringify(config, null, 2), 'utf-8');
327
+ // Create sessions directory for tracking (if not copied)
328
+ const sessionsDir = path.join(squadDir, 'sessions');
329
+ if (!fs.existsSync(sessionsDir)) {
330
+ fs.mkdirSync(sessionsDir, { recursive: true });
331
+ }
332
+ // Create extract/ directory for staging generic learnings
333
+ const extractDir = path.join(squadDir, 'extract');
334
+ fs.mkdirSync(extractDir, { recursive: true });
335
+ // Patch scribe-charter.md with consult mode extraction instructions
336
+ patchScribeCharterForConsultMode(squadDir);
337
+ // Create .github/agents/squad.agent.md for `gh copilot --agent squad`
338
+ const agentDir = path.dirname(agentFile);
339
+ if (!fs.existsSync(agentDir)) {
340
+ fs.mkdirSync(agentDir, { recursive: true });
341
+ }
342
+ fs.writeFileSync(agentFile, getConsultAgentContent(projectName), 'utf-8');
343
+ // Add .squad/ and .github/agents/squad.agent.md to .git/info/exclude
344
+ const excludeDir = path.dirname(gitExclude);
345
+ if (!fs.existsSync(excludeDir)) {
346
+ fs.mkdirSync(excludeDir, { recursive: true });
347
+ }
348
+ const excludeContent = fs.existsSync(gitExclude)
349
+ ? fs.readFileSync(gitExclude, 'utf-8')
350
+ : '';
351
+ const excludeLines = [];
352
+ if (!excludeContent.includes('.squad/')) {
353
+ excludeLines.push('.squad/');
354
+ }
355
+ if (!excludeContent.includes('.github/agents/squad.agent.md')) {
356
+ excludeLines.push('.github/agents/squad.agent.md');
357
+ }
358
+ if (excludeLines.length > 0) {
359
+ fs.appendFileSync(gitExclude, '\n# Squad consult mode (local only)\n' + excludeLines.join('\n') + '\n');
360
+ }
361
+ }
362
+ // List files created (from squad dir after copy, or from source for dry run)
363
+ const createdFiles = dryRun ? sourceFiles : listFilesInDir(squadDir);
364
+ return {
365
+ squadDir,
366
+ personalSquadRoot,
367
+ gitExclude,
368
+ projectName,
369
+ dryRun,
370
+ agentFile,
371
+ createdFiles,
372
+ extractionDisabled,
373
+ };
374
+ }
375
+ /**
376
+ * Load session history from .squad/sessions/ directory.
377
+ *
378
+ * @param squadDir - Path to project .squad/ directory
379
+ * @returns AgentHistory with entries from session files
380
+ */
381
+ export function loadSessionHistory(squadDir) {
382
+ const sessionsDir = path.join(squadDir, 'sessions');
383
+ const entries = [];
384
+ if (!fs.existsSync(sessionsDir)) {
385
+ return { entries };
386
+ }
387
+ const files = fs.readdirSync(sessionsDir)
388
+ .filter(f => f.endsWith('.json'))
389
+ .sort();
390
+ for (const file of files) {
391
+ try {
392
+ const content = fs.readFileSync(path.join(sessionsDir, file), 'utf-8');
393
+ const session = JSON.parse(content);
394
+ // Extract learnings from session data
395
+ if (session.learnings && Array.isArray(session.learnings)) {
396
+ for (const learning of session.learnings) {
397
+ entries.push({
398
+ id: learning.id || `${file}-${entries.length}`,
399
+ timestamp: learning.timestamp || session.timestamp || new Date().toISOString(),
400
+ type: learning.type || 'pattern',
401
+ content: learning.content || String(learning),
402
+ agent: learning.agent,
403
+ });
404
+ }
405
+ }
406
+ // Extract decisions
407
+ if (session.decisions && Array.isArray(session.decisions)) {
408
+ for (const decision of session.decisions) {
409
+ entries.push({
410
+ id: decision.id || `${file}-decision-${entries.length}`,
411
+ timestamp: decision.timestamp || session.timestamp || new Date().toISOString(),
412
+ type: 'decision',
413
+ content: decision.content || String(decision),
414
+ agent: decision.agent,
415
+ });
416
+ }
417
+ }
418
+ }
419
+ catch {
420
+ // Skip malformed session files
421
+ }
422
+ }
423
+ return { entries };
424
+ }
425
+ /**
426
+ * Extract learnings from a consult mode session.
427
+ *
428
+ * Reads staged learnings from .squad/extract/ (classified by Scribe during session)
429
+ * and optionally merges approved items to your personal squad.
430
+ *
431
+ * @param options - Extraction options
432
+ * @returns Extraction result with learnings, merge stats, and paths
433
+ * @throws Error if not in consult mode or license blocks extraction
434
+ */
435
+ export async function extractLearnings(options = {}) {
436
+ const projectRoot = options.projectRoot || process.cwd();
437
+ const personalSquadRoot = options.personalSquadRoot || getPersonalSquadRoot();
438
+ const dryRun = options.dryRun ?? false;
439
+ const clean = options.clean ?? false;
440
+ const acceptRisks = options.acceptRisks ?? false;
441
+ const force = options.force ?? false;
442
+ const squadDir = path.resolve(projectRoot, '.squad');
443
+ const projectName = options.projectName || path.basename(projectRoot);
444
+ // Check if we're in consult mode
445
+ if (!fs.existsSync(squadDir)) {
446
+ throw new Error('Not in consult mode. No .squad/ directory found.');
447
+ }
448
+ const configPath = path.join(squadDir, 'config.json');
449
+ if (!fs.existsSync(configPath)) {
450
+ throw new Error('Invalid consult mode: missing config.json');
451
+ }
452
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
453
+ if (!config.consult) {
454
+ throw new Error('This project has a .squad/ but is not in consult mode. Use normal squad commands.');
455
+ }
456
+ // Check if extraction is disabled for this consult session
457
+ if (config.extractionDisabled && !force) {
458
+ throw new ExtractionDisabledError();
459
+ }
460
+ // Detect license
461
+ const licensePath = path.join(projectRoot, 'LICENSE');
462
+ const licenseContent = fs.existsSync(licensePath)
463
+ ? fs.readFileSync(licensePath, 'utf-8')
464
+ : '';
465
+ const license = detectLicense(licenseContent);
466
+ // Block copyleft extraction unless --accept-risks
467
+ const blocked = license.type === 'copyleft' && !acceptRisks;
468
+ // Get repository URL for logging
469
+ const repoUrl = getGitRemoteUrl(projectRoot);
470
+ if (blocked) {
471
+ return {
472
+ extracted: [],
473
+ skipped: [],
474
+ license,
475
+ projectName,
476
+ repoUrl,
477
+ timestamp: new Date().toISOString(),
478
+ acceptedRisks: false,
479
+ blocked: true,
480
+ decisionsMerged: 0,
481
+ skillsCreated: 0,
482
+ cleaned: false,
483
+ };
484
+ }
485
+ // Load staged learnings from .squad/extract/
486
+ let staged = loadStagedLearnings(squadDir);
487
+ // If interactive selection callback provided, let user choose
488
+ let skipped = [];
489
+ if (options.selectLearnings && staged.length > 0) {
490
+ const selected = await options.selectLearnings(staged);
491
+ const selectedFilenames = new Set(selected.map(l => l.filename));
492
+ skipped = staged.filter(l => !selectedFilenames.has(l.filename));
493
+ staged = selected;
494
+ }
495
+ const result = {
496
+ extracted: staged,
497
+ skipped,
498
+ license,
499
+ projectName,
500
+ repoUrl,
501
+ timestamp: new Date().toISOString(),
502
+ acceptedRisks: acceptRisks,
503
+ };
504
+ let decisionsMerged = 0;
505
+ let skillsCreated = 0;
506
+ let consultationLogPath;
507
+ let cleaned = false;
508
+ if (!dryRun && staged.length > 0) {
509
+ // Merge to personal squad
510
+ const mergeResult = await mergeToPersonalSquad(staged, personalSquadRoot);
511
+ decisionsMerged = mergeResult.decisions;
512
+ skillsCreated = mergeResult.skills;
513
+ // Log consultation
514
+ consultationLogPath = await logConsultation(personalSquadRoot, result);
515
+ // Remove extracted files from .squad/extract/
516
+ for (const learning of staged) {
517
+ fs.rmSync(learning.filepath, { force: true });
518
+ }
519
+ }
520
+ // Clean up entire .squad/ if requested
521
+ if (clean && !dryRun) {
522
+ fs.rmSync(squadDir, { recursive: true, force: true });
523
+ cleaned = true;
524
+ }
525
+ return {
526
+ ...result,
527
+ blocked: false,
528
+ decisionsMerged,
529
+ skillsCreated,
530
+ consultationLogPath,
531
+ cleaned,
532
+ };
533
+ }
534
+ const COPYLEFT_LICENSES = [
535
+ 'GPL',
536
+ 'AGPL',
537
+ 'LGPL',
538
+ 'MPL',
539
+ 'EPL',
540
+ 'CDDL',
541
+ 'CC-BY-SA',
542
+ ];
543
+ const PERMISSIVE_LICENSES = [
544
+ 'MIT',
545
+ 'Apache',
546
+ 'BSD',
547
+ 'ISC',
548
+ 'Unlicense',
549
+ 'CC0',
550
+ 'WTFPL',
551
+ ];
552
+ /**
553
+ * Escape a string so it can be safely used inside a RegExp pattern.
554
+ */
555
+ function escapeRegex(value) {
556
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
557
+ }
558
+ /**
559
+ * Detect license type from LICENSE file content.
560
+ *
561
+ * @param licenseContent - Raw content of the LICENSE file
562
+ * @returns License classification with type, optional SPDX ID, and name
563
+ */
564
+ export function detectLicense(licenseContent) {
565
+ const content = licenseContent;
566
+ const upperContent = licenseContent.toUpperCase();
567
+ // 1. Prefer SPDX identifiers when present.
568
+ const spdxMatch = content.match(/SPDX-License-Identifier:\s*([^\s*]+)/i);
569
+ if (spdxMatch && spdxMatch[1]) {
570
+ const spdxId = spdxMatch[1];
571
+ const spdxIdUpper = spdxId.toUpperCase();
572
+ const copyleftUpper = COPYLEFT_LICENSES.map(id => id.toUpperCase());
573
+ const permissiveUpper = PERMISSIVE_LICENSES.map(id => id.toUpperCase());
574
+ // Check for copyleft first (LGPL should match before GPL)
575
+ for (const license of copyleftUpper) {
576
+ if (spdxIdUpper.includes(license)) {
577
+ return { type: 'copyleft', spdxId, name: spdxId };
578
+ }
579
+ }
580
+ for (const license of permissiveUpper) {
581
+ if (spdxIdUpper.includes(license)) {
582
+ return { type: 'permissive', spdxId, name: spdxId };
583
+ }
584
+ }
585
+ return { type: 'unknown', spdxId, name: spdxId };
586
+ }
587
+ // 2. Fallback: word-boundary regex, longest-first to avoid
588
+ // misclassifying e.g. "LGPL" as "GPL".
589
+ const detectFromList = (licenses, type) => {
590
+ const sorted = [...licenses].sort((a, b) => b.length - a.length);
591
+ for (const license of sorted) {
592
+ const pattern = new RegExp(`\\b${escapeRegex(license.toUpperCase())}\\b`, 'i');
593
+ if (pattern.test(upperContent)) {
594
+ return { type, spdxId: license, name: license };
595
+ }
596
+ }
597
+ return null;
598
+ };
599
+ // Check copyleft first (more restrictive)
600
+ const copyleftMatch = detectFromList(COPYLEFT_LICENSES, 'copyleft');
601
+ if (copyleftMatch)
602
+ return copyleftMatch;
603
+ const permissiveMatch = detectFromList(PERMISSIVE_LICENSES, 'permissive');
604
+ if (permissiveMatch)
605
+ return permissiveMatch;
606
+ return { type: 'unknown' };
607
+ }
608
+ /**
609
+ * Load staged learnings from .squad/extract/ directory.
610
+ * These are generic learnings that Scribe classified during the session.
611
+ *
612
+ * @param squadDir - Path to project .squad/ directory
613
+ * @returns Array of staged learnings
614
+ */
615
+ export function loadStagedLearnings(squadDir) {
616
+ const extractDir = path.join(squadDir, 'extract');
617
+ const learnings = [];
618
+ if (!fs.existsSync(extractDir)) {
619
+ return learnings;
620
+ }
621
+ const files = fs.readdirSync(extractDir).filter(f => f.endsWith('.md'));
622
+ for (const file of files) {
623
+ const filepath = path.join(extractDir, file);
624
+ try {
625
+ const content = fs.readFileSync(filepath, 'utf-8');
626
+ learnings.push({
627
+ filename: file,
628
+ filepath,
629
+ content,
630
+ });
631
+ }
632
+ catch {
633
+ // Skip unreadable files
634
+ }
635
+ }
636
+ return learnings;
637
+ }
638
+ // ============================================================================
639
+ // Consultation Logging
640
+ // ============================================================================
641
+ /**
642
+ * Write or append a consultation log entry to the personal squad.
643
+ *
644
+ * Creates the consultations directory if it doesn't exist.
645
+ * For new projects, creates a full header; for existing projects, appends session entry.
646
+ *
647
+ * @param personalSquadRoot - Path to personal squad root (e.g. ~/.config/squad/.squad)
648
+ * @param result - Extraction result with learnings and metadata
649
+ * @returns Path to the consultation log file
650
+ */
651
+ export async function logConsultation(personalSquadRoot, result) {
652
+ const consultDir = path.join(personalSquadRoot, 'consultations');
653
+ const logPath = path.join(consultDir, `${result.projectName}.md`);
654
+ // Create consultations directory if needed
655
+ if (!fs.existsSync(consultDir)) {
656
+ fs.mkdirSync(consultDir, { recursive: true });
657
+ }
658
+ const today = result.timestamp.split('T')[0] ?? new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
659
+ if (fs.existsSync(logPath)) {
660
+ // Append to existing log — update "Last session" and add new entry
661
+ let content = fs.readFileSync(logPath, 'utf-8');
662
+ // Update "Last session" date
663
+ content = content.replace(/\*\*Last session:\*\* \d{4}-\d{2}-\d{2}/, `**Last session:** ${today}`);
664
+ // Build session entry
665
+ const sessionEntry = formatSessionEntry(result, today);
666
+ // Append to file
667
+ fs.writeFileSync(logPath, content + sessionEntry, 'utf-8');
668
+ }
669
+ else {
670
+ // Create new consultation log with full header
671
+ const header = formatLogHeader(result, today);
672
+ const sessionEntry = formatSessionEntry(result, today);
673
+ fs.writeFileSync(logPath, header + sessionEntry, 'utf-8');
674
+ }
675
+ return logPath;
676
+ }
677
+ /**
678
+ * Format the header for a new consultation log file.
679
+ */
680
+ function formatLogHeader(result, date) {
681
+ const repoLine = result.repoUrl
682
+ ? `**Repository:** ${result.repoUrl}\n`
683
+ : '';
684
+ const licenseName = result.license.spdxId || result.license.name || result.license.type;
685
+ return `# ${result.projectName}
686
+
687
+ ${repoLine}**First consulted:** ${date}
688
+ **Last session:** ${date}
689
+ **License:** ${licenseName}
690
+
691
+ ## Extracted Learnings
692
+
693
+ `;
694
+ }
695
+ /**
696
+ * Format a session entry for the consultation log.
697
+ */
698
+ function formatSessionEntry(result, date) {
699
+ if (result.extracted.length === 0) {
700
+ return `### ${date}
701
+ - No learnings extracted
702
+
703
+ `;
704
+ }
705
+ // Just list titles/filenames, not content
706
+ const lines = result.extracted.map(l => `- ${l.filename}`);
707
+ return `### ${date}
708
+ ${lines.join('\n')}
709
+
710
+ `;
711
+ }
712
+ // ============================================================================
713
+ // Merge to Personal Squad
714
+ // ============================================================================
715
+ /**
716
+ * Check if content looks like a skill (has YAML frontmatter with skill markers).
717
+ */
718
+ function isSkillContent(content) {
719
+ // Skills have YAML frontmatter with name/confidence/domain
720
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
721
+ if (!frontmatterMatch || !frontmatterMatch[1])
722
+ return false;
723
+ const frontmatter = frontmatterMatch[1];
724
+ // Must have at least name and confidence to be a skill
725
+ return frontmatter.includes('name:') && frontmatter.includes('confidence:');
726
+ }
727
+ /**
728
+ * Extract skill name from YAML frontmatter.
729
+ */
730
+ function extractSkillName(content) {
731
+ const match = content.match(/^---\n[\s\S]*?name:\s*["']?([^"'\n]+)["']?/);
732
+ return match && match[1] ? match[1].trim() : null;
733
+ }
734
+ /**
735
+ * Merge staged learnings into personal squad.
736
+ *
737
+ * Routes skills to ~/.squad/skills/{name}/SKILL.md
738
+ * Routes decisions to ~/.squad/decisions.md (with smart merge)
739
+ *
740
+ * @param learnings - Staged learnings to merge
741
+ * @param personalSquadRoot - Path to personal squad root
742
+ */
743
+ export async function mergeToPersonalSquad(learnings, personalSquadRoot) {
744
+ if (learnings.length === 0) {
745
+ return { decisions: 0, skills: 0 };
746
+ }
747
+ let decisionsAdded = 0;
748
+ let skillsAdded = 0;
749
+ const decisions = [];
750
+ const skills = [];
751
+ // Classify learnings
752
+ for (const learning of learnings) {
753
+ if (isSkillContent(learning.content)) {
754
+ skills.push(learning);
755
+ }
756
+ else {
757
+ decisions.push(learning);
758
+ }
759
+ }
760
+ // Route skills to ~/.squad/skills/{name}/SKILL.md
761
+ const skillsDir = path.join(personalSquadRoot, 'skills');
762
+ for (const skill of skills) {
763
+ const skillName = extractSkillName(skill.content) || skill.filename.replace('.md', '');
764
+ const skillDir = path.join(skillsDir, skillName);
765
+ // Create skill directory if needed
766
+ if (!fs.existsSync(skillDir)) {
767
+ fs.mkdirSync(skillDir, { recursive: true });
768
+ }
769
+ const skillPath = path.join(skillDir, 'SKILL.md');
770
+ // Write skill (overwrites if exists — newer extraction wins)
771
+ fs.writeFileSync(skillPath, skill.content, 'utf-8');
772
+ skillsAdded++;
773
+ }
774
+ // Route decisions to ~/.squad/decisions.md
775
+ if (decisions.length > 0) {
776
+ const decisionsPath = path.join(personalSquadRoot, 'decisions.md');
777
+ const newContent = decisions.map(d => d.content.trim()).join('\n\n');
778
+ if (fs.existsSync(decisionsPath)) {
779
+ const existing = fs.readFileSync(decisionsPath, 'utf-8');
780
+ // Check if we already have an "Extracted from Consultations" section
781
+ if (existing.includes('## Extracted from Consultations')) {
782
+ // Append under the existing section (before any subsequent ## heading)
783
+ const parts = existing.split('## Extracted from Consultations');
784
+ const beforeSection = parts[0];
785
+ const afterSection = parts[1] ?? '';
786
+ // Find where the next section starts (if any)
787
+ const nextSectionMatch = afterSection.match(/\n## /);
788
+ if (nextSectionMatch && nextSectionMatch.index !== undefined) {
789
+ // Insert before next section
790
+ const sectionContent = afterSection.slice(0, nextSectionMatch.index);
791
+ const rest = afterSection.slice(nextSectionMatch.index);
792
+ fs.writeFileSync(decisionsPath, beforeSection +
793
+ '## Extracted from Consultations' +
794
+ sectionContent.trimEnd() +
795
+ '\n\n' +
796
+ newContent +
797
+ '\n' +
798
+ rest, 'utf-8');
799
+ }
800
+ else {
801
+ // No next section — append to end
802
+ fs.writeFileSync(decisionsPath, existing.trimEnd() + '\n\n' + newContent + '\n', 'utf-8');
803
+ }
804
+ }
805
+ else {
806
+ // No extraction section yet — create one
807
+ fs.writeFileSync(decisionsPath, existing.trimEnd() + '\n\n## Extracted from Consultations\n\n' + newContent + '\n', 'utf-8');
808
+ }
809
+ }
810
+ else {
811
+ // Create new decisions file
812
+ fs.writeFileSync(decisionsPath, `# Squad Decisions\n\n## Extracted from Consultations\n\n${newContent}\n`, 'utf-8');
813
+ }
814
+ decisionsAdded = decisions.length;
815
+ }
816
+ return { decisions: decisionsAdded, skills: skillsAdded };
817
+ }
818
+ //# sourceMappingURL=consult.js.map