freeharness 0.4.1 → 0.4.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 +8 -7
- package/canonical/HARVEST.yaml +25 -14
- package/canonical/PROVENANCE.yaml +1 -1
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +7 -8
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +217 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +9 -2
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
- package/dist/adapters/harness/claude-code.d.ts +19 -3
- package/dist/adapters/harness/claude-code.js +204 -39
- package/dist/adapters/harness/opencode.js +16 -2
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +174 -29
- package/dist/adapters/memory/doctor.js +758 -134
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +22 -0
- package/dist/adapters/memory/paths.js +85 -0
- package/dist/adapters/memory/vendored.d.ts +56 -29
- package/dist/adapters/memory/vendored.js +191 -154
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +182 -68
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +193 -44
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +28 -0
- package/dist/cli/install-facts.js +66 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +247 -70
- package/dist/infra/fsutil.d.ts +31 -3
- package/dist/infra/fsutil.js +69 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +1 -1
- package/provenance/memory.PROVENANCE.yaml +458 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
import * as fs from 'node:fs';
|
|
14
14
|
import * as path from 'node:path';
|
|
15
15
|
import { parse as parseYaml } from 'yaml';
|
|
16
|
-
import { loadChangeContext, generateInstructions, resolveSchema, resolveSchemaForChange, getSchemaDir,
|
|
17
|
-
import {
|
|
16
|
+
import { loadChangeContext, generateInstructions, resolveSchema, resolveSchemaForChange, getSchemaDir, resolveArtifactOutputs, parseTaskLines, METADATA_FILENAME, } from '../../vendor/openspec/engine/index.js';
|
|
17
|
+
import { findUnreadDeltaFilesSync } from '../../vendor/openspec/validation/spec-discovery.js';
|
|
18
|
+
import { escapeEnvelopeAttribute, escapeEnvelopeTags, findMissingTaskCheckboxWarning, planningHomeStub, printJson, sanitizeInline, validateChangeExists, validateSchemaExists, } from './shared.js';
|
|
19
|
+
import { missingCapabilitySpecs } from './proposal-capabilities.js';
|
|
20
|
+
import { missingDeltaSpecsWarning, specsIncompleteWarning, tasksWithoutCheckboxWarning, unknownArtifactIdWarning, unreadDeltaFileWarning, } from './warnings.js';
|
|
18
21
|
const SPECIAL_TARGETS = new Set(['apply', 'archive']);
|
|
19
22
|
export function instructionsHandler(projectRoot, artifactId, options) {
|
|
20
23
|
const changeName = validateChangeExists(options.change, projectRoot);
|
|
@@ -48,7 +51,10 @@ export function instructionsHandler(projectRoot, artifactId, options) {
|
|
|
48
51
|
}
|
|
49
52
|
export function printInstructionsText(instructions, isBlocked) {
|
|
50
53
|
const { artifactId, changeName, schemaName, changeDir, resolvedOutputPath, description, instruction, template, dependencies, unlocks, } = instructions;
|
|
51
|
-
|
|
54
|
+
// a quote in changeName closes the attribute otherwise
|
|
55
|
+
console.log(`<artifact id="${escapeEnvelopeAttribute(artifactId)}"` +
|
|
56
|
+
` change="${escapeEnvelopeAttribute(changeName)}"` +
|
|
57
|
+
` schema="${escapeEnvelopeAttribute(schemaName)}">`);
|
|
52
58
|
console.log();
|
|
53
59
|
if (instructions.skipped) {
|
|
54
60
|
console.log('<warning>');
|
|
@@ -62,13 +68,13 @@ export function printInstructionsText(instructions, isBlocked) {
|
|
|
62
68
|
const missing = dependencies.filter((d) => !d.done).map((d) => d.id);
|
|
63
69
|
console.log('<warning>');
|
|
64
70
|
console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.');
|
|
65
|
-
console.log(`Missing: ${missing.join(', ')}`);
|
|
71
|
+
console.log(`Missing: ${escapeEnvelopeTags(missing.join(', '))}`);
|
|
66
72
|
console.log('</warning>');
|
|
67
73
|
console.log();
|
|
68
74
|
}
|
|
69
75
|
console.log('<task>');
|
|
70
|
-
console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
|
|
71
|
-
console.log(description);
|
|
76
|
+
console.log(`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`);
|
|
77
|
+
console.log(escapeEnvelopeTags(description));
|
|
72
78
|
console.log('</task>');
|
|
73
79
|
console.log();
|
|
74
80
|
if (dependencies.length > 0) {
|
|
@@ -77,50 +83,70 @@ export function printInstructionsText(instructions, isBlocked) {
|
|
|
77
83
|
console.log();
|
|
78
84
|
for (const dep of dependencies) {
|
|
79
85
|
if (dep.skipped) {
|
|
80
|
-
console.log(`<dependency id="${dep.id}" status="skipped">`);
|
|
86
|
+
console.log(`<dependency id="${escapeEnvelopeAttribute(dep.id)}" status="skipped">`);
|
|
81
87
|
console.log(' <description>Skipped: the change declares skip_specs, so this artifact has no files to read.</description>');
|
|
82
88
|
console.log('</dependency>');
|
|
83
89
|
continue;
|
|
84
90
|
}
|
|
85
91
|
const status = dep.done ? 'done' : 'missing';
|
|
86
92
|
const fullPath = path.join(changeDir, dep.path);
|
|
87
|
-
console.log(`<dependency id="${dep.id}" status="${status}">`);
|
|
88
|
-
console.log(` <path>${fullPath}</path>`);
|
|
89
|
-
console.log(` <description>${dep.description}</description>`);
|
|
93
|
+
console.log(`<dependency id="${escapeEnvelopeAttribute(dep.id)}" status="${status}">`);
|
|
94
|
+
console.log(` <path>${escapeEnvelopeTags(fullPath)}</path>`);
|
|
95
|
+
console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
|
|
90
96
|
console.log('</dependency>');
|
|
91
97
|
}
|
|
92
98
|
console.log('</dependencies>');
|
|
93
99
|
console.log();
|
|
94
100
|
}
|
|
95
101
|
console.log('<output>');
|
|
96
|
-
console.log(`Write to: ${resolvedOutputPath}`);
|
|
102
|
+
console.log(`Write to: ${escapeEnvelopeTags(resolvedOutputPath)}`);
|
|
97
103
|
console.log('</output>');
|
|
98
104
|
console.log();
|
|
99
105
|
if (instruction) {
|
|
100
106
|
console.log('<instruction>');
|
|
101
|
-
console.log(instruction.trim());
|
|
107
|
+
console.log(escapeEnvelopeTags(instruction.trim()));
|
|
102
108
|
console.log('</instruction>');
|
|
103
109
|
console.log();
|
|
104
110
|
}
|
|
105
111
|
console.log('<template>');
|
|
106
112
|
console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
|
|
107
|
-
|
|
113
|
+
// Copied verbatim into the output file: only the envelope's own tags are neutralized.
|
|
114
|
+
console.log(escapeEnvelopeTags(template.trim()));
|
|
108
115
|
console.log('</template>');
|
|
109
116
|
console.log();
|
|
110
117
|
if (unlocks.length > 0) {
|
|
111
118
|
console.log('<unlocks>');
|
|
112
|
-
console.log(`Completing this artifact enables: ${unlocks.join(', ')}`);
|
|
119
|
+
console.log(`Completing this artifact enables: ${escapeEnvelopeTags(unlocks.join(', '))}`);
|
|
113
120
|
console.log('</unlocks>');
|
|
114
121
|
console.log();
|
|
115
122
|
}
|
|
116
123
|
console.log('</artifact>');
|
|
117
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Parses one line at a time so each task keeps its own index in the file; the
|
|
127
|
+
* engine reader stays the single definition of what a task line is.
|
|
128
|
+
*/
|
|
129
|
+
export function parseLocatedTasks(content, sourcePath) {
|
|
130
|
+
const tasks = [];
|
|
131
|
+
for (const [index, line] of content.split('\n').entries()) {
|
|
132
|
+
const [task] = parseTaskLines(line);
|
|
133
|
+
if (task)
|
|
134
|
+
tasks.push({ ...task, sourcePath, line: index + 1 });
|
|
135
|
+
}
|
|
136
|
+
return tasks;
|
|
137
|
+
}
|
|
118
138
|
function toTaskItems(parsed) {
|
|
119
139
|
const tasks = [];
|
|
120
140
|
for (const task of parsed) {
|
|
121
141
|
if (task.description.length === 0)
|
|
122
142
|
continue;
|
|
123
|
-
tasks.push({
|
|
143
|
+
tasks.push({
|
|
144
|
+
id: `${tasks.length + 1}`,
|
|
145
|
+
description: task.description,
|
|
146
|
+
done: task.done,
|
|
147
|
+
sourcePath: task.sourcePath,
|
|
148
|
+
line: task.line,
|
|
149
|
+
});
|
|
124
150
|
}
|
|
125
151
|
return tasks;
|
|
126
152
|
}
|
|
@@ -129,22 +155,29 @@ const SPECS_PATH_PREFIX = 'specs/';
|
|
|
129
155
|
function isSpecsArtifactPath(generates) {
|
|
130
156
|
return generates.replace(/^(?:\.\/)+/, '').startsWith(SPECS_PATH_PREFIX);
|
|
131
157
|
}
|
|
158
|
+
/** Empty when the schema writes no specs or the change skips any of them, so neither case warns. */
|
|
159
|
+
function activeSpecArtifacts(artifacts, skippedArtifacts) {
|
|
160
|
+
const specArtifacts = artifacts.filter((artifact) => isSpecsArtifactPath(artifact.generates));
|
|
161
|
+
return specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id)) ? [] : specArtifacts;
|
|
162
|
+
}
|
|
163
|
+
function missingCapabilityWarnings(changeDir) {
|
|
164
|
+
const missing = missingCapabilitySpecs(changeDir).map((capability) => sanitizeInline(capability));
|
|
165
|
+
return missing.length > 0 ? [specsIncompleteWarning(missing)] : [];
|
|
166
|
+
}
|
|
132
167
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* Apply blocks on the first artifact in `requiredArtifactIds` that is not yet
|
|
137
|
-
* complete, so naming only that one hides the rest of the chain: a change
|
|
138
|
-
* holding only a proposal, whose schema chains specs -> design -> tasks,
|
|
139
|
-
* would be told only "Missing artifacts: tasks" - an instruction that, taken
|
|
140
|
-
* literally, skips every artifact tasks itself depends on.
|
|
168
|
+
* The status side of the proposal-coverage check. Unlike apply it also fires
|
|
169
|
+
* with no delta at all, since status has no zero-delta warning to defer to.
|
|
141
170
|
*/
|
|
171
|
+
export function collectSpecsIncompleteWarnings(artifacts, changeDir, skippedArtifacts) {
|
|
172
|
+
return activeSpecArtifacts(artifacts, skippedArtifacts).length > 0 ? missingCapabilityWarnings(changeDir) : [];
|
|
173
|
+
}
|
|
142
174
|
/**
|
|
143
|
-
* Every artifact
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
146
|
-
* the
|
|
147
|
-
*
|
|
175
|
+
* Every artifact in the transitive closure of `apply.requires` still to build
|
|
176
|
+
* (`missing`), in the schema's build order, since naming only the first hop
|
|
177
|
+
* apply blocks on would hide the rest of the chain. Plus a warning for any
|
|
178
|
+
* id in the `requires` chain that names no artifact in the schema: without it,
|
|
179
|
+
* a typo in a schema's `requires` list reads as an empty, satisfied dependency
|
|
180
|
+
* instead of the configuration error it is.
|
|
148
181
|
*/
|
|
149
182
|
function collectMissingPrerequisites(requiredArtifactIds, schema, buildOrder, completed) {
|
|
150
183
|
const byId = new Map(schema.artifacts.map((artifact) => [artifact.id, artifact]));
|
|
@@ -175,9 +208,7 @@ function collectMissingPrerequisites(requiredArtifactIds, schema, buildOrder, co
|
|
|
175
208
|
// real artifact. Pushing it to the end keeps real artifacts in their proper
|
|
176
209
|
// order and leaves the broken one visibly last.
|
|
177
210
|
const missingSorted = [...missing].sort((a, b) => (order.get(a) ?? Number.MAX_SAFE_INTEGER) - (order.get(b) ?? Number.MAX_SAFE_INTEGER));
|
|
178
|
-
const warnings = unknownIds.length > 0
|
|
179
|
-
? [`Schema declares a dependency on an artifact id not defined in this schema: ${unknownIds.join(', ')}.`]
|
|
180
|
-
: [];
|
|
211
|
+
const warnings = unknownIds.length > 0 ? [unknownArtifactIdWarning(unknownIds)] : [];
|
|
181
212
|
return { missing: missingSorted, warnings };
|
|
182
213
|
}
|
|
183
214
|
/**
|
|
@@ -193,18 +224,22 @@ function collectMissingPrerequisites(requiredArtifactIds, schema, buildOrder, co
|
|
|
193
224
|
* required artifact, the absent specs are the next step, not a warning. A
|
|
194
225
|
* schema with no spec-producing artifact, or a change carrying `skip_specs`,
|
|
195
226
|
* never triggers it.
|
|
227
|
+
*
|
|
228
|
+
* A delta file the merge path never reads still satisfies the specs glob,
|
|
229
|
+
* so it reads as written here while validate rejects it. Each one is named.
|
|
230
|
+
* One delta also satisfies the glob, so a capability the proposal lists
|
|
231
|
+
* without its spec.md is named too.
|
|
196
232
|
*/
|
|
197
233
|
function collectApplyWarnings(state, schema, changeDir, changeName, skippedArtifacts) {
|
|
198
234
|
if (state === 'blocked')
|
|
199
235
|
return [];
|
|
200
|
-
const specArtifacts = schema.artifacts
|
|
236
|
+
const specArtifacts = activeSpecArtifacts(schema.artifacts, skippedArtifacts);
|
|
201
237
|
if (specArtifacts.length === 0)
|
|
202
238
|
return [];
|
|
203
|
-
|
|
204
|
-
return [];
|
|
239
|
+
const unreadWarnings = findUnreadDeltaFilesSync(path.join(changeDir, 'specs')).map((file) => unreadDeltaFileWarning(changeName, file));
|
|
205
240
|
const hasDeltas = specArtifacts.some((artifact) => resolveArtifactOutputs(changeDir, artifact.generates).length > 0);
|
|
206
241
|
if (hasDeltas)
|
|
207
|
-
return [];
|
|
242
|
+
return [...unreadWarnings, ...missingCapabilityWarnings(changeDir)];
|
|
208
243
|
const metadataPath = path.join(changeDir, METADATA_FILENAME);
|
|
209
244
|
// Names the artifact this schema actually declares rather than the literal
|
|
210
245
|
// "specs": a schema whose spec-producing artifact is, say, "contracts" would
|
|
@@ -212,11 +247,7 @@ function collectApplyWarnings(state, schema, changeDir, changeName, skippedArtif
|
|
|
212
247
|
// With more than one such artifact there is no single right answer, so the
|
|
213
248
|
// id becomes a placeholder instead of a guess.
|
|
214
249
|
const specTarget = specArtifacts.length === 1 ? specArtifacts[0].id : '<artifact-id>';
|
|
215
|
-
return [
|
|
216
|
-
`This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
|
|
217
|
-
`Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
|
|
218
|
-
`or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
|
|
219
|
-
];
|
|
250
|
+
return [...unreadWarnings, missingDeltaSpecsWarning(changeName, specTarget, metadataPath)];
|
|
220
251
|
}
|
|
221
252
|
function buildApplyInstructions(projectRoot, changeName, schemaName) {
|
|
222
253
|
const context = loadChangeContext(projectRoot, changeName, schemaName);
|
|
@@ -241,13 +272,25 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
|
|
|
241
272
|
if (outputs.length > 0)
|
|
242
273
|
contextFiles[artifact.id] = outputs;
|
|
243
274
|
}
|
|
275
|
+
// tracksFile may be a glob; resolve every concrete file it matches.
|
|
244
276
|
let parsedTasks = [];
|
|
277
|
+
const unavailableTrackingFiles = [];
|
|
245
278
|
let tracksFileExists = false;
|
|
246
279
|
if (tracksFile) {
|
|
247
|
-
const
|
|
248
|
-
tracksFileExists =
|
|
249
|
-
|
|
250
|
-
|
|
280
|
+
const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
|
|
281
|
+
tracksFileExists = tracksPaths.length > 0;
|
|
282
|
+
for (const tracksPath of tracksPaths) {
|
|
283
|
+
try {
|
|
284
|
+
parsedTasks.push(...parseLocatedTasks(fs.readFileSync(tracksPath, 'utf-8'), tracksPath));
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
const code = error?.code;
|
|
288
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
289
|
+
unavailableTrackingFiles.push({
|
|
290
|
+
path: tracksPath,
|
|
291
|
+
reason: code && !message.includes(code) ? `${code}: ${message}` : message,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
251
294
|
}
|
|
252
295
|
}
|
|
253
296
|
const tasks = toTaskItems(parsedTasks);
|
|
@@ -264,13 +307,17 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
|
|
|
264
307
|
state = 'blocked';
|
|
265
308
|
instruction = `The ${path.basename(tracksFile)} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
|
|
266
309
|
}
|
|
310
|
+
else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
|
|
311
|
+
state = 'blocked';
|
|
312
|
+
instruction = 'No readable task descriptions are available.';
|
|
313
|
+
}
|
|
267
314
|
else if (tracksFile && tracksFileExists && tasks.length === 0) {
|
|
268
315
|
state = 'blocked';
|
|
269
316
|
instruction = `The ${path.basename(tracksFile)} file exists but contains no tasks to work on.\nAdd tasks or regenerate it with openspec-continue-change.`;
|
|
270
317
|
}
|
|
271
|
-
else if (tracksFile && remaining === 0 && total > 0) {
|
|
318
|
+
else if (tracksFile && unavailableTrackingFiles.length === 0 && remaining === 0 && total > 0) {
|
|
272
319
|
state = 'all_done';
|
|
273
|
-
instruction = 'All tasks are complete
|
|
320
|
+
instruction = 'All tracked tasks are complete.\nReview or verify the change as appropriate before archiving.';
|
|
274
321
|
}
|
|
275
322
|
else if (!tracksFile) {
|
|
276
323
|
state = 'ready';
|
|
@@ -282,14 +329,23 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
|
|
|
282
329
|
schemaInstruction?.trim() ??
|
|
283
330
|
'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
|
|
284
331
|
}
|
|
332
|
+
if (unavailableTrackingFiles.length > 0) {
|
|
333
|
+
const details = unavailableTrackingFiles.map((file) => `- ${file.path}: ${file.reason}`).join('\n');
|
|
334
|
+
instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${details}`;
|
|
335
|
+
}
|
|
285
336
|
// Computed regardless of state: a "ready" change (apply.requires satisfied)
|
|
286
337
|
// can still be missing the artifacts its own required artifact transitively
|
|
287
338
|
// depends on - the schema's own gate does not require them, so this is the
|
|
288
339
|
// only place that names the rest of the chain.
|
|
289
340
|
const { missing: missingPrerequisites, warnings: unknownIdWarnings } = collectMissingPrerequisites(requiredArtifactIds, schema, context.graph.getBuildOrder(), context.completed);
|
|
341
|
+
// Orthogonal to `state`, and checked even when blocked: a tracks file with
|
|
342
|
+
// no checkbox reads as zero tasks and blocks apply with "contains no tasks
|
|
343
|
+
// to work on", which names the symptom but not the cause (#1774).
|
|
344
|
+
const checkboxWarning = findMissingTaskCheckboxWarning(changeDir, projectRoot);
|
|
290
345
|
const warnings = [
|
|
291
346
|
...unknownIdWarnings,
|
|
292
347
|
...collectApplyWarnings(state, schema, changeDir, changeName, context.skippedArtifacts),
|
|
348
|
+
...(checkboxWarning ? [tasksWithoutCheckboxWarning(checkboxWarning)] : []),
|
|
293
349
|
];
|
|
294
350
|
return {
|
|
295
351
|
changeName,
|
|
@@ -298,6 +354,8 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
|
|
|
298
354
|
contextFiles,
|
|
299
355
|
progress: { total, complete, remaining },
|
|
300
356
|
tasks,
|
|
357
|
+
taskTrackingConfigured: tracksFile !== null,
|
|
358
|
+
...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
|
|
301
359
|
state,
|
|
302
360
|
...(missingArtifacts.length > 0 ? { missingArtifacts } : {}),
|
|
303
361
|
...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
|
|
@@ -346,7 +404,7 @@ function applyInstructions(projectRoot, changeName, options) {
|
|
|
346
404
|
if (instructions.warnings && instructions.warnings.length > 0) {
|
|
347
405
|
console.log('### Warnings');
|
|
348
406
|
for (const warning of instructions.warnings) {
|
|
349
|
-
console.log(`- ${warning}`);
|
|
407
|
+
console.log(`- ${warning.message}`);
|
|
350
408
|
}
|
|
351
409
|
console.log();
|
|
352
410
|
}
|
|
@@ -367,7 +425,8 @@ function applyInstructions(projectRoot, changeName, options) {
|
|
|
367
425
|
if (instructions.tasks.length > 0) {
|
|
368
426
|
console.log('### Tasks');
|
|
369
427
|
for (const task of instructions.tasks) {
|
|
370
|
-
|
|
428
|
+
// Upstream prints no location; a text reader needs it as much as the JSON skill does.
|
|
429
|
+
console.log(`- ${task.done ? '[x]' : '[ ]'} ${task.description} (${task.sourcePath}:${task.line})`);
|
|
371
430
|
}
|
|
372
431
|
console.log();
|
|
373
432
|
}
|
|
@@ -442,14 +501,16 @@ function archiveInstructions(projectRoot, changeName, options) {
|
|
|
442
501
|
console.log(`## Archive Inputs: ${changeName}`);
|
|
443
502
|
console.log();
|
|
444
503
|
if (payload.context) {
|
|
504
|
+
// Verbatim on purpose: escaping '#' would also fire inside fenced code.
|
|
445
505
|
console.log('### Project Context (required instruction input)');
|
|
446
506
|
console.log(payload.context);
|
|
447
507
|
console.log();
|
|
448
508
|
}
|
|
449
509
|
if (payload.operationGuidance) {
|
|
450
510
|
console.log('### Operation Guidance (advisory)');
|
|
511
|
+
// Flattened, never truncated: guidance an agent has to follow in full.
|
|
451
512
|
for (const g of payload.operationGuidance)
|
|
452
|
-
console.log(`- ${g}`);
|
|
513
|
+
console.log(`- ${sanitizeInline(g, Infinity)}`);
|
|
453
514
|
console.log();
|
|
454
515
|
}
|
|
455
516
|
if (!payload.context && !payload.operationGuidance) {
|
|
@@ -2,4 +2,4 @@ export interface ListOptions {
|
|
|
2
2
|
json?: boolean;
|
|
3
3
|
sort?: 'recent' | 'name';
|
|
4
4
|
}
|
|
5
|
-
export declare function listHandler(projectRoot: string, options: ListOptions): Promise<number>;
|
|
5
|
+
export declare function listHandler(projectRoot: string | null, options: ListOptions): Promise<number>;
|
|
@@ -2,24 +2,43 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Reconstructed from src/core/list.ts (changes mode), minus store/root-selection.
|
|
4
4
|
// JSON shape matches upstream: { changes: [{ name, completedTasks, totalTasks,
|
|
5
|
-
// lastModified, status }] }, sorted most-recently-modified first by
|
|
5
|
+
// lastModified, status }], root }, sorted most-recently-modified first by
|
|
6
|
+
// default. Root resolution is the caller's job (bin.ts): a null projectRoot
|
|
7
|
+
// still yields a valid JSON document, `{ changes: [], root: null }`, on exit
|
|
8
|
+
// code 1, and creates nothing on disk, so a caller such as the emitted /opsx
|
|
9
|
+
// workflows can probe for a project without a write.
|
|
6
10
|
import * as fs from 'node:fs';
|
|
7
11
|
import * as path from 'node:path';
|
|
8
12
|
import { getTaskProgressForChange, formatTaskStatus } from '../../vendor/openspec/engine/index.js';
|
|
9
13
|
import { changesDirFor, printableId, printJson } from './shared.js';
|
|
14
|
+
import { describeNestedChange, findNestedChanges } from './nested-change.js';
|
|
15
|
+
import { nestedChangeWarning } from './warnings.js';
|
|
16
|
+
/** A removed-mid-listing or dangling/looping symlink entry: skip it rather than fail the listing. */
|
|
17
|
+
function isUnresolvableEntryError(error) {
|
|
18
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
19
|
+
return false;
|
|
20
|
+
const code = error.code;
|
|
21
|
+
return code === 'ENOENT' || code === 'ELOOP';
|
|
22
|
+
}
|
|
10
23
|
/** Most recent mtime of any file under a directory (recursive). */
|
|
11
24
|
function getLastModified(dirPath) {
|
|
12
25
|
let latest = null;
|
|
13
26
|
const walk = (dir) => {
|
|
14
27
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
15
28
|
const full = path.join(dir, entry.name);
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
try {
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
walk(full);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
const stat = fs.statSync(full);
|
|
35
|
+
if (latest === null || stat.mtime > latest)
|
|
36
|
+
latest = stat.mtime;
|
|
37
|
+
}
|
|
18
38
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
latest = stat.mtime;
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (!isUnresolvableEntryError(error))
|
|
41
|
+
throw error;
|
|
23
42
|
}
|
|
24
43
|
}
|
|
25
44
|
};
|
|
@@ -28,6 +47,14 @@ function getLastModified(dirPath) {
|
|
|
28
47
|
}
|
|
29
48
|
export async function listHandler(projectRoot, options) {
|
|
30
49
|
const { json = false, sort = 'recent' } = options;
|
|
50
|
+
if (!projectRoot) {
|
|
51
|
+
if (json)
|
|
52
|
+
printJson({ changes: [], root: null });
|
|
53
|
+
else
|
|
54
|
+
console.error('No OpenSpec root found. Run this inside a project with an openspec/ directory.');
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
const root = { path: projectRoot };
|
|
31
58
|
const changesDir = changesDirFor(projectRoot);
|
|
32
59
|
let entries;
|
|
33
60
|
try {
|
|
@@ -44,11 +71,13 @@ export async function listHandler(projectRoot, options) {
|
|
|
44
71
|
.map((e) => e.name);
|
|
45
72
|
if (changeDirs.length === 0) {
|
|
46
73
|
if (json)
|
|
47
|
-
printJson({ changes: [] });
|
|
74
|
+
printJson({ changes: [], root });
|
|
48
75
|
else
|
|
49
76
|
console.log('No active changes found.');
|
|
50
77
|
return 0;
|
|
51
78
|
}
|
|
79
|
+
const nestedFindings = findNestedChanges(changesDir, changeDirs);
|
|
80
|
+
const nestedByName = new Map(nestedFindings.map((f) => [f.name, f]));
|
|
52
81
|
const changes = [];
|
|
53
82
|
for (const name of changeDirs) {
|
|
54
83
|
const progress = await getTaskProgressForChange(changesDir, name, projectRoot);
|
|
@@ -59,6 +88,7 @@ export async function listHandler(projectRoot, options) {
|
|
|
59
88
|
openTasks: progress.open,
|
|
60
89
|
dispositionedTasks: progress.dispositioned,
|
|
61
90
|
lastModified: getLastModified(path.join(changesDir, name)),
|
|
91
|
+
...(nestedByName.has(name) ? { nested: nestedByName.get(name).nested } : {}),
|
|
62
92
|
});
|
|
63
93
|
}
|
|
64
94
|
if (sort === 'recent') {
|
|
@@ -75,7 +105,14 @@ export async function listHandler(projectRoot, options) {
|
|
|
75
105
|
totalTasks: c.totalTasks,
|
|
76
106
|
lastModified: c.lastModified.toISOString(),
|
|
77
107
|
status: c.totalTasks === 0 ? 'no-tasks' : c.openTasks === 0 ? 'complete' : 'in-progress',
|
|
108
|
+
...(c.nested ? { nested: c.nested } : {}),
|
|
78
109
|
})),
|
|
110
|
+
...(nestedFindings.length > 0
|
|
111
|
+
? {
|
|
112
|
+
warnings: nestedFindings.map(nestedChangeWarning),
|
|
113
|
+
}
|
|
114
|
+
: {}),
|
|
115
|
+
root,
|
|
79
116
|
});
|
|
80
117
|
return 0;
|
|
81
118
|
}
|
|
@@ -83,13 +120,19 @@ export async function listHandler(projectRoot, options) {
|
|
|
83
120
|
const names = new Map(changes.map((c) => [c.name, printableId(c.name)]));
|
|
84
121
|
const nameWidth = Math.max(...[...names.values()].map((n) => n.length));
|
|
85
122
|
for (const change of changes) {
|
|
86
|
-
const status =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
const status = change.nested
|
|
124
|
+
? 'not a change'
|
|
125
|
+
: formatTaskStatus({
|
|
126
|
+
total: change.totalTasks,
|
|
127
|
+
completed: change.completedTasks,
|
|
128
|
+
open: change.openTasks,
|
|
129
|
+
dispositioned: change.dispositionedTasks,
|
|
130
|
+
});
|
|
92
131
|
console.log(` ${names.get(change.name).padEnd(nameWidth)} ${status}`);
|
|
93
132
|
}
|
|
133
|
+
for (const finding of nestedFindings) {
|
|
134
|
+
console.log();
|
|
135
|
+
console.log(`Warning: ${describeNestedChange(finding)}`);
|
|
136
|
+
}
|
|
94
137
|
return 0;
|
|
95
138
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface NestedChangeFinding {
|
|
2
|
+
name: string;
|
|
3
|
+
nested: string[];
|
|
4
|
+
}
|
|
5
|
+
export declare function findNestedChangesIn(changesDir: string, name: string): NestedChangeFinding | undefined;
|
|
6
|
+
export declare function findNestedChanges(changesDir: string, names: string[]): NestedChangeFinding[];
|
|
7
|
+
export declare function describeNestedChange(finding: NestedChangeFinding): string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Detects `changes/<area>/<name>/` namespace folders wrapping nested change
|
|
2
|
+
// directories, which OpenSpec does not support: a change is a directory
|
|
3
|
+
// directly under `changes/` and nothing else.
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { artifactOutputExists, resolveSchema, resolveSchemaForChange, METADATA_FILENAME, } from '../../vendor/openspec/engine/index.js';
|
|
7
|
+
const CHANGE_ROOT_MARKERS = [METADATA_FILENAME, 'proposal.md', 'tasks.md', 'design.md'];
|
|
8
|
+
const DELTA_SPECS_DIR = 'specs';
|
|
9
|
+
const MAX_NESTING_DEPTH = 3;
|
|
10
|
+
function hasChangeRootMarker(dir) {
|
|
11
|
+
return CHANGE_ROOT_MARKERS.some((marker) => {
|
|
12
|
+
try {
|
|
13
|
+
return fs.statSync(path.join(dir, marker)).isFile();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function hasAnyFileUnder(dir) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.name.startsWith('.'))
|
|
30
|
+
continue;
|
|
31
|
+
if (entry.isFile() || entry.isSymbolicLink())
|
|
32
|
+
return true;
|
|
33
|
+
if (entry.isDirectory() && hasAnyFileUnder(path.join(dir, entry.name)))
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function hasSchemaOutput(dir, projectRoot) {
|
|
39
|
+
try {
|
|
40
|
+
const schema = resolveSchema(resolveSchemaForChange(dir, undefined, projectRoot), projectRoot);
|
|
41
|
+
return schema.artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function looksLikeChange(dir, projectRoot) {
|
|
48
|
+
if (hasChangeRootMarker(dir))
|
|
49
|
+
return true;
|
|
50
|
+
if (hasAnyFileUnder(path.join(dir, DELTA_SPECS_DIR)))
|
|
51
|
+
return true;
|
|
52
|
+
return hasSchemaOutput(dir, projectRoot);
|
|
53
|
+
}
|
|
54
|
+
function hasOwnFile(dir) {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return entries.some((entry) => !entry.name.startsWith('.') && !entry.isDirectory());
|
|
63
|
+
}
|
|
64
|
+
function readSubdirectories(dir) {
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).map((entry) => entry.name);
|
|
73
|
+
}
|
|
74
|
+
function collectNested(dir, prefix, depth, found, projectRoot) {
|
|
75
|
+
if (depth > MAX_NESTING_DEPTH)
|
|
76
|
+
return;
|
|
77
|
+
for (const child of readSubdirectories(dir)) {
|
|
78
|
+
const childPath = path.join(dir, child);
|
|
79
|
+
const id = `${prefix}/${child}`;
|
|
80
|
+
if (looksLikeChange(childPath, projectRoot)) {
|
|
81
|
+
found.push(id);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
collectNested(childPath, id, depth + 1, found, projectRoot);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function findNestedChangesIn(changesDir, name) {
|
|
88
|
+
if (name === 'archive' || name.startsWith('.'))
|
|
89
|
+
return undefined;
|
|
90
|
+
const dir = path.join(changesDir, name);
|
|
91
|
+
const projectRoot = path.resolve(changesDir, '..', '..');
|
|
92
|
+
if (looksLikeChange(dir, projectRoot))
|
|
93
|
+
return undefined;
|
|
94
|
+
if (hasOwnFile(dir))
|
|
95
|
+
return undefined;
|
|
96
|
+
const nested = [];
|
|
97
|
+
collectNested(dir, name, 1, nested, projectRoot);
|
|
98
|
+
if (nested.length === 0)
|
|
99
|
+
return undefined;
|
|
100
|
+
return { name, nested: nested.sort() };
|
|
101
|
+
}
|
|
102
|
+
export function findNestedChanges(changesDir, names) {
|
|
103
|
+
return names
|
|
104
|
+
.map((name) => findNestedChangesIn(changesDir, name))
|
|
105
|
+
.filter((finding) => finding !== undefined);
|
|
106
|
+
}
|
|
107
|
+
function flatten(nestedId) {
|
|
108
|
+
return nestedId.split('/').join('-');
|
|
109
|
+
}
|
|
110
|
+
export function describeNestedChange(finding) {
|
|
111
|
+
const list = finding.nested.map((id) => `openspec/changes/${id}/`).join(', ');
|
|
112
|
+
const example = flatten(finding.nested[0]);
|
|
113
|
+
return (`"${finding.name}" is not a change: it is a folder wrapping ${list}. ` +
|
|
114
|
+
'A change must be a directory directly under openspec/changes/, so those ' +
|
|
115
|
+
'nested directories are invisible to OpenSpec while the folder around them ' +
|
|
116
|
+
'is reported as a change. Nested paths are supported under openspec/specs/ ' +
|
|
117
|
+
`only. Rename each nested change to a flat name (for example "${example}").`);
|
|
118
|
+
}
|
|
@@ -92,7 +92,9 @@ export function newChangeHandler(projectRoot, name, options) {
|
|
|
92
92
|
if (!name)
|
|
93
93
|
throw new Error('Missing required argument <name>');
|
|
94
94
|
validateChangeName(name);
|
|
95
|
-
|
|
95
|
+
// An empty or blank flag value (including a trailing `--schema`) is no schema choice,
|
|
96
|
+
// so it must not reach the lookup as a schema name.
|
|
97
|
+
const schemaName = options.schema?.trim() ? options.schema : DEFAULT_SCHEMA;
|
|
96
98
|
validateSchemaExists(schemaName, projectRoot);
|
|
97
99
|
const openspecDir = path.join(projectRoot, 'openspec');
|
|
98
100
|
const changeDir = path.join(openspecDir, 'changes', name);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names in document order, deduplicated. Only top-level `- ` bullets count, so
|
|
3
|
+
* a sub-bullet or a wrapped description line naming code never reads as a
|
|
4
|
+
* capability; any other unindented label or subheading closes the counted list.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseProposalCapabilities(markdown: string): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Listed capabilities without a `spec.md` under the change's `specs/`. A name
|
|
9
|
+
* that resolves outside `specs/` (an untrusted `../` path) can hold no delta
|
|
10
|
+
* there, so it counts as missing instead of probing the path.
|
|
11
|
+
*/
|
|
12
|
+
export declare function missingCapabilitySpecs(changeDir: string): string[];
|