@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
|
@@ -22,8 +22,21 @@ const HEADER_LINE = /^#{1,6}\s/;
|
|
|
22
22
|
* as a scenario, so the delta counter must too (parity). The delta/loss path
|
|
23
23
|
* reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
|
|
24
24
|
* keep both paths on it rather than reintroducing a separate `Scenario:` regex.
|
|
25
|
+
* A header alone is not yet a scenario on either path: see hasScenarioBody.
|
|
25
26
|
*/
|
|
26
27
|
export const SCENARIO_HEADER = /^####\s+/;
|
|
28
|
+
/** A header at scenario level or above (`#` to `####`): where a scenario body ends. */
|
|
29
|
+
const SCENARIO_BODY_END = /^#{1,4}\s/;
|
|
30
|
+
/**
|
|
31
|
+
* Whether a scenario's body has content. The spec path
|
|
32
|
+
* (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
|
|
33
|
+
* the delta counter must too (parity): otherwise `validate` accepts a
|
|
34
|
+
* requirement whose only scenario is a bare header, and archive rejects it when
|
|
35
|
+
* it validates the rebuilt spec.
|
|
36
|
+
*/
|
|
37
|
+
export function hasScenarioBody(body) {
|
|
38
|
+
return body.trim().length > 0;
|
|
39
|
+
}
|
|
27
40
|
/**
|
|
28
41
|
* The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
|
|
29
42
|
* as whole words so the change-delta reader and the schema-based reader accept
|
|
@@ -82,18 +95,32 @@ export function extractRequirementText(headerTitle, bodyLines) {
|
|
|
82
95
|
}
|
|
83
96
|
/**
|
|
84
97
|
* Count the real scenarios in a requirement block: `#### ` headers on non-fenced
|
|
85
|
-
* lines. A `#### Scenario:` that lives inside a fenced
|
|
86
|
-
* scenario and is not counted.
|
|
98
|
+
* lines whose body has content. A `#### Scenario:` that lives inside a fenced
|
|
99
|
+
* example is not a real scenario and is not counted.
|
|
87
100
|
*/
|
|
88
101
|
export function countScenarios(bodyLines) {
|
|
102
|
+
return readScenarioBodies(bodyLines).filter(hasScenarioBody).length;
|
|
103
|
+
}
|
|
104
|
+
/** Count the `#### ` headers in a requirement block that have no body under them. */
|
|
105
|
+
export function countEmptyScenarios(bodyLines) {
|
|
106
|
+
return readScenarioBodies(bodyLines).filter((body) => !hasScenarioBody(body)).length;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The body of each scenario in a requirement block. A body runs to the next
|
|
110
|
+
* non-fenced header of level 4 or above, the boundary the spec path uses, so a
|
|
111
|
+
* fenced block or a deeper `#####` header is part of it.
|
|
112
|
+
*/
|
|
113
|
+
function readScenarioBodies(bodyLines) {
|
|
89
114
|
const mask = buildCodeFenceMask(bodyLines);
|
|
90
|
-
|
|
115
|
+
const bodies = [];
|
|
91
116
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
92
|
-
if (mask[i])
|
|
117
|
+
if (mask[i] || !SCENARIO_HEADER.test(bodyLines[i]))
|
|
93
118
|
continue;
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
let end = i + 1;
|
|
120
|
+
while (end < bodyLines.length && (mask[end] || !SCENARIO_BODY_END.test(bodyLines[end])))
|
|
121
|
+
end++;
|
|
122
|
+
bodies.push(bodyLines.slice(i + 1, end).join('\n'));
|
|
96
123
|
}
|
|
97
|
-
return
|
|
124
|
+
return bodies;
|
|
98
125
|
}
|
|
99
126
|
//# sourceMappingURL=requirement-text.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildCodeFenceMask } from './code-fence.js';
|
|
2
|
+
import { normalizeRequirementName } from './requirement-blocks.js';
|
|
2
3
|
const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
|
|
3
4
|
const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
|
|
4
5
|
const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
|
|
@@ -53,7 +54,9 @@ export function findMainSpecStructureIssues(content) {
|
|
|
53
54
|
});
|
|
54
55
|
continue;
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
+
// The same name every other reader uses, so a closed heading
|
|
58
|
+
// (`### Requirement: Foo ###`) duplicates `### Requirement: Foo`.
|
|
59
|
+
const requirementName = normalizeRequirementName(requirementMatch[1]);
|
|
57
60
|
const previousLine = requirementLines.get(requirementName);
|
|
58
61
|
if (previousLine !== undefined) {
|
|
59
62
|
issues.push({
|
|
@@ -116,7 +116,10 @@ export declare function resolveConfigFilePath(projectRoot: string): string | nul
|
|
|
116
116
|
/** Human rendering of a malformed pointer reason, shared by every surface. */
|
|
117
117
|
export declare function storePointerProblem(reason: 'unparseable' | 'non_string'): string;
|
|
118
118
|
export interface OpenSpecDirClassification {
|
|
119
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* True when openspec/specs or openspec/changes exists as a directory
|
|
121
|
+
* that is not itself a store root.
|
|
122
|
+
*/
|
|
120
123
|
hasPlanningShape: boolean;
|
|
121
124
|
pointer: StorePointerRead;
|
|
122
125
|
}
|
|
@@ -124,6 +127,11 @@ export interface OpenSpecDirClassification {
|
|
|
124
127
|
* One classification for "real root vs config-only pointer dir", shared
|
|
125
128
|
* by root resolution and the init pointer guard so they can never
|
|
126
129
|
* disagree (slice 3.2).
|
|
130
|
+
*
|
|
131
|
+
* A specs/ or changes/ directory carrying store metadata is a store at
|
|
132
|
+
* the recommended `~/openspec/<id>` layout whose id is `specs` or
|
|
133
|
+
* `changes`, not planning content of the directory above it. Counting it
|
|
134
|
+
* would make $HOME the phantom root the qualifying walk exists to prevent.
|
|
127
135
|
*/
|
|
128
136
|
export declare function classifyOpenSpecDir(projectRoot: string): OpenSpecDirClassification;
|
|
129
137
|
//# sourceMappingURL=project-config.d.ts.map
|
|
@@ -2,6 +2,7 @@ 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';
|
|
5
|
+
import { getStoreMetadataPath } from './store/foundation.js';
|
|
5
6
|
export const OPERATION_IDS = ['apply', 'archive'];
|
|
6
7
|
const OperationConfigSchema = z.object({
|
|
7
8
|
guidance: z.array(z.string()).optional(),
|
|
@@ -455,13 +456,21 @@ export function storePointerProblem(reason) {
|
|
|
455
456
|
* One classification for "real root vs config-only pointer dir", shared
|
|
456
457
|
* by root resolution and the init pointer guard so they can never
|
|
457
458
|
* disagree (slice 3.2).
|
|
459
|
+
*
|
|
460
|
+
* A specs/ or changes/ directory carrying store metadata is a store at
|
|
461
|
+
* the recommended `~/openspec/<id>` layout whose id is `specs` or
|
|
462
|
+
* `changes`, not planning content of the directory above it. Counting it
|
|
463
|
+
* would make $HOME the phantom root the qualifying walk exists to prevent.
|
|
458
464
|
*/
|
|
459
465
|
export function classifyOpenSpecDir(projectRoot) {
|
|
460
466
|
const openspecDir = path.join(projectRoot, 'openspec');
|
|
461
|
-
const hasPlanningShape =
|
|
462
|
-
|
|
467
|
+
const hasPlanningShape = isPlanningDirectorySync(path.join(openspecDir, 'specs')) ||
|
|
468
|
+
isPlanningDirectorySync(path.join(openspecDir, 'changes'));
|
|
463
469
|
return { hasPlanningShape, pointer: readStorePointer(projectRoot) };
|
|
464
470
|
}
|
|
471
|
+
function isPlanningDirectorySync(candidatePath) {
|
|
472
|
+
return isDirectorySync(candidatePath) && !existsSync(getStoreMetadataPath(candidatePath));
|
|
473
|
+
}
|
|
465
474
|
function isDirectorySync(candidatePath) {
|
|
466
475
|
try {
|
|
467
476
|
return statSync(candidatePath).isDirectory();
|
|
@@ -36,6 +36,26 @@ export declare function renderReferencedStoresSection(entries: ReferenceIndexEnt
|
|
|
36
36
|
* let hostile content forge instruction lines (slice 6.1 hardening).
|
|
37
37
|
*/
|
|
38
38
|
export declare function sanitizeInline(value: string, maxLength?: number): string;
|
|
39
|
+
/**
|
|
40
|
+
* Neutralize the envelope's own tags - opening and closing - in repo-supplied
|
|
41
|
+
* text, so it cannot close the block that frames it as data nor forge a new
|
|
42
|
+
* block that carries authority.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately narrow: only this fixed vocabulary is touched. Escaping every
|
|
45
|
+
* angle bracket also works, but it mangles ordinary content for everyone.
|
|
46
|
+
* OpenSpec's own spec-driven schema writes `### Requirement: <name>` and
|
|
47
|
+
* `openspec show "<spec-id>"`; custom templates carry `<details>`; and
|
|
48
|
+
* `context:` routinely holds `R&D`, `pnpm build && pnpm test` or
|
|
49
|
+
* `Result<T, E>`. All of those would reach the agent entity-encoded - a real
|
|
50
|
+
* cost paid by every user, against a threat only these tags can carry.
|
|
51
|
+
*/
|
|
52
|
+
export declare function escapeEnvelopeTags(value: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Attribute values are ids and directory names, never prose, so escaping every
|
|
55
|
+
* metacharacter here costs nothing and stops a quote from closing the
|
|
56
|
+
* attribute and forging siblings on the tag.
|
|
57
|
+
*/
|
|
58
|
+
export declare function escapeEnvelopeAttribute(value: string): string;
|
|
39
59
|
export interface AssembleReferenceIndexInput {
|
|
40
60
|
references: DeclarationEntry[];
|
|
41
61
|
resolvedRoot: ResolvedOpenSpecRoot;
|
package/dist/core/references.js
CHANGED
|
@@ -194,6 +194,62 @@ export function sanitizeInline(value, maxLength = 300) {
|
|
|
194
194
|
const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
|
|
195
195
|
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* The tags the instruction printer uses to frame its blocks. A block ends at
|
|
199
|
+
* its own closing tag and nowhere else, so this is the entire breakout
|
|
200
|
+
* surface: neutralize these and repo-supplied text cannot escape the element
|
|
201
|
+
* that marks it as data.
|
|
202
|
+
*/
|
|
203
|
+
const ENVELOPE_TAGS = [
|
|
204
|
+
'artifact',
|
|
205
|
+
'dependencies',
|
|
206
|
+
'dependency',
|
|
207
|
+
'description',
|
|
208
|
+
'instruction',
|
|
209
|
+
'output',
|
|
210
|
+
'path',
|
|
211
|
+
'project_context',
|
|
212
|
+
'rules',
|
|
213
|
+
'success_criteria',
|
|
214
|
+
'task',
|
|
215
|
+
'template',
|
|
216
|
+
'unlocks',
|
|
217
|
+
'warning',
|
|
218
|
+
];
|
|
219
|
+
// The attribute tail uses `[^<>]` rather than `[^>]` so a run of unterminated
|
|
220
|
+
// `<task ...` openers cannot make each start position scan to end of input,
|
|
221
|
+
// which is how the first version of this escape became quadratic. The separator
|
|
222
|
+
// is `\s`, not a space or tab: XML allows a line break before `>` or an
|
|
223
|
+
// attribute, so a multiline repo value could otherwise split a tag past this.
|
|
224
|
+
const ENVELOPE_TAG = new RegExp(`<(/?)(${ENVELOPE_TAGS.join('|')})(\\s[^<>]*)?>`, 'gi');
|
|
225
|
+
/**
|
|
226
|
+
* Neutralize the envelope's own tags - opening and closing - in repo-supplied
|
|
227
|
+
* text, so it cannot close the block that frames it as data nor forge a new
|
|
228
|
+
* block that carries authority.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately narrow: only this fixed vocabulary is touched. Escaping every
|
|
231
|
+
* angle bracket also works, but it mangles ordinary content for everyone.
|
|
232
|
+
* OpenSpec's own spec-driven schema writes `### Requirement: <name>` and
|
|
233
|
+
* `openspec show "<spec-id>"`; custom templates carry `<details>`; and
|
|
234
|
+
* `context:` routinely holds `R&D`, `pnpm build && pnpm test` or
|
|
235
|
+
* `Result<T, E>`. All of those would reach the agent entity-encoded - a real
|
|
236
|
+
* cost paid by every user, against a threat only these tags can carry.
|
|
237
|
+
*/
|
|
238
|
+
export function escapeEnvelopeTags(value) {
|
|
239
|
+
return value.replace(ENVELOPE_TAG, (_match, slash, tag, attrs) => `<${slash}${tag}${attrs ?? ''}>`);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Attribute values are ids and directory names, never prose, so escaping every
|
|
243
|
+
* metacharacter here costs nothing and stops a quote from closing the
|
|
244
|
+
* attribute and forging siblings on the tag.
|
|
245
|
+
*/
|
|
246
|
+
export function escapeEnvelopeAttribute(value) {
|
|
247
|
+
return value
|
|
248
|
+
.replace(/&/g, '&')
|
|
249
|
+
.replace(/</g, '<')
|
|
250
|
+
.replace(/>/g, '>')
|
|
251
|
+
.replace(/"/g, '"');
|
|
252
|
+
}
|
|
197
253
|
function renderEntryLines(entry) {
|
|
198
254
|
const lines = [];
|
|
199
255
|
if (entry.root !== undefined) {
|
|
@@ -11,7 +11,11 @@ function normalizeGeneratedSkill(content) {
|
|
|
11
11
|
const frontmatter = normalized.match(/^---\n[\s\S]*?\n---(?:\n|$)/)?.[0];
|
|
12
12
|
if (!frontmatter)
|
|
13
13
|
return normalized;
|
|
14
|
-
|
|
14
|
+
// `[ \t]` rather than `\s` so a leading/trailing whitespace run can never
|
|
15
|
+
// cross a newline: an `m`-anchored `\s*` re-scans from every line start,
|
|
16
|
+
// which is quadratic on a whitespace-heavy frontmatter. YAML indentation is
|
|
17
|
+
// spaces and tabs only, so matching is unchanged.
|
|
18
|
+
const versionLine = /^([ \t]*generatedBy:[ \t]*)(?:"([^"\n]+)"|'([^'\n]+)'|([^\s"'#]+))[ \t]*$/m;
|
|
15
19
|
const normalizedFrontmatter = frontmatter.replace(versionLine, (line, prefix, doubleQuoted, singleQuoted, bare) => {
|
|
16
20
|
const version = doubleQuoted ?? singleQuoted ?? bare;
|
|
17
21
|
return version && GENERATED_VERSION.test(version)
|
|
@@ -4,7 +4,24 @@
|
|
|
4
4
|
* Shared utilities for generating skill and command files.
|
|
5
5
|
*/
|
|
6
6
|
import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getUpdateChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOnboardSkillTemplate, getOpsxProposeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxUpdateCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate, getOpsxOnboardCommandTemplate, getOpsxProposeCommandTemplate, } from '../templates/skill-templates.js';
|
|
7
|
+
import { assertWorkflowConditionalsResolved, resolveOptionalWorkflows, } from '../templates/optional-workflow.js';
|
|
8
|
+
import { ALL_WORKFLOWS } from '../profiles.js';
|
|
7
9
|
import { OPENSPEC_CLI_ALLOWED_TOOLS } from './allowed-tools.js';
|
|
10
|
+
/**
|
|
11
|
+
* The workflow set a template body is rendered against.
|
|
12
|
+
*
|
|
13
|
+
* `workflowFilter` is both the list of workflows to install and the set a
|
|
14
|
+
* template may refer to, so resolving optional-workflow conditionals here —
|
|
15
|
+
* the one place every generation path (init, update, migration, the skills.sh
|
|
16
|
+
* distribution) already funnels through — keeps a reference to an uninstalled
|
|
17
|
+
* workflow out of every generated file (#1734, umbrella #919).
|
|
18
|
+
*
|
|
19
|
+
* With no filter, every workflow is installed (that is what an unfiltered call
|
|
20
|
+
* means), so the installed branch is kept.
|
|
21
|
+
*/
|
|
22
|
+
function resolveInstalledWorkflows(workflowFilter) {
|
|
23
|
+
return new Set(workflowFilter ?? ALL_WORKFLOWS);
|
|
24
|
+
}
|
|
8
25
|
/**
|
|
9
26
|
* Gets skill templates with their directory names, optionally filtered by workflow IDs.
|
|
10
27
|
*
|
|
@@ -25,10 +42,15 @@ export function getSkillTemplates(workflowFilter) {
|
|
|
25
42
|
{ template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' },
|
|
26
43
|
{ template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' },
|
|
27
44
|
];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
const installed = resolveInstalledWorkflows(workflowFilter);
|
|
46
|
+
const selected = workflowFilter ? all.filter(entry => installed.has(entry.workflowId)) : all;
|
|
47
|
+
return selected.map(entry => ({
|
|
48
|
+
...entry,
|
|
49
|
+
template: {
|
|
50
|
+
...entry.template,
|
|
51
|
+
instructions: resolveOptionalWorkflows(entry.template.instructions, installed),
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
32
54
|
}
|
|
33
55
|
/**
|
|
34
56
|
* Gets command templates with their IDs, optionally filtered by workflow IDs.
|
|
@@ -50,10 +72,15 @@ export function getCommandTemplates(workflowFilter) {
|
|
|
50
72
|
{ template: getOpsxOnboardCommandTemplate(), id: 'onboard' },
|
|
51
73
|
{ template: getOpsxProposeCommandTemplate(), id: 'propose' },
|
|
52
74
|
];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
const installed = resolveInstalledWorkflows(workflowFilter);
|
|
76
|
+
const selected = workflowFilter ? all.filter(entry => installed.has(entry.id)) : all;
|
|
77
|
+
return selected.map(entry => ({
|
|
78
|
+
...entry,
|
|
79
|
+
template: {
|
|
80
|
+
...entry.template,
|
|
81
|
+
content: resolveOptionalWorkflows(entry.template.content, installed),
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
57
84
|
}
|
|
58
85
|
/**
|
|
59
86
|
* Converts command templates to CommandContent array, optionally filtered by workflow IDs.
|
|
@@ -82,6 +109,7 @@ export function generateSkillContent(template, generatedByVersion, transformInst
|
|
|
82
109
|
const instructions = transformInstructions
|
|
83
110
|
? transformInstructions(template.instructions)
|
|
84
111
|
: template.instructions;
|
|
112
|
+
assertWorkflowConditionalsResolved(instructions, `Skill '${template.name}' was generated without resolving its optional-workflow blocks`);
|
|
85
113
|
return `---
|
|
86
114
|
name: ${template.name}
|
|
87
115
|
description: ${template.description}
|
|
@@ -191,9 +191,15 @@ export function extractGeneratedByVersion(skillFilePath) {
|
|
|
191
191
|
// version: "1.0"
|
|
192
192
|
// generatedBy: "0.23.0"
|
|
193
193
|
// ---
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
// Scanned per line, and with `[ \t]` rather than `\s`, so the leading
|
|
195
|
+
// whitespace run can never cross a newline. A single `m`-anchored `\s*`
|
|
196
|
+
// over the whole file re-scans it from every line start, which is
|
|
197
|
+
// quadratic on a whitespace-heavy SKILL.md.
|
|
198
|
+
for (const line of content.split(/\r\n?|\n/)) {
|
|
199
|
+
const generatedByMatch = line.match(/^[ \t]*generatedBy:[ \t]*["']?([^"'\n]+)["']?[ \t]*$/);
|
|
200
|
+
if (generatedByMatch && generatedByMatch[1]) {
|
|
201
|
+
return generatedByMatch[1].trim();
|
|
202
|
+
}
|
|
197
203
|
}
|
|
198
204
|
return null;
|
|
199
205
|
}
|
|
@@ -102,6 +102,26 @@ export declare function retireSpec(update: SpecUpdate, mainSpecsDir: string, opt
|
|
|
102
102
|
displacedPath?: string;
|
|
103
103
|
}>;
|
|
104
104
|
export declare function finalizeRetiredSpec(target: string, displacedPath: string, mainSpecsDir: string): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Remove now-empty directories from `startDir` upward, never leaving the real
|
|
107
|
+
* `boundaryDir` and never removing that directory itself.
|
|
108
|
+
*
|
|
109
|
+
* The boundary is a parameter rather than the specs root directly so the walk's
|
|
110
|
+
* containment is stated at the call site, where the root it must not escape is
|
|
111
|
+
* the thing being reasoned about.
|
|
112
|
+
*
|
|
113
|
+
* The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
|
|
114
|
+
* a parent that is not the real one is simply re-resolved and rejected. Errors
|
|
115
|
+
* are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
|
|
116
|
+
* file arriving mid-walk must win), and a permissions failure leaves an empty
|
|
117
|
+
* directory behind, which the next successful archive clears.
|
|
118
|
+
*
|
|
119
|
+
* Not race-free: an attacker who can swap an ancestor between the check and the
|
|
120
|
+
* `rmdir` could get an empty directory outside the root removed. Closing that
|
|
121
|
+
* needs fd-relative syscalls Node does not expose, and it requires local write
|
|
122
|
+
* access to `openspec/specs` during an archive.
|
|
123
|
+
*/
|
|
124
|
+
export declare function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void>;
|
|
105
125
|
/**
|
|
106
126
|
* Write an updated spec to disk.
|
|
107
127
|
*/
|
package/dist/core/specs-apply.js
CHANGED
|
@@ -8,13 +8,14 @@ import { promises as fs } from 'fs';
|
|
|
8
8
|
import { randomUUID } from 'crypto';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import chalk from 'chalk';
|
|
11
|
-
import { extractRequirementsSection,
|
|
11
|
+
import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
|
|
12
12
|
import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
|
|
13
13
|
import { buildCodeFenceMask } from './parsers/code-fence.js';
|
|
14
14
|
import { MarkdownParser } from './parsers/markdown-parser.js';
|
|
15
15
|
import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
|
|
16
16
|
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
|
17
17
|
import { FileSystemUtils } from '../utils/file-system.js';
|
|
18
|
+
import { matchLineEnding } from '../utils/line-endings.js';
|
|
18
19
|
function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
19
20
|
const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
|
|
20
21
|
return (relative === '' ||
|
|
@@ -22,7 +23,7 @@ function isLexicallyWithin(allowedDirectory, targetPath) {
|
|
|
22
23
|
!relative.startsWith(`..${path.sep}`) &&
|
|
23
24
|
!path.isAbsolute(relative)));
|
|
24
25
|
}
|
|
25
|
-
function resolveTrustedSpecPath(specsRoot, specPath) {
|
|
26
|
+
function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
|
|
26
27
|
if (!isLexicallyWithin(specsRoot, specPath)) {
|
|
27
28
|
throw new Error(`Path is outside the allowed directory: ${specPath}`);
|
|
28
29
|
}
|
|
@@ -42,6 +43,14 @@ function resolveTrustedSpecPath(specsRoot, specPath) {
|
|
|
42
43
|
// Freeze their canonical location as the trust root so later swaps are
|
|
43
44
|
// rejected while a nested spec.md link still cannot escape.
|
|
44
45
|
const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
|
|
46
|
+
// An external capability link is deliberate and supported (see
|
|
47
|
+
// assertDiscoveredSpecPath), so it is not refused here. What was wrong is
|
|
48
|
+
// that the write was silent: the CLI reported the in-project path while
|
|
49
|
+
// writing somewhere else entirely, so a link swapped underneath a repo
|
|
50
|
+
// left nothing on screen to notice. Name the real destination instead.
|
|
51
|
+
if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
|
|
52
|
+
process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
|
|
53
|
+
}
|
|
45
54
|
const file = path.join(root, path.basename(specPath));
|
|
46
55
|
FileSystemUtils.assertPathWithin(root, file);
|
|
47
56
|
return { root, file };
|
|
@@ -69,7 +78,10 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
|
|
|
69
78
|
for (const { id, specFile } of discovered) {
|
|
70
79
|
const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
|
|
71
80
|
const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
|
|
72
|
-
|
|
81
|
+
// Main specs always live at `<project root>/openspec/specs`, so the
|
|
82
|
+
// project root is the grandparent - a linked capability directory may not
|
|
83
|
+
// leave it.
|
|
84
|
+
const target = resolveTrustedSpecPath(mainSpecsDir, targetFile, path.dirname(path.dirname(mainSpecsDir)));
|
|
73
85
|
// Check if target exists
|
|
74
86
|
let exists = false;
|
|
75
87
|
try {
|
|
@@ -110,6 +122,29 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
110
122
|
// Parse deltas from the change spec file
|
|
111
123
|
const plan = parseDeltaSpec(changeContent);
|
|
112
124
|
const specName = update.id;
|
|
125
|
+
// A FROM:/TO: line that never formed a pair means the RENAMED section does not
|
|
126
|
+
// say what the author meant. Refuse rather than apply the pairing the reader
|
|
127
|
+
// happened to form: with interleaved lines that pairing renames a requirement
|
|
128
|
+
// the delta never named, under a name written for a different one.
|
|
129
|
+
if (plan.unpairedRenames.length > 0) {
|
|
130
|
+
const first = plan.unpairedRenames[0];
|
|
131
|
+
const missing = first.side === 'FROM' ? 'TO' : 'FROM';
|
|
132
|
+
throw new Error(`${specName} validation failed - RENAMED entry on line ${first.line} has no matching ${missing}: ` +
|
|
133
|
+
`for header "### Requirement: ${first.name}". ` +
|
|
134
|
+
`Write each rename as a FROM: line followed immediately by its TO: line.`);
|
|
135
|
+
}
|
|
136
|
+
// A well-formed requirement written outside every delta section is not
|
|
137
|
+
// applied. Say so here as well as in validate: archive is the last point at
|
|
138
|
+
// which the author can still notice, and the block reads exactly like one
|
|
139
|
+
// that would have applied.
|
|
140
|
+
for (const orphan of plan.orphanedRequirements) {
|
|
141
|
+
const where = orphan.section
|
|
142
|
+
? `under "## ${orphan.section}"`
|
|
143
|
+
: 'above the first "## " section';
|
|
144
|
+
warn(`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
|
|
145
|
+
`which is not a delta section, so it was not applied. ` +
|
|
146
|
+
`Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
|
|
147
|
+
}
|
|
113
148
|
// Pre-validate duplicates within sections
|
|
114
149
|
const addedNames = new Set();
|
|
115
150
|
for (const add of plan.added) {
|
|
@@ -287,6 +322,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
287
322
|
if (nameToBlock.has(to)) {
|
|
288
323
|
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
|
|
289
324
|
}
|
|
325
|
+
// A target that differs from another requirement only in case or interior
|
|
326
|
+
// whitespace would leave two copies of one requirement. The source itself
|
|
327
|
+
// is exempt, so a case-only rename of a requirement stays allowed.
|
|
328
|
+
const targetNearMiss = [...nameToBlock.keys()].find((k) => k !== from && foldRequirementName(k) === foldRequirementName(to));
|
|
329
|
+
if (targetNearMiss !== undefined) {
|
|
330
|
+
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${nameToBlock.get(targetNearMiss).name}" already exists and differs only in case or spacing; choose a distinct name`);
|
|
331
|
+
}
|
|
290
332
|
const block = nameToBlock.get(from);
|
|
291
333
|
const newHeader = `### Requirement: ${to}`;
|
|
292
334
|
const rawLines = block.raw.split('\n');
|
|
@@ -342,9 +384,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
342
384
|
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
|
|
343
385
|
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
|
|
344
386
|
}
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
347
|
-
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${
|
|
387
|
+
const scenarioDiff = diffScenarioNames(currentBlock, mod);
|
|
388
|
+
if (scenarioDiff.missing.length > 0) {
|
|
389
|
+
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${scenarioDiff.missing.map(name => `"${name}"`).join(', ')}. ${describeScenarioBalance(scenarioDiff)} Refresh the change spec before archiving to avoid dropping scenarios.`);
|
|
348
390
|
}
|
|
349
391
|
// Identical content means the modification was already synced to the
|
|
350
392
|
// baseline (early-sync pattern) — count only real replacements, so a
|
|
@@ -369,6 +411,15 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
|
|
|
369
411
|
}
|
|
370
412
|
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
|
|
371
413
|
}
|
|
414
|
+
// A name that differs from an existing requirement only in case or
|
|
415
|
+
// interior whitespace is that requirement written again: adding it would
|
|
416
|
+
// leave two contradicting copies in the spec. Like the exact check above,
|
|
417
|
+
// this compares against the spec as it stands after the earlier operations,
|
|
418
|
+
// so a variant of a requirement this delta removed or renamed away is fine.
|
|
419
|
+
const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
|
|
420
|
+
if (nearMiss !== undefined) {
|
|
421
|
+
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${nameToBlock.get(nearMiss).name}" already exists and differs only in case or spacing; use MODIFIED with that exact header to change it, or choose a distinct name`);
|
|
422
|
+
}
|
|
372
423
|
nameToBlock.set(key, add);
|
|
373
424
|
addedApplied++;
|
|
374
425
|
}
|
|
@@ -945,7 +996,7 @@ async function isInsideRealDir(realPath, dir) {
|
|
|
945
996
|
* needs fd-relative syscalls Node does not expose, and it requires local write
|
|
946
997
|
* access to `openspec/specs` during an archive.
|
|
947
998
|
*/
|
|
948
|
-
async function pruneEmptyDirs(startDir, boundaryDir) {
|
|
999
|
+
export async function pruneEmptyDirs(startDir, boundaryDir) {
|
|
949
1000
|
let boundary;
|
|
950
1001
|
try {
|
|
951
1002
|
boundary = await fs.realpath(boundaryDir);
|
|
@@ -990,11 +1041,23 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
990
1041
|
// Create target directory if needed
|
|
991
1042
|
const targetDir = path.dirname(update.target);
|
|
992
1043
|
await fs.mkdir(targetDir, { recursive: true });
|
|
1044
|
+
// The parsers normalize CRLF to LF on read, so `rebuilt` is always LF. Write
|
|
1045
|
+
// it back with the convention the file already used, or a Windows checkout
|
|
1046
|
+
// (core.autocrlf=true) sees every line of the spec change when one
|
|
1047
|
+
// requirement moved. A spec that does not exist yet stays LF.
|
|
1048
|
+
// Only a missing file means "no convention to match". Swallowing every error
|
|
1049
|
+
// would read an existing but unreadable spec as absent and rewrite it as LF.
|
|
1050
|
+
const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
|
|
1051
|
+
if (error.code === 'ENOENT')
|
|
1052
|
+
return undefined;
|
|
1053
|
+
throw error;
|
|
1054
|
+
});
|
|
1055
|
+
const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
|
|
993
1056
|
await options.beforeMutate?.();
|
|
994
1057
|
// Preserve the established in-place write semantics: symlink referents,
|
|
995
1058
|
// hard-linked specs, ACLs, extended attributes, and filesystems without hard
|
|
996
1059
|
// links must continue to behave as they did before capability retirement.
|
|
997
|
-
await fs.writeFile(update.target,
|
|
1060
|
+
await fs.writeFile(update.target, toWrite);
|
|
998
1061
|
if (options.silent)
|
|
999
1062
|
return;
|
|
1000
1063
|
const specName = update.id;
|
|
@@ -1011,15 +1074,36 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
|
|
|
1011
1074
|
/** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
|
|
1012
1075
|
function maskHtmlComments(content) {
|
|
1013
1076
|
const blank = (text) => text.replace(/[^\n]/g, ' ');
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1016
|
-
//
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1077
|
+
// Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
|
|
1078
|
+
// replace re-scans to end of file from every `<!--`, which is quadratic on a
|
|
1079
|
+
// spec dense in comment openers.
|
|
1080
|
+
let out = '';
|
|
1081
|
+
let index = 0;
|
|
1082
|
+
for (;;) {
|
|
1083
|
+
const open = content.indexOf('<!--', index);
|
|
1084
|
+
if (open === -1)
|
|
1085
|
+
return out + content.slice(index);
|
|
1086
|
+
out += content.slice(index, open);
|
|
1087
|
+
// `--!>` is a comment terminator as well as `-->`.
|
|
1088
|
+
let close = -1;
|
|
1089
|
+
for (let i = open + 4; i < content.length; i++) {
|
|
1090
|
+
if (content.startsWith('-->', i)) {
|
|
1091
|
+
close = i + 3;
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
if (content.startsWith('--!>', i)) {
|
|
1095
|
+
close = i + 4;
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// A comment that is never closed runs to end of file, so everything after
|
|
1100
|
+
// it is commented out too. Without this an unterminated `<!--` above a
|
|
1101
|
+
// `## Purpose` left the commented-out header looking real (#1413).
|
|
1102
|
+
if (close === -1)
|
|
1103
|
+
return out + blank(content.slice(open));
|
|
1104
|
+
out += blank(content.slice(open, close));
|
|
1105
|
+
index = close;
|
|
1106
|
+
}
|
|
1023
1107
|
}
|
|
1024
1108
|
/**
|
|
1025
1109
|
* Read the body of a `## Purpose` section, ignoring markdown that only appears
|
package/dist/core/store/git.d.ts
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds every read-only git probe. Without a timeout a wedged network mount, an
|
|
3
|
+
* fsmonitor daemon, or a credential/GPG prompt hangs the CLI forever; without a
|
|
4
|
+
* raised maxBuffer a very large dirty tree makes `git status --porcelain` throw
|
|
5
|
+
* ENOBUFS, which the probes below would otherwise report as "no git facts".
|
|
6
|
+
* A probe writes nothing, so a hard kill is safe.
|
|
7
|
+
*/
|
|
8
|
+
export declare const GIT_EXEC_OPTIONS: {
|
|
9
|
+
readonly encoding: "utf8";
|
|
10
|
+
readonly timeout: 15000;
|
|
11
|
+
readonly killSignal: "SIGKILL";
|
|
12
|
+
readonly maxBuffer: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Writes get their own bounds, and deliberately NOT SIGKILL: git traps SIGTERM
|
|
16
|
+
* to remove `.git/index.lock` on its way out, and a signal it cannot catch
|
|
17
|
+
* leaves that lock behind - every later git command in the user's store then
|
|
18
|
+
* fails with "Another git process seems to be running", including the
|
|
19
|
+
* best-effort unstage below. The timeout is also far longer, because a signed
|
|
20
|
+
* commit can legitimately sit waiting on pinentry or a hardware key.
|
|
21
|
+
*/
|
|
22
|
+
export declare const GIT_WRITE_EXEC_OPTIONS: {
|
|
23
|
+
readonly encoding: "utf8";
|
|
24
|
+
readonly timeout: 120000;
|
|
25
|
+
readonly maxBuffer: number;
|
|
26
|
+
};
|
|
1
27
|
export declare function isGitRepositoryAtRoot(storeRoot: string): Promise<boolean>;
|
|
2
28
|
export declare function initGitRepository(storeRoot: string): Promise<boolean>;
|
|
3
29
|
/**
|
|
@@ -11,6 +37,13 @@ export declare function assertGitCommitIdentity(probeCwd: string): Promise<void>
|
|
|
11
37
|
* Pathspecs may be files or directories.
|
|
12
38
|
*/
|
|
13
39
|
export declare function commitStoreFiles(storeRoot: string, id: string, pathspecs: string[]): Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* A probe that hit a resource limit rather than an ordinary Git answer: the
|
|
42
|
+
* command was killed by the timeout above, or its output exceeded maxBuffer.
|
|
43
|
+
* Both produce the same `null` as "not a repository", so without this the CLI
|
|
44
|
+
* would quietly stop reporting facts it is capable of reporting.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isProbeResourceFailure(error: unknown): boolean;
|
|
14
47
|
export declare function gitHasCommits(storeRoot: string): Promise<boolean | null>;
|
|
15
48
|
export declare function gitHasUncommittedChanges(storeRoot: string): Promise<boolean | null>;
|
|
16
49
|
export declare function gitHasRemote(storeRoot: string): Promise<boolean | null>;
|