@fission-ai/openspec 1.13.0 → 1.13.1

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 (102) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/change.js +12 -1
  3. package/dist/commands/config.d.ts +9 -0
  4. package/dist/commands/config.js +161 -22
  5. package/dist/commands/feedback.js +6 -4
  6. package/dist/commands/schema.js +30 -9
  7. package/dist/commands/store.js +5 -2
  8. package/dist/commands/validate.d.ts +7 -0
  9. package/dist/commands/validate.js +63 -1
  10. package/dist/commands/workflow/instructions.js +33 -14
  11. package/dist/commands/workflow/new-change.js +20 -0
  12. package/dist/commands/workflow/shared.js +8 -0
  13. package/dist/commands/workflow/status.d.ts +5 -1
  14. package/dist/commands/workflow/status.js +42 -5
  15. package/dist/core/archive.js +17 -1
  16. package/dist/core/artifact-graph/schema.d.ts +14 -0
  17. package/dist/core/artifact-graph/schema.js +52 -0
  18. package/dist/core/artifact-graph/types.js +12 -1
  19. package/dist/core/change-status-policy.d.ts +14 -0
  20. package/dist/core/change-status-policy.js +14 -6
  21. package/dist/core/command-generation/generator.js +2 -0
  22. package/dist/core/completion-tip.js +7 -7
  23. package/dist/core/completions/installers/bash-installer.js +15 -7
  24. package/dist/core/completions/installers/shell-quote.d.ts +11 -0
  25. package/dist/core/completions/installers/shell-quote.js +13 -0
  26. package/dist/core/completions/installers/zsh-installer.js +3 -2
  27. package/dist/core/config-schema.d.ts +1 -1
  28. package/dist/core/config.d.ts +16 -0
  29. package/dist/core/config.js +27 -1
  30. package/dist/core/global-config.d.ts +30 -2
  31. package/dist/core/global-config.js +57 -3
  32. package/dist/core/init.js +13 -3
  33. package/dist/core/legacy-cleanup.d.ts +4 -0
  34. package/dist/core/legacy-cleanup.js +180 -12
  35. package/dist/core/list.js +55 -11
  36. package/dist/core/migration.js +6 -1
  37. package/dist/core/parsers/change-parser.d.ts +12 -1
  38. package/dist/core/parsers/change-parser.js +85 -87
  39. package/dist/core/parsers/markdown-parser.js +4 -3
  40. package/dist/core/parsers/requirement-blocks.d.ts +44 -3
  41. package/dist/core/parsers/requirement-blocks.js +90 -15
  42. package/dist/core/parsers/requirement-text.d.ts +13 -2
  43. package/dist/core/parsers/requirement-text.js +34 -7
  44. package/dist/core/parsers/spec-structure.js +4 -1
  45. package/dist/core/project-config.d.ts +9 -1
  46. package/dist/core/project-config.js +11 -2
  47. package/dist/core/references.d.ts +20 -0
  48. package/dist/core/references.js +56 -0
  49. package/dist/core/shared/skill-content-equivalence.js +5 -1
  50. package/dist/core/shared/skill-generation.js +36 -8
  51. package/dist/core/shared/tool-detection.js +9 -3
  52. package/dist/core/specs-apply.js +82 -11
  53. package/dist/core/store/git.d.ts +33 -0
  54. package/dist/core/store/git.js +60 -6
  55. package/dist/core/store/operations.d.ts +1 -1
  56. package/dist/core/store/operations.js +32 -10
  57. package/dist/core/store/registry.d.ts +2 -1
  58. package/dist/core/store/registry.js +5 -1
  59. package/dist/core/templates/optional-workflow.d.ts +69 -0
  60. package/dist/core/templates/optional-workflow.js +140 -0
  61. package/dist/core/templates/workflows/apply-change.js +18 -3
  62. package/dist/core/templates/workflows/archive-change.js +50 -15
  63. package/dist/core/templates/workflows/bulk-archive-change.js +63 -13
  64. package/dist/core/templates/workflows/continue-change.js +14 -2
  65. package/dist/core/templates/workflows/explore.js +32 -14
  66. package/dist/core/templates/workflows/ff-change.js +19 -3
  67. package/dist/core/templates/workflows/new-change.js +15 -3
  68. package/dist/core/templates/workflows/onboard.js +67 -37
  69. package/dist/core/templates/workflows/project-root.d.ts +29 -0
  70. package/dist/core/templates/workflows/project-root.js +38 -0
  71. package/dist/core/templates/workflows/propose.js +22 -5
  72. package/dist/core/templates/workflows/sync-specs.js +40 -1
  73. package/dist/core/templates/workflows/update-change.js +52 -25
  74. package/dist/core/templates/workflows/verify-change.js +12 -3
  75. package/dist/core/update.d.ts +12 -0
  76. package/dist/core/update.js +58 -3
  77. package/dist/core/validation/task-checkboxes.d.ts +34 -0
  78. package/dist/core/validation/task-checkboxes.js +181 -0
  79. package/dist/core/validation/validator.d.ts +53 -1
  80. package/dist/core/validation/validator.js +154 -40
  81. package/dist/core/version-check.d.ts +14 -7
  82. package/dist/core/version-check.js +63 -17
  83. package/dist/core/worksets.js +6 -4
  84. package/dist/prompts/searchable-multi-select.d.ts +7 -0
  85. package/dist/prompts/searchable-multi-select.js +35 -6
  86. package/dist/telemetry/config.js +14 -2
  87. package/dist/telemetry/index.d.ts +4 -3
  88. package/dist/telemetry/index.js +22 -10
  89. package/dist/telemetry/opt-out.d.ts +17 -0
  90. package/dist/telemetry/opt-out.js +27 -0
  91. package/dist/utils/nested-change.d.ts +35 -0
  92. package/dist/utils/nested-change.js +186 -0
  93. package/dist/utils/spec-discovery.d.ts +20 -0
  94. package/dist/utils/spec-discovery.js +59 -0
  95. package/dist/utils/task-progress.d.ts +1 -1
  96. package/dist/utils/task-progress.js +38 -6
  97. package/package.json +4 -4
  98. package/schemas/spec-driven/schema.yaml +10 -3
  99. package/schemas/spec-driven/templates/design.md +2 -0
  100. package/schemas/spec-driven/templates/proposal.md +2 -0
  101. package/schemas/spec-driven/templates/spec.md +2 -0
  102. package/schemas/spec-driven/templates/tasks.md +2 -0
@@ -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 execFileAsync = promisify(execFile);
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 execFileAsync('git', ['init'], { cwd: storeRoot });
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 execFileAsync('git', ['add', '--', ...pathspecs], { cwd: storeRoot });
78
- await execFileAsync('git', ['commit', '-m', `Initialize OpenSpec store ${id}`, '--', ...pathspecs], { cwd: storeRoot });
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 execFileAsync('git', ['rm', '--cached', '-r', '-f', '-q', '--', ...pathspecs], {
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
- beforeCommit?: (entry: RegisteredStoreEntry) => Promise<void>;
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
- await input.beforeCommit?.(removedEntry);
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"\` (missing artifacts): show message, suggest using \`/opsx:continue\` (if it is not installed, run \`openspec status --change "<name>" --json\` to see the next artifact and \`openspec instructions <artifact-id> --change "<name>" --json\` for how to create it)
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! You can archive this change with \`/opsx:archive\`.
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.',
@@ -1,12 +1,25 @@
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
+ * Archiving must merge delta specs into the main specs; the `sync` workflow is
6
+ * how it normally does that. A profile that selects `archive` gets `sync`
7
+ * injected (see getProfileWorkflows), but an install whose workflow set was
8
+ * read back off disk can still be missing it — in which case the merge has to
9
+ * happen inline rather than be handed to a workflow that is not there.
10
+ */
11
+ const SYNC_INLINE_HANDOFF = optionalWorkflow('sync', 'run the `/opsx:sync` workflow inline (agent-driven intelligent merge)', 'perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)');
12
+ const SYNC_GUARDRAIL = optionalWorkflow('sync', 'run the `/opsx:sync` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)');
2
13
  export function getArchiveChangeSkillTemplate() {
3
14
  return {
4
15
  name: 'openspec-archive-change',
5
- description: 'Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.',
16
+ description: 'Archive a completed OpenSpec change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. Also use when the user says "openspec archive" or "opsx archive".',
6
17
  instructions: `Archive a completed change in the experimental workflow.
7
18
 
8
19
  ${STORE_SELECTION_GUIDANCE}
9
20
 
21
+ ${PROJECT_ROOT_GUARD}
22
+
10
23
  \`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec.
11
24
 
12
25
  **Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
@@ -70,7 +83,11 @@ ${STORE_SELECTION_GUIDANCE}
70
83
 
71
84
  Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.
72
85
 
73
- Count tasks marked with \`- [ ]\` (incomplete) vs \`- [x]\` (complete).
86
+ A checkbox is complete when its only content is \`x\` or \`X\`; spacing inside
87
+ the brackets does not matter, so \`- [ x]\` counts as complete too. Every
88
+ other marker is incomplete - \`- [ ]\`, an empty \`- []\`, and markers OpenSpec
89
+ assigns no meaning to such as \`- [~]\` or \`- [-]\`. Never read an unfamiliar
90
+ marker as complete.
74
91
 
75
92
  **If incomplete tasks found:**
76
93
  - Display warning showing count of incomplete tasks
@@ -88,17 +105,23 @@ ${STORE_SELECTION_GUIDANCE}
88
105
 
89
106
  **If delta specs exist:**
90
107
  - Compare each delta spec with its corresponding main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path)
108
+ - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
109
+ - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
110
+ - Otherwise, if the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
111
+ - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`.
112
+ - Otherwise, count the capability as needing sync and name it in the summary (\`<capability-path>: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does.
91
113
  - Determine what changes would be applied (adds, modifications, removals, renames)
92
- - Show a combined summary before prompting
114
+ - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
93
115
 
94
116
  **Prompt options:**
95
- - If changes needed: "Sync now (recommended)", "Archive without syncing"
96
- - If already synced: "Archive now", "Sync anyway", "Cancel"
117
+ - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
118
+ - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
119
+ - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
97
120
 
98
121
  Route on the answer:
99
122
  - "Cancel" — stop, do not archive
100
123
  - "Archive without syncing" or "Archive now" — proceed to archive
101
- - "Sync now" or "Sync anyway" — sync, then verify (below)
124
+ - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
102
125
  - Anything else — ask again rather than archiving
103
126
 
104
127
  Before a selected sync writes any main spec, run
@@ -112,7 +135,7 @@ ${STORE_SELECTION_GUIDANCE}
112
135
 
113
136
  Then run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
114
137
 
115
- Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
138
+ Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
116
139
  - ADDED requirements present
117
140
  - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
118
141
  - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
@@ -188,6 +211,8 @@ export function getOpsxArchiveCommandTemplate() {
188
211
 
189
212
  ${STORE_SELECTION_GUIDANCE}
190
213
 
214
+ ${PROJECT_ROOT_GUARD}
215
+
191
216
  \`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve the full path from each delta spec when resolving its main spec.
192
217
 
193
218
  **Input**: Optionally specify a change name after \`/opsx:archive\` (e.g., \`/opsx:archive add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
@@ -251,7 +276,11 @@ ${STORE_SELECTION_GUIDANCE}
251
276
 
252
277
  Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.
253
278
 
254
- Count tasks marked with \`- [ ]\` (incomplete) vs \`- [x]\` (complete).
279
+ A checkbox is complete when its only content is \`x\` or \`X\`; spacing inside
280
+ the brackets does not matter, so \`- [ x]\` counts as complete too. Every
281
+ other marker is incomplete - \`- [ ]\`, an empty \`- []\`, and markers OpenSpec
282
+ assigns no meaning to such as \`- [~]\` or \`- [-]\`. Never read an unfamiliar
283
+ marker as complete.
255
284
 
256
285
  **If incomplete tasks found:**
257
286
  - Display warning showing count of incomplete tasks
@@ -269,17 +298,23 @@ ${STORE_SELECTION_GUIDANCE}
269
298
 
270
299
  **If delta specs exist:**
271
300
  - Compare each delta spec with its corresponding main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` (use the store-aware \`planningHome.root\` from step 2, not a hardcoded repo path)
301
+ - A missing main spec is **not automatically** "already synced". For a new capability, the main spec is an *output* of the sync, not an input:
302
+ - If the delta has MODIFIED or RENAMED requirements, report that only ADDED requirements can create a new main spec and mark that capability as sync-blocked. Never invent a requirement that has no current version.
303
+ - Otherwise, if the delta has only REMOVED requirements and the change's \`.openspec.yaml\` declares \`retire_capabilities: true\`, the capability is already retired: count it as already synced, warn that there is nothing left to remove, and do not recreate the main spec. Apply this rule both now and when verifying a completed sync.
304
+ - Otherwise, if the delta has no ADDED requirements, report that no sync is possible and mark that capability as sync-blocked. For a REMOVED-only delta, warn that there is no main spec to remove from and leave the main-spec tree unchanged. \`openspec archive\` refuses the unmarked REMOVED-only case with \`Spec must have at least one requirement\`.
305
+ - Otherwise, count the capability as needing sync and name it in the summary (\`<capability-path>: new main spec will be created\`). If the delta also has REMOVED requirements, warn that they will be ignored because there is no main spec to remove from. The sync creates the main spec from only the delta's ADDED requirements, exactly as \`openspec archive\` does.
272
306
  - Determine what changes would be applied (adds, modifications, removals, renames)
273
- - Show a combined summary before prompting
307
+ - Continue assessing the remaining capabilities even when one is sync-blocked. Show a combined summary before prompting.
274
308
 
275
309
  **Prompt options:**
276
- - If changes needed: "Sync now (recommended)", "Archive without syncing"
277
- - If already synced: "Archive now", "Sync anyway", "Cancel"
310
+ - If any capability is sync-blocked: explain why and offer only "Archive without syncing", "Cancel"
311
+ - Otherwise, if changes needed: "Sync now (recommended)", "Archive without syncing"
312
+ - Otherwise, if already synced: "Archive now", "Sync anyway", "Cancel"
278
313
 
279
314
  Route on the answer:
280
315
  - "Cancel" — stop, do not archive
281
316
  - "Archive without syncing" or "Archive now" — proceed to archive
282
- - "Sync now" or "Sync anyway" — sync, then verify (below)
317
+ - "Sync now" or "Sync anyway" — sync, then verify (below). Do not start any sync while a capability is sync-blocked; explain the blocker and repeat the available choices.
283
318
  - Anything else — ask again rather than archiving
284
319
 
285
320
  Before a selected sync writes any main spec, run
@@ -291,9 +326,9 @@ ${STORE_SELECTION_GUIDANCE}
291
326
  form of main specs produced by this merge; do not use them as archive guidance,
292
327
  change CLI behavior, or copy the rule text into any output file.
293
328
 
294
- Then run the \`/opsx:sync\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
329
+ Then ${SYNC_INLINE_HANDOFF} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
295
330
 
296
- Then re-run the comparison from the top of this step against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
331
+ Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
297
332
  - ADDED requirements present
298
333
  - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
299
334
  - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
@@ -393,7 +428,7 @@ Target archive directory already exists.
393
428
  - Don't block archive on warnings - just inform and confirm
394
429
  - Preserve .openspec.yaml when moving to archive (it moves with the directory)
395
430
  - Show clear summary of what happened
396
- - If sync is requested, run the \`/opsx:sync\` workflow inline (agent-driven)
431
+ - If sync is requested, ${SYNC_GUARDRAIL}
397
432
  - Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\`
398
433
  - If delta specs exist, always run the sync assessment and show the combined summary before prompting
399
434
  - Apply relevant runtime context and report conflicts; operation guidance remains advisory