@fission-ai/openspec 1.6.0 → 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 +31 -2
- package/dist/cli/index.js +79 -8
- package/dist/commands/change.d.ts +0 -1
- package/dist/commands/change.js +105 -51
- package/dist/commands/config.js +14 -8
- package/dist/commands/doctor.js +9 -4
- package/dist/commands/feedback.js +74 -23
- package/dist/commands/schema.js +110 -31
- package/dist/commands/show.js +5 -2
- package/dist/commands/spec.js +49 -29
- package/dist/commands/validate.js +20 -5
- package/dist/commands/workflow/index.d.ts +1 -1
- package/dist/commands/workflow/index.js +1 -1
- package/dist/commands/workflow/instructions.d.ts +7 -1
- package/dist/commands/workflow/instructions.js +121 -33
- package/dist/commands/workflow/shared.d.ts +13 -2
- package/dist/commands/workflow/shared.js +34 -4
- package/dist/commands/workflow/status.js +9 -4
- package/dist/commands/workflow/templates.js +15 -5
- package/dist/core/archive.d.ts +14 -1
- package/dist/core/archive.js +1332 -155
- package/dist/core/artifact-graph/graph.d.ts +16 -0
- package/dist/core/artifact-graph/graph.js +30 -6
- package/dist/core/artifact-graph/index.d.ts +1 -1
- package/dist/core/artifact-graph/index.js +1 -1
- package/dist/core/artifact-graph/instruction-loader.d.ts +30 -3
- package/dist/core/artifact-graph/instruction-loader.js +70 -12
- package/dist/core/artifact-graph/outputs.d.ts +1 -0
- package/dist/core/artifact-graph/outputs.js +67 -5
- package/dist/core/artifact-graph/resolver.d.ts +14 -0
- package/dist/core/artifact-graph/resolver.js +69 -15
- package/dist/core/artifact-graph/types.js +21 -3
- package/dist/core/available-tools.d.ts +2 -2
- package/dist/core/available-tools.js +14 -3
- package/dist/core/change-metadata/schema.d.ts +2 -0
- package/dist/core/change-metadata/schema.js +13 -0
- package/dist/core/change-status-policy.d.ts +1 -1
- package/dist/core/change-status-policy.js +2 -2
- package/dist/core/command-generation/adapters/amazon-q.d.ts +4 -0
- package/dist/core/command-generation/adapters/amazon-q.js +7 -1
- package/dist/core/command-generation/adapters/antigravity.js +2 -1
- package/dist/core/command-generation/adapters/auggie.js +2 -1
- package/dist/core/command-generation/adapters/bob.d.ts +5 -1
- package/dist/core/command-generation/adapters/bob.js +6 -5
- package/dist/core/command-generation/adapters/claude.js +1 -8
- package/dist/core/command-generation/adapters/codebuddy.js +3 -2
- package/dist/core/command-generation/adapters/continue.js +3 -2
- package/dist/core/command-generation/adapters/costrict.js +2 -1
- package/dist/core/command-generation/adapters/crush.js +5 -5
- package/dist/core/command-generation/adapters/cursor.js +2 -2
- package/dist/core/command-generation/adapters/devin.d.ts +19 -0
- package/dist/core/command-generation/adapters/devin.js +36 -0
- package/dist/core/command-generation/adapters/factory.js +2 -1
- package/dist/core/command-generation/adapters/gemini.js +37 -2
- package/dist/core/command-generation/adapters/github-copilot.js +2 -1
- package/dist/core/command-generation/adapters/iflow.js +5 -4
- package/dist/core/command-generation/adapters/index.d.ts +2 -2
- package/dist/core/command-generation/adapters/index.js +2 -2
- package/dist/core/command-generation/adapters/junie.js +2 -1
- package/dist/core/command-generation/adapters/kiro.js +2 -1
- package/dist/core/command-generation/adapters/lingma.js +5 -5
- package/dist/core/command-generation/adapters/oh-my-pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/oh-my-pi.js +3 -5
- package/dist/core/command-generation/adapters/opencode.js +3 -5
- package/dist/core/command-generation/adapters/pi.d.ts +2 -2
- package/dist/core/command-generation/adapters/pi.js +3 -6
- package/dist/core/command-generation/adapters/qoder.js +5 -5
- package/dist/core/command-generation/adapters/qwen.d.ts +7 -3
- package/dist/core/command-generation/adapters/qwen.js +12 -7
- package/dist/core/command-generation/adapters/roocode.d.ts +4 -4
- package/dist/core/command-generation/adapters/roocode.js +4 -4
- package/dist/core/command-generation/adapters/trae.js +1 -21
- package/dist/core/command-generation/adapters/zcode.d.ts +16 -0
- package/dist/core/command-generation/adapters/zcode.js +33 -0
- package/dist/core/command-generation/generator.d.ts +8 -0
- package/dist/core/command-generation/generator.js +15 -1
- package/dist/core/command-generation/index.d.ts +1 -1
- package/dist/core/command-generation/index.js +1 -1
- package/dist/core/command-generation/invocation.d.ts +73 -0
- package/dist/core/command-generation/invocation.js +77 -0
- package/dist/core/command-generation/registry.js +4 -4
- package/dist/core/command-generation/types.d.ts +9 -2
- package/dist/core/command-generation/yaml.d.ts +20 -6
- package/dist/core/command-generation/yaml.js +31 -21
- package/dist/core/command-surface.d.ts +15 -0
- package/dist/core/command-surface.js +33 -0
- package/dist/core/completions/command-registry.js +16 -2
- package/dist/core/completions/generators/powershell-generator.js +15 -8
- package/dist/core/completions/installers/zsh-installer.d.ts +9 -0
- package/dist/core/completions/installers/zsh-installer.js +20 -4
- package/dist/core/config-prompts.d.ts +1 -1
- package/dist/core/config-prompts.js +14 -1
- package/dist/core/config-schema.d.ts +10 -1
- package/dist/core/config-schema.js +67 -1
- package/dist/core/config.d.ts +15 -0
- package/dist/core/config.js +45 -5
- package/dist/core/file-state.js +56 -24
- package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
- package/dist/core/github-copilot/cloud-agent.js +497 -0
- package/dist/core/global-config.d.ts +16 -0
- package/dist/core/init.d.ts +58 -0
- package/dist/core/init.js +450 -69
- package/dist/core/legacy-cleanup.d.ts +59 -0
- package/dist/core/legacy-cleanup.js +253 -24
- package/dist/core/list.js +5 -7
- package/dist/core/migration.d.ts +73 -1
- package/dist/core/migration.js +360 -11
- package/dist/core/onboarding-commands.d.ts +30 -0
- package/dist/core/onboarding-commands.js +38 -0
- package/dist/core/parsers/change-parser.d.ts +17 -2
- package/dist/core/parsers/change-parser.js +33 -20
- package/dist/core/parsers/code-fence.d.ts +15 -0
- package/dist/core/parsers/code-fence.js +49 -0
- package/dist/core/parsers/markdown-parser.js +2 -1
- package/dist/core/parsers/requirement-blocks.d.ts +18 -0
- package/dist/core/parsers/requirement-blocks.js +120 -35
- package/dist/core/parsers/requirement-text.d.ts +1 -8
- package/dist/core/parsers/requirement-text.js +4 -42
- package/dist/core/parsers/spec-structure.d.ts +1 -1
- package/dist/core/parsers/spec-structure.js +20 -30
- package/dist/core/profile-sync-drift.d.ts +0 -8
- package/dist/core/profile-sync-drift.js +48 -49
- package/dist/core/project-config.d.ts +28 -6
- package/dist/core/project-config.js +108 -8
- package/dist/core/references.js +42 -4
- package/dist/core/relationship-health.d.ts +8 -0
- package/dist/core/relationship-health.js +13 -0
- package/dist/core/root-selection.d.ts +9 -6
- package/dist/core/root-selection.js +42 -5
- package/dist/core/shared/index.d.ts +1 -0
- package/dist/core/shared/index.js +1 -0
- package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
- package/dist/core/shared/skill-content-equivalence.js +41 -0
- package/dist/core/shared/skill-paths.d.ts +13 -0
- package/dist/core/shared/skill-paths.js +23 -0
- package/dist/core/shared/tool-detection.d.ts +25 -5
- package/dist/core/shared/tool-detection.js +166 -36
- package/dist/core/shared-skill-target.d.ts +16 -0
- package/dist/core/shared-skill-target.js +156 -0
- package/dist/core/specs-apply.d.ts +80 -33
- package/dist/core/specs-apply.js +659 -171
- package/dist/core/store/git.d.ts +13 -0
- package/dist/core/store/git.js +24 -0
- package/dist/core/templates/skill-templates.d.ts +1 -1
- package/dist/core/templates/skill-templates.js +1 -1
- package/dist/core/templates/workflows/apply-change.d.ts +10 -0
- package/dist/core/templates/workflows/apply-change.js +48 -158
- package/dist/core/templates/workflows/archive-change.js +170 -42
- package/dist/core/templates/workflows/bulk-archive-change.js +256 -75
- package/dist/core/templates/workflows/continue-change.js +38 -46
- package/dist/core/templates/workflows/explore.js +54 -18
- package/dist/core/templates/workflows/ff-change.js +46 -30
- package/dist/core/templates/workflows/new-change.js +2 -2
- package/dist/core/templates/workflows/onboard.js +13 -7
- package/dist/core/templates/workflows/propose.js +116 -46
- package/dist/core/templates/workflows/store-selection.d.ts +1 -1
- package/dist/core/templates/workflows/store-selection.js +1 -1
- package/dist/core/templates/workflows/sync-specs.js +260 -32
- package/dist/core/templates/workflows/update-change.js +26 -16
- package/dist/core/templates/workflows/verify-change.js +16 -10
- package/dist/core/update.d.ts +40 -5
- package/dist/core/update.js +389 -93
- package/dist/core/validation/constants.d.ts +4 -1
- package/dist/core/validation/constants.js +4 -1
- package/dist/core/validation/validator.d.ts +22 -8
- package/dist/core/validation/validator.js +222 -50
- package/dist/core/version-check.d.ts +152 -0
- package/dist/core/version-check.js +731 -0
- package/dist/core/view.js +12 -17
- package/dist/prompts/searchable-multi-select.js +1 -1
- package/dist/telemetry/config.d.ts +3 -4
- package/dist/telemetry/index.d.ts +10 -5
- package/dist/telemetry/index.js +75 -41
- package/dist/ui/welcome-screen.d.ts +9 -1
- package/dist/ui/welcome-screen.js +91 -38
- package/dist/utils/change-metadata.d.ts +43 -0
- package/dist/utils/change-metadata.js +123 -7
- package/dist/utils/change-utils.d.ts +10 -4
- package/dist/utils/change-utils.js +20 -13
- package/dist/utils/ci.d.ts +12 -0
- package/dist/utils/ci.js +16 -0
- package/dist/utils/command-references.d.ts +92 -6
- package/dist/utils/command-references.js +180 -7
- package/dist/utils/date.d.ts +8 -0
- package/dist/utils/date.js +13 -0
- package/dist/utils/file-system.d.ts +10 -0
- package/dist/utils/file-system.js +70 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/interactive.d.ts +23 -0
- package/dist/utils/interactive.js +32 -0
- package/dist/utils/item-discovery.d.ts +19 -0
- package/dist/utils/item-discovery.js +30 -48
- package/dist/utils/shell-detection.d.ts +1 -1
- package/dist/utils/shell-detection.js +64 -11
- package/dist/utils/spec-discovery.d.ts +40 -0
- package/dist/utils/spec-discovery.js +115 -0
- package/dist/utils/task-progress.d.ts +17 -0
- package/dist/utils/task-progress.js +41 -13
- package/package.json +14 -12
- package/schemas/spec-driven/schema.yaml +67 -10
- package/schemas/spec-driven/templates/design.md +2 -2
- package/schemas/spec-driven/templates/proposal.md +10 -4
- package/schemas/spec-driven/templates/spec.md +3 -0
- package/dist/core/command-generation/adapters/codex.d.ts +0 -16
- package/dist/core/command-generation/adapters/codex.js +0 -39
- package/dist/core/command-generation/adapters/windsurf.d.ts +0 -14
- package/dist/core/command-generation/adapters/windsurf.js +0 -38
|
@@ -6,7 +6,23 @@ import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './typ
|
|
|
6
6
|
export declare class ArtifactGraph {
|
|
7
7
|
private artifacts;
|
|
8
8
|
private schema;
|
|
9
|
+
/** Artifact id -> its position in the schema's `artifacts:` list. */
|
|
10
|
+
private declarationOrder;
|
|
9
11
|
private constructor();
|
|
12
|
+
/**
|
|
13
|
+
* Orders artifact ids by where the schema declares them.
|
|
14
|
+
*
|
|
15
|
+
* The dependency graph leaves siblings tied - spec-driven's `specs` and
|
|
16
|
+
* `design` both require only `proposal`, so both become ready at the same
|
|
17
|
+
* time. Ties used to be broken alphabetically, which put `design` ahead of
|
|
18
|
+
* `specs` and made the CLI recommend the artifacts in an order that
|
|
19
|
+
* contradicted the schema's own documented sequence
|
|
20
|
+
* (proposal -> specs -> design -> tasks). Breaking ties by declaration order
|
|
21
|
+
* follows the sequence the schema author wrote, for built-in and custom
|
|
22
|
+
* schemas alike, and stays just as deterministic. Ids not in the schema sort
|
|
23
|
+
* last so the comparator stays total.
|
|
24
|
+
*/
|
|
25
|
+
private compareByDeclarationOrder;
|
|
10
26
|
/**
|
|
11
27
|
* Creates an ArtifactGraph from a YAML file path.
|
|
12
28
|
*/
|
|
@@ -6,9 +6,29 @@ import { loadSchema, parseSchema } from './schema.js';
|
|
|
6
6
|
export class ArtifactGraph {
|
|
7
7
|
artifacts;
|
|
8
8
|
schema;
|
|
9
|
+
/** Artifact id -> its position in the schema's `artifacts:` list. */
|
|
10
|
+
declarationOrder;
|
|
9
11
|
constructor(schema) {
|
|
10
12
|
this.schema = schema;
|
|
11
13
|
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
|
|
14
|
+
this.declarationOrder = new Map(schema.artifacts.map((a, index) => [a.id, index]));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Orders artifact ids by where the schema declares them.
|
|
18
|
+
*
|
|
19
|
+
* The dependency graph leaves siblings tied - spec-driven's `specs` and
|
|
20
|
+
* `design` both require only `proposal`, so both become ready at the same
|
|
21
|
+
* time. Ties used to be broken alphabetically, which put `design` ahead of
|
|
22
|
+
* `specs` and made the CLI recommend the artifacts in an order that
|
|
23
|
+
* contradicted the schema's own documented sequence
|
|
24
|
+
* (proposal -> specs -> design -> tasks). Breaking ties by declaration order
|
|
25
|
+
* follows the sequence the schema author wrote, for built-in and custom
|
|
26
|
+
* schemas alike, and stays just as deterministic. Ids not in the schema sort
|
|
27
|
+
* last so the comparator stays total.
|
|
28
|
+
*/
|
|
29
|
+
compareByDeclarationOrder(a, b) {
|
|
30
|
+
return ((this.declarationOrder.get(a) ?? Number.MAX_SAFE_INTEGER) -
|
|
31
|
+
(this.declarationOrder.get(b) ?? Number.MAX_SAFE_INTEGER));
|
|
12
32
|
}
|
|
13
33
|
/**
|
|
14
34
|
* Creates an ArtifactGraph from a YAML file path.
|
|
@@ -72,10 +92,10 @@ export class ArtifactGraph {
|
|
|
72
92
|
dependents.get(req).push(artifact.id);
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
|
-
// Start with roots (in-degree 0),
|
|
95
|
+
// Start with roots (in-degree 0), in declaration order for determinism
|
|
76
96
|
const queue = [...this.artifacts.keys()]
|
|
77
97
|
.filter(id => inDegree.get(id) === 0)
|
|
78
|
-
.sort();
|
|
98
|
+
.sort((a, b) => this.compareByDeclarationOrder(a, b));
|
|
79
99
|
const result = [];
|
|
80
100
|
while (queue.length > 0) {
|
|
81
101
|
const current = queue.shift();
|
|
@@ -89,7 +109,10 @@ export class ArtifactGraph {
|
|
|
89
109
|
newlyReady.push(dep);
|
|
90
110
|
}
|
|
91
111
|
}
|
|
92
|
-
queue
|
|
112
|
+
// Re-sort the whole queue, not just the new arrivals: an artifact that
|
|
113
|
+
// has been waiting can be declared after one that just became ready.
|
|
114
|
+
queue.push(...newlyReady);
|
|
115
|
+
queue.sort((a, b) => this.compareByDeclarationOrder(a, b));
|
|
93
116
|
}
|
|
94
117
|
return result;
|
|
95
118
|
}
|
|
@@ -107,8 +130,9 @@ export class ArtifactGraph {
|
|
|
107
130
|
ready.push(artifact.id);
|
|
108
131
|
}
|
|
109
132
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
133
|
+
// Declaration order: deterministic, and the first entry is the artifact the
|
|
134
|
+
// schema wants written next.
|
|
135
|
+
return ready.sort((a, b) => this.compareByDeclarationOrder(a, b));
|
|
112
136
|
}
|
|
113
137
|
/**
|
|
114
138
|
* Checks if all artifacts in the graph are completed.
|
|
@@ -132,7 +156,7 @@ export class ArtifactGraph {
|
|
|
132
156
|
}
|
|
133
157
|
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
|
|
134
158
|
if (unmetDeps.length > 0) {
|
|
135
|
-
blocked[artifact.id] = unmetDeps.sort();
|
|
159
|
+
blocked[artifact.id] = unmetDeps.sort((a, b) => this.compareByDeclarationOrder(a, b));
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
return blocked;
|
|
@@ -2,7 +2,7 @@ export { ArtifactSchema, SchemaYamlSchema, type Artifact, type SchemaYaml, type
|
|
|
2
2
|
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
|
3
3
|
export { ArtifactGraph } from './graph.js';
|
|
4
4
|
export { detectCompleted } from './state.js';
|
|
5
|
-
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
|
5
|
+
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
6
6
|
export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, type SchemaInfo, } from './resolver.js';
|
|
7
7
|
export { loadTemplate, loadChangeContext, generateInstructions, formatChangeStatus, TemplateLoadError, type ChangeContext, type LoadChangeContextOptions, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, type ArtifactPathSummary, } from './instruction-loader.js';
|
|
8
8
|
export type { PlanningHomeSummary, ActionContext, } from '../change-status-policy.js';
|
|
@@ -6,7 +6,7 @@ export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
|
|
|
6
6
|
export { ArtifactGraph } from './graph.js';
|
|
7
7
|
// State detection
|
|
8
8
|
export { detectCompleted } from './state.js';
|
|
9
|
-
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputs } from './outputs.js';
|
|
9
|
+
export { artifactOutputExists, isGlobPattern, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
|
|
10
10
|
// Schema resolution
|
|
11
11
|
export { resolveSchema, listSchemas, listSchemasWithInfo, getSchemaDir, getPackageSchemasDir, getUserSchemasDir, SchemaLoadError, } from './resolver.js';
|
|
12
12
|
// Instruction loading
|
|
@@ -32,10 +32,18 @@ export interface ChangeContext {
|
|
|
32
32
|
planningHome?: PlanningHome;
|
|
33
33
|
/** Parsed change metadata, when present */
|
|
34
34
|
metadata?: ChangeMetadata;
|
|
35
|
+
/**
|
|
36
|
+
* Artifact IDs counted as complete only because the change declares
|
|
37
|
+
* skip_specs, not because their files exist. Kept separate so status can
|
|
38
|
+
* render them as skipped rather than done.
|
|
39
|
+
*/
|
|
40
|
+
skippedArtifacts?: Set<string>;
|
|
35
41
|
}
|
|
36
42
|
export interface LoadChangeContextOptions {
|
|
37
43
|
changeDir?: string;
|
|
38
44
|
planningHome?: PlanningHome;
|
|
45
|
+
/** Pre-read project config; suppresses schema resolution's fallback config read. */
|
|
46
|
+
projectConfig?: ProjectConfig | null;
|
|
39
47
|
}
|
|
40
48
|
/**
|
|
41
49
|
* Enriched instructions for creating an artifact.
|
|
@@ -73,7 +81,17 @@ export interface ArtifactInstructions {
|
|
|
73
81
|
dependencies: DependencyInfo[];
|
|
74
82
|
/** Artifacts that become available after completing this one */
|
|
75
83
|
unlocks: string[];
|
|
84
|
+
/** True when the change declares skip_specs and this artifact is skipped */
|
|
85
|
+
skipped?: boolean;
|
|
86
|
+
/** Present only when skipped: tells the consumer not to create the artifact */
|
|
87
|
+
warning?: string;
|
|
76
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Warning attached to instructions for an artifact skipped via skip_specs.
|
|
91
|
+
* Carried in the JSON payload too, so agents driving the CLI with --json see
|
|
92
|
+
* the same do-not-create signal as the text output.
|
|
93
|
+
*/
|
|
94
|
+
export declare const SKIP_SPECS_INSTRUCTIONS_WARNING: string;
|
|
77
95
|
/**
|
|
78
96
|
* Dependency information including path and description.
|
|
79
97
|
*/
|
|
@@ -86,6 +104,8 @@ export interface DependencyInfo {
|
|
|
86
104
|
path: string;
|
|
87
105
|
/** Description of the dependency artifact */
|
|
88
106
|
description: string;
|
|
107
|
+
/** True when the dependency is satisfied via skip_specs - no files exist to read */
|
|
108
|
+
skipped?: boolean;
|
|
89
109
|
}
|
|
90
110
|
/**
|
|
91
111
|
* Status of a single artifact in the workflow.
|
|
@@ -95,8 +115,13 @@ export interface ArtifactStatus {
|
|
|
95
115
|
id: string;
|
|
96
116
|
/** Output path pattern */
|
|
97
117
|
outputPath: string;
|
|
98
|
-
/** Status: done, ready, or blocked */
|
|
99
|
-
status: 'done' | 'ready' | 'blocked';
|
|
118
|
+
/** Status: done, skipped (via skip_specs), ready, or blocked */
|
|
119
|
+
status: 'done' | 'skipped' | 'ready' | 'blocked';
|
|
120
|
+
/** Artifact IDs this artifact directly requires (its `requires` edges).
|
|
121
|
+
* Present for every status so callers can compute the transitive required
|
|
122
|
+
* set even when the artifact is already `done` (file-existence status does
|
|
123
|
+
* not imply its dependencies exist). */
|
|
124
|
+
requires: string[];
|
|
100
125
|
/** Missing dependencies (only for blocked) */
|
|
101
126
|
missingDeps?: string[];
|
|
102
127
|
}
|
|
@@ -119,7 +144,9 @@ export interface ChangeStatus {
|
|
|
119
144
|
nextSteps: string[];
|
|
120
145
|
/** Machine-readable action constraints for agents */
|
|
121
146
|
actionContext: ActionContext;
|
|
122
|
-
/** Whether all artifacts are complete */
|
|
147
|
+
/** Whether all planning artifacts are complete */
|
|
148
|
+
isPlanningComplete: boolean;
|
|
149
|
+
/** Compatibility alias for isPlanningComplete */
|
|
123
150
|
isComplete: boolean;
|
|
124
151
|
/** Artifact IDs required before apply phase (from schema's apply.requires) */
|
|
125
152
|
applyRequires: string[];
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { getSchemaDir, resolveSchema } from './resolver.js';
|
|
3
|
+
import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
|
|
4
4
|
import { ArtifactGraph } from './graph.js';
|
|
5
5
|
import { detectCompleted } from './state.js';
|
|
6
|
-
import { resolveArtifactOutputs } from './outputs.js';
|
|
6
|
+
import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
|
|
7
7
|
import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
|
|
8
8
|
import { FileSystemUtils } from '../../utils/file-system.js';
|
|
9
9
|
import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
|
|
@@ -21,6 +21,13 @@ export class TemplateLoadError extends Error {
|
|
|
21
21
|
this.name = 'TemplateLoadError';
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Warning attached to instructions for an artifact skipped via skip_specs.
|
|
26
|
+
* Carried in the JSON payload too, so agents driving the CLI with --json see
|
|
27
|
+
* the same do-not-create signal as the text output.
|
|
28
|
+
*/
|
|
29
|
+
export const SKIP_SPECS_INSTRUCTIONS_WARNING = 'This change declares skip_specs: true in .openspec.yaml (no spec-level behavior changes), so this artifact is skipped.\n' +
|
|
30
|
+
'Do not create spec files - they will conflict with that marker. If requirements now change, remove skip_specs from .openspec.yaml and rerun this command.';
|
|
24
31
|
/**
|
|
25
32
|
* Loads a template from a schema's templates directory.
|
|
26
33
|
*
|
|
@@ -35,7 +42,14 @@ export function loadTemplate(schemaName, templatePath, projectRoot) {
|
|
|
35
42
|
if (!schemaDir) {
|
|
36
43
|
throw new TemplateLoadError(`Schema '${schemaName}' not found`, templatePath);
|
|
37
44
|
}
|
|
38
|
-
const
|
|
45
|
+
const templatesDir = path.join(schemaDir, 'templates');
|
|
46
|
+
const templatePathOnDisk = path.join(templatesDir, templatePath);
|
|
47
|
+
try {
|
|
48
|
+
FileSystemUtils.assertPathWithin(templatesDir, templatePathOnDisk);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new TemplateLoadError(error instanceof Error ? error.message : String(error), templatePathOnDisk);
|
|
52
|
+
}
|
|
39
53
|
if (!fs.existsSync(templatePathOnDisk)) {
|
|
40
54
|
throw new TemplateLoadError(`Template not found: ${templatePathOnDisk}`, templatePathOnDisk);
|
|
41
55
|
}
|
|
@@ -66,10 +80,29 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
|
|
|
66
80
|
const metadata = readChangeMetadata(changeDir, projectRoot) ?? undefined;
|
|
67
81
|
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName, projectRoot, {
|
|
68
82
|
metadata: metadata ?? null,
|
|
83
|
+
projectConfig: options.projectConfig,
|
|
69
84
|
});
|
|
70
85
|
const schema = resolveSchema(resolvedSchemaName, projectRoot);
|
|
71
86
|
const graph = ArtifactGraph.fromSchema(schema);
|
|
72
87
|
const completed = detectCompleted(graph, changeDir);
|
|
88
|
+
// A change that declares skip_specs has no spec deltas by design, so
|
|
89
|
+
// artifacts generating into specs/ count as complete; otherwise the graph
|
|
90
|
+
// would block their dependents (e.g. tasks) on files that must not exist.
|
|
91
|
+
// Tracked separately so status renders them as skipped, not done.
|
|
92
|
+
const skippedArtifacts = new Set();
|
|
93
|
+
if (metadata?.skip_specs) {
|
|
94
|
+
for (const artifact of graph.getAllArtifacts()) {
|
|
95
|
+
// A schema may write generates as './specs/...' - the globs treat that
|
|
96
|
+
// identically to 'specs/...', so the skip set must too, or validate
|
|
97
|
+
// would honor the marker while instructions tell the agent to create
|
|
98
|
+
// the very files the conflict gate polices.
|
|
99
|
+
const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
|
|
100
|
+
if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
|
|
101
|
+
completed.add(artifact.id);
|
|
102
|
+
skippedArtifacts.add(artifact.id);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
73
106
|
return {
|
|
74
107
|
graph,
|
|
75
108
|
completed,
|
|
@@ -79,6 +112,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
|
|
|
79
112
|
projectRoot,
|
|
80
113
|
...(options.planningHome ? { planningHome: options.planningHome } : {}),
|
|
81
114
|
...(metadata ? { metadata } : {}),
|
|
115
|
+
...(skippedArtifacts.size > 0 ? { skippedArtifacts } : {}),
|
|
82
116
|
};
|
|
83
117
|
}
|
|
84
118
|
export function generateInstructions(context, artifactId, projectRoot, options = {}) {
|
|
@@ -87,7 +121,7 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
87
121
|
throw new Error(`Artifact '${artifactId}' not found in schema '${context.schemaName}'`);
|
|
88
122
|
}
|
|
89
123
|
const templateContent = loadTemplate(context.schemaName, artifact.template, context.projectRoot);
|
|
90
|
-
const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
|
|
124
|
+
const dependencies = getDependencyInfo(artifact, context.graph, context.completed, context.skippedArtifacts);
|
|
91
125
|
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
|
|
92
126
|
// Use projectRoot from context if not explicitly provided
|
|
93
127
|
const effectiveProjectRoot = projectRoot ?? context.projectRoot;
|
|
@@ -101,10 +135,12 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
101
135
|
// If config read fails, continue without config
|
|
102
136
|
}
|
|
103
137
|
}
|
|
104
|
-
// Validate rules artifact IDs if config has rules (only once per session)
|
|
138
|
+
// Validate rules artifact IDs if config has rules (only once per session).
|
|
139
|
+
// The rules map is global while each change can use a different schema, so a
|
|
140
|
+
// key is only "unknown" when it matches no artifact in ANY available schema.
|
|
105
141
|
if (projectConfig?.rules) {
|
|
106
|
-
const validArtifactIds = new Set(
|
|
107
|
-
const warnings = validateConfigRules(projectConfig.rules, validArtifactIds
|
|
142
|
+
const validArtifactIds = new Set(listSchemasWithInfo(effectiveProjectRoot ?? undefined).flatMap((s) => s.artifacts));
|
|
143
|
+
const warnings = validateConfigRules(projectConfig.rules, validArtifactIds);
|
|
108
144
|
// Show each unique warning only once per session
|
|
109
145
|
for (const warning of warnings) {
|
|
110
146
|
if (!shownWarnings.has(warning)) {
|
|
@@ -115,7 +151,9 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
115
151
|
}
|
|
116
152
|
// Extract context and rules as separate fields (not prepended to template)
|
|
117
153
|
const configContext = projectConfig?.context?.trim() || undefined;
|
|
118
|
-
const rulesForArtifact = projectConfig?.rules
|
|
154
|
+
const rulesForArtifact = projectConfig?.rules && Object.hasOwn(projectConfig.rules, artifactId)
|
|
155
|
+
? projectConfig.rules[artifactId]
|
|
156
|
+
: undefined;
|
|
119
157
|
const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined;
|
|
120
158
|
return {
|
|
121
159
|
changeName: context.changeName,
|
|
@@ -124,13 +162,16 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
124
162
|
changeDir: context.changeDir,
|
|
125
163
|
planningHome: summarizePlanningHome(context.planningHome),
|
|
126
164
|
outputPath: artifact.generates,
|
|
127
|
-
resolvedOutputPath:
|
|
165
|
+
resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates),
|
|
128
166
|
existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
|
|
129
167
|
description: artifact.description,
|
|
130
168
|
instruction: artifact.instruction,
|
|
131
169
|
context: configContext,
|
|
132
170
|
rules: configRules,
|
|
133
171
|
...(options.references !== undefined ? { references: options.references } : {}),
|
|
172
|
+
...(context.skippedArtifacts?.has(artifact.id)
|
|
173
|
+
? { skipped: true, warning: SKIP_SPECS_INSTRUCTIONS_WARNING }
|
|
174
|
+
: {}),
|
|
134
175
|
template: templateContent,
|
|
135
176
|
dependencies,
|
|
136
177
|
unlocks,
|
|
@@ -139,7 +180,7 @@ export function generateInstructions(context, artifactId, projectRoot, options =
|
|
|
139
180
|
/**
|
|
140
181
|
* Gets dependency info including paths and descriptions.
|
|
141
182
|
*/
|
|
142
|
-
function getDependencyInfo(artifact, graph, completed) {
|
|
183
|
+
function getDependencyInfo(artifact, graph, completed, skippedArtifacts) {
|
|
143
184
|
return artifact.requires.map(id => {
|
|
144
185
|
const depArtifact = graph.getArtifact(id);
|
|
145
186
|
return {
|
|
@@ -147,11 +188,16 @@ function getDependencyInfo(artifact, graph, completed) {
|
|
|
147
188
|
done: completed.has(id),
|
|
148
189
|
path: depArtifact?.generates ?? id,
|
|
149
190
|
description: depArtifact?.description ?? '',
|
|
191
|
+
...(skippedArtifacts?.has(id) ? { skipped: true } : {}),
|
|
150
192
|
};
|
|
151
193
|
});
|
|
152
194
|
}
|
|
153
195
|
/**
|
|
154
196
|
* Gets artifacts that become available after completing the given artifact.
|
|
197
|
+
*
|
|
198
|
+
* `getAllArtifacts()` already yields the schema's declaration order, so the list
|
|
199
|
+
* is returned as collected: sorting it alphabetically would have `unlocks` name
|
|
200
|
+
* the artifacts in a different order than `status` recommends them.
|
|
155
201
|
*/
|
|
156
202
|
function getUnlockedArtifacts(graph, artifactId) {
|
|
157
203
|
const unlocks = [];
|
|
@@ -160,7 +206,7 @@ function getUnlockedArtifacts(graph, artifactId) {
|
|
|
160
206
|
unlocks.push(artifact.id);
|
|
161
207
|
}
|
|
162
208
|
}
|
|
163
|
-
return unlocks
|
|
209
|
+
return unlocks;
|
|
164
210
|
}
|
|
165
211
|
/**
|
|
166
212
|
* Formats the status of all artifacts in a change.
|
|
@@ -179,14 +225,23 @@ export function formatChangeStatus(context, options = {}) {
|
|
|
179
225
|
const artifactStatuses = artifacts.map(artifact => {
|
|
180
226
|
artifactPaths[artifact.id] = {
|
|
181
227
|
outputPath: artifact.generates,
|
|
182
|
-
resolvedOutputPath:
|
|
228
|
+
resolvedOutputPath: resolveArtifactOutputPath(context.changeDir, artifact.generates),
|
|
183
229
|
existingOutputPaths: resolveArtifactOutputs(context.changeDir, artifact.generates),
|
|
184
230
|
};
|
|
231
|
+
if (context.skippedArtifacts?.has(artifact.id)) {
|
|
232
|
+
return {
|
|
233
|
+
id: artifact.id,
|
|
234
|
+
outputPath: artifact.generates,
|
|
235
|
+
status: 'skipped',
|
|
236
|
+
requires: artifact.requires,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
185
239
|
if (context.completed.has(artifact.id)) {
|
|
186
240
|
return {
|
|
187
241
|
id: artifact.id,
|
|
188
242
|
outputPath: artifact.generates,
|
|
189
243
|
status: 'done',
|
|
244
|
+
requires: artifact.requires,
|
|
190
245
|
};
|
|
191
246
|
}
|
|
192
247
|
if (ready.has(artifact.id)) {
|
|
@@ -194,12 +249,14 @@ export function formatChangeStatus(context, options = {}) {
|
|
|
194
249
|
id: artifact.id,
|
|
195
250
|
outputPath: artifact.generates,
|
|
196
251
|
status: 'ready',
|
|
252
|
+
requires: artifact.requires,
|
|
197
253
|
};
|
|
198
254
|
}
|
|
199
255
|
return {
|
|
200
256
|
id: artifact.id,
|
|
201
257
|
outputPath: artifact.generates,
|
|
202
258
|
status: 'blocked',
|
|
259
|
+
requires: artifact.requires,
|
|
203
260
|
missingDeps: blocked[artifact.id] ?? [],
|
|
204
261
|
};
|
|
205
262
|
});
|
|
@@ -215,6 +272,7 @@ export function formatChangeStatus(context, options = {}) {
|
|
|
215
272
|
planningHome: summarizePlanningHome(context.planningHome),
|
|
216
273
|
changeRoot: context.changeDir,
|
|
217
274
|
artifactPaths,
|
|
275
|
+
isPlanningComplete: isComplete,
|
|
218
276
|
isComplete,
|
|
219
277
|
applyRequires,
|
|
220
278
|
nextSteps: buildNextSteps({
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Checks if a path contains glob pattern characters.
|
|
3
3
|
*/
|
|
4
4
|
export declare function isGlobPattern(pattern: string): boolean;
|
|
5
|
+
export declare function resolveArtifactOutputPath(changeDir: string, generates: string): string;
|
|
5
6
|
/**
|
|
6
7
|
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
7
8
|
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
@@ -8,16 +8,66 @@ import { FileSystemUtils } from '../../utils/file-system.js';
|
|
|
8
8
|
export function isGlobPattern(pattern) {
|
|
9
9
|
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
|
|
10
10
|
}
|
|
11
|
+
export function resolveArtifactOutputPath(changeDir, generates) {
|
|
12
|
+
const outputPath = path.join(changeDir, generates);
|
|
13
|
+
FileSystemUtils.assertPathWithin(changeDir, outputPath);
|
|
14
|
+
return outputPath;
|
|
15
|
+
}
|
|
16
|
+
function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments, segmentIndex = 0, visited = new Set(), canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir), ancestors = new Set()) {
|
|
17
|
+
if (segmentIndex >= directorySegments.length)
|
|
18
|
+
return;
|
|
19
|
+
const canonicalDir = FileSystemUtils.canonicalizeExistingPath(currentDir);
|
|
20
|
+
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalDir);
|
|
21
|
+
const visitKey = `${canonicalDir}\0${segmentIndex}`;
|
|
22
|
+
if (ancestors.has(visitKey)) {
|
|
23
|
+
throw new Error(`Cannot resolve artifact outputs through a linked directory cycle: ${currentDir}`);
|
|
24
|
+
}
|
|
25
|
+
if (visited.has(visitKey))
|
|
26
|
+
return;
|
|
27
|
+
visited.add(visitKey);
|
|
28
|
+
ancestors.add(visitKey);
|
|
29
|
+
try {
|
|
30
|
+
const segment = directorySegments[segmentIndex];
|
|
31
|
+
if (segment === '**') {
|
|
32
|
+
// `**` may consume no directory at all.
|
|
33
|
+
assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
34
|
+
}
|
|
35
|
+
const matches = fg.sync(segment === '**' ? '*' : segment, {
|
|
36
|
+
cwd: canonicalDir,
|
|
37
|
+
onlyFiles: false,
|
|
38
|
+
followSymbolicLinks: false,
|
|
39
|
+
deep: 1,
|
|
40
|
+
});
|
|
41
|
+
for (const match of matches) {
|
|
42
|
+
const candidate = path.join(canonicalDir, match);
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.statSync(candidate).isDirectory())
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code === 'ENOENT')
|
|
49
|
+
continue;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
|
|
53
|
+
FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
|
|
54
|
+
assertGlobDirectoryTraversal(changeDir, canonicalCandidate, directorySegments, segment === '**' ? segmentIndex : segmentIndex + 1, visited, canonicalChangeDir, ancestors);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
ancestors.delete(visitKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
11
61
|
/**
|
|
12
62
|
* Resolves an artifact's output path(s) to concrete files that currently exist.
|
|
13
63
|
* Returns absolute file paths. Glob matches are sorted for deterministic output.
|
|
14
64
|
*/
|
|
15
65
|
export function resolveArtifactOutputs(changeDir, generates) {
|
|
66
|
+
const outputPath = resolveArtifactOutputPath(changeDir, generates);
|
|
16
67
|
if (!isGlobPattern(generates)) {
|
|
17
|
-
const fullPath = path.join(changeDir, generates);
|
|
18
68
|
try {
|
|
19
|
-
return fs.statSync(
|
|
20
|
-
? [FileSystemUtils.canonicalizeExistingPath(
|
|
69
|
+
return fs.statSync(outputPath).isFile()
|
|
70
|
+
? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
|
|
21
71
|
: [];
|
|
22
72
|
}
|
|
23
73
|
catch {
|
|
@@ -25,9 +75,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
|
|
|
25
75
|
}
|
|
26
76
|
}
|
|
27
77
|
const normalizedPattern = FileSystemUtils.toPosixPath(generates);
|
|
78
|
+
assertGlobDirectoryTraversal(changeDir, changeDir, normalizedPattern.split('/').slice(0, -1));
|
|
28
79
|
const matches = fg
|
|
29
|
-
.sync(normalizedPattern, {
|
|
30
|
-
|
|
80
|
+
.sync(normalizedPattern, {
|
|
81
|
+
cwd: changeDir,
|
|
82
|
+
onlyFiles: true,
|
|
83
|
+
absolute: true,
|
|
84
|
+
// Preserve existing support for linked artifact directories. Every
|
|
85
|
+
// concrete match is canonically confined below before it is returned.
|
|
86
|
+
followSymbolicLinks: true,
|
|
87
|
+
})
|
|
88
|
+
.map((match) => {
|
|
89
|
+
const normalizedMatch = path.normalize(match);
|
|
90
|
+
FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
|
|
91
|
+
return FileSystemUtils.canonicalizeExistingPath(normalizedMatch);
|
|
92
|
+
});
|
|
31
93
|
return Array.from(new Set(matches)).sort();
|
|
32
94
|
}
|
|
33
95
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
1
2
|
import type { SchemaYaml } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* Error thrown when loading a schema fails.
|
|
@@ -22,6 +23,19 @@ export declare function getUserSchemasDir(): string;
|
|
|
22
23
|
* @returns The path to the project's schemas directory
|
|
23
24
|
*/
|
|
24
25
|
export declare function getProjectSchemasDir(projectRoot: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Determines whether a directory entry represents a schema directory candidate.
|
|
28
|
+
*
|
|
29
|
+
* Returns true for real directories and for symlinks whose target is a
|
|
30
|
+
* directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
|
|
31
|
+
* (even one pointing at a directory) has `isDirectory() === false`; we
|
|
32
|
+
* dereference such entries via `fs.statSync` to admit symlinked schema dirs
|
|
33
|
+
* while still rejecting symlinks-to-files and broken/dangling symlinks.
|
|
34
|
+
*
|
|
35
|
+
* @param parentDir - The directory containing the entry
|
|
36
|
+
* @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
|
|
37
|
+
*/
|
|
38
|
+
export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
|
|
25
39
|
/**
|
|
26
40
|
* Resolves a schema name to its directory path.
|
|
27
41
|
*
|