swarmdo 1.19.0 → 1.29.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 (59) hide show
  1. package/.claude/helpers/auto-memory-hook.mjs +5 -1
  2. package/.claude-plugin/README.md +3 -3
  3. package/.claude-plugin/docs/INSTALLATION.md +1 -1
  4. package/.claude-plugin/docs/PLUGIN_SUMMARY.md +1 -1
  5. package/.claude-plugin/docs/QUICKSTART.md +3 -3
  6. package/.claude-plugin/hooks/hooks.json +19 -1
  7. package/.claude-plugin/marketplace.json +17 -17
  8. package/.claude-plugin/plugin.json +7 -39
  9. package/.claude-plugin/scripts/install.sh +3 -3
  10. package/.claude-plugin/scripts/swarmdo-hook.sh +1 -1
  11. package/.claude-plugin/scripts/verify.sh +2 -2
  12. package/README.md +10 -8
  13. package/package.json +1 -1
  14. package/v3/@swarmdo/cli/.claude/helpers/auto-memory-hook.mjs +5 -1
  15. package/v3/@swarmdo/cli/bin/cli.js +38 -0
  16. package/v3/@swarmdo/cli/dist/src/apply/apply.d.ts +8 -0
  17. package/v3/@swarmdo/cli/dist/src/apply/apply.js +70 -10
  18. package/v3/@swarmdo/cli/dist/src/changelog/changelog.js +26 -3
  19. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.d.ts +26 -7
  20. package/v3/@swarmdo/cli/dist/src/codegraph/codegraph.js +97 -13
  21. package/v3/@swarmdo/cli/dist/src/codegraph/store.d.ts +8 -1
  22. package/v3/@swarmdo/cli/dist/src/codegraph/store.js +78 -1
  23. package/v3/@swarmdo/cli/dist/src/commands/apply.js +17 -4
  24. package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.d.ts +17 -0
  25. package/v3/@swarmdo/cli/dist/src/commands/compact-snapshot.js +133 -0
  26. package/v3/@swarmdo/cli/dist/src/commands/cycles.js +4 -1
  27. package/v3/@swarmdo/cli/dist/src/commands/hotspots.js +5 -2
  28. package/v3/@swarmdo/cli/dist/src/commands/index.js +4 -3
  29. package/v3/@swarmdo/cli/dist/src/commands/pack.d.ts +8 -0
  30. package/v3/@swarmdo/cli/dist/src/commands/pack.js +28 -2
  31. package/v3/@swarmdo/cli/dist/src/commands/release.js +5 -1
  32. package/v3/@swarmdo/cli/dist/src/commands/testreport.js +2 -1
  33. package/v3/@swarmdo/cli/dist/src/compact/compact.js +17 -7
  34. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.d.ts +58 -0
  35. package/v3/@swarmdo/cli/dist/src/compact-snapshot/compact-snapshot.js +104 -0
  36. package/v3/@swarmdo/cli/dist/src/config-lint/lint.js +16 -2
  37. package/v3/@swarmdo/cli/dist/src/cycles/cycles.d.ts +12 -2
  38. package/v3/@swarmdo/cli/dist/src/cycles/cycles.js +5 -3
  39. package/v3/@swarmdo/cli/dist/src/env/env.d.ts +5 -2
  40. package/v3/@swarmdo/cli/dist/src/env/env.js +24 -3
  41. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.d.ts +15 -0
  42. package/v3/@swarmdo/cli/dist/src/hotspots/hotspots.js +33 -1
  43. package/v3/@swarmdo/cli/dist/src/init/helpers-generator.js +33 -3
  44. package/v3/@swarmdo/cli/dist/src/license/license.d.ts +31 -2
  45. package/v3/@swarmdo/cli/dist/src/license/license.js +133 -11
  46. package/v3/@swarmdo/cli/dist/src/pack/pack.d.ts +2 -3
  47. package/v3/@swarmdo/cli/dist/src/pack/pack.js +68 -12
  48. package/v3/@swarmdo/cli/dist/src/redact/redact.js +14 -2
  49. package/v3/@swarmdo/cli/dist/src/sbom/sbom.d.ts +16 -0
  50. package/v3/@swarmdo/cli/dist/src/sbom/sbom.js +33 -3
  51. package/v3/@swarmdo/cli/dist/src/swarmvector/model-prices.js +12 -2
  52. package/v3/@swarmdo/cli/dist/src/testreport/testreport.d.ts +14 -0
  53. package/v3/@swarmdo/cli/dist/src/testreport/testreport.js +82 -9
  54. package/v3/@swarmdo/cli/dist/src/transcript/export.d.ts +5 -0
  55. package/v3/@swarmdo/cli/dist/src/transcript/export.js +8 -1
  56. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.d.ts +12 -2
  57. package/v3/@swarmdo/cli/dist/src/usage/claude-pricing.js +45 -13
  58. package/v3/@swarmdo/cli/dist/src/usage/transcript-usage.js +5 -2
  59. package/v3/@swarmdo/cli/package.json +2 -2
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `swarmdo compact-snapshot` — capture / restore a compaction-survival digest.
3
+ *
4
+ * swarmdo compact-snapshot write # snapshot working state (call on PreCompact)
5
+ * swarmdo compact-snapshot read # print the digest, then consume it (first
6
+ * # prompt after compaction re-injects it)
7
+ * swarmdo compact-snapshot read --keep # print without consuming
8
+ *
9
+ * The digest (recently edited files, uncommitted changes, branch) lets an agent
10
+ * re-ground after context compaction instead of re-exploring. The engine is
11
+ * pure + tested (../compact-snapshot/compact-snapshot.ts); this wrapper does the
12
+ * fs read (edit ledger), git calls, and one-shot persistence.
13
+ */
14
+ import { execFileSync } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { output } from '../output.js';
18
+ import { buildDigest, formatDigest, isDigestEmpty, } from '../compact-snapshot/compact-snapshot.js';
19
+ const DIGEST_FILE = 'compact-digest.json';
20
+ const LEDGER_FILE = 'pending-insights.jsonl';
21
+ function dataDir(root) {
22
+ return path.join(root, '.swarmdo', 'data');
23
+ }
24
+ /** Best-effort git; returns '' on any failure (not-a-repo, no git). */
25
+ function git(root, args) {
26
+ try {
27
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 8 * 1024 * 1024 });
28
+ }
29
+ catch {
30
+ return '';
31
+ }
32
+ }
33
+ /** Read edit records from the pending-insights ledger. Tolerant of junk lines. */
34
+ function readEdits(root) {
35
+ const p = path.join(dataDir(root), LEDGER_FILE);
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(p, 'utf8');
39
+ }
40
+ catch {
41
+ return [];
42
+ }
43
+ const out = [];
44
+ for (const line of raw.split('\n')) {
45
+ if (!line.trim())
46
+ continue;
47
+ try {
48
+ const rec = JSON.parse(line);
49
+ if (rec && rec.type === 'edit' && typeof rec.file === 'string') {
50
+ out.push({ file: rec.file, timestamp: typeof rec.timestamp === 'number' ? rec.timestamp : 0 });
51
+ }
52
+ }
53
+ catch { /* skip malformed line */ }
54
+ }
55
+ return out;
56
+ }
57
+ function doWrite(root) {
58
+ // Drop swarmdo's own state dirs — they're not the user's working set and
59
+ // are usually gitignored anyway; filtering keeps the digest signal clean.
60
+ const gitStatus = git(root, ['status', '--porcelain'])
61
+ .split('\n')
62
+ .filter(Boolean)
63
+ .filter((l) => !/(^|\s|"|-> )\.(swarmdo|swarm|git)\//.test(l));
64
+ const digest = buildDigest({
65
+ now: Date.now(),
66
+ edits: readEdits(root),
67
+ gitStatus,
68
+ branch: git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() || undefined,
69
+ });
70
+ if (isDigestEmpty(digest)) {
71
+ // Nothing worth restoring — remove any stale digest so a later `read` is clean.
72
+ try {
73
+ fs.rmSync(path.join(dataDir(root), DIGEST_FILE));
74
+ }
75
+ catch { /* none */ }
76
+ output.writeln('compact-snapshot: no working state to capture');
77
+ return { success: true, exitCode: 0 };
78
+ }
79
+ try {
80
+ fs.mkdirSync(dataDir(root), { recursive: true });
81
+ fs.writeFileSync(path.join(dataDir(root), DIGEST_FILE), JSON.stringify(digest, null, 2) + '\n');
82
+ }
83
+ catch (e) {
84
+ output.printError(`compact-snapshot: could not write digest — ${e.message}`);
85
+ return { success: false, exitCode: 1 };
86
+ }
87
+ output.writeln(`compact-snapshot: captured ${digest.recentFiles.length} edited + ${digest.uncommitted.length} uncommitted`);
88
+ return { success: true, exitCode: 0 };
89
+ }
90
+ function doRead(root, keep) {
91
+ const file = path.join(dataDir(root), DIGEST_FILE);
92
+ let digest;
93
+ try {
94
+ digest = JSON.parse(fs.readFileSync(file, 'utf8'));
95
+ }
96
+ catch {
97
+ return { success: true, exitCode: 0 };
98
+ } // no digest → nothing to inject
99
+ const text = formatDigest(digest, Date.now());
100
+ if (text)
101
+ process.stdout.write(text + '\n');
102
+ if (!keep) {
103
+ try {
104
+ fs.rmSync(file);
105
+ }
106
+ catch { /* already gone */ }
107
+ } // consume-once
108
+ return { success: true, exitCode: 0 };
109
+ }
110
+ async function run(ctx) {
111
+ const root = ctx.cwd || process.cwd();
112
+ const mode = (ctx.args[0] ?? 'read').toLowerCase();
113
+ if (mode === 'write')
114
+ return doWrite(root);
115
+ if (mode === 'read')
116
+ return doRead(root, ctx.flags.keep === true);
117
+ output.printError(`compact-snapshot: unknown mode "${mode}" (use write | read)`);
118
+ return { success: false, exitCode: 1 };
119
+ }
120
+ export const compactSnapshotCommand = {
121
+ name: 'compact-snapshot',
122
+ description: 'Capture/restore a working-state digest that survives context compaction — recent edits, uncommitted changes, branch — so an agent re-grounds instead of re-exploring',
123
+ options: [
124
+ { name: 'keep', description: 'on read, print the digest without consuming it', type: 'boolean' },
125
+ ],
126
+ examples: [
127
+ { command: 'swarmdo compact-snapshot write', description: 'Snapshot working state (wire to a PreCompact hook)' },
128
+ { command: 'swarmdo compact-snapshot read', description: 'Print + consume the digest (wire to the first post-compaction prompt)' },
129
+ ],
130
+ action: run,
131
+ };
132
+ export default compactSnapshotCommand;
133
+ //# sourceMappingURL=compact-snapshot.js.map
@@ -15,6 +15,7 @@ async function run(ctx) {
15
15
  const root = ctx.cwd || process.cwd();
16
16
  const asJson = ctx.flags.format === 'json';
17
17
  const ci = ctx.flags.ci === true;
18
+ const includeTypeOnly = ctx.flags['include-type-only'] === true;
18
19
  let index = loadIndex(root);
19
20
  if (!index) {
20
21
  index = scanRepo(root);
@@ -23,7 +24,7 @@ async function run(ctx) {
23
24
  }
24
25
  catch { /* read-only fs — index is in memory */ }
25
26
  }
26
- const res = findCycles(index);
27
+ const res = findCycles(index, { includeTypeOnly });
27
28
  const count = res.cycles.length + res.selfLoops.length;
28
29
  if (asJson) {
29
30
  process.stdout.write(JSON.stringify({ count, ...res }, null, 2) + '\n');
@@ -40,10 +41,12 @@ export const cyclesCommand = {
40
41
  description: 'Find circular import dependencies via the import graph (madge --circular style) — catch the TDZ/undefined-export bugs they cause',
41
42
  options: [
42
43
  { name: 'ci', description: 'exit 1 if any circular dependency exists (gate a build)', type: 'boolean' },
44
+ { name: 'include-type-only', description: 'also count TypeScript `import type` edges (excluded by default — they erase at compile time and cause no runtime cycle)', type: 'boolean' },
43
45
  ],
44
46
  examples: [
45
47
  { command: 'swarmdo cycles', description: 'List circular imports in the repo' },
46
48
  { command: 'swarmdo cycles --ci', description: 'Fail CI when a cycle is introduced' },
49
+ { command: 'swarmdo cycles --include-type-only', description: 'Strict view: count type-only imports too' },
47
50
  ],
48
51
  action: run,
49
52
  };
@@ -33,8 +33,11 @@ async function run(ctx) {
33
33
  }
34
34
  // Global --format (text|json|table); text and table both render the table.
35
35
  const asJson = ctx.flags.format === 'json';
36
- // Capture history: SOH-delimited header + numstat, no merges.
37
- const args = ['log', '--no-merges', '--numstat', `--since=${since}`, '--format=format:%x01%H%x1f%an%x1f%aI'];
36
+ // Capture history: SOH-delimited header + numstat, no merges. `%aN` (not
37
+ // `%an`) resolves author names through `.mailmap`, so name/email variants of
38
+ // the same person fold into one identity — otherwise the author-spread factor
39
+ // in the risk score is silently inflated. Identical to `%an` when no .mailmap.
40
+ const args = ['log', '--no-merges', '--numstat', `--since=${since}`, '--format=format:%x01%H%x1f%aN%x1f%aI'];
38
41
  if (pathArg)
39
42
  args.push('--', pathArg);
40
43
  let raw;
@@ -22,6 +22,7 @@ const commandLoaders = {
22
22
  // Deterministic command-output compression (rtk/headroom demand) — a
23
23
  // zero-token stream filter, distinct from caveman `compress` for files.
24
24
  compact: () => import('./compact.js'),
25
+ 'compact-snapshot': () => import('./compact-snapshot.js'),
25
26
  // Secret detection + redaction on the agent data path (gitleaks/trufflehog
26
27
  // demand) — mask API keys/tokens before they reach an LLM/log/memory.
27
28
  redact: () => import('./redact.js'),
@@ -286,7 +287,7 @@ export async function getCommandsByCategory() {
286
287
  // three slots from 'statusline' onward (completionsCmd received the
287
288
  // statusline module, analyzeCmd received completions, …), scrambling the
288
289
  // categorized help. Every load now has a named slot.
289
- const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd,] = await Promise.all([
290
+ const [daemonCmd, doctorCmd, embeddingsCmd, neuralCmd, performanceCmd, securityCmd, swarmvectorCmd, hiveMindCmd, configCmd, statuslineCmd, compressCmd, efficiencyCmd, completionsCmd, migrateCmd, workflowCmd, analyzeCmd, routeCmd, progressCmd, providersCmd, pluginsCmd, deploymentCmd, claimsCmd, issuesCmd, updateCmd, processCmd, guidanceCmd, applianceCmd, cleanupCmd, autopilotCmd, demoCmd, usageCmd, repairCmd, hudCmd, compactCmd, codegraphCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd,] = await Promise.all([
290
291
  loadCommand('daemon'), loadCommand('doctor'), loadCommand('embeddings'), loadCommand('neural'),
291
292
  loadCommand('performance'), loadCommand('security'), loadCommand('swarmvector'), loadCommand('hive-mind'),
292
293
  loadCommand('config'), loadCommand('statusline'), loadCommand('compress'), loadCommand('efficiency'),
@@ -294,7 +295,7 @@ export async function getCommandsByCategory() {
294
295
  loadCommand('analyze'), loadCommand('route'), loadCommand('progress'), loadCommand('providers'),
295
296
  loadCommand('plugins'), loadCommand('deployment'), loadCommand('claims'), loadCommand('issues'),
296
297
  loadCommand('update'), loadCommand('process'), loadCommand('guidance'), loadCommand('appliance'),
297
- loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('testreport'),
298
+ loadCommand('cleanup'), loadCommand('autopilot'), loadCommand('demo'), loadCommand('usage'), loadCommand('repair'), loadCommand('hud'), loadCommand('compact'), loadCommand('codegraph'), loadCommand('redact'), loadCommand('pack'), loadCommand('env'), loadCommand('license'), loadCommand('sbom'), loadCommand('apply'), loadCommand('hotspots'), loadCommand('affected'), loadCommand('cycles'), loadCommand('testreport'), loadCommand('compact-snapshot'),
298
299
  ]);
299
300
  return {
300
301
  primary: [
@@ -310,7 +311,7 @@ export async function getCommandsByCategory() {
310
311
  utility: [
311
312
  configCmd, doctorCmd, daemonCmd, completionsCmd,
312
313
  migrateCmd, workflowCmd, demoCmd,
313
- statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, efficiencyCmd,
314
+ statuslineCmd, compressCmd, compactCmd, redactCmd, packCmd, envCmd, licenseCmd, sbomCmd, applyCmd, hotspotsCmd, affectedCmd, cyclesCmd, testreportCmd, compactSnapshotCmd, efficiencyCmd,
314
315
  ].filter(Boolean),
315
316
  analysis: [
316
317
  analyzeCmd, routeCmd, progressCmd, usageCmd, hudCmd, codegraphCmd,
@@ -13,6 +13,14 @@
13
13
  * land in the bundle.
14
14
  */
15
15
  import type { Command } from '../types.js';
16
+ /**
17
+ * Decode a file buffer to bundle text, honoring BOMs (#10). UTF-16 LE/BE
18
+ * files decode to text (previously their NUL bytes tripped the binary
19
+ * heuristic and the file was silently dropped); a UTF-8 BOM is stripped
20
+ * instead of leaking into the bundle. Returns null for genuinely-binary
21
+ * content (NUL byte without a text BOM). Exported for tests.
22
+ */
23
+ export declare function decodeText(buf: Buffer): string | null;
16
24
  export declare const packCommand: Command;
17
25
  export default packCommand;
18
26
  //# sourceMappingURL=pack.d.ts.map
@@ -35,6 +35,31 @@ function looksBinary(buf) {
35
35
  return true;
36
36
  return false;
37
37
  }
38
+ /**
39
+ * Decode a file buffer to bundle text, honoring BOMs (#10). UTF-16 LE/BE
40
+ * files decode to text (previously their NUL bytes tripped the binary
41
+ * heuristic and the file was silently dropped); a UTF-8 BOM is stripped
42
+ * instead of leaking into the bundle. Returns null for genuinely-binary
43
+ * content (NUL byte without a text BOM). Exported for tests.
44
+ */
45
+ export function decodeText(buf) {
46
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
47
+ return buf.subarray(3).toString('utf8');
48
+ }
49
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
50
+ const body = buf.subarray(2);
51
+ if (body.length % 2 !== 0)
52
+ return null;
53
+ return body.toString('utf16le');
54
+ }
55
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
56
+ const body = Buffer.from(buf.subarray(2)); // copy: swap16 mutates in place
57
+ if (body.length % 2 !== 0)
58
+ return null;
59
+ return body.swap16().toString('utf16le');
60
+ }
61
+ return looksBinary(buf) ? null : buf.toString('utf8');
62
+ }
38
63
  function walk(o) {
39
64
  const files = [];
40
65
  const stack = [o.root];
@@ -82,9 +107,10 @@ function walk(o) {
82
107
  catch {
83
108
  continue;
84
109
  }
85
- if (looksBinary(buf))
110
+ const text = decodeText(buf);
111
+ if (text === null)
86
112
  continue;
87
- files.push({ path: rel, content: buf.toString('utf8') });
113
+ files.push({ path: rel, content: text });
88
114
  }
89
115
  }
90
116
  }
@@ -15,6 +15,10 @@ import * as os from 'node:os';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { output } from '../output.js';
17
17
  import { planRelease, renderStep } from '../release/release.js';
18
+ /** Escape every regex metacharacter (incl. backslash) so a value is a literal in `new RegExp`. */
19
+ function escapeRegExp(s) {
20
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ }
18
22
  function repoRootFrom(cwd) {
19
23
  try {
20
24
  return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
@@ -47,7 +51,7 @@ function syncDocs(root, files, current, next) {
47
51
  let after = before;
48
52
  // exact patterns used by every release since 1.4.x
49
53
  after = after.replaceAll(`**Swarmdo v${current}** (`, `**Swarmdo v${next}** (`);
50
- after = after.replace(new RegExp(`\\*\\*Swarmdo v${next.replace(/\./g, '\\.')}\\*\\* \\(\\d{4}-\\d{2}-\\d{2}\\)`), `**Swarmdo v${next}** (${today})`);
54
+ after = after.replace(new RegExp(`\\*\\*Swarmdo v${escapeRegExp(next)}\\*\\* \\(\\d{4}-\\d{2}-\\d{2}\\)`), `**Swarmdo v${next}** (${today})`);
51
55
  after = after.replaceAll(`swarmdo@${current}\` (umbrella), \`@swarmdo/cli@${current}\`, \`swarmdo-bridge@${current}\``, `swarmdo@${next}\` (umbrella), \`@swarmdo/cli@${next}\`, \`swarmdo-bridge@${next}\``);
52
56
  // README badge + website carry the last PUBLISHED version, which lags
53
57
  // the repo version (`current`) between releases — match any semver in
@@ -98,7 +98,8 @@ async function run(ctx) {
98
98
  else {
99
99
  output.writeln(formatSummary(summary, { top: top > 0 ? top : undefined }));
100
100
  }
101
- const code = ci && summary.failed > 0 ? 1 : 0;
101
+ // A bail-out aborts the suite results are incomplete treat as failure in CI.
102
+ const code = ci && (summary.failed > 0 || summary.bailedOut) ? 1 : 0;
102
103
  return { success: code === 0, exitCode: code };
103
104
  }
104
105
  export const testreportCommand = {
@@ -95,21 +95,31 @@ function foldNodeFrames(lines) {
95
95
  }
96
96
  return out;
97
97
  }
98
- /** Collapse ≥3 consecutive blank lines to a single blank. */
98
+ /**
99
+ * Collapse runs of ≥3 consecutive blank lines to a single blank. Runs of 1 or 2
100
+ * blanks are preserved (a 2-blank run is meaningful — e.g. PEP 8 spacing between
101
+ * top-level defs), so the full run length is counted before deciding.
102
+ */
99
103
  function collapseBlankLines(lines) {
100
104
  const out = [];
101
105
  let blanks = 0;
106
+ const flush = () => {
107
+ if (blanks === 0)
108
+ return;
109
+ const keep = blanks >= 3 ? 1 : blanks; // ≥3 → 1; 1 or 2 → unchanged
110
+ for (let i = 0; i < keep; i++)
111
+ out.push('');
112
+ blanks = 0;
113
+ };
102
114
  for (const line of lines) {
103
115
  if (line.trim() === '') {
104
116
  blanks++;
105
- if (blanks <= 1)
106
- out.push('');
107
- }
108
- else {
109
- blanks = 0;
110
- out.push(line);
117
+ continue;
111
118
  }
119
+ flush();
120
+ out.push(line);
112
121
  }
122
+ flush();
113
123
  return out;
114
124
  }
115
125
  /** Keep head + tail lines, eliding the middle with a marker. */
@@ -0,0 +1,58 @@
1
+ /**
2
+ * compact-snapshot.ts — build a "survival digest" that lets an agent re-ground
3
+ * after context compaction. Post-compaction, the model loses its working set
4
+ * and tends to re-explore files it just edited and repeat corrected mistakes
5
+ * (a widely-reported pain, #45). This captures the cheap signals swarmdo
6
+ * already tracks — recently edited files (the pending-insights edit ledger),
7
+ * uncommitted changes (`git status --porcelain`), and the current branch —
8
+ * into a compact digest that the first post-compaction prompt re-injects.
9
+ *
10
+ * Pure + deterministic: inputs in (including `now`, so no clock dependency),
11
+ * a digest / rendered string out. The fs read (edit ledger), git calls, and
12
+ * digest persistence live in ../commands/compact-snapshot.ts, so this is
13
+ * fixture-testable.
14
+ */
15
+ /** One edit-ledger record (a line of .swarmdo/data/pending-insights.jsonl). */
16
+ export interface EditRecord {
17
+ file: string;
18
+ timestamp: number;
19
+ }
20
+ export interface DigestInput {
21
+ /** recent edit-ledger records; most-recent-wins when a file repeats */
22
+ edits?: EditRecord[];
23
+ /** raw `git status --porcelain` lines (uncommitted changes) */
24
+ gitStatus?: string[];
25
+ /** current branch name, if known */
26
+ branch?: string;
27
+ /** epoch ms the snapshot was taken (injected, not read from a clock) */
28
+ now: number;
29
+ }
30
+ export interface CompactDigest {
31
+ takenAt: number;
32
+ branch?: string;
33
+ /** distinct edited files, most-recently-edited first, capped */
34
+ recentFiles: string[];
35
+ /** distinct uncommitted file paths, capped */
36
+ uncommitted: string[];
37
+ }
38
+ /**
39
+ * Extract the working-tree path from one `git status --porcelain` line.
40
+ * Format is `XY <path>` (two status chars + space); a rename is
41
+ * `R <old> -> <new>` — we want the destination. Quoted paths (git quotes
42
+ * names with special chars) are unwrapped. Returns null for a blank line.
43
+ * Pure.
44
+ */
45
+ export declare function porcelainPath(line: string): string | null;
46
+ /** Build the digest from raw session signals. Pure. */
47
+ export declare function buildDigest(input: DigestInput, opts?: {
48
+ maxFiles?: number;
49
+ }): CompactDigest;
50
+ /** A digest with no files and no branch carries nothing worth re-injecting. Pure. */
51
+ export declare function isDigestEmpty(d: CompactDigest): boolean;
52
+ /**
53
+ * Render the digest as a re-grounding block for the first post-compaction
54
+ * prompt. Empty digest → '' (nothing to inject). `now` is passed so the
55
+ * relative age is deterministic. Pure.
56
+ */
57
+ export declare function formatDigest(d: CompactDigest, now: number): string;
58
+ //# sourceMappingURL=compact-snapshot.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * compact-snapshot.ts — build a "survival digest" that lets an agent re-ground
3
+ * after context compaction. Post-compaction, the model loses its working set
4
+ * and tends to re-explore files it just edited and repeat corrected mistakes
5
+ * (a widely-reported pain, #45). This captures the cheap signals swarmdo
6
+ * already tracks — recently edited files (the pending-insights edit ledger),
7
+ * uncommitted changes (`git status --porcelain`), and the current branch —
8
+ * into a compact digest that the first post-compaction prompt re-injects.
9
+ *
10
+ * Pure + deterministic: inputs in (including `now`, so no clock dependency),
11
+ * a digest / rendered string out. The fs read (edit ledger), git calls, and
12
+ * digest persistence live in ../commands/compact-snapshot.ts, so this is
13
+ * fixture-testable.
14
+ */
15
+ const DEFAULT_MAX_FILES = 12;
16
+ /**
17
+ * Extract the working-tree path from one `git status --porcelain` line.
18
+ * Format is `XY <path>` (two status chars + space); a rename is
19
+ * `R <old> -> <new>` — we want the destination. Quoted paths (git quotes
20
+ * names with special chars) are unwrapped. Returns null for a blank line.
21
+ * Pure.
22
+ */
23
+ export function porcelainPath(line) {
24
+ if (!line || line.length < 4)
25
+ return null;
26
+ let rest = line.slice(3); // strip the 2 status chars + the separating space
27
+ const arrow = rest.indexOf(' -> ');
28
+ if (arrow >= 0)
29
+ rest = rest.slice(arrow + 4); // rename/copy → destination path
30
+ rest = rest.trim();
31
+ if (rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2) {
32
+ rest = rest.slice(1, -1); // unwrap git's quoted path (escapes left as-is)
33
+ }
34
+ return rest || null;
35
+ }
36
+ /** Build the digest from raw session signals. Pure. */
37
+ export function buildDigest(input, opts = {}) {
38
+ const max = opts.maxFiles ?? DEFAULT_MAX_FILES;
39
+ // Distinct edited files, most-recent edit first. A file edited repeatedly
40
+ // keeps its latest timestamp; ties preserve first-seen (stable) order.
41
+ const latest = new Map();
42
+ for (const e of input.edits ?? []) {
43
+ if (!e || typeof e.file !== 'string' || !e.file)
44
+ continue;
45
+ const t = typeof e.timestamp === 'number' ? e.timestamp : 0;
46
+ const prev = latest.get(e.file);
47
+ if (prev === undefined || t >= prev)
48
+ latest.set(e.file, t);
49
+ }
50
+ const recentFiles = [...latest.entries()]
51
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
52
+ .slice(0, max)
53
+ .map(([f]) => f);
54
+ const seenUncommitted = new Set();
55
+ const uncommitted = [];
56
+ for (const line of input.gitStatus ?? []) {
57
+ const p = porcelainPath(line);
58
+ if (p && !seenUncommitted.has(p)) {
59
+ seenUncommitted.add(p);
60
+ uncommitted.push(p);
61
+ }
62
+ if (uncommitted.length >= max)
63
+ break;
64
+ }
65
+ return {
66
+ takenAt: input.now,
67
+ ...(input.branch ? { branch: input.branch } : {}),
68
+ recentFiles,
69
+ uncommitted,
70
+ };
71
+ }
72
+ /** A digest with no files and no branch carries nothing worth re-injecting. Pure. */
73
+ export function isDigestEmpty(d) {
74
+ return d.recentFiles.length === 0 && d.uncommitted.length === 0;
75
+ }
76
+ /** Compact human "Nm ago" for the snapshot age. Pure. */
77
+ function ago(fromMs, now) {
78
+ const s = Math.max(0, Math.round((now - fromMs) / 1000));
79
+ if (s < 60)
80
+ return `${s}s ago`;
81
+ const m = Math.round(s / 60);
82
+ if (m < 60)
83
+ return `${m}m ago`;
84
+ return `${Math.round(m / 60)}h ago`;
85
+ }
86
+ /**
87
+ * Render the digest as a re-grounding block for the first post-compaction
88
+ * prompt. Empty digest → '' (nothing to inject). `now` is passed so the
89
+ * relative age is deterministic. Pure.
90
+ */
91
+ export function formatDigest(d, now) {
92
+ if (isDigestEmpty(d))
93
+ return '';
94
+ const lines = [`[swarmdo] Working context restored after compaction (snapshot ${ago(d.takenAt, now)}):`];
95
+ if (d.branch)
96
+ lines.push(`- Branch: ${d.branch}`);
97
+ if (d.recentFiles.length)
98
+ lines.push(`- Recently edited (${d.recentFiles.length}): ${d.recentFiles.join(', ')}`);
99
+ if (d.uncommitted.length)
100
+ lines.push(`- Uncommitted changes (${d.uncommitted.length}): ${d.uncommitted.join(', ')}`);
101
+ lines.push('Resume this work; re-read these files only as needed rather than re-exploring from scratch.');
102
+ return lines.join('\n');
103
+ }
104
+ //# sourceMappingURL=compact-snapshot.js.map
@@ -13,9 +13,23 @@ export const TOPOLOGIES = ['hierarchical', 'mesh', 'hierarchical-mesh', 'ring',
13
13
  export const MEMORY_BACKENDS = ['agentdb', 'sqlite', 'hybrid', 'memory'];
14
14
  import { parseOpenRouterConfig } from '../providers/openrouter-config.js';
15
15
  export const KNOWN_CONFIG_KEYS = ['topology', 'maxAgents', 'strategy', 'consensus', 'memory', 'memoryBackend', 'hnsw', 'neural', 'embeddings', 'providers', 'mcp', 'logging', 'daemon', 'hooks', 'version', 'openrouter', '$schema'];
16
+ // Current Claude Code hook events (source: code.claude.com/docs/en/hooks).
17
+ // Kept in sync with the runtime; a stale list here false-warns on valid hooks.
16
18
  export const HOOK_EVENTS = [
17
- 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
18
- 'SessionStart', 'SessionEnd', 'PreCompact', 'TeammateIdle', 'TaskCompleted', 'PermissionDecision',
19
+ // session lifecycle
20
+ 'SessionStart', 'Setup', 'SessionEnd',
21
+ // per-turn
22
+ 'UserPromptSubmit', 'UserPromptExpansion', 'Stop', 'StopFailure',
23
+ // agentic loop
24
+ 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PostToolBatch', 'PermissionRequest', 'PermissionDenied',
25
+ // subagent & task
26
+ 'SubagentStart', 'SubagentStop', 'TeammateIdle', 'TaskCreated', 'TaskCompleted',
27
+ // system & file
28
+ 'Notification', 'MessageDisplay', 'CwdChanged', 'FileChanged', 'ConfigChange', 'InstructionsLoaded', 'PreCompact', 'PostCompact',
29
+ // MCP elicitation
30
+ 'Elicitation', 'ElicitationResult',
31
+ // worktree
32
+ 'WorktreeCreate', 'WorktreeRemove',
19
33
  ];
20
34
  /** Parse a JSON file's raw text; a null raw means "file absent" (fine). */
21
35
  export function lintJson(file, raw) {
@@ -12,8 +12,18 @@
12
12
  * The index load lives in ../commands/cycles.ts, so this is fixture-testable.
13
13
  */
14
14
  import type { CodeIndex } from '../codegraph/codegraph.js';
15
+ export interface CycleOptions {
16
+ /**
17
+ * Include TypeScript `import type`/`export type` edges. They erase at compile
18
+ * time so a type-only "cycle" causes none of the runtime bugs this detector
19
+ * exists to catch — excluded by DEFAULT to avoid false positives. Set true for
20
+ * a strict structural view. An edge counts as a runtime edge if ANY import
21
+ * between the two files is a value import (mixed imports keep the edge).
22
+ */
23
+ includeTypeOnly?: boolean;
24
+ }
15
25
  /** Build file → sorted list of internal (resolved) imports. Pure. */
16
- export declare function buildAdjacency(index: CodeIndex): Map<string, string[]>;
26
+ export declare function buildAdjacency(index: CodeIndex, opts?: CycleOptions): Map<string, string[]>;
17
27
  /**
18
28
  * Tarjan's SCC. Returns every strongly-connected component as a member list.
19
29
  * Iterative (no recursion) so deep graphs don't overflow the stack. Deterministic:
@@ -31,7 +41,7 @@ export interface CycleResult {
31
41
  * it has ≥2 files (mutual reachability) or a single file with a self-edge.
32
42
  * Deterministic: groups sorted by size desc then first member.
33
43
  */
34
- export declare function findCycles(index: CodeIndex): CycleResult;
44
+ export declare function findCycles(index: CodeIndex, opts?: CycleOptions): CycleResult;
35
45
  /** Human-readable summary. Pure. */
36
46
  export declare function formatCycles(res: CycleResult): string;
37
47
  //# sourceMappingURL=cycles.d.ts.map
@@ -12,7 +12,7 @@
12
12
  * The index load lives in ../commands/cycles.ts, so this is fixture-testable.
13
13
  */
14
14
  /** Build file → sorted list of internal (resolved) imports. Pure. */
15
- export function buildAdjacency(index) {
15
+ export function buildAdjacency(index, opts = {}) {
16
16
  const adj = new Map();
17
17
  const ensure = (f) => {
18
18
  let l = adj.get(f);
@@ -25,6 +25,8 @@ export function buildAdjacency(index) {
25
25
  for (const e of index.imports) {
26
26
  if (!e.resolved)
27
27
  continue; // external — not part of internal cycles
28
+ if (e.isTypeOnly && !opts.includeTypeOnly)
29
+ continue; // type-only → no runtime edge
28
30
  const l = ensure(e.from);
29
31
  if (!l.includes(e.resolved))
30
32
  l.push(e.resolved);
@@ -101,8 +103,8 @@ export function stronglyConnectedComponents(adj) {
101
103
  * it has ≥2 files (mutual reachability) or a single file with a self-edge.
102
104
  * Deterministic: groups sorted by size desc then first member.
103
105
  */
104
- export function findCycles(index) {
105
- const adj = buildAdjacency(index);
106
+ export function findCycles(index, opts = {}) {
107
+ const adj = buildAdjacency(index, opts);
106
108
  const selfLoops = [];
107
109
  for (const [from, tos] of adj)
108
110
  if (tos.includes(from))
@@ -29,8 +29,11 @@ export interface EnvReport {
29
29
  export declare function extractEnvRefs(source: string, file: string): EnvRef[];
30
30
  /**
31
31
  * Parse a `.env` file into an ordered list of declared keys. Handles `KEY=val`,
32
- * `export KEY=val`, quoted values, `# comments`, and blank lines. Values are
33
- * ignored we only reconcile key presence. Pure.
32
+ * `export KEY=val`, quoted values, `# comments`, and blank lines. The key
33
+ * charset + `:` separator mirror the reference `dotenv` package's grammar
34
+ * (`[\w.-]+` with `=` or `: ` as separator), so dotted/hyphenated keys like
35
+ * `APP.NAME` aren't silently dropped. Values are ignored — we only reconcile
36
+ * key presence. Pure.
34
37
  */
35
38
  export declare function parseDotenv(text: string): string[];
36
39
  export interface ReconcileInput {