Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion apps/cli/src/commands.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -539,3 +539,85 @@ describe('inspector + export commands', () => {
expect(out.join('\n')).toMatch(/Nothing to compact/);
});
});

describe('/combo', () => {
let cwd: string;
let home: string;

beforeEach(async () => {
cwd = await mkdtemp(join(tmpdir(), 'dc-combo-cwd-'));
home = await mkdtemp(join(tmpdir(), 'dc-combo-home-'));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
});

const reg = new CommandRegistry();

function threadContext() {
return makeContext({
cwd,
home,
history: [
{ role: 'user', content: [{ type: 'text', text: 'fix the auth bug' }], timestamp: 'now' },
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'src/auth.ts' } },
{ type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: 'src/auth.ts' } },
],
timestamp: 'now',
},
],
});
}

async function run(args: string[], ctx = threadContext()) {
const match = reg.match('/combo');
if (!match) throw new Error('/combo not registered');
return { lines: await match.cmd.run(args, ctx), ctx };
}

it('previews without writing anything', async () => {
// A skill is a shareable artifact built from a transcript. The user reads it
// before it exists on disk.
const { lines } = await run([]);
expect(lines.join('\n')).toContain('Draft skill');
expect(lines.join('\n')).toContain('--write');
await expect(
readFile(join(cwd, '.deepcode', 'skills', 'fix-the-auth-bug', 'SKILL.md'), 'utf8'),
).rejects.toThrow();
});

it('shows the derived allowed-tools in the preview', async () => {
const { lines } = await run([]);
expect(lines.join('\n')).toContain('Edit, Read');
});

it('writes the skill on --write and logs it as governance', async () => {
const { lines } = await run(['--write']);
expect(lines.join('\n')).toContain('Wrote');
const written = await readFile(
join(cwd, '.deepcode', 'skills', 'fix-the-auth-bug', 'SKILL.md'),
'utf8',
);
expect(written).toContain('allowed-tools');
expect(written).toContain('TODO: review before use');

const { readProjectLedger } = await import('@deepcode/core');
const gov = await readProjectLedger(cwd, 'governance', home);
expect(gov[0]?.summary).toContain('created skill');
});

it('refuses to overwrite an existing skill', async () => {
await run(['--write']);
const { lines } = await run(['--write']);
expect(lines.join('\n')).toContain('already exists');
});

it('says there is nothing to distil on an empty thread', async () => {
const { lines } = await run([], makeContext({ cwd, home, history: [] }));
expect(lines.join('\n')).toContain('Nothing to distil');
});
});
85 changes: 85 additions & 0 deletions apps/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,90 @@ export const BackgroundCommand: SlashCommand = {
},
};

/**
* `/combo [name]` — distil the current thread into a SKILL.md draft.
*
* Two properties this deliberately keeps:
*
* - `allowed-tools` comes from what the thread actually called, not from a
* guess. Hand-written skills are almost always broader than needed.
* - Nothing is written without the user seeing the draft first, and a second
* `/combo <name> --write` is what commits it. `/combo` alone previews.
*/
export const ComboCommand: SlashCommand = {
name: '/combo',
description: 'Distil this thread into a reusable skill draft (/combo [name] [--write]).',
async run(args, ctx) {
const history = ctx.history ?? [];
if (history.length === 0) return ['Nothing to distil yet — do some work first.'];

const { distillSkill, loadFileContract, FileLedger } = await import('@deepcode/core');
const fs = await import('node:fs/promises');
const path = await import('node:path');

const write = args.includes('--write');
const name = args.find((a) => !a.startsWith('--'));
const contract = await loadFileContract({ cwd: ctx.cwd, home: ctx.home });

const skill = distillSkill({
history,
...(name ? { name } : {}),
cwd: ctx.cwd,
...(contract.contract ? { contract: contract.contract } : {}),
model: ctx.model,
effort: ctx.effort,
});

const target = path.join(ctx.cwd, '.deepcode', 'skills', skill.name, 'SKILL.md');
const lines: string[] = [];

if (!write) {
// Preview first. A skill is a shareable artifact assembled from a
// transcript, so the user reads it before it exists on disk.
lines.push(`Draft skill "${skill.name}" → ${target}`, '');
lines.push(`allowed-tools (from actual use): ${skill.allowedTools.join(', ') || 'none'}`);
if (skill.paths.length > 0) lines.push(`files: ${skill.paths.join(', ')}`);
for (const note of skill.redactions) lines.push(`withheld: ${note}`);
lines.push('', '---8<---', skill.content.trimEnd(), '---8<---', '');
lines.push(`Write it with: /combo ${skill.name} --write`);
return lines;
}

try {
await fs.access(target);
return [`${target} already exists — rename with /combo <other-name> --write.`];
} catch {
/* absent, which is the normal path */
}

try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, skill.content, 'utf8');
} catch (err) {
return [`(Could not write the skill: ${(err as Error).message})`];
}

// Creating a skill changes what future runs may do, which puts it on the
// governance timeline rather than the ordinary change stream.
await new FileLedger({ cwd: ctx.cwd, ...(ctx.home ? { home: ctx.home } : {}) }).append(
'governance',
{
actor: 'user',
intent: `distil thread ${ctx.sessionId} into a skill`,
paths: [path.relative(ctx.cwd, target)],
summary: `created skill "${skill.name}" from a thread`,
rollbackHint: { kind: 'manual', ref: `delete ${target}` },
},
);

lines.push(`✓ Wrote ${target}`);
lines.push(` allowed-tools: ${skill.allowedTools.join(', ') || 'none'}`);
for (const note of skill.redactions) lines.push(` withheld: ${note}`);
lines.push(' Review it before relying on it — it was generated from a transcript.');
return lines;
},
};

export const BUILTIN_COMMANDS: SlashCommand[] = [
HelpCommand,
ClearCommand,
Expand All @@ -1334,6 +1418,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
PermissionsCommand,
AgentsCommand,
SkillsCommand,
ComboCommand,
ExportCommand,
CompactCommand,
DiffCommand,
Expand Down
71 changes: 71 additions & 0 deletions docs/combo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Combo — turning a thread into a skill

`/combo` distils the work you just finished into a reusable `SKILL.md` draft.

```
/combo # preview the draft
/combo my-name --write # write it to .deepcode/skills/<name>/SKILL.md
```

## Why after, not before

DeepCode's skill system was already complete — the loader, the frontmatter
schema, three source layers, overrides. What was missing was the _generating_
end: every `SKILL.md` had to be written by hand, up front, before anyone knew
what the work would involve.

That's backwards. The moment you finish a task is the moment you understand it
best. `/combo` extracts the automation _after_ the work rather than asking you to
configure it before.

## `allowed-tools` is derived, not guessed

The draft's `allowed-tools` lists exactly the tools the thread actually called.

This matters more than it looks. Hand-written skills are almost always broader
than they need to be, because guessing generously is easier than auditing — a
skill that only reads files ends up with `Bash` in its list "just in case".
Deriving the set from a real run gives you least privilege for free.

## Nothing is written until you've seen it

`/combo` alone **prints the draft and writes nothing.** A second invocation with
`--write` commits it. A skill assembled from a transcript is a shareable
artifact, so you read it before it exists on disk.

Writing one is recorded on the [governance ledger](change-ledger.md) — creating a
skill changes what future runs may do, which is not an ordinary file edit.

`--write` refuses to overwrite an existing skill. Pick another name.

## What never makes it into the draft

**Credential-shaped values** are replaced with `[REDACTED]`: API keys, GitHub
tokens, AWS key ids, JWTs, PEM private keys, and `password:`/`token:`-style
assignments.

**Paths your [file contract](file-contract.md) denies reading** are dropped
entirely. A rule that stops at the tool call but not at the export isn't much of
a rule — the filename alone leaks.

Either way the draft tells you what was withheld, so you're not guessing:

```
withheld: redacted API key
withheld: path excluded by file contract: .env
```

## The draft is a draft

It carries a `# TODO: review before use` marker and is not auto-enabled. It was
generated from a transcript; read it before you rely on it.

## What this is not

Floatboat's Combo sits on a "Tacit Engine" that passively observes activity
across files, browser tabs, and system apps to build a habit model. **DeepCode
does not do that.** `/combo` reads the current thread, only when you type it,
and never aggregates across threads or runs in the background.

The useful part of the idea — extract the automation after the work — needs no
passive collection at all.
8 changes: 8 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,11 @@ export {
type BuildRuntimeCapabilitiesInput,
type RuntimeCapabilities,
} from './runtime/capabilities.js';

// Combo — distil a finished thread into a SKILL.md draft (plan §2.D)
export {
distillSkill,
sanitizeSkillName,
type DistilledSkill,
type DistillOpts,
} from './skills/index.js';
Loading
Loading