@fission-ai/openspec 0.17.2 → 0.18.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 (52) hide show
  1. package/dist/cli/index.js +7 -1
  2. package/dist/commands/artifact-workflow.d.ts +17 -0
  3. package/dist/commands/artifact-workflow.js +818 -0
  4. package/dist/core/archive.d.ts +0 -5
  5. package/dist/core/archive.js +4 -257
  6. package/dist/core/artifact-graph/graph.d.ts +56 -0
  7. package/dist/core/artifact-graph/graph.js +141 -0
  8. package/dist/core/artifact-graph/index.d.ts +7 -0
  9. package/dist/core/artifact-graph/index.js +13 -0
  10. package/dist/core/artifact-graph/instruction-loader.d.ts +130 -0
  11. package/dist/core/artifact-graph/instruction-loader.js +173 -0
  12. package/dist/core/artifact-graph/resolver.d.ts +61 -0
  13. package/dist/core/artifact-graph/resolver.js +187 -0
  14. package/dist/core/artifact-graph/schema.d.ts +13 -0
  15. package/dist/core/artifact-graph/schema.js +108 -0
  16. package/dist/core/artifact-graph/state.d.ts +12 -0
  17. package/dist/core/artifact-graph/state.js +54 -0
  18. package/dist/core/artifact-graph/types.d.ts +45 -0
  19. package/dist/core/artifact-graph/types.js +43 -0
  20. package/dist/core/converters/json-converter.js +2 -1
  21. package/dist/core/global-config.d.ts +10 -0
  22. package/dist/core/global-config.js +28 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/index.js +1 -1
  25. package/dist/core/list.d.ts +6 -1
  26. package/dist/core/list.js +88 -6
  27. package/dist/core/specs-apply.d.ts +73 -0
  28. package/dist/core/specs-apply.js +384 -0
  29. package/dist/core/templates/skill-templates.d.ts +76 -0
  30. package/dist/core/templates/skill-templates.js +1472 -0
  31. package/dist/core/update.js +1 -1
  32. package/dist/core/validation/validator.js +2 -1
  33. package/dist/core/view.js +28 -8
  34. package/dist/utils/change-metadata.d.ts +47 -0
  35. package/dist/utils/change-metadata.js +130 -0
  36. package/dist/utils/change-utils.d.ts +51 -0
  37. package/dist/utils/change-utils.js +100 -0
  38. package/dist/utils/file-system.d.ts +5 -0
  39. package/dist/utils/file-system.js +7 -0
  40. package/dist/utils/index.d.ts +3 -1
  41. package/dist/utils/index.js +4 -1
  42. package/package.json +4 -1
  43. package/schemas/spec-driven/schema.yaml +148 -0
  44. package/schemas/spec-driven/templates/design.md +19 -0
  45. package/schemas/spec-driven/templates/proposal.md +23 -0
  46. package/schemas/spec-driven/templates/spec.md +8 -0
  47. package/schemas/spec-driven/templates/tasks.md +9 -0
  48. package/schemas/tdd/schema.yaml +213 -0
  49. package/schemas/tdd/templates/docs.md +15 -0
  50. package/schemas/tdd/templates/implementation.md +11 -0
  51. package/schemas/tdd/templates/spec.md +11 -0
  52. package/schemas/tdd/templates/test.md +11 -0
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ // Artifact definition schema
3
+ export const ArtifactSchema = z.object({
4
+ id: z.string().min(1, { error: 'Artifact ID is required' }),
5
+ generates: z.string().min(1, { error: 'generates field is required' }),
6
+ description: z.string(),
7
+ template: z.string().min(1, { error: 'template field is required' }),
8
+ instruction: z.string().optional(),
9
+ requires: z.array(z.string()).default([]),
10
+ });
11
+ // Apply phase configuration for schema-aware apply instructions
12
+ export const ApplyPhaseSchema = z.object({
13
+ // Artifact IDs that must exist before apply is available
14
+ requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),
15
+ // Path to file with checkboxes for progress (relative to change dir), or null if no tracking
16
+ tracks: z.string().nullable().optional(),
17
+ // Custom guidance for the apply phase
18
+ instruction: z.string().optional(),
19
+ });
20
+ // Full schema YAML structure
21
+ export const SchemaYamlSchema = z.object({
22
+ name: z.string().min(1, { error: 'Schema name is required' }),
23
+ version: z.number().int().positive({ error: 'Version must be a positive integer' }),
24
+ description: z.string().optional(),
25
+ artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
26
+ // Optional apply phase configuration (for schema-aware apply instructions)
27
+ apply: ApplyPhaseSchema.optional(),
28
+ });
29
+ // Per-change metadata schema
30
+ // Note: schema field is validated at parse time against available schemas
31
+ // using a lazy import to avoid circular dependencies
32
+ export const ChangeMetadataSchema = z.object({
33
+ // Required: which workflow schema this change uses
34
+ schema: z.string().min(1, { message: 'schema is required' }),
35
+ // Optional: creation timestamp (ISO date string)
36
+ created: z
37
+ .string()
38
+ .regex(/^\d{4}-\d{2}-\d{2}$/, {
39
+ message: 'created must be YYYY-MM-DD format',
40
+ })
41
+ .optional(),
42
+ });
43
+ //# sourceMappingURL=types.js.map
@@ -2,6 +2,7 @@ import { readFileSync } from 'fs';
2
2
  import path from 'path';
3
3
  import { MarkdownParser } from '../parsers/markdown-parser.js';
4
4
  import { ChangeParser } from '../parsers/change-parser.js';
5
+ import { FileSystemUtils } from '../../utils/file-system.js';
5
6
  export class JsonConverter {
6
7
  convertSpecToJson(filePath) {
7
8
  const content = readFileSync(filePath, 'utf-8');
@@ -33,7 +34,7 @@ export class JsonConverter {
33
34
  return JSON.stringify(jsonChange, null, 2);
34
35
  }
35
36
  extractNameFromPath(filePath) {
36
- const normalizedPath = filePath.replaceAll('\\', '/');
37
+ const normalizedPath = FileSystemUtils.toPosixPath(filePath);
37
38
  const parts = normalizedPath.split('/');
38
39
  for (let i = parts.length - 1; i >= 0; i--) {
39
40
  if (parts[i] === 'specs' || parts[i] === 'changes') {
@@ -1,5 +1,6 @@
1
1
  export declare const GLOBAL_CONFIG_DIR_NAME = "openspec";
2
2
  export declare const GLOBAL_CONFIG_FILE_NAME = "config.json";
3
+ export declare const GLOBAL_DATA_DIR_NAME = "openspec";
3
4
  export interface GlobalConfig {
4
5
  featureFlags?: Record<string, boolean>;
5
6
  }
@@ -11,6 +12,15 @@ export interface GlobalConfig {
11
12
  * - Windows fallback: %APPDATA%/openspec/
12
13
  */
13
14
  export declare function getGlobalConfigDir(): string;
15
+ /**
16
+ * Gets the global data directory path following XDG Base Directory Specification.
17
+ * Used for user data like schema overrides.
18
+ *
19
+ * - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
20
+ * - Unix/macOS fallback: ~/.local/share/openspec/
21
+ * - Windows fallback: %LOCALAPPDATA%/openspec/
22
+ */
23
+ export declare function getGlobalDataDir(): string;
14
24
  /**
15
25
  * Gets the path to the global config file.
16
26
  */
@@ -4,6 +4,7 @@ import * as os from 'node:os';
4
4
  // Constants
5
5
  export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
6
6
  export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
7
+ export const GLOBAL_DATA_DIR_NAME = 'openspec';
7
8
  const DEFAULT_CONFIG = {
8
9
  featureFlags: {}
9
10
  };
@@ -33,6 +34,33 @@ export function getGlobalConfigDir() {
33
34
  // Unix/macOS fallback: ~/.config
34
35
  return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
35
36
  }
37
+ /**
38
+ * Gets the global data directory path following XDG Base Directory Specification.
39
+ * Used for user data like schema overrides.
40
+ *
41
+ * - All platforms: $XDG_DATA_HOME/openspec/ if XDG_DATA_HOME is set
42
+ * - Unix/macOS fallback: ~/.local/share/openspec/
43
+ * - Windows fallback: %LOCALAPPDATA%/openspec/
44
+ */
45
+ export function getGlobalDataDir() {
46
+ // XDG_DATA_HOME takes precedence on all platforms when explicitly set
47
+ const xdgDataHome = process.env.XDG_DATA_HOME;
48
+ if (xdgDataHome) {
49
+ return path.join(xdgDataHome, GLOBAL_DATA_DIR_NAME);
50
+ }
51
+ const platform = os.platform();
52
+ if (platform === 'win32') {
53
+ // Windows: use %LOCALAPPDATA%
54
+ const localAppData = process.env.LOCALAPPDATA;
55
+ if (localAppData) {
56
+ return path.join(localAppData, GLOBAL_DATA_DIR_NAME);
57
+ }
58
+ // Fallback for Windows if LOCALAPPDATA is not set
59
+ return path.join(os.homedir(), 'AppData', 'Local', GLOBAL_DATA_DIR_NAME);
60
+ }
61
+ // Unix/macOS fallback: ~/.local/share
62
+ return path.join(os.homedir(), '.local', 'share', GLOBAL_DATA_DIR_NAME);
63
+ }
36
64
  /**
37
65
  * Gets the path to the global config file.
38
66
  */
@@ -1,2 +1,2 @@
1
- export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, type GlobalConfig, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig } from './global-config.js';
1
+ export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, GLOBAL_DATA_DIR_NAME, type GlobalConfig, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, getGlobalDataDir } from './global-config.js';
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Core OpenSpec logic will be implemented here
2
- export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig } from './global-config.js';
2
+ export { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME, GLOBAL_DATA_DIR_NAME, getGlobalConfigDir, getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, getGlobalDataDir } from './global-config.js';
3
3
  //# sourceMappingURL=index.js.map
@@ -1,4 +1,9 @@
1
+ interface ListOptions {
2
+ sort?: 'recent' | 'name';
3
+ json?: boolean;
4
+ }
1
5
  export declare class ListCommand {
2
- execute(targetPath?: string, mode?: 'changes' | 'specs'): Promise<void>;
6
+ execute(targetPath?: string, mode?: 'changes' | 'specs', options?: ListOptions): Promise<void>;
3
7
  }
8
+ export {};
4
9
  //# sourceMappingURL=list.d.ts.map
package/dist/core/list.js CHANGED
@@ -4,8 +4,64 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
4
4
  import { readFileSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { MarkdownParser } from './parsers/markdown-parser.js';
7
+ /**
8
+ * Get the most recent modification time of any file in a directory (recursive).
9
+ * Falls back to the directory's own mtime if no files are found.
10
+ */
11
+ async function getLastModified(dirPath) {
12
+ let latest = null;
13
+ async function walk(dir) {
14
+ const entries = await fs.readdir(dir, { withFileTypes: true });
15
+ for (const entry of entries) {
16
+ const fullPath = path.join(dir, entry.name);
17
+ if (entry.isDirectory()) {
18
+ await walk(fullPath);
19
+ }
20
+ else {
21
+ const stat = await fs.stat(fullPath);
22
+ if (latest === null || stat.mtime > latest) {
23
+ latest = stat.mtime;
24
+ }
25
+ }
26
+ }
27
+ }
28
+ await walk(dirPath);
29
+ // If no files found, use the directory's own modification time
30
+ if (latest === null) {
31
+ const dirStat = await fs.stat(dirPath);
32
+ return dirStat.mtime;
33
+ }
34
+ return latest;
35
+ }
36
+ /**
37
+ * Format a date as relative time (e.g., "2 hours ago", "3 days ago")
38
+ */
39
+ function formatRelativeTime(date) {
40
+ const now = new Date();
41
+ const diffMs = now.getTime() - date.getTime();
42
+ const diffSecs = Math.floor(diffMs / 1000);
43
+ const diffMins = Math.floor(diffSecs / 60);
44
+ const diffHours = Math.floor(diffMins / 60);
45
+ const diffDays = Math.floor(diffHours / 24);
46
+ if (diffDays > 30) {
47
+ return date.toLocaleDateString();
48
+ }
49
+ else if (diffDays > 0) {
50
+ return `${diffDays}d ago`;
51
+ }
52
+ else if (diffHours > 0) {
53
+ return `${diffHours}h ago`;
54
+ }
55
+ else if (diffMins > 0) {
56
+ return `${diffMins}m ago`;
57
+ }
58
+ else {
59
+ return 'just now';
60
+ }
61
+ }
7
62
  export class ListCommand {
8
- async execute(targetPath = '.', mode = 'changes') {
63
+ async execute(targetPath = '.', mode = 'changes', options = {}) {
64
+ const { sort = 'recent', json = false } = options;
9
65
  if (mode === 'changes') {
10
66
  const changesDir = path.join(targetPath, 'openspec', 'changes');
11
67
  // Check if changes directory exists
@@ -21,21 +77,46 @@ export class ListCommand {
21
77
  .filter(entry => entry.isDirectory() && entry.name !== 'archive')
22
78
  .map(entry => entry.name);
23
79
  if (changeDirs.length === 0) {
24
- console.log('No active changes found.');
80
+ if (json) {
81
+ console.log(JSON.stringify({ changes: [] }));
82
+ }
83
+ else {
84
+ console.log('No active changes found.');
85
+ }
25
86
  return;
26
87
  }
27
88
  // Collect information about each change
28
89
  const changes = [];
29
90
  for (const changeDir of changeDirs) {
30
91
  const progress = await getTaskProgressForChange(changesDir, changeDir);
92
+ const changePath = path.join(changesDir, changeDir);
93
+ const lastModified = await getLastModified(changePath);
31
94
  changes.push({
32
95
  name: changeDir,
33
96
  completedTasks: progress.completed,
34
- totalTasks: progress.total
97
+ totalTasks: progress.total,
98
+ lastModified
35
99
  });
36
100
  }
37
- // Sort alphabetically by name
38
- changes.sort((a, b) => a.name.localeCompare(b.name));
101
+ // Sort by preference (default: recent first)
102
+ if (sort === 'recent') {
103
+ changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
104
+ }
105
+ else {
106
+ changes.sort((a, b) => a.name.localeCompare(b.name));
107
+ }
108
+ // JSON output for programmatic use
109
+ if (json) {
110
+ const jsonOutput = changes.map(c => ({
111
+ name: c.name,
112
+ completedTasks: c.completedTasks,
113
+ totalTasks: c.totalTasks,
114
+ lastModified: c.lastModified.toISOString(),
115
+ status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
116
+ }));
117
+ console.log(JSON.stringify({ changes: jsonOutput }, null, 2));
118
+ return;
119
+ }
39
120
  // Display results
40
121
  console.log('Changes:');
41
122
  const padding = ' ';
@@ -43,7 +124,8 @@ export class ListCommand {
43
124
  for (const change of changes) {
44
125
  const paddedName = change.name.padEnd(nameWidth);
45
126
  const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
46
- console.log(`${padding}${paddedName} ${status}`);
127
+ const timeAgo = formatRelativeTime(change.lastModified);
128
+ console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
47
129
  }
48
130
  return;
49
131
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Spec Application Logic
3
+ *
4
+ * Extracted from ArchiveCommand to enable standalone spec application.
5
+ * Applies delta specs from a change to main specs without archiving.
6
+ */
7
+ export interface SpecUpdate {
8
+ source: string;
9
+ target: string;
10
+ exists: boolean;
11
+ }
12
+ export interface ApplyResult {
13
+ capability: string;
14
+ added: number;
15
+ modified: number;
16
+ removed: number;
17
+ renamed: number;
18
+ }
19
+ export interface SpecsApplyOutput {
20
+ changeName: string;
21
+ capabilities: ApplyResult[];
22
+ totals: {
23
+ added: number;
24
+ modified: number;
25
+ removed: number;
26
+ renamed: number;
27
+ };
28
+ noChanges: boolean;
29
+ }
30
+ /**
31
+ * Find all delta spec files that need to be applied from a change.
32
+ */
33
+ export declare function findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]>;
34
+ /**
35
+ * Build an updated spec by applying delta operations.
36
+ * Returns the rebuilt content and counts of operations.
37
+ */
38
+ export declare function buildUpdatedSpec(update: SpecUpdate, changeName: string): Promise<{
39
+ rebuilt: string;
40
+ counts: {
41
+ added: number;
42
+ modified: number;
43
+ removed: number;
44
+ renamed: number;
45
+ };
46
+ }>;
47
+ /**
48
+ * Write an updated spec to disk.
49
+ */
50
+ export declare function writeUpdatedSpec(update: SpecUpdate, rebuilt: string, counts: {
51
+ added: number;
52
+ modified: number;
53
+ removed: number;
54
+ renamed: number;
55
+ }): Promise<void>;
56
+ /**
57
+ * Build a skeleton spec for new capabilities.
58
+ */
59
+ export declare function buildSpecSkeleton(specFolderName: string, changeName: string): string;
60
+ /**
61
+ * Apply all delta specs from a change to main specs.
62
+ *
63
+ * @param projectRoot - The project root directory
64
+ * @param changeName - The name of the change to apply
65
+ * @param options - Options for the operation
66
+ * @returns Result of the operation with counts
67
+ */
68
+ export declare function applySpecs(projectRoot: string, changeName: string, options?: {
69
+ dryRun?: boolean;
70
+ skipValidation?: boolean;
71
+ silent?: boolean;
72
+ }): Promise<SpecsApplyOutput>;
73
+ //# sourceMappingURL=specs-apply.d.ts.map