@fission-ai/openspec 1.13.0 → 1.13.2
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 +16 -4
- package/dist/commands/change.js +12 -1
- package/dist/commands/config.d.ts +9 -0
- package/dist/commands/config.js +161 -22
- package/dist/commands/feedback.js +6 -4
- package/dist/commands/schema.js +30 -9
- package/dist/commands/store.js +5 -2
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.js +63 -1
- package/dist/commands/workflow/instructions.js +69 -22
- package/dist/commands/workflow/new-change.js +20 -0
- package/dist/commands/workflow/shared.d.ts +5 -0
- package/dist/commands/workflow/shared.js +8 -0
- package/dist/commands/workflow/status.d.ts +5 -1
- package/dist/commands/workflow/status.js +42 -5
- package/dist/core/archive.js +260 -77
- package/dist/core/artifact-graph/outputs.d.ts +1 -1
- package/dist/core/artifact-graph/outputs.js +38 -8
- package/dist/core/artifact-graph/schema.d.ts +14 -0
- package/dist/core/artifact-graph/schema.js +52 -0
- package/dist/core/artifact-graph/types.js +12 -1
- package/dist/core/change-status-policy.d.ts +14 -0
- package/dist/core/change-status-policy.js +14 -6
- package/dist/core/command-generation/adapters/continue.js +4 -0
- package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
- package/dist/core/command-generation/adapters/kilocode.js +2 -2
- package/dist/core/command-generation/generator.js +2 -0
- package/dist/core/completion-tip.js +7 -7
- package/dist/core/completions/installers/bash-installer.js +15 -7
- package/dist/core/completions/installers/shell-quote.d.ts +11 -0
- package/dist/core/completions/installers/shell-quote.js +13 -0
- package/dist/core/completions/installers/zsh-installer.js +3 -2
- package/dist/core/config-schema.d.ts +1 -1
- package/dist/core/config.d.ts +16 -0
- package/dist/core/config.js +27 -1
- package/dist/core/global-config.d.ts +30 -2
- package/dist/core/global-config.js +57 -3
- package/dist/core/init.js +22 -6
- package/dist/core/legacy-cleanup.d.ts +5 -1
- package/dist/core/legacy-cleanup.js +207 -14
- package/dist/core/list.js +55 -11
- package/dist/core/migration.js +6 -1
- package/dist/core/parsers/change-parser.d.ts +12 -1
- package/dist/core/parsers/change-parser.js +85 -87
- package/dist/core/parsers/markdown-parser.js +4 -3
- package/dist/core/parsers/requirement-blocks.d.ts +78 -6
- package/dist/core/parsers/requirement-blocks.js +155 -38
- package/dist/core/parsers/requirement-text.d.ts +13 -2
- package/dist/core/parsers/requirement-text.js +34 -7
- package/dist/core/parsers/spec-structure.js +4 -1
- package/dist/core/project-config.d.ts +9 -1
- package/dist/core/project-config.js +11 -2
- package/dist/core/references.d.ts +20 -0
- package/dist/core/references.js +56 -0
- package/dist/core/shared/skill-content-equivalence.js +5 -1
- package/dist/core/shared/skill-generation.js +36 -8
- package/dist/core/shared/tool-detection.js +9 -3
- package/dist/core/specs-apply.d.ts +20 -0
- package/dist/core/specs-apply.js +101 -17
- package/dist/core/store/git.d.ts +33 -0
- package/dist/core/store/git.js +60 -6
- package/dist/core/store/operations.d.ts +1 -1
- package/dist/core/store/operations.js +32 -10
- package/dist/core/store/registry.d.ts +2 -1
- package/dist/core/store/registry.js +5 -1
- package/dist/core/templates/optional-workflow.d.ts +69 -0
- package/dist/core/templates/optional-workflow.js +140 -0
- package/dist/core/templates/workflows/apply-change.js +18 -3
- package/dist/core/templates/workflows/archive-change.js +70 -21
- package/dist/core/templates/workflows/bulk-archive-change.js +85 -21
- package/dist/core/templates/workflows/continue-change.js +14 -4
- package/dist/core/templates/workflows/explore.js +34 -16
- package/dist/core/templates/workflows/ff-change.js +21 -5
- package/dist/core/templates/workflows/new-change.js +15 -3
- package/dist/core/templates/workflows/onboard.js +76 -41
- package/dist/core/templates/workflows/project-root.d.ts +29 -0
- package/dist/core/templates/workflows/project-root.js +38 -0
- package/dist/core/templates/workflows/propose.js +22 -5
- package/dist/core/templates/workflows/sync-specs.js +40 -1
- package/dist/core/templates/workflows/update-change.js +66 -27
- package/dist/core/templates/workflows/verify-change.js +121 -49
- package/dist/core/update.d.ts +12 -0
- package/dist/core/update.js +78 -10
- package/dist/core/validation/purpose-placeholder.js +24 -9
- package/dist/core/validation/task-checkboxes.d.ts +34 -0
- package/dist/core/validation/task-checkboxes.js +181 -0
- package/dist/core/validation/validator.d.ts +53 -1
- package/dist/core/validation/validator.js +160 -45
- package/dist/core/version-check.d.ts +14 -7
- package/dist/core/version-check.js +63 -17
- package/dist/core/worksets.js +6 -4
- package/dist/prompts/searchable-multi-select.d.ts +7 -0
- package/dist/prompts/searchable-multi-select.js +35 -6
- package/dist/telemetry/config.js +14 -2
- package/dist/telemetry/index.d.ts +4 -3
- package/dist/telemetry/index.js +22 -10
- package/dist/telemetry/opt-out.d.ts +17 -0
- package/dist/telemetry/opt-out.js +27 -0
- package/dist/utils/file-system.js +20 -3
- package/dist/utils/line-endings.d.ts +34 -0
- package/dist/utils/line-endings.js +46 -0
- package/dist/utils/nested-change.d.ts +35 -0
- package/dist/utils/nested-change.js +186 -0
- package/dist/utils/spec-discovery.d.ts +20 -0
- package/dist/utils/spec-discovery.js +59 -0
- package/dist/utils/task-progress.d.ts +1 -1
- package/dist/utils/task-progress.js +38 -6
- package/package.json +5 -5
- package/schemas/spec-driven/schema.yaml +18 -3
- package/schemas/spec-driven/templates/design.md +2 -0
- package/schemas/spec-driven/templates/proposal.md +2 -0
- package/schemas/spec-driven/templates/spec.md +2 -0
- package/schemas/spec-driven/templates/tasks.md +2 -0
package/dist/core/store/git.js
CHANGED
|
@@ -4,7 +4,40 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { StoreError } from './errors.js';
|
|
6
6
|
const fs = nodeFs.promises;
|
|
7
|
-
const
|
|
7
|
+
const rawExecFileAsync = promisify(execFile);
|
|
8
|
+
/**
|
|
9
|
+
* Bounds every read-only git probe. Without a timeout a wedged network mount, an
|
|
10
|
+
* fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
|
|
11
|
+
* raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
|
|
12
|
+
* ENOBUFS, which the probes below would otherwise report as "no git facts".
|
|
13
|
+
* A probe writes nothing, so a hard kill is safe.
|
|
14
|
+
*/
|
|
15
|
+
export const GIT_EXEC_OPTIONS = {
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
timeout: 15_000,
|
|
18
|
+
killSignal: 'SIGKILL',
|
|
19
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
|
|
23
|
+
* to remove `.git/index.lock` on its way out, and a signal it cannot catch
|
|
24
|
+
* leaves that lock behind - every later git command in the user's store then
|
|
25
|
+
* fails with "Another git process seems to be running", including the
|
|
26
|
+
* best-effort unstage below. The timeout is also far longer, because a signed
|
|
27
|
+
* commit can legitimately sit waiting on pinentry or a hardware key.
|
|
28
|
+
*/
|
|
29
|
+
export const GIT_WRITE_EXEC_OPTIONS = {
|
|
30
|
+
encoding: 'utf8',
|
|
31
|
+
timeout: 120_000,
|
|
32
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
33
|
+
};
|
|
34
|
+
function execFileAsync(file, args, options = {}) {
|
|
35
|
+
return rawExecFileAsync(file, args, { ...GIT_EXEC_OPTIONS, ...options });
|
|
36
|
+
}
|
|
37
|
+
/** Same as execFileAsync, for commands that modify the user's repository. */
|
|
38
|
+
function execGitWrite(args, options = {}) {
|
|
39
|
+
return rawExecFileAsync('git', args, { ...GIT_WRITE_EXEC_OPTIONS, ...options });
|
|
40
|
+
}
|
|
8
41
|
/**
|
|
9
42
|
* Git mechanics for stores: repository detection, setup-time init and
|
|
10
43
|
* commit, and the read-only facts doctor reports. Nothing here clones, pulls,
|
|
@@ -31,7 +64,7 @@ export async function initGitRepository(storeRoot) {
|
|
|
31
64
|
return false;
|
|
32
65
|
}
|
|
33
66
|
try {
|
|
34
|
-
await
|
|
67
|
+
await execGitWrite(['init'], { cwd: storeRoot });
|
|
35
68
|
}
|
|
36
69
|
catch (error) {
|
|
37
70
|
throw new StoreError(`Failed to initialize Git repository: ${error instanceof Error ? error.message : String(error)}`, 'store_git_init_failed', {
|
|
@@ -74,13 +107,13 @@ export async function commitStoreFiles(storeRoot, id, pathspecs) {
|
|
|
74
107
|
return false;
|
|
75
108
|
}
|
|
76
109
|
try {
|
|
77
|
-
await
|
|
78
|
-
await
|
|
110
|
+
await execGitWrite(['add', '--', ...pathspecs], { cwd: storeRoot });
|
|
111
|
+
await execGitWrite(['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs], { cwd: storeRoot });
|
|
79
112
|
}
|
|
80
113
|
catch (error) {
|
|
81
114
|
// Best-effort unstage so a failed commit (gpg signing, hooks) does not
|
|
82
115
|
// leave setup's files in the user's index after rollback deletes them.
|
|
83
|
-
await
|
|
116
|
+
await execGitWrite(['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
|
|
84
117
|
cwd: storeRoot,
|
|
85
118
|
}).catch(() => undefined);
|
|
86
119
|
throw new StoreError(`Failed to create the initial store commit: ${error instanceof Error ? error.message : String(error)}`, 'store_git_commit_failed', {
|
|
@@ -90,12 +123,33 @@ export async function commitStoreFiles(storeRoot, id, pathspecs) {
|
|
|
90
123
|
}
|
|
91
124
|
return true;
|
|
92
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* A probe that hit a resource limit rather than an ordinary Git answer: the
|
|
128
|
+
* command was killed by the timeout above, or its output exceeded maxBuffer.
|
|
129
|
+
* Both produce the same `null` as "not a repository", so without this the CLI
|
|
130
|
+
* would quietly stop reporting facts it is capable of reporting.
|
|
131
|
+
*/
|
|
132
|
+
export function isProbeResourceFailure(error) {
|
|
133
|
+
if (typeof error !== 'object' || error === null)
|
|
134
|
+
return false;
|
|
135
|
+
const { code, killed, signal } = error;
|
|
136
|
+
return (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ||
|
|
137
|
+
code === 'ETIMEDOUT' ||
|
|
138
|
+
(killed === true && signal === 'SIGKILL'));
|
|
139
|
+
}
|
|
93
140
|
async function gitProbe(storeRoot, args) {
|
|
94
141
|
try {
|
|
95
142
|
const { stdout } = await execFileAsync('git', ['-C', storeRoot, ...args]);
|
|
96
143
|
return stdout;
|
|
97
144
|
}
|
|
98
|
-
catch {
|
|
145
|
+
catch (error) {
|
|
146
|
+
// "git is absent" and "this is not a repository" are expected answers and
|
|
147
|
+
// stay silent; a probe that timed out or overflowed its buffer is a
|
|
148
|
+
// degraded result the user should know about, since callers cannot tell
|
|
149
|
+
// the two apart from the null alone.
|
|
150
|
+
if (isProbeResourceFailure(error)) {
|
|
151
|
+
process.emitWarning(`git ${args.join(' ')} did not complete in ${storeRoot}; store Git facts are unavailable for this run.`, 'OpenSpecGitProbeWarning');
|
|
152
|
+
}
|
|
99
153
|
return null;
|
|
100
154
|
}
|
|
101
155
|
}
|
|
@@ -100,7 +100,7 @@ export declare function expandUserPath(inputPath: string): string;
|
|
|
100
100
|
* no-ops), and always honoring an explicit --init-git/--no-init-git.
|
|
101
101
|
*/
|
|
102
102
|
export declare function resolveSetupGitEnabled(prepared: PreparedStoreSetup, initGit?: boolean): boolean;
|
|
103
|
-
export declare function prepareStoreSetup(input: Pick<SetupStoreInput, 'id' | 'path' | 'allowInsideGitRepository' | 'remote'>): Promise<PreparedStoreSetup>;
|
|
103
|
+
export declare function prepareStoreSetup(input: Pick<SetupStoreInput, 'id' | 'path' | 'initGit' | 'allowInsideGitRepository' | 'remote'>): Promise<PreparedStoreSetup>;
|
|
104
104
|
export declare function setupPreparedStore(prepared: PreparedStoreSetup, input?: Pick<SetupStoreInput, 'initGit'>): Promise<StoreMutationResult>;
|
|
105
105
|
export declare function setupStore(input: SetupStoreInput): Promise<StoreMutationResult>;
|
|
106
106
|
export declare function registerExistingStore(input: RegisterExistingStoreInput): Promise<StoreMutationResult>;
|
|
@@ -8,7 +8,7 @@ import { classifyOpenSpecDir, storePointerProblem, } from '../project-config.js'
|
|
|
8
8
|
import { ANCHORED_OPENSPEC_DIRS, DIRECTORY_ANCHOR_FILE_NAME, OPENSPEC_ROOT_DIR, ensureOpenSpecRoot, inspectOpenSpecRoot, rollbackCreatedPaths, } from '../openspec-root.js';
|
|
9
9
|
import { STORE_METADATA_DIR_NAME, getStoreMetadataDir, getStoreMetadataPath, getStoreRegistryPath, listStoreRegistryEntries, readStoreRegistryState, readOptionalStoreMetadataState, resolveGitStoreBackendConfig, validateStoreId, writeStoreMetadataState, } from './foundation.js';
|
|
10
10
|
import { StoreError, makeStoreDiagnostic } from './errors.js';
|
|
11
|
-
import { assertGitCommitIdentity, commitStoreFiles, gitDirectoryHasTrackedFiles, gitHasCommits, gitHasRemote, gitHasUncommittedChanges, gitOriginUrl, initGitRepository, isGitRepositoryAtRoot, } from './git.js';
|
|
11
|
+
import { GIT_EXEC_OPTIONS, assertGitCommitIdentity, commitStoreFiles, gitDirectoryHasTrackedFiles, gitHasCommits, gitHasRemote, gitHasUncommittedChanges, gitOriginUrl, initGitRepository, isGitRepositoryAtRoot, } from './git.js';
|
|
12
12
|
import { getStoreRootForBackend, assertNoRegisteredStoreConflict, commitStoreRegistration, getRegisteredStore, listRegisteredStores, unregisterStoreRegistration, } from './registry.js';
|
|
13
13
|
const fs = nodeFs.promises;
|
|
14
14
|
const execFileAsync = promisify(execFile);
|
|
@@ -106,12 +106,7 @@ async function findContainingGitRepositoryRoot(storeRoot) {
|
|
|
106
106
|
: null;
|
|
107
107
|
};
|
|
108
108
|
try {
|
|
109
|
-
const { stdout } = await execFileAsync('git', [
|
|
110
|
-
'-C',
|
|
111
|
-
nearestParent,
|
|
112
|
-
'rev-parse',
|
|
113
|
-
'--show-toplevel',
|
|
114
|
-
]);
|
|
109
|
+
const { stdout } = await execFileAsync('git', ['-C', nearestParent, 'rev-parse', '--show-toplevel'], GIT_EXEC_OPTIONS);
|
|
115
110
|
return gitRootContainsStore(stdout.trim());
|
|
116
111
|
}
|
|
117
112
|
catch {
|
|
@@ -244,9 +239,10 @@ async function prepareSetupPlan(input) {
|
|
|
244
239
|
});
|
|
245
240
|
}
|
|
246
241
|
// Stores may be Git-backed, but creating one inside an implementation
|
|
247
|
-
// repo is almost always an accidental nested-repo setup.
|
|
242
|
+
// repo is almost always an accidental nested-repo setup. --no-init-git
|
|
243
|
+
// creates no repository, so there is nothing to nest.
|
|
248
244
|
await assertSetupPathIsNotNestedInGitRepo(storeRoot, {
|
|
249
|
-
allowInsideGitRepository: input.allowInsideGitRepository,
|
|
245
|
+
allowInsideGitRepository: input.allowInsideGitRepository || input.initGit === false,
|
|
250
246
|
});
|
|
251
247
|
let metadata = null;
|
|
252
248
|
let backend;
|
|
@@ -594,6 +590,29 @@ async function assertSafeToDeleteStoreRoot(storeRoot, id) {
|
|
|
594
590
|
}
|
|
595
591
|
return { exists: true };
|
|
596
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* Deleting a store root takes everything under it, including any other
|
|
595
|
+
* store registered inside it (a shared store vendored as a submodule, for
|
|
596
|
+
* example). `store remove <id>` never asked for that store to go.
|
|
597
|
+
*/
|
|
598
|
+
function assertNoRegisteredStoreInside(storeRoot, id, others) {
|
|
599
|
+
const root = normalizeRegistryPathForComparison(storeRoot);
|
|
600
|
+
const nested = others.filter((other) => {
|
|
601
|
+
const relative = path.relative(root, normalizeRegistryPathForComparison(other.storeRoot));
|
|
602
|
+
return (relative.length > 0 &&
|
|
603
|
+
relative !== '..' &&
|
|
604
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
605
|
+
!path.isAbsolute(relative));
|
|
606
|
+
});
|
|
607
|
+
if (nested.length === 0)
|
|
608
|
+
return;
|
|
609
|
+
const listed = nested.map((other) => `'${other.id}' (${other.storeRoot})`).join(', ');
|
|
610
|
+
const unregister = nested.map((other) => `openspec store unregister ${other.id}`).join(', then ');
|
|
611
|
+
throw new StoreError(`Store remove refuses to delete ${storeRoot}: it contains ${nested.length === 1 ? 'another registered store' : 'other registered stores'}: ${listed}.`, 'store_remove_contains_registered_store', {
|
|
612
|
+
target: 'store.root',
|
|
613
|
+
fix: `Unregister or remove ${nested.length === 1 ? 'that store' : 'those stores'} first (${unregister}), or run "openspec store unregister ${id}" to forget '${id}' without deleting files.`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
597
616
|
export async function removeStore(target) {
|
|
598
617
|
const id = validateStoreId(target.id);
|
|
599
618
|
const diagnostics = [];
|
|
@@ -606,9 +625,12 @@ export async function removeStore(target) {
|
|
|
606
625
|
id,
|
|
607
626
|
expectedBackend: target.backend,
|
|
608
627
|
globalDataDir: target.globalDataDir,
|
|
609
|
-
beforeCommit: async (entry) => {
|
|
628
|
+
beforeCommit: async (entry, remaining) => {
|
|
610
629
|
const safeTarget = await assertSafeToDeleteStoreRoot(entry.storeRoot, id);
|
|
611
630
|
rootMissing = !safeTarget.exists;
|
|
631
|
+
if (safeTarget.exists) {
|
|
632
|
+
assertNoRegisteredStoreInside(entry.storeRoot, id, remaining);
|
|
633
|
+
}
|
|
612
634
|
},
|
|
613
635
|
});
|
|
614
636
|
if (rootMissing) {
|
|
@@ -15,7 +15,8 @@ export interface GetRegisteredStoreInput extends ResolveRegisteredStoreInput {
|
|
|
15
15
|
export interface UnregisterStoreInput extends StorePathOptions {
|
|
16
16
|
id: string;
|
|
17
17
|
expectedBackend?: StoreGitBackendConfig;
|
|
18
|
-
|
|
18
|
+
/** Runs under the registry lock, with the registrations that will remain. */
|
|
19
|
+
beforeCommit?: (entry: RegisteredStoreEntry, remaining: RegisteredStoreEntry[]) => Promise<void>;
|
|
19
20
|
}
|
|
20
21
|
export type ListRegisteredStoresOptions = StorePathOptions;
|
|
21
22
|
export interface RegisteredStoreEntry extends StoreRegistryEntry {
|
|
@@ -236,7 +236,11 @@ export async function unregisterStoreRegistration(input) {
|
|
|
236
236
|
...result.removed,
|
|
237
237
|
storeRoot: getStoreRootForBackend(result.removed.backend),
|
|
238
238
|
};
|
|
239
|
-
|
|
239
|
+
const remaining = listStoreRegistryEntries(result.next).map((entry) => ({
|
|
240
|
+
...entry,
|
|
241
|
+
storeRoot: getStoreRootForBackend(entry.backend),
|
|
242
|
+
}));
|
|
243
|
+
await input.beforeCommit?.(removedEntry, remaining);
|
|
240
244
|
removed = result.removed;
|
|
241
245
|
return result.next;
|
|
242
246
|
}, { globalDataDir: input.globalDataDir });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional-Workflow Conditionals
|
|
3
|
+
*
|
|
4
|
+
* Not every workflow is installed. The `core` profile ships six of the twelve
|
|
5
|
+
* (`propose`, `explore`, `apply`, `update`, `sync`, `archive`), and a `custom`
|
|
6
|
+
* profile can ship any subset. A template that names `/opsx:continue` is
|
|
7
|
+
* therefore writing a dead reference for anyone whose profile omits it — the
|
|
8
|
+
* agent is told to hand off to a workflow that was never generated (#1734,
|
|
9
|
+
* umbrella #919).
|
|
10
|
+
*
|
|
11
|
+
* `command-references.ts` rewrites how a reference is spelled; this module
|
|
12
|
+
* decides whether it is emitted at all. Templates author both branches with
|
|
13
|
+
* `optionalWorkflow()`, and `resolveOptionalWorkflows()` picks one at
|
|
14
|
+
* generation time against the resolved workflow set, so the generated file
|
|
15
|
+
* states one path instead of asking the model to check availability at runtime.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Authors a passage whose wording depends on whether `workflowId` is installed.
|
|
19
|
+
*
|
|
20
|
+
* Both branches must read correctly on their own: the generated file contains
|
|
21
|
+
* exactly one of them, with no trace of the other.
|
|
22
|
+
*
|
|
23
|
+
* @param workflowId - Workflow id as it appears in ALL_WORKFLOWS (e.g. 'continue')
|
|
24
|
+
* @param whenInstalled - Text to emit when the workflow is part of the profile
|
|
25
|
+
* @param whenMissing - Text to emit otherwise, typically a CLI fallback
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* optionalWorkflow('continue', 'suggest `/opsx:continue`', 'run `openspec status`')
|
|
29
|
+
*/
|
|
30
|
+
export declare function optionalWorkflow(workflowId: string, whenInstalled: string, whenMissing: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* A passage that is dropped entirely when `workflowId` is not installed.
|
|
33
|
+
*
|
|
34
|
+
* Use for a line that only makes sense alongside the workflow it names — a
|
|
35
|
+
* command-reference table row, a bullet listing one workflow. When the
|
|
36
|
+
* conditional is the whole line, the line goes with it rather than leaving a
|
|
37
|
+
* blank one behind.
|
|
38
|
+
*
|
|
39
|
+
* @param workflowId - Workflow id as it appears in ALL_WORKFLOWS
|
|
40
|
+
* @param whenInstalled - Text to emit when the workflow is part of the profile
|
|
41
|
+
*/
|
|
42
|
+
export declare function onlyWithWorkflow(workflowId: string, whenInstalled: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Resolves every `optionalWorkflow()` passage in `text` against the workflows
|
|
45
|
+
* that will actually be installed.
|
|
46
|
+
*
|
|
47
|
+
* Runs before the command-reference transformers, so a reference in a branch
|
|
48
|
+
* that was dropped never reaches them.
|
|
49
|
+
*
|
|
50
|
+
* @param text - Template body, possibly containing conditionals
|
|
51
|
+
* @param installedWorkflows - The resolved workflow set for this installation
|
|
52
|
+
* @returns The body with one branch of each conditional kept
|
|
53
|
+
* @throws If a malformed conditional leaves a marker in the output
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveOptionalWorkflows(text: string, installedWorkflows: ReadonlySet<string>): string;
|
|
56
|
+
/**
|
|
57
|
+
* Fails loudly if `text` still carries a conditional marker.
|
|
58
|
+
*
|
|
59
|
+
* Called at the end of resolution to catch a malformed block, and again at the
|
|
60
|
+
* points that write a generated file — so a body that skipped resolution
|
|
61
|
+
* altogether (a generation path that bypassed getSkillTemplates /
|
|
62
|
+
* getCommandTemplates) throws instead of shipping literal markers to a user.
|
|
63
|
+
*
|
|
64
|
+
* @param text - Text about to be written, or just resolved
|
|
65
|
+
* @param reason - What went wrong, used as the message prefix
|
|
66
|
+
* @throws If any `[[opsx:...]]` marker remains
|
|
67
|
+
*/
|
|
68
|
+
export declare function assertWorkflowConditionalsResolved(text: string, reason: string): void;
|
|
69
|
+
//# sourceMappingURL=optional-workflow.d.ts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional-Workflow Conditionals
|
|
3
|
+
*
|
|
4
|
+
* Not every workflow is installed. The `core` profile ships six of the twelve
|
|
5
|
+
* (`propose`, `explore`, `apply`, `update`, `sync`, `archive`), and a `custom`
|
|
6
|
+
* profile can ship any subset. A template that names `/opsx:continue` is
|
|
7
|
+
* therefore writing a dead reference for anyone whose profile omits it — the
|
|
8
|
+
* agent is told to hand off to a workflow that was never generated (#1734,
|
|
9
|
+
* umbrella #919).
|
|
10
|
+
*
|
|
11
|
+
* `command-references.ts` rewrites how a reference is spelled; this module
|
|
12
|
+
* decides whether it is emitted at all. Templates author both branches with
|
|
13
|
+
* `optionalWorkflow()`, and `resolveOptionalWorkflows()` picks one at
|
|
14
|
+
* generation time against the resolved workflow set, so the generated file
|
|
15
|
+
* states one path instead of asking the model to check availability at runtime.
|
|
16
|
+
*/
|
|
17
|
+
const OPEN = '[[opsx:if-workflow ';
|
|
18
|
+
const OPEN_END = ']]';
|
|
19
|
+
const ELSE = '[[opsx:else]]';
|
|
20
|
+
const END = '[[opsx:end]]';
|
|
21
|
+
/**
|
|
22
|
+
* A conditional that occupies a whole line on its own. Matched first so a
|
|
23
|
+
* branch that resolves to empty takes its line with it — otherwise dropping a
|
|
24
|
+
* table row or a bullet would leave a blank line behind, which markdown reads
|
|
25
|
+
* as the end of the table or list.
|
|
26
|
+
*/
|
|
27
|
+
const WHOLE_LINE_PATTERN = /^([ \t]*)\[\[opsx:if-workflow ([a-z-]+)\]\]([^\n]*?)\[\[opsx:else\]\]([^\n]*?)\[\[opsx:end\]\][ \t]*\r?\n/gm;
|
|
28
|
+
const CONDITIONAL_PATTERN = /\[\[opsx:if-workflow ([a-z-]+)\]\]([\s\S]*?)\[\[opsx:else\]\]([\s\S]*?)\[\[opsx:end\]\]/g;
|
|
29
|
+
/** Any leftover marker, used to fail loudly on malformed authoring. */
|
|
30
|
+
const RESIDUAL_MARKER_PATTERN = /\[\[opsx:(if-workflow|else|end)/;
|
|
31
|
+
/** A single well-formed marker, in any position. */
|
|
32
|
+
const MARKER_PATTERN = /\[\[opsx:(?:if-workflow [a-z-]+|else|end)\]\]/g;
|
|
33
|
+
/** Anything that opens like a marker, well-formed or not. */
|
|
34
|
+
const MARKER_LIKE_PATTERN = /\[\[opsx:/;
|
|
35
|
+
/**
|
|
36
|
+
* Rejects a malformed conditional before any branch is chosen.
|
|
37
|
+
*
|
|
38
|
+
* Checking after resolution is not enough: the unselected branch is discarded
|
|
39
|
+
* first, so a truncated block inside it would pass for one profile and throw
|
|
40
|
+
* for another — the exact profile-dependent behavior this module exists to
|
|
41
|
+
* remove. Authoring is either valid for every profile or valid for none.
|
|
42
|
+
*
|
|
43
|
+
* @param text - Template body as authored
|
|
44
|
+
* @throws If a marker is unrecognized, or the blocks are not a flat sequence
|
|
45
|
+
* of if / else / end
|
|
46
|
+
*/
|
|
47
|
+
function assertConditionalsWellFormed(text) {
|
|
48
|
+
const kinds = [];
|
|
49
|
+
const withoutMarkers = text.replace(MARKER_PATTERN, (marker) => {
|
|
50
|
+
kinds.push(marker.startsWith(OPEN) ? 'if' : marker === ELSE ? 'else' : 'end');
|
|
51
|
+
return '';
|
|
52
|
+
});
|
|
53
|
+
const unrecognized = MARKER_LIKE_PATTERN.exec(withoutMarkers);
|
|
54
|
+
if (unrecognized) {
|
|
55
|
+
throw new Error(`Malformed optional-workflow conditional: unrecognized marker at '${withoutMarkers
|
|
56
|
+
.slice(unrecognized.index, unrecognized.index + 40)
|
|
57
|
+
.split('\n')[0]}'. Markers are [[opsx:if-workflow <id>]], [[opsx:else]] and [[opsx:end]].`);
|
|
58
|
+
}
|
|
59
|
+
for (let i = 0; i < kinds.length; i += 3) {
|
|
60
|
+
if (kinds[i] !== 'if' || kinds[i + 1] !== 'else' || kinds[i + 2] !== 'end') {
|
|
61
|
+
throw new Error('Malformed optional-workflow conditional: markers are out of order or a ' +
|
|
62
|
+
'block is incomplete. Each block needs the full [[opsx:if-workflow <id>]] ' +
|
|
63
|
+
'... [[opsx:else]] ... [[opsx:end]] form, and blocks cannot nest.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Authors a passage whose wording depends on whether `workflowId` is installed.
|
|
69
|
+
*
|
|
70
|
+
* Both branches must read correctly on their own: the generated file contains
|
|
71
|
+
* exactly one of them, with no trace of the other.
|
|
72
|
+
*
|
|
73
|
+
* @param workflowId - Workflow id as it appears in ALL_WORKFLOWS (e.g. 'continue')
|
|
74
|
+
* @param whenInstalled - Text to emit when the workflow is part of the profile
|
|
75
|
+
* @param whenMissing - Text to emit otherwise, typically a CLI fallback
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* optionalWorkflow('continue', 'suggest `/opsx:continue`', 'run `openspec status`')
|
|
79
|
+
*/
|
|
80
|
+
export function optionalWorkflow(workflowId, whenInstalled, whenMissing) {
|
|
81
|
+
return `${OPEN}${workflowId}${OPEN_END}${whenInstalled}${ELSE}${whenMissing}${END}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A passage that is dropped entirely when `workflowId` is not installed.
|
|
85
|
+
*
|
|
86
|
+
* Use for a line that only makes sense alongside the workflow it names — a
|
|
87
|
+
* command-reference table row, a bullet listing one workflow. When the
|
|
88
|
+
* conditional is the whole line, the line goes with it rather than leaving a
|
|
89
|
+
* blank one behind.
|
|
90
|
+
*
|
|
91
|
+
* @param workflowId - Workflow id as it appears in ALL_WORKFLOWS
|
|
92
|
+
* @param whenInstalled - Text to emit when the workflow is part of the profile
|
|
93
|
+
*/
|
|
94
|
+
export function onlyWithWorkflow(workflowId, whenInstalled) {
|
|
95
|
+
return optionalWorkflow(workflowId, whenInstalled, '');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolves every `optionalWorkflow()` passage in `text` against the workflows
|
|
99
|
+
* that will actually be installed.
|
|
100
|
+
*
|
|
101
|
+
* Runs before the command-reference transformers, so a reference in a branch
|
|
102
|
+
* that was dropped never reaches them.
|
|
103
|
+
*
|
|
104
|
+
* @param text - Template body, possibly containing conditionals
|
|
105
|
+
* @param installedWorkflows - The resolved workflow set for this installation
|
|
106
|
+
* @returns The body with one branch of each conditional kept
|
|
107
|
+
* @throws If a malformed conditional leaves a marker in the output
|
|
108
|
+
*/
|
|
109
|
+
export function resolveOptionalWorkflows(text, installedWorkflows) {
|
|
110
|
+
assertConditionalsWellFormed(text);
|
|
111
|
+
const wholeLinesResolved = text.replace(WHOLE_LINE_PATTERN, (_match, indent, workflowId, whenInstalled, whenMissing) => {
|
|
112
|
+
const chosen = installedWorkflows.has(workflowId) ? whenInstalled : whenMissing;
|
|
113
|
+
return chosen === '' ? '' : `${indent}${chosen}\n`;
|
|
114
|
+
});
|
|
115
|
+
const resolved = wholeLinesResolved.replace(CONDITIONAL_PATTERN, (_match, workflowId, whenInstalled, whenMissing) => installedWorkflows.has(workflowId) ? whenInstalled : whenMissing);
|
|
116
|
+
assertWorkflowConditionalsResolved(resolved, 'Malformed optional-workflow conditional');
|
|
117
|
+
return resolved;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Fails loudly if `text` still carries a conditional marker.
|
|
121
|
+
*
|
|
122
|
+
* Called at the end of resolution to catch a malformed block, and again at the
|
|
123
|
+
* points that write a generated file — so a body that skipped resolution
|
|
124
|
+
* altogether (a generation path that bypassed getSkillTemplates /
|
|
125
|
+
* getCommandTemplates) throws instead of shipping literal markers to a user.
|
|
126
|
+
*
|
|
127
|
+
* @param text - Text about to be written, or just resolved
|
|
128
|
+
* @param reason - What went wrong, used as the message prefix
|
|
129
|
+
* @throws If any `[[opsx:...]]` marker remains
|
|
130
|
+
*/
|
|
131
|
+
export function assertWorkflowConditionalsResolved(text, reason) {
|
|
132
|
+
const residual = RESIDUAL_MARKER_PATTERN.exec(text);
|
|
133
|
+
if (residual) {
|
|
134
|
+
throw new Error(`${reason}: '${residual[0]}' is unresolved. Optional-workflow blocks are ` +
|
|
135
|
+
'resolved by getSkillTemplates()/getCommandTemplates() against the installed ' +
|
|
136
|
+
'workflow set, and each needs the full [[opsx:if-workflow <id>]] ... ' +
|
|
137
|
+
'[[opsx:else]] ... [[opsx:end]] form.');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=optional-workflow.js.map
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
import { optionalWorkflow } from '../optional-workflow.js';
|
|
1
2
|
import { STORE_SELECTION_GUIDANCE } from './store-selection.js';
|
|
3
|
+
import { PROJECT_ROOT_GUARD } from './project-root.js';
|
|
4
|
+
/**
|
|
5
|
+
* `/opsx:continue` is not in the `core` profile, so the blocked-state handoff
|
|
6
|
+
* is authored with a CLI fallback and resolved at generation time (see
|
|
7
|
+
* optional-workflow.ts).
|
|
8
|
+
*/
|
|
9
|
+
const BLOCKED_STATE_HANDOFF = optionalWorkflow('continue', 'suggest using `/opsx:continue` to create them.', 'suggest completing the missing artifacts. Run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.');
|
|
10
|
+
/** The archive handoff shown once every task is done. */
|
|
11
|
+
const ARCHIVE_HANDOFF = optionalWorkflow('archive', 'You can archive this change with `/opsx:archive`.', 'You can archive this change by running `openspec archive "<name>"`.');
|
|
2
12
|
/**
|
|
3
13
|
* The apply workflow instructions, authored once and rendered by both the
|
|
4
14
|
* skill and command surfaces. The surfaces are intentionally distinct, but
|
|
@@ -13,6 +23,8 @@ export function getApplyInstructions() {
|
|
|
13
23
|
|
|
14
24
|
${STORE_SELECTION_GUIDANCE}
|
|
15
25
|
|
|
26
|
+
${PROJECT_ROOT_GUARD}
|
|
27
|
+
|
|
16
28
|
**Input**: Optionally specify a change name (e.g., \`/opsx:apply add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
17
29
|
|
|
18
30
|
**Steps**
|
|
@@ -48,9 +60,12 @@ ${STORE_SELECTION_GUIDANCE}
|
|
|
48
60
|
- Dynamic instruction based on current state
|
|
49
61
|
- Optional \`context\`: current required project instruction input from the selected root
|
|
50
62
|
- Optional \`operationGuidance\`: current advisory guidance for apply
|
|
63
|
+
- \`missingArtifacts\` (when present): required artifact ids with no output
|
|
51
64
|
|
|
52
65
|
**Handle states:**
|
|
53
|
-
- If \`state: "blocked"
|
|
66
|
+
- If \`state: "blocked"\`: show the message and pause implementation.
|
|
67
|
+
- If \`missingArtifacts\` is non-empty: ${BLOCKED_STATE_HANDOFF}
|
|
68
|
+
- Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
|
|
54
69
|
- If \`state: "all_done"\`: congratulate, suggest archive
|
|
55
70
|
- Otherwise: proceed to implementation
|
|
56
71
|
|
|
@@ -139,7 +154,7 @@ Working on task 4/7: <task description>
|
|
|
139
154
|
- [x] Task 2
|
|
140
155
|
...
|
|
141
156
|
|
|
142
|
-
All tasks complete!
|
|
157
|
+
All tasks complete! ${ARCHIVE_HANDOFF}
|
|
143
158
|
\`\`\`
|
|
144
159
|
|
|
145
160
|
**Output On Pause (Issue Encountered)**
|
|
@@ -189,7 +204,7 @@ This skill supports the "actions on a change" model:
|
|
|
189
204
|
export function getApplyChangeSkillTemplate() {
|
|
190
205
|
return {
|
|
191
206
|
name: 'openspec-apply-change',
|
|
192
|
-
description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.',
|
|
207
|
+
description: 'Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. Also use when the user says "openspec apply", "opsx apply", or "openspec implement".',
|
|
193
208
|
instructions: getApplyInstructions(),
|
|
194
209
|
license: 'MIT',
|
|
195
210
|
compatibility: 'Requires openspec CLI.',
|