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
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

## Official Docs

| Feature | Official docs | Upstream surface |
| ------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| index | `https://docs.factory.ai/cli/getting-started/quickstart` | Factory Droid CLI documentation |
| `rules` | `https://docs.factory.ai/cli/configuration/agents-md` | `AGENTS.md`, nested AGENTS.md, personal `~/.factory/AGENTS.md` |
| `ignore` | No dedicated upstream ignore surface in map | No Rulesync-supported Factory Droid ignore target in map |
| `mcp` | `https://docs.factory.ai/cli/configuration/mcp` | `.factory/mcp.json`, `~/.factory/mcp.json`, stdio and HTTP servers |
| `commands` | `https://docs.factory.ai/cli/configuration/custom-slash-commands` | `.factory/commands` and `~/.factory/commands`, `description`/`argument-hint`/`allowed-tools` frontmatter |
| `subagents` | `https://docs.factory.ai/cli/configuration/custom-droids` | Custom droids in `.factory/droids` and `~/.factory/droids` |
| `skills` | `https://docs.factory.ai/cli/configuration/skills` | `.factory/skills/<name>/SKILL.md`, `skill.mdx`, invocation controls |
| `hooks` | `https://docs.factory.ai/reference/hooks-reference` | `.factory/hooks.json` and `~/.factory/hooks.json`, hook events, matcher groups, command-type hooks only |
| `permissions` | `https://docs.factory.ai/cli/configuration/settings` | `commandAllowlist`, `commandDenylist`, autonomy settings |
| Feature | Official docs | Upstream surface |
| ------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| index | `https://docs.factory.ai/cli/getting-started/quickstart` | Factory Droid CLI documentation |
| `rules` | `https://docs.factory.ai/cli/configuration/agents-md` | `AGENTS.md`, nested AGENTS.md, personal `~/.factory/AGENTS.md` |
| `ignore` | No dedicated upstream ignore surface in map | No Rulesync-supported Factory Droid ignore target in map |
| `mcp` | `https://docs.factory.ai/cli/configuration/mcp` | `.factory/mcp.json`, `~/.factory/mcp.json`, stdio and HTTP servers |
| `commands` | `https://docs.factory.ai/cli/configuration/custom-slash-commands` | `.factory/commands` and `~/.factory/commands`, `description`/`argument-hint` frontmatter only (no tool scoping) |
| `subagents` | `https://docs.factory.ai/cli/configuration/custom-droids` | Custom droids in `.factory/droids` and `~/.factory/droids` |
| `skills` | `https://docs.factory.ai/cli/configuration/skills` | `.factory/skills/<name>/SKILL.md`, `skill.mdx`, invocation controls |
| `hooks` | `https://docs.factory.ai/reference/hooks-reference` | `.factory/hooks.json` and `~/.factory/hooks.json`, hook events, matcher groups, command-type hooks only |
| `permissions` | `https://docs.factory.ai/cli/configuration/settings` | `commandAllowlist`, `commandDenylist`, autonomy settings |

## Client Anchors

Expand Down
5 changes: 4 additions & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Example:
- `timeout` (optional): Per-hook timeout in seconds, forwarded to tools that support it.
- `cacheTtl` (optional): Number of seconds to cache a successful hook result. Forwarded to Kiro CLI as `cache_ttl_seconds`; `0` disables caching and Kiro never caches `AgentSpawn` hooks.
- `failClosed` (optional): Boolean. When `true`, a hook failure (crash, timeout, invalid JSON) blocks the action instead of allowing it through. Passed through to Cursor's `.cursor/hooks.json` and to JetBrains Junie's `~/.junie/config.json` (as Junie's equivalently-named `blockOnError` flag).
- `commandRegex` (optional): Regex applied to the shell command string, narrowing an `Execute` matcher group further (e.g. `"^git "`). Forwarded to Factory Droid, which skips invalid regex values. Like `matcher`, it belongs to the whole matcher group, so every hook sharing that matcher receives it.
- `async` (optional): Boolean. When `true`, the hook command runs in the background without blocking. Forwarded to Qwen Code (`.qwen/settings.json`) and JetBrains Junie (`~/.junie/config.json`, same field name).
- `shell` (optional): Either `"bash"` or `"powershell"` — the only two interpreter values any tool accepts. Forwarded to Qwen Code and Claude Code command hooks. Like `args`, `async` and `asyncRewake`, it is documented on command hooks only, so it is not emitted on a hook of another type.
- `url` / `headers` / `allowedEnvVars` (optional, `http` hooks): the POST target URL, request headers (values support `$VAR` interpolation), and the env-var allowlist for that interpolation. Forwarded to Claude Code and Qwen Code http hooks.
Expand Down Expand Up @@ -733,6 +734,8 @@ cursor: # for Cursor-specific parameters (optional)
factorydroid: # for Factory Droid-specific parameters (optional)
disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill
user-invocable: false # (optional) hide from the slash-command menu, keep model access
enabled: false # (optional, default true) keep the skill on disk but stop Droid loading it
allowed-tools: "Read Execute" # (optional) tools the skill is designed to use (string or list)
takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit)
name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..")
extends: "base" # (optional) emit a leading `{extends:<parent>}` facet-inheritance directive (Takt 0.39.0+)
Expand Down Expand Up @@ -1305,7 +1308,7 @@ For AugmentCode CLI, this generates `toolPermissions` entries in `.augment/setti

For Factory Droid, this generates `commandAllowlist` / `commandDenylist` arrays in `.factory/settings.json` (project mode) or `~/.factory/settings.json` (global mode). Factory Droid only gates **shell commands** through these two lists, so only the rulesync `bash` category is translated: `allow` patterns become `commandAllowlist` entries (run without confirmation) and `deny` patterns become `commandDenylist` entries (always require confirmation; the denylist wins when a command is in both). Factory Droid has **no separate `ask` list** — any command not in the allowlist already prompts — so rulesync `ask` rules are dropped. Categories other than `bash` cannot be represented in the command allow/deny model and are skipped, with a `logger.warn` when a skipped category carries a `deny` rule (to surface the gap). rulesync owns the `commandAllowlist` / `commandDenylist` keys (they are replaced from the rulesync output), while every other key in `settings.json` (e.g. `hooks`) is preserved verbatim on round-trip — except the Factory-specific security keys covered by the `factorydroid` override below, which are lifted into that override on import. Importing reads the two lists back into the `bash` category.

> **Factory Droid-only override (`factorydroid` key):** Factory Droid has security controls that do not fit the per-command `allow`/`ask`/`deny` model — the hard-block `commandBlocklist` tier (commands that can **never** run, not even under full autonomy — distinct from an approvable `deny`), plus `networkPolicy` (`allowedIps`), `sandbox` (`enabled`/`mode`/`filesystem`/`network`), `mcpPolicy`, `enableDroidShield`, autonomy settings (`sessionDefaultSettings`, `maxAutonomyLevel`, `interactionMode`), the plugin-bootstrap keys `extraKnownMarketplaces` / `enabledPlugins` (Droid auto-registers those marketplaces and installs those plugins on start — the upstream distribution path for the same artifacts rulesync generates), and the `hooksDisabled` kill-switch. Add a tool-scoped `factorydroid` override to author them: its keys are merged into `settings.json` (the override wins) while the shared `permission` block keeps driving `commandAllowlist`/`commandDenylist`. On **import**, these keys are lifted into the `factorydroid` override — so `commandBlocklist` now round-trips faithfully (its never-runs guarantee is preserved) rather than being collapsed onto an approvable `deny`.
> **Factory Droid-only override (`factorydroid` key):** Factory Droid has security controls that do not fit the per-command `allow`/`ask`/`deny` model — the hard-block `commandBlocklist` tier (commands that can **never** run, not even under full autonomy — distinct from an approvable `deny`), plus `networkPolicy` (`allowedIps`), `sandbox` (`enabled`/`mode`/`filesystem`/`network`), `mcpPolicy`, `enableDroidShield`, autonomy settings (`sessionDefaultSettings`, `maxAutonomyLevel`, `interactionMode`), the plugin-bootstrap keys `extraKnownMarketplaces` / `enabledPlugins` (Droid auto-registers those marketplaces and installs those plugins on start — the upstream distribution path for the same artifacts rulesync generates), the `hooksDisabled` kill-switch, and `disabledSkills` (an array of skill names to disable without deleting their files). Add a tool-scoped `factorydroid` override to author them: its keys are merged into `settings.json` (the override wins) while the shared `permission` block keeps driving `commandAllowlist`/`commandDenylist`. On **import**, these keys are lifted into the `factorydroid` override — so `commandBlocklist` now round-trips faithfully (its never-runs guarantee is preserved) rather than being collapsed onto an approvable `deny`.
>
> ```json
> {
Expand Down
27 changes: 24 additions & 3 deletions src/features/commands/factorydroid-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Body content`;
expect(factorydroidCommand.getRelativeDirPath()).toBe(join(".factory", "commands"));
});

it("should merge factorydroid section fields (argument-hint, allowed-tools)", () => {
it("should merge argument-hint but drop allowed-tools, which Droid commands do not support", () => {
const rulesyncCommand = new RulesyncCommand({
outputRoot: testDir,
relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH,
Expand All @@ -114,9 +114,9 @@ Body content`;
expect(factorydroidCommand.getFrontmatter()).toEqual({
description: "Review a PR",
"argument-hint": "[pr-number]",
"allowed-tools": ["Read", "Bash"],
});
expect(factorydroidCommand.getFileContent()).toContain("argument-hint");
expect(factorydroidCommand.getFileContent()).not.toContain("allowed-tools");
});

it("should generate into the same relative path in global mode", () => {
Expand Down Expand Up @@ -168,6 +168,27 @@ Body content`;
expect(rulesyncCommand.getBody()).toBe("Review $ARGUMENTS");
});

it("should not lift allowed-tools into the override, so a round trip cannot reintroduce it", () => {
const command = new FactorydroidCommand({
outputRoot: testDir,
relativeDirPath: join(".factory", "commands"),
relativeFilePath: "review.md",
frontmatter: {
description: "Review a PR",
"argument-hint": "[pr-number]",
"allowed-tools": ["Read"],
},
body: "Review $ARGUMENTS",
validate: true,
});

expect(command.toRulesyncCommand().getFrontmatter()).toEqual({
targets: ["*"],
description: "Review a PR",
factorydroid: { "argument-hint": "[pr-number]" },
});
});

it("should not emit a factorydroid section when only description is present", () => {
const command = new FactorydroidCommand({
outputRoot: testDir,
Expand Down Expand Up @@ -294,7 +315,7 @@ Body content`;
});

describe("FactorydroidCommandFrontmatterSchema", () => {
it("should accept description, argument-hint and allowed-tools", () => {
it("should accept the two documented fields, and pass unknown keys through loosely", () => {
const result = FactorydroidCommandFrontmatterSchema.safeParse({
description: "Test",
"argument-hint": "[arg]",
Expand Down
20 changes: 17 additions & 3 deletions src/features/commands/factorydroid-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ import {
// See https://docs.factory.ai/cli/configuration/custom-slash-commands
// - `description`: optional human-readable summary.
// - `argument-hint`: optional hint shown for `$ARGUMENTS` usage.
// - `allowed-tools`: reserved/optional; passed through verbatim when present.
// Those two are the only documented fields. The docs state verbatim: "Tool
// scoping is not available for custom commands. Use Skills or Custom Droids for
// tool policy." — so `allowed-tools` is dropped on generate rather than written
// as frontmatter Droid ignores.
export const FactorydroidCommandFrontmatterSchema = z.looseObject({
description: z.optional(z.string()),
"argument-hint": z.optional(z.string()),
"allowed-tools": z.optional(z.union([z.string(), z.array(z.string())])),
});

/** Not a Droid command surface; see the schema comment above. */
const FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS = ["allowed-tools"] as const;

export type FactorydroidCommandFrontmatter = z.infer<typeof FactorydroidCommandFrontmatterSchema>;

export type FactorydroidCommandParams = {
Expand Down Expand Up @@ -79,11 +84,17 @@ export class FactorydroidCommand extends ToolCommand {

toRulesyncCommand(): RulesyncCommand {
const { description, ...restFields } = this.frontmatter;
// Symmetric with generation: a field Droid does not honor is not lifted
// into the override either, so a round trip does not reintroduce it.
for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) {
delete restFields[field];
}

const rulesyncFrontmatter: RulesyncCommandFrontmatter = {
targets: ["*"],
description,
// Preserve extra fields (e.g. argument-hint, allowed-tools) in factorydroid section
// Preserve the remaining extra fields (e.g. argument-hint) in the
// factorydroid section.
...(Object.keys(restFields).length > 0 && { factorydroid: restFields }),
};

Expand Down Expand Up @@ -116,6 +127,9 @@ export class FactorydroidCommand extends ToolCommand {
description: rulesyncFrontmatter.description,
...factorydroidFields,
};
for (const field of FACTORYDROID_UNSUPPORTED_COMMAND_FIELDS) {
delete factorydroidFrontmatter[field];
}

const body = rulesyncCommand.getBody();

Expand Down
32 changes: 32 additions & 0 deletions src/features/hooks/augmentcode-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,38 @@ describe("AugmentcodeHooks upstream additions", () => {
]);
});

it("ignores a metadata value that is not an object, in both directions", async () => {
const hooks = new AugmentcodeHooks({
outputRoot: testDir,
relativeDirPath: ".augment",
relativeFilePath: "settings.json",
fileContent: JSON.stringify({
hooks: {
PromptSubmit: [{ hooks: [{ type: "command", command: "./check.sh" }], metadata: "nope" }],
},
}),
});

const imported = JSON.parse(hooks.toRulesyncHooks().getFileContent());
expect(imported.hooks.beforeSubmitPrompt[0].metadata).toBeUndefined();

const regenerated = await AugmentcodeHooks.fromRulesyncHooks({
outputRoot: testDir,
rulesyncHooks: new RulesyncHooks({
outputRoot: testDir,
relativeDirPath: RULESYNC_RELATIVE_DIR_PATH,
relativeFilePath: "hooks.json",
fileContent: JSON.stringify({
version: 1,
hooks: { beforeSubmitPrompt: [{ command: "./check.sh", metadata: "nope" }] },
}),
validate: false,
}),
validate: false,
});
expect(JSON.parse(regenerated.getFileContent()).hooks.PromptSubmit[0].metadata).toBeUndefined();
});

it("warns and keeps the first metadata when a group disagrees", async () => {
// One value per group upstream, so the payload a script receives must not
// hinge on which hook was authored first without saying so.
Expand Down
Loading
Loading