reffy-cli 1.7.1 → 1.8.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.
package/README.md CHANGED
@@ -33,6 +33,7 @@ Command summary:
33
33
  - `reffy plan create`: generates proposal, task, design, and spec scaffolds from indexed Reffy artifacts.
34
34
  - `reffy plan validate|list|show|archive`: manages the planning lifecycle under `.reffy/reffyspec/`.
35
35
  - `reffy spec list|show`: inspects current spec state under `.reffy/reffyspec/`.
36
+ - `reffy skill list|show|create|validate`: lists, prints, scaffolds, and validates task-oriented skills under `.reffy/skills/`.
36
37
  - `reffy remote init|status|push|ls|cat|snapshot`: links, publishes, and inspects a Paseo-backed remote `.reffy/` workspace.
37
38
  - `reffy remote workspace create|get` and `reffy remote project register|list`: control-plane operations against the workspace manager actor.
38
39
  - `reffy diagram render`: renders Mermaid diagrams as SVG or ASCII, including spec-aware generation from compatible `spec.md` files.
@@ -58,6 +59,10 @@ reffy plan create --change-id add-login-flow --artifacts login-idea.md
58
59
  reffy plan list --output json
59
60
  reffy plan archive add-login-flow
60
61
  reffy spec show auth --output json
62
+ reffy skill list --output json
63
+ reffy skill show create-change
64
+ reffy skill create my-workflow
65
+ reffy skill validate
61
66
  reffy remote init --provision
62
67
  reffy remote status --output json
63
68
  reffy remote push
@@ -97,6 +102,52 @@ reffy spec list
97
102
  reffy spec show remote-workspace-manager --output json
98
103
  ```
99
104
 
105
+ ## Skills
106
+
107
+ Reffy gives *procedures* the same deterministic treatment it gives data. Skills are named, agent-readable task recipes that live as files under `.reffy/skills/`, one directory per skill:
108
+
109
+ ```
110
+ .reffy/skills/
111
+ ├── create-change/
112
+ │ └── SKILL.md
113
+ └── <your-skill>/
114
+ └── SKILL.md
115
+ ```
116
+
117
+ Each `SKILL.md` opens with frontmatter that doubles as a discovery index, followed by a markdown body:
118
+
119
+ ```markdown
120
+ ---
121
+ name: create-change
122
+ description: Turn one or more ideation artifacts into a ReffySpec change proposal.
123
+ triggers: ["new change", "plan create", "turn artifact into proposal"]
124
+ commands: ["reffy plan create", "reffy plan validate"]
125
+ managed: true
126
+ ---
127
+
128
+ ## When to use this skill
129
+ ...
130
+
131
+ ## Steps
132
+ 1. ...
133
+ ```
134
+
135
+ - `name`, `description`, and `triggers` are required; `triggers` must have at least one entry so the skill is discoverable.
136
+ - `commands` declares the CLI commands the skill wraps, which `reffy doctor` cross-checks against the installed CLI.
137
+ - `managed: true` marks skills Reffy owns. `reffy init` scaffolds and refreshes managed skills in place and never touches unmanaged ones.
138
+
139
+ `reffy init` ships six managed skills covering the core workflows: `create-artifact`, `create-change`, `archive-change`, `inspect-specs`, `sync-remote`, and `diagnose`. Author your own with `reffy skill create <name>`.
140
+
141
+ ```bash
142
+ reffy skill list # name + description + managed flag
143
+ reffy skill list --output json # harness-native descriptors for programmatic discovery
144
+ reffy skill show create-change # print the procedure body
145
+ reffy skill create my-workflow # scaffold an unmanaged skill from a template
146
+ reffy skill validate # check the frontmatter contract for every skill
147
+ ```
148
+
149
+ `reffy skill list`/`show --output json` emit a tool/function-definition style descriptor (`name`, `description`, `triggers`, `commands`, `managed`, `path`; `show` adds `body`) so an agent harness can feed skills into its own discovery machinery without parsing markdown. Skills are discovered from the filesystem and validated by contract — they are not indexed in `manifest.json`. `reffy validate` enforces the skills contract (required fields, unique names, kebab-case directory names matching `name`) alongside the manifest, and `reffy doctor` warns when a skill's declared commands drift from the installed CLI.
150
+
100
151
  ## Remote Sync
101
152
 
102
153
  Reffy can publish the local `.reffy/` workspace to a Paseo-backed remote workspace and inspect it later with native CLI commands.
@@ -315,6 +366,10 @@ Useful side commands during the arc:
315
366
  - `reffy plan show <change-id>` — inspect one change's state without opening every file.
316
367
  - `reffy spec list` / `reffy spec show <capability>` — see the canonical specs the deltas will land into.
317
368
 
369
+ ### Representing pivots
370
+
371
+ Pivots, deprecations, and wind-downs are not a separate concept in ReffySpec — they are ordinary changes whose delta is mostly REMOVED or MODIFIED requirements instead of ADDED ones. Because every change's `specs/<capability>/spec.md` is already a delta against the canonical spec, a course correction reuses the same machinery as a feature addition: scaffold a change, author a delta that removes or rewrites the relevant requirements, pair it with code-removal tasks, and archive it when shipped. The history of pivots stays legible as a series of deltas under `changes/archive/`, rather than getting buried in code commits.
372
+
318
373
  ### Reference implementation in this repo
319
374
 
320
375
  - `AGENTS.md`: contains both managed instruction blocks and encodes sequencing.
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { DEFAULT_PLANNING_RELATIVE_DIR, looksLikePlanningDir, resolveCanonicalPl
12
12
  import { listPlanningChanges, showPlanningChange, validatePlanningChange } from "./plan-runtime.js";
13
13
  import { assertWorkspaceSummaryIdentity, collectWorkspaceDocuments, describeRemoteLinkage, ensureManagerInit, ensureWorkspaceTarget, extractWorkspaceSummaryIdentity, PaseoManagerClient, PaseoWorkspaceBackendClient, readRemoteConfig, RemoteHttpError, removeWorkspaceTarget, requireWorkspaceIdentity, resolveRemoteConfigPath, resolveRemoteTarget, resolveSelectedWorkspaceId, updateRemoteConfigMetadata, validateImportResult, } from "./remote.js";
14
14
  import { DEFAULT_REFS_DIRNAME, detectWorkspaceState, looksLikeRefsDir } from "./refs-paths.js";
15
+ import { createSkill, discoverSkills, findSkill, scaffoldManagedSkills, validateSkills, } from "./skills.js";
15
16
  import { prepareCanonicalPlanningLayout } from "./planning-workspace.js";
16
17
  import { ReferencesStore } from "./storage.js";
17
18
  import { listSpecs, showSpec } from "./spec-runtime.js";
@@ -47,6 +48,8 @@ Use \`@/${refsDirName}/AGENTS.md\` to learn:
47
48
  - How Reffy owns the runtime while preserving ReffySpec planning files
48
49
  - How to store and consume ideation context in \`${refsDirName}/\`
49
50
 
51
+ Before performing a Reffy workflow, check \`${refsDirName}/skills/\` (or run \`reffy skill list\`) and follow the matching skill.
52
+
50
53
  Keep this managed block so \`reffy init\` can refresh the instructions.
51
54
 
52
55
  <!-- REFFY:END -->`;
@@ -83,6 +86,7 @@ These instructions are for AI assistants working in this project.
83
86
  - Add/update exploratory artifacts and keep them concise.
84
87
  - Run \`reffy reindex\` and \`reffy validate\` after artifact changes.
85
88
  - Use \`reffy summarize --output json\` and \`reffy plan create\` to turn artifact context into planning scaffolds.
89
+ - Before performing a Reffy workflow, check \`${refsDirName}/skills/\` (or run \`reffy skill list\`) and follow the matching skill.
86
90
 
87
91
  ## When To Use Reffy
88
92
 
@@ -381,6 +385,7 @@ async function runSetupCommand(commandName, output, repoRoot) {
381
385
  const workspace = await prepareCanonicalWorkspace(repoRoot);
382
386
  const planning = await prepareCanonicalPlanningLayout(repoRoot);
383
387
  const agents = await initAgents(repoRoot);
388
+ const skills = await scaffoldManagedSkills(repoRoot);
384
389
  const store = new ReferencesStore(repoRoot);
385
390
  const reindex = await store.reindexArtifacts();
386
391
  const payload = {
@@ -393,6 +398,7 @@ async function runSetupCommand(commandName, output, repoRoot) {
393
398
  migrated_planning_layout: planning.migrated,
394
399
  created_planning_layout: planning.created,
395
400
  ...agents,
401
+ skills,
396
402
  refs_dir: store.refsDir,
397
403
  manifest_path: store.manifestPath,
398
404
  reindex,
@@ -411,6 +417,7 @@ async function runSetupCommand(commandName, output, repoRoot) {
411
417
  console.log(`Updated ${agents.root_agents_path}`);
412
418
  console.log(`Updated ${agents.reffy_agents_path}`);
413
419
  console.log(`Updated ${agents.reffyspec_agents_path}`);
420
+ console.log(`Skills: ${String(skills.written_skills.length)} managed (${skills.preserved_unmanaged.length} unmanaged preserved)`);
414
421
  console.log(`Reindex: added=${String(reindex.added)} removed=${String(reindex.removed)} total=${String(reindex.total)}`);
415
422
  if (shouldPrintBootstrapOnboarding(workspace.created, workspace.migrated, planning.created, planning.migrated)) {
416
423
  printBootstrapOnboarding();
@@ -434,6 +441,7 @@ function usage() {
434
441
  " summarize Generate a read-only summary of indexed Reffy artifacts.",
435
442
  " plan Generate and manage ReffySpec planning scaffolds from indexed Reffy artifacts.",
436
443
  " spec Inspect current specs from the ReffySpec layout.",
444
+ " skill List, show, create, and validate Reffy skills under .reffy/skills.",
437
445
  " remote Link, publish, and inspect a Paseo-backed remote Reffy workspace.",
438
446
  " diagram Render Mermaid diagrams (supports SVG and ASCII).",
439
447
  ].join("\n");
@@ -465,6 +473,15 @@ function specUsage() {
465
473
  " reffy spec show <spec-id> [--repo PATH] [--output text|json]",
466
474
  ].join("\n");
467
475
  }
476
+ function skillUsage() {
477
+ return [
478
+ "Usage:",
479
+ " reffy skill list [--repo PATH] [--output text|json]",
480
+ " reffy skill show <name> [--repo PATH] [--output text|json]",
481
+ " reffy skill create <name> [--repo PATH] [--output text|json]",
482
+ " reffy skill validate [<name>] [--repo PATH] [--output text|json]",
483
+ ].join("\n");
484
+ }
468
485
  function planUsage() {
469
486
  return [
470
487
  "Usage:",
@@ -1744,6 +1761,129 @@ async function main() {
1744
1761
  console.error(specUsage());
1745
1762
  return 1;
1746
1763
  }
1764
+ if (command === "skill") {
1765
+ const [subcommand, ...skillArgs] = rest;
1766
+ if (!subcommand || subcommand === "--help" || subcommand === "-h") {
1767
+ console.error(skillUsage());
1768
+ return 1;
1769
+ }
1770
+ const output = parseOutputMode(skillArgs);
1771
+ const repoRoot = parseRepoArg(skillArgs);
1772
+ await prepareCanonicalWorkspace(repoRoot);
1773
+ const positionals = getPlanPositionalArgs(skillArgs);
1774
+ if (subcommand === "list") {
1775
+ const skills = await discoverSkills(repoRoot);
1776
+ const descriptors = skills.map(({ name, description, triggers, commands, managed, path: relPath }) => ({
1777
+ name,
1778
+ description,
1779
+ triggers,
1780
+ commands,
1781
+ managed,
1782
+ path: relPath,
1783
+ }));
1784
+ if (output === "json") {
1785
+ printResult(output, { status: "ok", command: "skill", subcommand: "list", skills: descriptors });
1786
+ }
1787
+ else if (descriptors.length === 0) {
1788
+ console.log("Skills:");
1789
+ console.log("- (none)");
1790
+ }
1791
+ else {
1792
+ console.log("Skills:");
1793
+ for (const skill of descriptors) {
1794
+ console.log(`- ${skill.name}${skill.managed ? " [managed]" : ""} - ${skill.description}`);
1795
+ }
1796
+ }
1797
+ return 0;
1798
+ }
1799
+ if (subcommand === "show") {
1800
+ const name = positionals[0];
1801
+ if (!name) {
1802
+ console.error("reffy skill show requires a skill name");
1803
+ console.error(skillUsage());
1804
+ return 1;
1805
+ }
1806
+ const skill = await findSkill(repoRoot, name);
1807
+ if (!skill) {
1808
+ if (output === "json") {
1809
+ printResult(output, { status: "error", command: "skill", subcommand: "show", error: `skill "${name}" not found` });
1810
+ }
1811
+ else {
1812
+ console.error(`Skill "${name}" not found`);
1813
+ }
1814
+ return 1;
1815
+ }
1816
+ if (output === "json") {
1817
+ printResult(output, {
1818
+ status: "ok",
1819
+ command: "skill",
1820
+ subcommand: "show",
1821
+ skill: {
1822
+ name: skill.name,
1823
+ description: skill.description,
1824
+ triggers: skill.triggers,
1825
+ commands: skill.commands,
1826
+ managed: skill.managed,
1827
+ path: skill.path,
1828
+ body: skill.body,
1829
+ },
1830
+ });
1831
+ }
1832
+ else {
1833
+ console.log(skill.body.trim());
1834
+ }
1835
+ return 0;
1836
+ }
1837
+ if (subcommand === "create") {
1838
+ const name = positionals[0];
1839
+ if (!name) {
1840
+ console.error("reffy skill create requires a skill name");
1841
+ console.error(skillUsage());
1842
+ return 1;
1843
+ }
1844
+ try {
1845
+ const result = await createSkill(repoRoot, name);
1846
+ if (output === "json") {
1847
+ printResult(output, { status: "ok", command: "skill", subcommand: "create", ...result });
1848
+ }
1849
+ else {
1850
+ console.log(`Created skill "${result.name}" at ${result.entry_path}`);
1851
+ }
1852
+ return 0;
1853
+ }
1854
+ catch (error) {
1855
+ const message = error instanceof Error ? error.message : String(error);
1856
+ if (output === "json") {
1857
+ printResult(output, { status: "error", command: "skill", subcommand: "create", error: message });
1858
+ }
1859
+ else {
1860
+ console.error(message);
1861
+ }
1862
+ return 1;
1863
+ }
1864
+ }
1865
+ if (subcommand === "validate") {
1866
+ const name = positionals[0];
1867
+ const result = await validateSkills(repoRoot, name);
1868
+ const payload = { status: result.ok ? "ok" : "error", command: "skill", subcommand: "validate", ...result };
1869
+ if (output === "json") {
1870
+ printResult(output, payload);
1871
+ }
1872
+ else if (result.ok) {
1873
+ console.log(`Skills valid: skills=${String(result.skill_count)}`);
1874
+ }
1875
+ else {
1876
+ console.error(`Skills invalid: ${String(result.issues.length)} issue(s)`);
1877
+ for (const issue of result.issues) {
1878
+ console.error(`error: ${issue.skill}${issue.field ? ` (${issue.field})` : ""}: ${issue.message}`);
1879
+ }
1880
+ }
1881
+ return result.ok ? 0 : 1;
1882
+ }
1883
+ console.error(`Unknown skill subcommand: ${subcommand}`);
1884
+ console.error(skillUsage());
1885
+ return 1;
1886
+ }
1747
1887
  const output = parseOutputMode(rest);
1748
1888
  if (command === "init") {
1749
1889
  printBanner(output);
@@ -1759,6 +1899,7 @@ async function main() {
1759
1899
  const workspace = await prepareCanonicalWorkspace(repoRoot);
1760
1900
  const planning = await prepareCanonicalPlanningLayout(repoRoot);
1761
1901
  const agents = await initAgents(repoRoot);
1902
+ const skills = await scaffoldManagedSkills(repoRoot);
1762
1903
  const payload = {
1763
1904
  status: "ok",
1764
1905
  command: "migrate",
@@ -1770,6 +1911,7 @@ async function main() {
1770
1911
  created_planning_layout: planning.created,
1771
1912
  refs_dir: workspace.state.canonicalDir,
1772
1913
  ...agents,
1914
+ skills,
1773
1915
  };
1774
1916
  if (output === "json") {
1775
1917
  printResult(output, payload);
@@ -1782,6 +1924,7 @@ async function main() {
1782
1924
  console.log(`Updated ${agents.root_agents_path}`);
1783
1925
  console.log(`Updated ${agents.reffy_agents_path}`);
1784
1926
  console.log(`Updated ${agents.reffyspec_agents_path}`);
1927
+ console.log(`Skills: ${String(skills.written_skills.length)} managed (${skills.preserved_unmanaged.length} unmanaged preserved)`);
1785
1928
  }
1786
1929
  return 0;
1787
1930
  }
@@ -1830,12 +1973,20 @@ async function main() {
1830
1973
  const repoRoot = parseRepoArg(rest);
1831
1974
  const store = new ReferencesStore(repoRoot);
1832
1975
  const result = await store.validateManifest();
1833
- const payload = { status: result.ok ? "ok" : "error", command: "validate", ...result };
1976
+ const skills = await validateSkills(repoRoot);
1977
+ const ok = result.ok && skills.ok;
1978
+ const payload = {
1979
+ status: ok ? "ok" : "error",
1980
+ command: "validate",
1981
+ ...result,
1982
+ skills: { ok: skills.ok, issues: skills.issues, skill_count: skills.skill_count },
1983
+ };
1834
1984
  if (output === "json") {
1835
1985
  printResult(output, payload);
1836
1986
  }
1837
- else if (result.ok) {
1987
+ else if (ok) {
1838
1988
  console.log(`Manifest valid: artifacts=${String(result.artifact_count)}`);
1989
+ console.log(`Skills valid: skills=${String(skills.skill_count)}`);
1839
1990
  if (result.warnings.length > 0) {
1840
1991
  for (const warning of result.warnings) {
1841
1992
  console.log(`warn: ${warning}`);
@@ -1843,15 +1994,23 @@ async function main() {
1843
1994
  }
1844
1995
  }
1845
1996
  else {
1846
- console.error(`Manifest invalid: ${String(result.errors.length)} error(s)`);
1847
- for (const error of result.errors) {
1848
- console.error(`error: ${error}`);
1997
+ if (!result.ok) {
1998
+ console.error(`Manifest invalid: ${String(result.errors.length)} error(s)`);
1999
+ for (const error of result.errors) {
2000
+ console.error(`error: ${error}`);
2001
+ }
2002
+ for (const warning of result.warnings) {
2003
+ console.error(`warn: ${warning}`);
2004
+ }
1849
2005
  }
1850
- for (const warning of result.warnings) {
1851
- console.error(`warn: ${warning}`);
2006
+ if (!skills.ok) {
2007
+ console.error(`Skills invalid: ${String(skills.issues.length)} issue(s)`);
2008
+ for (const issue of skills.issues) {
2009
+ console.error(`error: ${issue.skill}${issue.field ? ` (${issue.field})` : ""}: ${issue.message}`);
2010
+ }
1852
2011
  }
1853
2012
  }
1854
- return result.ok ? 0 : 1;
2013
+ return ok ? 0 : 1;
1855
2014
  }
1856
2015
  if (command === "summarize") {
1857
2016
  const repoRoot = parseRepoArg(rest);
package/dist/doctor.js CHANGED
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { validateManifest } from "./manifest.js";
4
4
  import { detectWorkspaceState, resolveRefsDirName } from "./refs-paths.js";
5
+ import { findCommandDrift } from "./skills.js";
5
6
  async function pathExists(targetPath) {
6
7
  try {
7
8
  await fs.access(targetPath);
@@ -88,5 +89,16 @@ export async function runDoctor(repoRoot) {
88
89
  ? "legacy .references workspace detected; run `reffy migrate` to adopt .reffy/"
89
90
  : ".reffy/ is the active workspace",
90
91
  });
92
+ const commandDrift = await findCommandDrift(repoRoot);
93
+ checks.push({
94
+ id: "skills_commands_current",
95
+ level: "optional",
96
+ ok: commandDrift.length === 0,
97
+ message: commandDrift.length === 0
98
+ ? "skill command references match the installed CLI"
99
+ : `stale skill command references: ${commandDrift
100
+ .map((drift) => `${drift.skill} -> "${drift.command}"`)
101
+ .join("; ")}`,
102
+ });
91
103
  return { checks, summary: summarizeChecks(checks) };
92
104
  }
@@ -0,0 +1,96 @@
1
+ export declare const SKILLS_DIRNAME = "skills";
2
+ export declare const SKILL_ENTRY_FILENAME = "SKILL.md";
3
+ export interface SkillFrontmatter {
4
+ name?: string;
5
+ description?: string;
6
+ triggers: string[];
7
+ commands: string[];
8
+ managed: boolean;
9
+ }
10
+ export interface SkillDescriptor {
11
+ name: string;
12
+ description: string;
13
+ triggers: string[];
14
+ commands: string[];
15
+ managed: boolean;
16
+ path: string;
17
+ }
18
+ export interface SkillRecord extends SkillDescriptor {
19
+ dir: string;
20
+ entryPath: string;
21
+ body: string;
22
+ }
23
+ export interface SkillValidationIssue {
24
+ skill: string;
25
+ field?: string;
26
+ message: string;
27
+ }
28
+ export interface SkillValidationResult {
29
+ ok: boolean;
30
+ issues: SkillValidationIssue[];
31
+ skill_count: number;
32
+ }
33
+ export declare function resolveSkillsDir(repoRoot: string): string;
34
+ export declare function isKebabCase(value: string): boolean;
35
+ /**
36
+ * Parse the `SKILL.md` frontmatter contract. Supports a leading `---` fenced
37
+ * block with scalar fields (`name`, `description`, `managed`) and list fields
38
+ * (`triggers`, `commands`) in either inline (`[a, b]`) or block (`- a`) form.
39
+ * Returns the parsed frontmatter and the remaining markdown body.
40
+ */
41
+ export declare function parseSkillFile(content: string): {
42
+ frontmatter: SkillFrontmatter;
43
+ body: string;
44
+ };
45
+ /**
46
+ * Discover skills by scanning `.reffy/skills/` for directories containing a
47
+ * `SKILL.md` entry file. Skills are filesystem-discovered and never indexed in
48
+ * `manifest.json`.
49
+ */
50
+ export declare function discoverSkills(repoRoot: string): Promise<SkillRecord[]>;
51
+ export declare function findSkill(repoRoot: string, name: string): Promise<SkillRecord | null>;
52
+ /**
53
+ * Validate every skill (or one named skill) against the frontmatter contract:
54
+ * required fields, non-empty triggers, unique names, and kebab-case directory
55
+ * names matching `name`.
56
+ */
57
+ export declare function validateSkills(repoRoot: string, name?: string): Promise<SkillValidationResult>;
58
+ /**
59
+ * The canonical command table of the installed CLI. `reffy doctor` cross-checks
60
+ * each skill's declared `commands` against this table and warns on drift.
61
+ */
62
+ export declare const KNOWN_COMMANDS: readonly string[];
63
+ export declare function isKnownCommand(command: string): boolean;
64
+ export interface SkillCommandDrift {
65
+ skill: string;
66
+ command: string;
67
+ }
68
+ export declare function findCommandDrift(repoRoot: string): Promise<SkillCommandDrift[]>;
69
+ interface ManagedSkillDefinition {
70
+ name: string;
71
+ description: string;
72
+ triggers: string[];
73
+ commands: string[];
74
+ body: string;
75
+ }
76
+ export declare const MANAGED_SKILLS: readonly ManagedSkillDefinition[];
77
+ export declare function isManagedSkillName(name: string): boolean;
78
+ export interface SkillScaffoldResult {
79
+ created_dir: boolean;
80
+ written_skills: string[];
81
+ preserved_unmanaged: string[];
82
+ }
83
+ /**
84
+ * Create `.reffy/skills/` and write the built-in managed skills. Managed skill
85
+ * bodies are (re)written in place on every call; unmanaged skills are never
86
+ * touched.
87
+ */
88
+ export declare function scaffoldManagedSkills(repoRoot: string): Promise<SkillScaffoldResult>;
89
+ export interface SkillCreateResult {
90
+ name: string;
91
+ entry_path: string;
92
+ created: boolean;
93
+ }
94
+ /** Scaffold a new unmanaged skill from a template. */
95
+ export declare function createSkill(repoRoot: string, name: string): Promise<SkillCreateResult>;
96
+ export {};
package/dist/skills.js ADDED
@@ -0,0 +1,471 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveRefsDir } from "./refs-paths.js";
4
+ export const SKILLS_DIRNAME = "skills";
5
+ export const SKILL_ENTRY_FILENAME = "SKILL.md";
6
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ export function resolveSkillsDir(repoRoot) {
8
+ return path.join(resolveRefsDir(repoRoot), SKILLS_DIRNAME);
9
+ }
10
+ export function isKebabCase(value) {
11
+ return KEBAB_CASE.test(value);
12
+ }
13
+ async function pathExists(targetPath) {
14
+ try {
15
+ await fs.access(targetPath);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function parseScalar(raw) {
23
+ const trimmed = raw.trim();
24
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
25
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
26
+ return trimmed.slice(1, -1);
27
+ }
28
+ return trimmed;
29
+ }
30
+ function parseInlineList(raw) {
31
+ const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
32
+ if (inner.trim() === "") {
33
+ return [];
34
+ }
35
+ return inner
36
+ .split(",")
37
+ .map((item) => parseScalar(item))
38
+ .filter((item) => item.length > 0);
39
+ }
40
+ /**
41
+ * Parse the `SKILL.md` frontmatter contract. Supports a leading `---` fenced
42
+ * block with scalar fields (`name`, `description`, `managed`) and list fields
43
+ * (`triggers`, `commands`) in either inline (`[a, b]`) or block (`- a`) form.
44
+ * Returns the parsed frontmatter and the remaining markdown body.
45
+ */
46
+ export function parseSkillFile(content) {
47
+ const normalized = content.replace(/^/, "");
48
+ const frontmatter = { triggers: [], commands: [], managed: false };
49
+ const lines = normalized.split(/\r?\n/);
50
+ if (lines[0]?.trim() !== "---") {
51
+ return { frontmatter, body: normalized };
52
+ }
53
+ let end = -1;
54
+ for (let i = 1; i < lines.length; i += 1) {
55
+ if (lines[i].trim() === "---") {
56
+ end = i;
57
+ break;
58
+ }
59
+ }
60
+ if (end === -1) {
61
+ return { frontmatter, body: normalized };
62
+ }
63
+ let currentListKey = null;
64
+ for (let i = 1; i < end; i += 1) {
65
+ const line = lines[i];
66
+ if (line.trim() === "") {
67
+ continue;
68
+ }
69
+ const blockItem = /^\s*-\s+(.*)$/.exec(line);
70
+ if (blockItem && currentListKey) {
71
+ const value = parseScalar(blockItem[1]);
72
+ if (value.length > 0) {
73
+ frontmatter[currentListKey].push(value);
74
+ }
75
+ continue;
76
+ }
77
+ const keyMatch = /^([A-Za-z_][A-Za-z0-9_]*):(.*)$/.exec(line);
78
+ if (!keyMatch) {
79
+ continue;
80
+ }
81
+ const key = keyMatch[1].trim();
82
+ const rawValue = keyMatch[2].trim();
83
+ currentListKey = null;
84
+ if (key === "name") {
85
+ frontmatter.name = parseScalar(rawValue);
86
+ }
87
+ else if (key === "description") {
88
+ frontmatter.description = parseScalar(rawValue);
89
+ }
90
+ else if (key === "managed") {
91
+ frontmatter.managed = parseScalar(rawValue).toLowerCase() === "true";
92
+ }
93
+ else if (key === "triggers" || key === "commands") {
94
+ if (rawValue.startsWith("[")) {
95
+ frontmatter[key] = parseInlineList(rawValue);
96
+ }
97
+ else if (rawValue === "") {
98
+ currentListKey = key;
99
+ }
100
+ else {
101
+ frontmatter[key] = [parseScalar(rawValue)];
102
+ }
103
+ }
104
+ }
105
+ const body = lines.slice(end + 1).join("\n").replace(/^\n+/, "");
106
+ return { frontmatter, body };
107
+ }
108
+ function toDescriptor(record) {
109
+ const { frontmatter, dirName, relPath } = record;
110
+ return {
111
+ name: frontmatter.name ?? dirName,
112
+ description: frontmatter.description ?? "",
113
+ triggers: frontmatter.triggers,
114
+ commands: frontmatter.commands,
115
+ managed: frontmatter.managed,
116
+ path: relPath,
117
+ };
118
+ }
119
+ /**
120
+ * Discover skills by scanning `.reffy/skills/` for directories containing a
121
+ * `SKILL.md` entry file. Skills are filesystem-discovered and never indexed in
122
+ * `manifest.json`.
123
+ */
124
+ export async function discoverSkills(repoRoot) {
125
+ const skillsDir = resolveSkillsDir(repoRoot);
126
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []);
127
+ const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
128
+ const records = [];
129
+ for (const dirName of dirs) {
130
+ const dir = path.join(skillsDir, dirName);
131
+ const entryPath = path.join(dir, SKILL_ENTRY_FILENAME);
132
+ if (!(await pathExists(entryPath))) {
133
+ continue;
134
+ }
135
+ const content = await fs.readFile(entryPath, "utf8");
136
+ const { frontmatter, body } = parseSkillFile(content);
137
+ const relPath = path.relative(repoRoot, entryPath);
138
+ const descriptor = toDescriptor({ frontmatter, dirName, relPath });
139
+ records.push({ ...descriptor, dir, entryPath, body });
140
+ }
141
+ return records;
142
+ }
143
+ export async function findSkill(repoRoot, name) {
144
+ const skills = await discoverSkills(repoRoot);
145
+ return skills.find((skill) => skill.name === name) ?? null;
146
+ }
147
+ /**
148
+ * Validate every skill (or one named skill) against the frontmatter contract:
149
+ * required fields, non-empty triggers, unique names, and kebab-case directory
150
+ * names matching `name`.
151
+ */
152
+ export async function validateSkills(repoRoot, name) {
153
+ const skillsDir = resolveSkillsDir(repoRoot);
154
+ const issues = [];
155
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []);
156
+ const dirs = entries
157
+ .filter((entry) => entry.isDirectory())
158
+ .map((entry) => entry.name)
159
+ .filter((dirName) => name === undefined || dirName === name)
160
+ .sort();
161
+ const seenNames = new Map();
162
+ for (const dirName of dirs) {
163
+ const label = dirName;
164
+ const entryPath = path.join(skillsDir, dirName, SKILL_ENTRY_FILENAME);
165
+ if (!(await pathExists(entryPath))) {
166
+ issues.push({ skill: label, message: `${SKILL_ENTRY_FILENAME} is missing` });
167
+ continue;
168
+ }
169
+ const content = await fs.readFile(entryPath, "utf8");
170
+ const { frontmatter } = parseSkillFile(content);
171
+ if (!frontmatter.name || frontmatter.name.trim() === "") {
172
+ issues.push({ skill: label, field: "name", message: "frontmatter is missing required field: name" });
173
+ }
174
+ else {
175
+ if (frontmatter.name !== dirName) {
176
+ issues.push({
177
+ skill: label,
178
+ field: "name",
179
+ message: `name "${frontmatter.name}" does not match directory name "${dirName}"`,
180
+ });
181
+ }
182
+ const prior = seenNames.get(frontmatter.name);
183
+ if (prior) {
184
+ issues.push({
185
+ skill: label,
186
+ field: "name",
187
+ message: `duplicate skill name "${frontmatter.name}" (also defined in "${prior}")`,
188
+ });
189
+ }
190
+ else {
191
+ seenNames.set(frontmatter.name, dirName);
192
+ }
193
+ }
194
+ if (!isKebabCase(dirName)) {
195
+ issues.push({ skill: label, message: `directory name "${dirName}" is not kebab-case` });
196
+ }
197
+ if (!frontmatter.description || frontmatter.description.trim() === "") {
198
+ issues.push({ skill: label, field: "description", message: "frontmatter is missing required field: description" });
199
+ }
200
+ if (frontmatter.triggers.length === 0) {
201
+ issues.push({ skill: label, field: "triggers", message: "frontmatter field triggers must contain at least one entry" });
202
+ }
203
+ }
204
+ if (name !== undefined && dirs.length === 0) {
205
+ issues.push({ skill: name, message: `skill "${name}" was not found` });
206
+ }
207
+ return { ok: issues.length === 0, issues, skill_count: dirs.length };
208
+ }
209
+ // --- Command-reference staleness -------------------------------------------
210
+ /**
211
+ * The canonical command table of the installed CLI. `reffy doctor` cross-checks
212
+ * each skill's declared `commands` against this table and warns on drift.
213
+ */
214
+ export const KNOWN_COMMANDS = [
215
+ "reffy init",
216
+ "reffy bootstrap",
217
+ "reffy migrate",
218
+ "reffy doctor",
219
+ "reffy reindex",
220
+ "reffy validate",
221
+ "reffy summarize",
222
+ "reffy plan create",
223
+ "reffy plan validate",
224
+ "reffy plan list",
225
+ "reffy plan show",
226
+ "reffy plan archive",
227
+ "reffy spec list",
228
+ "reffy spec show",
229
+ "reffy remote init",
230
+ "reffy remote status",
231
+ "reffy remote push",
232
+ "reffy remote ls",
233
+ "reffy remote cat",
234
+ "reffy remote snapshot",
235
+ "reffy remote workspace create",
236
+ "reffy remote workspace get",
237
+ "reffy remote workspace delete",
238
+ "reffy remote project register",
239
+ "reffy remote project list",
240
+ "reffy diagram render",
241
+ "reffy skill list",
242
+ "reffy skill show",
243
+ "reffy skill create",
244
+ "reffy skill validate",
245
+ ];
246
+ /** Reduce a declared command string to its leading non-flag tokens. */
247
+ function commandHead(command) {
248
+ const tokens = command.trim().split(/\s+/);
249
+ const head = [];
250
+ for (const token of tokens) {
251
+ if (token.startsWith("-")) {
252
+ break;
253
+ }
254
+ head.push(token);
255
+ }
256
+ return head.join(" ");
257
+ }
258
+ export function isKnownCommand(command) {
259
+ const head = commandHead(command);
260
+ if (head === "") {
261
+ return false;
262
+ }
263
+ return KNOWN_COMMANDS.some((known) => head === known || head.startsWith(`${known} `));
264
+ }
265
+ export async function findCommandDrift(repoRoot) {
266
+ const skills = await discoverSkills(repoRoot);
267
+ const drift = [];
268
+ for (const skill of skills) {
269
+ for (const command of skill.commands) {
270
+ if (!isKnownCommand(command)) {
271
+ drift.push({ skill: skill.name, command });
272
+ }
273
+ }
274
+ }
275
+ return drift;
276
+ }
277
+ function renderSkillFile(def) {
278
+ const lines = ["---"];
279
+ lines.push(`name: ${def.name}`);
280
+ lines.push(`description: ${def.description}`);
281
+ lines.push(`triggers: [${def.triggers.map((t) => JSON.stringify(t)).join(", ")}]`);
282
+ lines.push(`commands: [${def.commands.map((c) => JSON.stringify(c)).join(", ")}]`);
283
+ lines.push(`managed: ${def.managed ? "true" : "false"}`);
284
+ lines.push("---");
285
+ lines.push("");
286
+ lines.push(def.body.trim());
287
+ lines.push("");
288
+ return lines.join("\n");
289
+ }
290
+ export const MANAGED_SKILLS = [
291
+ {
292
+ name: "create-artifact",
293
+ description: "Capture ideation context as a Reffy artifact and register it in the manifest.",
294
+ triggers: ["new artifact", "capture idea", "add note", "reindex"],
295
+ commands: ["reffy reindex", "reffy validate"],
296
+ body: `## When to use this skill
297
+ Use this when you have raw ideation, exploration, or research context to capture before any formal planning.
298
+
299
+ ## Steps
300
+ 1. Write the context as a markdown file under \`.reffy/artifacts/\` with a clear, stable, kebab-case filename.
301
+ 2. Run \`reffy reindex\` to add the new file to \`.reffy/manifest.json\`.
302
+ 3. Run \`reffy validate\` to confirm the manifest still satisfies the v1 contract.
303
+ 4. Confirm the artifact appears in the manifest with the expected name and id.
304
+
305
+ ## Failure modes
306
+ - If \`reffy validate\` reports an invalid manifest, fix the reported entry before continuing — do not hand-edit ids.
307
+ - Keep artifacts exploratory; do not duplicate full proposal or spec content here.`,
308
+ },
309
+ {
310
+ name: "create-change",
311
+ description: "Turn one or more ideation artifacts into a ReffySpec change proposal.",
312
+ triggers: ["new change", "plan create", "turn artifact into proposal"],
313
+ commands: ["reffy plan create", "reffy plan validate"],
314
+ body: `## When to use this skill
315
+ Use this when ideation has converged and you are ready to scaffold a formal ReffySpec change from one or more artifacts.
316
+
317
+ ## Steps
318
+ 1. Pick the source artifacts in \`.reffy/artifacts/\` that inform the change.
319
+ 2. Run \`reffy plan create --change-id <kebab-id> --artifacts <files> --title "<title>"\`.
320
+ 3. Fill in the scaffolded \`proposal.md\`, \`design.md\`, \`tasks.md\`, and \`specs/<capability>/spec.md\` deltas.
321
+ Each delta requirement needs at least one \`#### Scenario:\`.
322
+ 4. Run \`reffy plan validate <change-id>\` and resolve every error before implementing.
323
+
324
+ ## Failure modes
325
+ - If validation reports a missing scenario block, add at least one scenario to each requirement.
326
+ - Use a unique verb-led, kebab-case \`change-id\`.`,
327
+ },
328
+ {
329
+ name: "archive-change",
330
+ description: "Complete and archive a shipped ReffySpec change, merging its delta into canonical specs.",
331
+ triggers: ["archive change", "ship change", "plan archive", "merge spec"],
332
+ commands: ["reffy plan validate", "reffy plan archive", "reffy spec show"],
333
+ body: `## When to use this skill
334
+ Use this once a change is implemented and verified and you want to fold its spec delta into canonical truth.
335
+
336
+ ## Steps
337
+ 1. Confirm every task in \`tasks.md\` is checked off.
338
+ 2. Run \`reffy plan validate <change-id>\` one last time.
339
+ 3. Run \`reffy plan archive <change-id>\` to move the change under \`changes/archive/<date>-<change-id>/\` and merge its delta specs.
340
+ 4. Verify the merge with \`reffy spec show <capability>\`.
341
+
342
+ ## Failure modes
343
+ - If archive reports unmerged or conflicting requirements, inspect the delta against the canonical spec before retrying.`,
344
+ },
345
+ {
346
+ name: "inspect-specs",
347
+ description: "Ground work in current truth by inspecting canonical specs before changing behavior.",
348
+ triggers: ["inspect specs", "current truth", "spec list", "spec show"],
349
+ commands: ["reffy spec list", "reffy spec show"],
350
+ body: `## When to use this skill
351
+ Use this before drafting a change or implementing behavior, to confirm what the specs already say.
352
+
353
+ ## Steps
354
+ 1. Run \`reffy spec list\` to enumerate capabilities and their requirement counts.
355
+ 2. Run \`reffy spec show <capability>\` to read the requirements and scenarios for a capability.
356
+ 3. Reference the relevant spec from your proposal or skill rather than restating its requirements.
357
+
358
+ ## Failure modes
359
+ - If a capability is missing, it likely needs a new \`ADDED\` delta in a change rather than an edit to canonical specs.`,
360
+ },
361
+ {
362
+ name: "sync-remote",
363
+ description: "Publish and inspect the local .reffy/ workspace on a Paseo-backed remote.",
364
+ triggers: ["remote sync", "push workspace", "paseo", "remote status"],
365
+ commands: ["reffy remote init", "reffy remote status", "reffy remote push", "reffy remote snapshot"],
366
+ body: `## When to use this skill
367
+ Use this to link, publish, or inspect the shared remote workspace projection.
368
+
369
+ ## Required environment
370
+ - \`PASEO_ENDPOINT\` — the Paseo endpoint URL (never persisted).
371
+ - \`PASEO_TOKEN\` — the bearer token (never persisted by the CLI).
372
+
373
+ Provide them either by exporting them in your shell or by placing them in a
374
+ \`.env\` file at the repo root — every \`reffy remote\` command auto-loads \`.env\`
375
+ (use \`--env-file PATH\` to point at a different file). Exported shell vars take
376
+ precedence over \`.env\`.
377
+
378
+ ## Steps
379
+ 1. Ensure both values are available via the shell or \`.env\`; the CLI fails fast and names a missing one.
380
+ 2. First time: \`reffy remote init --provision\` to create the workspace and mint a token. Save the token immediately.
381
+ 3. \`reffy remote status\` to confirm linkage and identity.
382
+ 4. \`reffy remote push\` to import the full local \`.reffy/\` tree.
383
+ 5. \`reffy remote snapshot\` / \`ls\` / \`cat\` to inspect the remote projection.
384
+
385
+ ## Failure modes
386
+ - A missing token makes the stored identifiers in \`.reffy/state/remote.json\` inert — set \`PASEO_TOKEN\`.`,
387
+ },
388
+ {
389
+ name: "diagnose",
390
+ description: "Diagnose Reffy workspace health and resolve common failure classes.",
391
+ triggers: ["diagnose", "doctor", "validate", "workspace health"],
392
+ commands: ["reffy doctor", "reffy validate"],
393
+ body: `## When to use this skill
394
+ Use this first when a Reffy command misbehaves in an unfamiliar repo.
395
+
396
+ ## Steps
397
+ 1. Run \`reffy doctor\` for the required/optional check list.
398
+ 2. Run \`reffy validate\` to confirm the manifest contract.
399
+ 3. Resolve each failure by class:
400
+ - Missing \`.reffy/\` or \`AGENTS.md\`: run \`reffy init\`.
401
+ - Legacy \`.references/\` workspace: run \`reffy migrate\`.
402
+ - Invalid manifest: fix the reported entry, then re-run \`reffy validate\`.
403
+ - Skill command drift: update the stale skill's \`commands\` list.
404
+
405
+ ## Failure modes
406
+ - A non-zero exit from \`reffy doctor\` means a required check failed; address required failures before optional warnings.`,
407
+ },
408
+ ];
409
+ export function isManagedSkillName(name) {
410
+ return MANAGED_SKILLS.some((skill) => skill.name === name);
411
+ }
412
+ /**
413
+ * Create `.reffy/skills/` and write the built-in managed skills. Managed skill
414
+ * bodies are (re)written in place on every call; unmanaged skills are never
415
+ * touched.
416
+ */
417
+ export async function scaffoldManagedSkills(repoRoot) {
418
+ const skillsDir = resolveSkillsDir(repoRoot);
419
+ const created_dir = !(await pathExists(skillsDir));
420
+ await fs.mkdir(skillsDir, { recursive: true });
421
+ const written = [];
422
+ for (const def of MANAGED_SKILLS) {
423
+ const dir = path.join(skillsDir, def.name);
424
+ await fs.mkdir(dir, { recursive: true });
425
+ const entryPath = path.join(dir, SKILL_ENTRY_FILENAME);
426
+ const content = renderSkillFile({ ...def, managed: true });
427
+ await fs.writeFile(entryPath, content, "utf8");
428
+ written.push(def.name);
429
+ }
430
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []);
431
+ const preserved_unmanaged = entries
432
+ .filter((entry) => entry.isDirectory() && !isManagedSkillName(entry.name))
433
+ .map((entry) => entry.name)
434
+ .sort();
435
+ return { created_dir, written_skills: written, preserved_unmanaged };
436
+ }
437
+ const UNMANAGED_TEMPLATE_BODY = `## When to use this skill
438
+ Describe the situation in which an agent should follow this skill.
439
+
440
+ ## Steps
441
+ 1. First step, naming the exact \`reffy\` command to run.
442
+ 2. Next step.
443
+
444
+ ## Failure modes
445
+ - Describe a likely failure and how to recover from it.`;
446
+ /** Scaffold a new unmanaged skill from a template. */
447
+ export async function createSkill(repoRoot, name) {
448
+ if (!isKebabCase(name)) {
449
+ throw new Error(`Skill name must be kebab-case: "${name}"`);
450
+ }
451
+ if (isManagedSkillName(name)) {
452
+ throw new Error(`"${name}" is a managed skill name and is reserved by reffy init`);
453
+ }
454
+ const skillsDir = resolveSkillsDir(repoRoot);
455
+ const dir = path.join(skillsDir, name);
456
+ const entryPath = path.join(dir, SKILL_ENTRY_FILENAME);
457
+ if (await pathExists(entryPath)) {
458
+ throw new Error(`Skill "${name}" already exists at ${path.relative(repoRoot, entryPath)}`);
459
+ }
460
+ await fs.mkdir(dir, { recursive: true });
461
+ const content = renderSkillFile({
462
+ name,
463
+ description: "One-line summary of what this skill does.",
464
+ triggers: ["describe", "when", "to", "use"],
465
+ commands: [],
466
+ managed: false,
467
+ body: UNMANAGED_TEMPLATE_BODY,
468
+ });
469
+ await fs.writeFile(entryPath, content, "utf8");
470
+ return { name, entry_path: path.relative(repoRoot, entryPath), created: true };
471
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reffy-cli",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Planning system for AI assisted development.",
5
5
  "keywords": [
6
6
  "reffy",