@fission-ai/openspec 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +24 -3
  2. package/dist/cli/index.d.ts +5 -0
  3. package/dist/cli/index.js +95 -95
  4. package/dist/commands/change.d.ts +5 -1
  5. package/dist/commands/change.js +17 -15
  6. package/dist/commands/config.js +6 -65
  7. package/dist/commands/context.d.ts +3 -0
  8. package/dist/commands/context.js +155 -0
  9. package/dist/commands/doctor.d.ts +8 -0
  10. package/dist/commands/doctor.js +163 -0
  11. package/dist/commands/shared-gather.d.ts +14 -0
  12. package/dist/commands/shared-gather.js +31 -0
  13. package/dist/commands/shared-output.d.ts +18 -0
  14. package/dist/commands/shared-output.js +61 -0
  15. package/dist/commands/show.d.ts +11 -6
  16. package/dist/commands/show.js +70 -25
  17. package/dist/commands/spec.d.ts +5 -1
  18. package/dist/commands/spec.js +15 -4
  19. package/dist/commands/store.d.ts +3 -0
  20. package/dist/commands/store.js +547 -0
  21. package/dist/commands/validate.d.ts +2 -0
  22. package/dist/commands/validate.js +75 -39
  23. package/dist/commands/workflow/index.d.ts +0 -2
  24. package/dist/commands/workflow/index.js +0 -1
  25. package/dist/commands/workflow/instructions.d.ts +11 -1
  26. package/dist/commands/workflow/instructions.js +71 -22
  27. package/dist/commands/workflow/new-change.d.ts +6 -3
  28. package/dist/commands/workflow/new-change.js +42 -77
  29. package/dist/commands/workflow/shared.d.ts +15 -3
  30. package/dist/commands/workflow/shared.js +20 -3
  31. package/dist/commands/workflow/status.d.ts +2 -0
  32. package/dist/commands/workflow/status.js +19 -17
  33. package/dist/commands/workset-input.d.ts +19 -0
  34. package/dist/commands/workset-input.js +112 -0
  35. package/dist/commands/workset-prompts.d.ts +12 -0
  36. package/dist/commands/workset-prompts.js +143 -0
  37. package/dist/commands/workset.d.ts +25 -0
  38. package/dist/commands/workset.js +446 -0
  39. package/dist/core/archive.d.ts +17 -6
  40. package/dist/core/archive.js +209 -56
  41. package/dist/core/artifact-graph/index.d.ts +1 -1
  42. package/dist/core/artifact-graph/instruction-loader.d.ts +18 -13
  43. package/dist/core/artifact-graph/instruction-loader.js +8 -31
  44. package/dist/core/change-metadata/schema.d.ts +1 -0
  45. package/dist/core/change-metadata/schema.js +4 -2
  46. package/dist/core/change-status-policy.d.ts +5 -18
  47. package/dist/core/change-status-policy.js +2 -37
  48. package/dist/core/command-generation/adapters/bob.js +1 -14
  49. package/dist/core/command-generation/adapters/claude.js +1 -14
  50. package/dist/core/command-generation/adapters/cursor.js +1 -14
  51. package/dist/core/command-generation/adapters/pi.js +1 -14
  52. package/dist/core/command-generation/adapters/windsurf.js +1 -14
  53. package/dist/core/command-generation/yaml.d.ts +22 -0
  54. package/dist/core/command-generation/yaml.js +38 -0
  55. package/dist/core/completions/command-registry.js +87 -289
  56. package/dist/core/completions/generators/zsh-generator.js +3 -1
  57. package/dist/core/completions/installers/bash-installer.js +3 -0
  58. package/dist/core/completions/installers/fish-installer.js +8 -0
  59. package/dist/core/completions/installers/powershell-installer.js +30 -2
  60. package/dist/core/completions/installers/zsh-installer.js +3 -0
  61. package/dist/core/completions/shared-flags.d.ts +1 -0
  62. package/dist/core/completions/shared-flags.js +5 -0
  63. package/dist/core/config-schema.d.ts +2 -1
  64. package/dist/core/config-schema.js +26 -0
  65. package/dist/core/file-state.d.ts +36 -0
  66. package/dist/core/file-state.js +112 -0
  67. package/dist/core/global-config.d.ts +2 -0
  68. package/dist/core/id.d.ts +17 -0
  69. package/dist/core/id.js +30 -0
  70. package/dist/core/index.d.ts +3 -3
  71. package/dist/core/index.js +3 -3
  72. package/dist/core/init.js +24 -4
  73. package/dist/core/list.d.ts +2 -0
  74. package/dist/core/list.js +19 -5
  75. package/dist/core/openers.d.ts +77 -0
  76. package/dist/core/openers.js +251 -0
  77. package/dist/core/openspec-root.d.ts +45 -0
  78. package/dist/core/openspec-root.js +192 -0
  79. package/dist/core/planning-home.d.ts +1 -6
  80. package/dist/core/planning-home.js +2 -43
  81. package/dist/core/project-config.d.ts +44 -1
  82. package/dist/core/project-config.js +160 -10
  83. package/dist/core/references.d.ts +63 -0
  84. package/dist/core/references.js +310 -0
  85. package/dist/core/relationship-health.d.ts +65 -0
  86. package/dist/core/relationship-health.js +64 -0
  87. package/dist/core/root-selection.d.ts +122 -0
  88. package/dist/core/root-selection.js +337 -0
  89. package/dist/core/specs-apply.d.ts +6 -1
  90. package/dist/core/specs-apply.js +6 -4
  91. package/dist/core/store/errors.d.ts +20 -0
  92. package/dist/core/{context-store → store}/errors.js +3 -3
  93. package/dist/core/store/foundation.d.ts +56 -0
  94. package/dist/core/store/foundation.js +251 -0
  95. package/dist/core/store/git.d.ts +23 -0
  96. package/dist/core/store/git.js +137 -0
  97. package/dist/core/{context-store → store}/index.d.ts +0 -1
  98. package/dist/core/{context-store → store}/index.js +0 -1
  99. package/dist/core/store/operations.d.ts +114 -0
  100. package/dist/core/store/operations.js +783 -0
  101. package/dist/core/store/registry.d.ts +58 -0
  102. package/dist/core/store/registry.js +275 -0
  103. package/dist/core/templates/workflows/apply-change.js +5 -4
  104. package/dist/core/templates/workflows/archive-change.js +5 -4
  105. package/dist/core/templates/workflows/bulk-archive-change.js +5 -4
  106. package/dist/core/templates/workflows/continue-change.js +7 -2
  107. package/dist/core/templates/workflows/explore.js +5 -0
  108. package/dist/core/templates/workflows/ff-change.js +5 -0
  109. package/dist/core/templates/workflows/new-change.js +5 -0
  110. package/dist/core/templates/workflows/onboard.js +4 -1
  111. package/dist/core/templates/workflows/propose.js +5 -0
  112. package/dist/core/templates/workflows/store-selection.d.ts +8 -0
  113. package/dist/core/templates/workflows/store-selection.js +8 -0
  114. package/dist/core/templates/workflows/sync-specs.js +5 -4
  115. package/dist/core/templates/workflows/verify-change.js +5 -4
  116. package/dist/core/working-set.d.ts +47 -0
  117. package/dist/core/working-set.js +43 -0
  118. package/dist/core/worksets.d.ts +75 -0
  119. package/dist/core/worksets.js +245 -0
  120. package/dist/core/zod-issues.d.ts +4 -0
  121. package/dist/core/zod-issues.js +10 -0
  122. package/dist/utils/change-metadata.d.ts +1 -0
  123. package/dist/utils/change-metadata.js +1 -1
  124. package/dist/utils/change-utils.js +15 -0
  125. package/dist/utils/file-system.js +36 -17
  126. package/package.json +6 -6
  127. package/dist/commands/context-store.d.ts +0 -3
  128. package/dist/commands/context-store.js +0 -475
  129. package/dist/commands/initiative.d.ts +0 -13
  130. package/dist/commands/initiative.js +0 -318
  131. package/dist/commands/workflow/initiative-link.d.ts +0 -24
  132. package/dist/commands/workflow/initiative-link.js +0 -47
  133. package/dist/commands/workflow/set-change.d.ts +0 -13
  134. package/dist/commands/workflow/set-change.js +0 -87
  135. package/dist/commands/workspace/context-status.d.ts +0 -4
  136. package/dist/commands/workspace/context-status.js +0 -59
  137. package/dist/commands/workspace/open-target-selection.d.ts +0 -13
  138. package/dist/commands/workspace/open-target-selection.js +0 -146
  139. package/dist/commands/workspace/open-view.d.ts +0 -62
  140. package/dist/commands/workspace/open-view.js +0 -249
  141. package/dist/commands/workspace/open.d.ts +0 -37
  142. package/dist/commands/workspace/open.js +0 -110
  143. package/dist/commands/workspace/opener-selection.d.ts +0 -11
  144. package/dist/commands/workspace/opener-selection.js +0 -98
  145. package/dist/commands/workspace/operations.d.ts +0 -29
  146. package/dist/commands/workspace/operations.js +0 -543
  147. package/dist/commands/workspace/prompt-theme.d.ts +0 -29
  148. package/dist/commands/workspace/prompt-theme.js +0 -24
  149. package/dist/commands/workspace/registration.d.ts +0 -13
  150. package/dist/commands/workspace/registration.js +0 -84
  151. package/dist/commands/workspace/selection.d.ts +0 -8
  152. package/dist/commands/workspace/selection.js +0 -104
  153. package/dist/commands/workspace/setup-prompts.d.ts +0 -13
  154. package/dist/commands/workspace/setup-prompts.js +0 -121
  155. package/dist/commands/workspace/types.d.ts +0 -103
  156. package/dist/commands/workspace/types.js +0 -36
  157. package/dist/commands/workspace.d.ts +0 -5
  158. package/dist/commands/workspace.js +0 -574
  159. package/dist/core/collections/index.d.ts +0 -3
  160. package/dist/core/collections/index.js +0 -3
  161. package/dist/core/collections/initiatives/collection.d.ts +0 -4
  162. package/dist/core/collections/initiatives/collection.js +0 -17
  163. package/dist/core/collections/initiatives/index.d.ts +0 -6
  164. package/dist/core/collections/initiatives/index.js +0 -6
  165. package/dist/core/collections/initiatives/operations.d.ts +0 -49
  166. package/dist/core/collections/initiatives/operations.js +0 -175
  167. package/dist/core/collections/initiatives/resolution.d.ts +0 -87
  168. package/dist/core/collections/initiatives/resolution.js +0 -374
  169. package/dist/core/collections/initiatives/schema.d.ts +0 -41
  170. package/dist/core/collections/initiatives/schema.js +0 -134
  171. package/dist/core/collections/initiatives/templates.d.ts +0 -12
  172. package/dist/core/collections/initiatives/templates.js +0 -90
  173. package/dist/core/collections/runtime.d.ts +0 -46
  174. package/dist/core/collections/runtime.js +0 -194
  175. package/dist/core/context-store/binding.d.ts +0 -53
  176. package/dist/core/context-store/binding.js +0 -197
  177. package/dist/core/context-store/errors.d.ts +0 -20
  178. package/dist/core/context-store/foundation.d.ts +0 -55
  179. package/dist/core/context-store/foundation.js +0 -321
  180. package/dist/core/context-store/operations.d.ts +0 -85
  181. package/dist/core/context-store/operations.js +0 -528
  182. package/dist/core/context-store/registry.d.ts +0 -45
  183. package/dist/core/context-store/registry.js +0 -229
  184. package/dist/core/workspace/foundation.d.ts +0 -67
  185. package/dist/core/workspace/foundation.js +0 -295
  186. package/dist/core/workspace/index.d.ts +0 -8
  187. package/dist/core/workspace/index.js +0 -8
  188. package/dist/core/workspace/legacy-state.d.ts +0 -28
  189. package/dist/core/workspace/legacy-state.js +0 -200
  190. package/dist/core/workspace/link-input.d.ts +0 -9
  191. package/dist/core/workspace/link-input.js +0 -32
  192. package/dist/core/workspace/open-surface.d.ts +0 -45
  193. package/dist/core/workspace/open-surface.js +0 -215
  194. package/dist/core/workspace/openers.d.ts +0 -21
  195. package/dist/core/workspace/openers.js +0 -124
  196. package/dist/core/workspace/registry.d.ts +0 -24
  197. package/dist/core/workspace/registry.js +0 -146
  198. package/dist/core/workspace/skills.d.ts +0 -57
  199. package/dist/core/workspace/skills.js +0 -334
  200. package/dist/core/workspace/state-io.d.ts +0 -10
  201. package/dist/core/workspace/state-io.js +0 -121
  202. package/schemas/workspace-planning/schema.yaml +0 -72
  203. package/schemas/workspace-planning/templates/design.md +0 -33
  204. package/schemas/workspace-planning/templates/proposal.md +0 -28
  205. package/schemas/workspace-planning/templates/spec.md +0 -9
  206. package/schemas/workspace-planning/templates/tasks.md +0 -15
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'fs';
1
+ import { existsSync, readFileSync, statSync } from 'fs';
2
2
  import path from 'path';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { z } from 'zod';
@@ -34,8 +34,77 @@ export const ProjectConfigSchema = z.object({
34
34
  )
35
35
  .optional()
36
36
  .describe('Per-artifact rules, keyed by artifact ID'),
37
+ // Note: the `references` field (id strings or {id, remote} maps) is
38
+ // deliberately absent here — readProjectConfig parses and normalizes
39
+ // it by hand (see DeclarationEntry below); a schema entry nothing
40
+ // parses would only drift from the real behavior.
41
+ // Optional: the declared default store. Only consulted by root
42
+ // resolution when this openspec/ directory is config-only (no specs/
43
+ // or changes/); a fallback, never an override.
44
+ store: z
45
+ .string()
46
+ .optional()
47
+ .describe('Store id used as the OpenSpec root when no local planning shape exists'),
37
48
  });
38
- const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit
49
+ /**
50
+ * Parser for `references:` declarations: string entries or
51
+ * {id, remote} maps, normalized to DeclarationEntry[]. Dedup keys on
52
+ * id and keeps the first position; the first entry carrying a remote
53
+ * supplies it (a later duplicate fills a missing remote, never
54
+ * overrides). Invalid entries drop with a warning like other resilient
55
+ * fields; returns undefined when the field is absent or normalizes to
56
+ * empty.
57
+ */
58
+ function parseDeclarationList(raw) {
59
+ const fieldName = 'references';
60
+ if (raw === undefined) {
61
+ return undefined;
62
+ }
63
+ if (!Array.isArray(raw)) {
64
+ console.warn(`Invalid '${fieldName}' field in config (must be an array of store ids)`);
65
+ return undefined;
66
+ }
67
+ const byId = new Map();
68
+ let droppedEntries = false;
69
+ let droppedRemotes = false;
70
+ for (const entry of raw) {
71
+ let declaration = null;
72
+ if (typeof entry === 'string') {
73
+ declaration = { id: entry };
74
+ }
75
+ else if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
76
+ const candidate = entry;
77
+ if (typeof candidate.id === 'string') {
78
+ declaration = { id: candidate.id };
79
+ if (typeof candidate.remote === 'string' && candidate.remote.length > 0) {
80
+ declaration.remote = candidate.remote;
81
+ }
82
+ else if (candidate.remote !== undefined) {
83
+ droppedRemotes = true; // remote dropped, id kept
84
+ }
85
+ }
86
+ }
87
+ if (!declaration) {
88
+ droppedEntries = true;
89
+ continue;
90
+ }
91
+ const existing = byId.get(declaration.id);
92
+ if (!existing) {
93
+ byId.set(declaration.id, declaration);
94
+ }
95
+ else if (existing.remote === undefined && declaration.remote !== undefined) {
96
+ existing.remote = declaration.remote;
97
+ }
98
+ }
99
+ if (droppedEntries) {
100
+ console.warn(`Some '${fieldName}' entries are invalid, ignoring them`);
101
+ }
102
+ if (droppedRemotes) {
103
+ console.warn(`Some '${fieldName}' remotes are not non-empty strings; the ids are kept without a clone source`);
104
+ }
105
+ return byId.size > 0 ? [...byId.values()] : undefined;
106
+ }
107
+ export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the references index
39
108
  /**
40
109
  * Read and parse openspec/config.yaml from project root.
41
110
  * Uses resilient parsing - validates each field independently using Zod safeParse.
@@ -56,13 +125,9 @@ const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit
56
125
  * @returns Parsed config or null if file doesn't exist
57
126
  */
58
127
  export function readProjectConfig(projectRoot) {
59
- // Try both .yaml and .yml, prefer .yaml
60
- let configPath = path.join(projectRoot, 'openspec', 'config.yaml');
61
- if (!existsSync(configPath)) {
62
- configPath = path.join(projectRoot, 'openspec', 'config.yml');
63
- if (!existsSync(configPath)) {
64
- return null; // No config is OK
65
- }
128
+ const configPath = resolveConfigFilePath(projectRoot);
129
+ if (configPath === null) {
130
+ return null; // No config is OK
66
131
  }
67
132
  try {
68
133
  const content = readFileSync(configPath, 'utf-8');
@@ -131,14 +196,32 @@ export function readProjectConfig(projectRoot) {
131
196
  console.warn(`Invalid 'rules' field in config (must be object)`);
132
197
  }
133
198
  }
199
+ const references = parseDeclarationList(raw.references);
200
+ if (references) {
201
+ config.references = references;
202
+ }
203
+ // Parse store pointer field: a string, or dropped with a warning.
204
+ // (Root resolution does NOT use this parse — it uses readStorePointer
205
+ // below, which errors on malformed pointers instead of dropping.)
206
+ if (raw.store !== undefined) {
207
+ if (typeof raw.store === 'string') {
208
+ config.store = raw.store;
209
+ }
210
+ else {
211
+ console.warn(`Warning: ignoring invalid store: field in ${configPathForWarnings(projectRoot)} (must be a single store id string).`);
212
+ }
213
+ }
134
214
  // Return partial config even if some fields failed
135
215
  return Object.keys(config).length > 0 ? config : null;
136
216
  }
137
217
  catch (error) {
138
- console.warn(`Failed to parse openspec/config.yaml:`, error);
218
+ console.warn(`Warning: could not parse ${configPathForWarnings(projectRoot)} (${error instanceof Error ? error.message.split('\n')[0] : String(error)}); ignoring it.`);
139
219
  return null;
140
220
  }
141
221
  }
222
+ function configPathForWarnings(projectRoot) {
223
+ return resolveConfigFilePath(projectRoot) ?? path.join(projectRoot, 'openspec', 'config.yaml');
224
+ }
142
225
  /**
143
226
  * Validate artifact IDs in rules against a schema's artifacts.
144
227
  * Called during instruction loading (when schema is known).
@@ -220,4 +303,71 @@ export function suggestSchemas(invalidSchemaName, availableSchemas) {
220
303
  message += `\nFix: Edit openspec/config.yaml and change 'schema: ${invalidSchemaName}' to a valid schema name`;
221
304
  return message;
222
305
  }
306
+ /**
307
+ * Warning-silent targeted read of the `store:` pointer. Used by root
308
+ * resolution (which must not re-emit the resilient parser's field
309
+ * warnings) and by `openspec init`'s pointer guard. Unlike
310
+ * `readProjectConfig`, a malformed value is REPORTED, not dropped —
311
+ * a dropped pointer would silently flip where work lands.
312
+ */
313
+ export function readStorePointer(projectRoot) {
314
+ const configPath = resolveConfigFilePath(projectRoot);
315
+ if (configPath === null) {
316
+ return { filePath: null };
317
+ }
318
+ try {
319
+ const raw = parseYaml(readFileSync(configPath, 'utf-8'));
320
+ // Empty, comments-only, or non-mapping configs carry no pointer;
321
+ // they are imperfect, not malformed (readProjectConfig owns the
322
+ // field warnings for those).
323
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
324
+ return { filePath: configPath };
325
+ }
326
+ const value = raw.store;
327
+ if (value === undefined) {
328
+ return { filePath: configPath };
329
+ }
330
+ if (typeof value === 'string') {
331
+ return { value, filePath: configPath };
332
+ }
333
+ return { malformed: 'non_string', filePath: configPath };
334
+ }
335
+ catch {
336
+ return { malformed: 'unparseable', filePath: configPath };
337
+ }
338
+ }
339
+ /** Shared .yaml/.yml probe used by readProjectConfig and readStorePointer. */
340
+ export function resolveConfigFilePath(projectRoot) {
341
+ const yamlPath = path.join(projectRoot, 'openspec', 'config.yaml');
342
+ if (existsSync(yamlPath)) {
343
+ return yamlPath;
344
+ }
345
+ const ymlPath = path.join(projectRoot, 'openspec', 'config.yml');
346
+ return existsSync(ymlPath) ? ymlPath : null;
347
+ }
348
+ /** Human rendering of a malformed pointer reason, shared by every surface. */
349
+ export function storePointerProblem(reason) {
350
+ return reason === 'unparseable'
351
+ ? 'the config file could not be read as YAML'
352
+ : 'the store key must be a single store id string';
353
+ }
354
+ /**
355
+ * One classification for "real root vs config-only pointer dir", shared
356
+ * by root resolution and the init pointer guard so they can never
357
+ * disagree (slice 3.2).
358
+ */
359
+ export function classifyOpenSpecDir(projectRoot) {
360
+ const openspecDir = path.join(projectRoot, 'openspec');
361
+ const hasPlanningShape = isDirectorySync(path.join(openspecDir, 'specs')) ||
362
+ isDirectorySync(path.join(openspecDir, 'changes'));
363
+ return { hasPlanningShape, pointer: readStorePointer(projectRoot) };
364
+ }
365
+ function isDirectorySync(candidatePath) {
366
+ try {
367
+ return statSync(candidatePath).isDirectory();
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ }
223
373
  //# sourceMappingURL=project-config.js.map
@@ -0,0 +1,63 @@
1
+ import { type StoreDiagnostic } from './store/errors.js';
2
+ import { listStoreRegistryEntries } from './store/foundation.js';
3
+ import { type ResolvedOpenSpecRoot } from './root-selection.js';
4
+ import { type DeclarationEntry } from './project-config.js';
5
+ export interface ReferenceSpecEntry {
6
+ id: string;
7
+ summary: string;
8
+ }
9
+ export interface ReferenceIndexEntry {
10
+ store_id: string;
11
+ root?: string;
12
+ specs?: ReferenceSpecEntry[];
13
+ fetch?: string;
14
+ status: StoreDiagnostic[];
15
+ }
16
+ /**
17
+ * Tolerant first-Purpose-line extraction. parseSpec() throws on specs
18
+ * without Purpose/Requirements sections; the index must never fail on an
19
+ * imperfect upstream spec, so this scans for the heading directly —
20
+ * fence-aware, so `## Purpose` inside a code block never matches, and
21
+ * tolerant of CommonMark closing hashes (`## Purpose ##`).
22
+ */
23
+ export declare function extractFirstPurposeLine(markdown: string): string;
24
+ export declare function fetchRecipe(storeId: string): string;
25
+ /**
26
+ * Pure renderer for the artifact-instructions XML block. Also the byte
27
+ * budget's measuring stick (it is the larger rendering).
28
+ */
29
+ export declare function renderReferencedStoresBlock(entries: ReferenceIndexEntry[]): string;
30
+ /** Pure renderer for the apply-instructions markdown section. */
31
+ export declare function renderReferencedStoresSection(entries: ReferenceIndexEntry[]): string;
32
+ /**
33
+ * Strings rendered into agent guidance can come from cloned content
34
+ * (spec directory names, Purpose lines, config-declared remotes). One
35
+ * line in, one line out: control characters and newlines must never
36
+ * let hostile content forge instruction lines (slice 6.1 hardening).
37
+ */
38
+ export declare function sanitizeInline(value: string, maxLength?: number): string;
39
+ export interface AssembleReferenceIndexInput {
40
+ references: DeclarationEntry[];
41
+ resolvedRoot: ResolvedOpenSpecRoot;
42
+ globalDataDir?: string;
43
+ /**
44
+ * Health mode (3.6): false skips the spec-file reads AND the byte
45
+ * budget — entries carry no `specs`/`fetch` keys at all, and the
46
+ * content-only truncation diagnostic can never appear.
47
+ */
48
+ includeSpecs?: boolean;
49
+ /**
50
+ * Pre-read registry entries (3.6): `[]` = registry empty or absent,
51
+ * `null` = unreadable, undefined = read internally as before.
52
+ * (Mirrors the internal post-read variable — never inject a raw
53
+ * read result: a healthy-absent registry reads as null.)
54
+ */
55
+ registryEntries?: ReturnType<typeof listStoreRegistryEntries> | null;
56
+ }
57
+ /**
58
+ * Builds the referenced-store index. One registry read per call; one
59
+ * level deep (a referenced store's own references are never followed);
60
+ * self-references omitted; every failure degrades to a warning entry.
61
+ */
62
+ export declare function assembleReferenceIndex(input: AssembleReferenceIndexInput): Promise<ReferenceIndexEntry[]>;
63
+ //# sourceMappingURL=references.d.ts.map
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Referenced-store index assembly (slice 3.1).
3
+ *
4
+ * A root's `openspec/config.yaml` may declare `references:` — store ids
5
+ * whose specs the root's work draws on. Instructions output carries an
6
+ * INDEX of those stores' specs (id, one-line summary, fetch recipe via
7
+ * `--store`), built live from the registered checkouts at assembly time.
8
+ * Content is never inlined; root resolution is never affected; problems
9
+ * degrade to `warning` diagnostics instead of failing generation.
10
+ */
11
+ import * as fs from 'node:fs/promises';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { makeStoreDiagnostic } from './store/errors.js';
15
+ import { isValidStoreId, listStoreRegistryEntries, readStoreRegistryState, } from './store/foundation.js';
16
+ import { getStoreRootForBackend } from './store/registry.js';
17
+ import { inspectRegisteredStore } from './root-selection.js';
18
+ import { getSpecIds } from '../utils/item-discovery.js';
19
+ import { FileSystemUtils } from '../utils/file-system.js';
20
+ import { MAX_CONTEXT_SIZE } from './project-config.js';
21
+ /**
22
+ * Shares the project-context cap: the rendered index is prompt material.
23
+ * Measured in UTF-8 bytes against the XML rendering (the larger of the
24
+ * two), entries and diagnostics included; only the truncation warning
25
+ * itself is exempt (no oscillation).
26
+ */
27
+ const MAX_RENDERED_INDEX_SIZE = MAX_CONTEXT_SIZE;
28
+ function warning(code, message, fix) {
29
+ return makeStoreDiagnostic('warning', code, message, { target: 'references', fix });
30
+ }
31
+ /**
32
+ * A remote is rendered into the pasteable clone command only when it is
33
+ * shell-inert: no whitespace, quotes, or metacharacters, and not
34
+ * flag-like (a config-supplied `--upload-pack=...` must never reach a
35
+ * command agents execute verbatim). Anything else falls back to the
36
+ * teammate-checkout wording.
37
+ */
38
+ function isShellSafeRemote(remote) {
39
+ return /^[A-Za-z0-9@:/._~+-]+$/.test(remote) && !remote.startsWith('-');
40
+ }
41
+ function registerFix(id, remote) {
42
+ if (remote && isShellSafeRemote(remote)) {
43
+ // Verbatim-pasteable: absolute home path because tilde never
44
+ // expands outside a shell and agent JSON consumers execute argv.
45
+ // The checkout is quoted (homedirs may contain spaces); the remote
46
+ // is unquoted but gated by isShellSafeRemote above.
47
+ const checkout = path.join(os.homedir(), 'openspec', id);
48
+ // The fix renders on the machine that will paste it: POSIX shells
49
+ // get single quotes; cmd/PowerShell treat single quotes as literal
50
+ // characters, so win32 gets double quotes (valid everywhere).
51
+ const quoted = process.platform === 'win32' ? `"${checkout}"` : `'${checkout}'`;
52
+ return `git clone -- ${remote} ${quoted} && openspec store register ${quoted} --id ${id}`;
53
+ }
54
+ return `Get a checkout from a teammate and run: openspec store register <path> --id ${id}`;
55
+ }
56
+ /**
57
+ * Tolerant first-Purpose-line extraction. parseSpec() throws on specs
58
+ * without Purpose/Requirements sections; the index must never fail on an
59
+ * imperfect upstream spec, so this scans for the heading directly —
60
+ * fence-aware, so `## Purpose` inside a code block never matches, and
61
+ * tolerant of CommonMark closing hashes (`## Purpose ##`).
62
+ */
63
+ export function extractFirstPurposeLine(markdown) {
64
+ const lines = markdown.split(/\r?\n/);
65
+ let inPurpose = false;
66
+ let fenceMarker = null;
67
+ for (const line of lines) {
68
+ // CommonMark: a fence closes only with its own marker kind.
69
+ const fence = line.match(/^\s*(```|~~~)/);
70
+ if (fence) {
71
+ if (fenceMarker === null) {
72
+ fenceMarker = fence[1];
73
+ }
74
+ else if (fence[1] === fenceMarker) {
75
+ fenceMarker = null;
76
+ }
77
+ continue;
78
+ }
79
+ if (fenceMarker !== null) {
80
+ continue;
81
+ }
82
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
83
+ if (heading) {
84
+ if (inPurpose) {
85
+ return '';
86
+ }
87
+ const title = heading[2].replace(/\s+#+\s*$/, '').trim();
88
+ inPurpose = title.toLowerCase() === 'purpose';
89
+ continue;
90
+ }
91
+ if (inPurpose && line.trim().length > 0) {
92
+ return line.trim();
93
+ }
94
+ }
95
+ return '';
96
+ }
97
+ async function collectSpecEntries(referencedRoot) {
98
+ const specIds = await getSpecIds(referencedRoot);
99
+ return Promise.all(specIds.map(async (specId) => {
100
+ let summary = '';
101
+ try {
102
+ const content = await fs.readFile(path.join(referencedRoot, 'openspec', 'specs', specId, 'spec.md'), 'utf-8');
103
+ summary = sanitizeInline(extractFirstPurposeLine(content));
104
+ }
105
+ catch {
106
+ // Unreadable spec file: index the id with an empty summary.
107
+ }
108
+ return { id: specId, summary };
109
+ }));
110
+ }
111
+ export function fetchRecipe(storeId) {
112
+ return `openspec show <spec-id> --type spec --store ${storeId}`;
113
+ }
114
+ function specLine(spec) {
115
+ // Ids are raw directory names from cloned content; summaries are
116
+ // sanitized at index time (collectSpecEntries).
117
+ const id = sanitizeInline(spec.id, 100);
118
+ return spec.summary ? ` - ${id}: ${spec.summary}` : ` - ${id}`;
119
+ }
120
+ /**
121
+ * Pure renderer for the artifact-instructions XML block. Also the byte
122
+ * budget's measuring stick (it is the larger rendering).
123
+ */
124
+ export function renderReferencedStoresBlock(entries) {
125
+ const lines = [
126
+ '<referenced_stores>',
127
+ '<!-- Read-only upstream context. Fetch what you need; cite what you use. -->',
128
+ ];
129
+ for (const entry of entries) {
130
+ lines.push(...renderEntryLines(entry));
131
+ }
132
+ lines.push('</referenced_stores>');
133
+ return lines.join('\n');
134
+ }
135
+ /** Pure renderer for the apply-instructions markdown section. */
136
+ export function renderReferencedStoresSection(entries) {
137
+ const lines = [
138
+ '### Referenced Stores',
139
+ '',
140
+ 'Read-only upstream context. Fetch what you need; cite what you use.',
141
+ '',
142
+ ];
143
+ for (const entry of entries) {
144
+ lines.push(...renderEntryLines(entry));
145
+ }
146
+ return lines.join('\n');
147
+ }
148
+ /**
149
+ * Strings rendered into agent guidance can come from cloned content
150
+ * (spec directory names, Purpose lines, config-declared remotes). One
151
+ * line in, one line out: control characters and newlines must never
152
+ * let hostile content forge instruction lines (slice 6.1 hardening).
153
+ */
154
+ export function sanitizeInline(value, maxLength = 300) {
155
+ // eslint-disable-next-line no-control-regex
156
+ const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
157
+ return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
158
+ }
159
+ function renderEntryLines(entry) {
160
+ const lines = [];
161
+ if (entry.root !== undefined) {
162
+ lines.push(`Store ${entry.store_id} (${entry.root}):`);
163
+ for (const spec of entry.specs ?? []) {
164
+ lines.push(specLine(spec));
165
+ }
166
+ if (entry.fetch) {
167
+ lines.push(` Fetch: ${entry.fetch}`);
168
+ }
169
+ // Diagnostics on a resolved entry (e.g. truncation) render message
170
+ // AND fix — an orphan fix line would hide that the list is partial.
171
+ for (const diagnostic of entry.status) {
172
+ lines.push(` Note: ${diagnostic.message}`);
173
+ if (diagnostic.fix) {
174
+ lines.push(` Fix: ${diagnostic.fix}`);
175
+ }
176
+ }
177
+ }
178
+ else {
179
+ for (const diagnostic of entry.status) {
180
+ lines.push(`Store ${entry.store_id}: ${diagnostic.message}`);
181
+ if (diagnostic.fix) {
182
+ lines.push(` Fix: ${diagnostic.fix}`);
183
+ }
184
+ }
185
+ }
186
+ return lines;
187
+ }
188
+ function renderedByteSize(entries) {
189
+ return Buffer.byteLength(renderReferencedStoresBlock(entries), 'utf-8');
190
+ }
191
+ /**
192
+ * Builds the referenced-store index. One registry read per call; one
193
+ * level deep (a referenced store's own references are never followed);
194
+ * self-references omitted; every failure degrades to a warning entry.
195
+ */
196
+ export async function assembleReferenceIndex(input) {
197
+ const declarations = input.references;
198
+ if (declarations.length === 0) {
199
+ return [];
200
+ }
201
+ // null means the registry itself was unreadable (corrupt file).
202
+ let registryEntries;
203
+ if (input.registryEntries !== undefined) {
204
+ registryEntries = input.registryEntries;
205
+ }
206
+ else {
207
+ try {
208
+ const registry = await readStoreRegistryState(input.globalDataDir ? { globalDataDir: input.globalDataDir } : {});
209
+ registryEntries = registry ? listStoreRegistryEntries(registry) : [];
210
+ }
211
+ catch {
212
+ registryEntries = null;
213
+ }
214
+ }
215
+ const includeSpecs = input.includeSpecs !== false;
216
+ const resolvedRootPath = FileSystemUtils.canonicalizeExistingPath(input.resolvedRoot.path);
217
+ const entries = [];
218
+ for (const { id, remote } of declarations) {
219
+ // Registry-independent checks come first: an invalid id is an
220
+ // invalid id (and a self-reference is omittable) even when the
221
+ // registry is corrupt. The declared remote is only consulted after
222
+ // the id passes grammar.
223
+ if (!isValidStoreId(id)) {
224
+ entries.push({
225
+ store_id: id,
226
+ status: [
227
+ warning('reference_invalid_id', `Reference '${id}' is not a valid store id.`, 'Use kebab-case store ids in the references list.'),
228
+ ],
229
+ });
230
+ continue;
231
+ }
232
+ if (input.resolvedRoot.storeId === id) {
233
+ continue; // Self-reference: meaningless, silently omitted.
234
+ }
235
+ if (registryEntries === null) {
236
+ entries.push({
237
+ store_id: id,
238
+ status: [
239
+ warning('reference_registry_unreadable', `Referenced store '${id}' cannot be checked: the store registry is unreadable.`, 'Run: openspec store doctor'),
240
+ ],
241
+ });
242
+ continue;
243
+ }
244
+ const registryEntry = registryEntries.find((candidate) => candidate.id === id);
245
+ if (!registryEntry) {
246
+ entries.push({
247
+ store_id: id,
248
+ status: [
249
+ warning('reference_unresolved', `Referenced store '${id}' is not registered on this machine.`, registerFix(id, remote)),
250
+ ],
251
+ });
252
+ continue;
253
+ }
254
+ let inspection;
255
+ try {
256
+ const storeRoot = getStoreRootForBackend(registryEntry.backend);
257
+ inspection = await inspectRegisteredStore(id, storeRoot);
258
+ }
259
+ catch (error) {
260
+ inspection = { kind: 'inspection_error', error };
261
+ }
262
+ if (inspection.kind !== 'ok') {
263
+ entries.push({
264
+ store_id: id,
265
+ status: [
266
+ warning('reference_root_unhealthy', `Referenced store '${id}' is registered but not usable (${inspection.kind.replace(/_/g, ' ')}).`, `Run: openspec store doctor ${id}`),
267
+ ],
268
+ });
269
+ continue;
270
+ }
271
+ if (inspection.canonicalRoot === resolvedRootPath) {
272
+ continue; // Self-reference by path: silently omitted.
273
+ }
274
+ if (!includeSpecs) {
275
+ // Health mode: resolution facts only — no content, no budget.
276
+ entries.push({ store_id: id, root: inspection.canonicalRoot, status: [] });
277
+ continue;
278
+ }
279
+ const specs = await collectSpecEntries(inspection.canonicalRoot);
280
+ const entry = {
281
+ store_id: id,
282
+ root: inspection.canonicalRoot,
283
+ specs,
284
+ fetch: fetchRecipe(id),
285
+ status: [],
286
+ };
287
+ // Budget the real rendering: keep the longest spec-list prefix whose
288
+ // full rendered index stays under the cap. The truncation warning
289
+ // itself is exempt (added after the size decision — no oscillation).
290
+ entries.push(entry);
291
+ if (renderedByteSize(entries) > MAX_RENDERED_INDEX_SIZE) {
292
+ let low = 0;
293
+ let high = specs.length;
294
+ while (low < high) {
295
+ const mid = Math.ceil((low + high) / 2);
296
+ entry.specs = specs.slice(0, mid);
297
+ if (renderedByteSize(entries) > MAX_RENDERED_INDEX_SIZE) {
298
+ high = mid - 1;
299
+ }
300
+ else {
301
+ low = mid;
302
+ }
303
+ }
304
+ entry.specs = specs.slice(0, low);
305
+ entry.status.push(warning('reference_index_truncated', `Referenced store '${id}' index truncated at the 50KB budget (${low} of ${specs.length} specs listed).`, `List the rest directly: openspec list --specs --store ${id}`));
306
+ }
307
+ }
308
+ return entries;
309
+ }
310
+ //# sourceMappingURL=references.js.map
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Relationship health composition (slice 3.6).
3
+ *
4
+ * One read-only answer to "are the roots this work relates to available
5
+ * on this machine?" — pure composition over inputs the doctor command
6
+ * gathers. The lock's four categories stay separated: root health,
7
+ * store metadata health, and reference health. Nothing here (or
8
+ * downstream) clones, syncs, or repairs.
9
+ */
10
+ import { type StoreDiagnostic } from './store/errors.js';
11
+ import { type ReferenceIndexEntry } from './references.js';
12
+ import { type ResolvedOpenSpecRoot } from './root-selection.js';
13
+ export interface RelationshipHealth {
14
+ root: {
15
+ path: string;
16
+ source: ResolvedOpenSpecRoot['source'];
17
+ store_id?: string;
18
+ healthy: boolean;
19
+ status: StoreDiagnostic[];
20
+ };
21
+ store: {
22
+ id: string;
23
+ metadata: {
24
+ present: boolean;
25
+ valid: boolean;
26
+ remote?: string;
27
+ };
28
+ origin_url?: string;
29
+ status: StoreDiagnostic[];
30
+ } | null;
31
+ references: ReferenceIndexEntry[];
32
+ status: StoreDiagnostic[];
33
+ }
34
+ export interface InspectRelationshipsInput {
35
+ root: ResolvedOpenSpecRoot;
36
+ rootHealthy: boolean;
37
+ rootStatus?: StoreDiagnostic[];
38
+ /** Store facts for store-backed roots (explicit or declared). */
39
+ storeFacts?: {
40
+ id: string;
41
+ metadataPresent: boolean;
42
+ metadataValid: boolean;
43
+ canonicalRemote?: string;
44
+ originUrl?: string;
45
+ };
46
+ referenceEntries: ReferenceIndexEntry[];
47
+ registryUnreadable: boolean;
48
+ /** A real root whose config also declares a store: pointer (3.2). */
49
+ bothShapesPointer?: {
50
+ value: string;
51
+ filePath: string;
52
+ };
53
+ /** A real root whose store: pointer value is malformed (3.2). */
54
+ malformedPointer?: {
55
+ filePath: string;
56
+ reason: 'unparseable' | 'non_string';
57
+ };
58
+ /** Reference declarations in a pointer directory's own config are inert. */
59
+ inertPointerDeclarations?: {
60
+ filePath: string;
61
+ fields: string[];
62
+ };
63
+ }
64
+ export declare function inspectRelationships(input: InspectRelationshipsInput): RelationshipHealth;
65
+ //# sourceMappingURL=relationship-health.d.ts.map