create-quiver 0.12.1 → 0.13.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.
- package/CHANGELOG.md +8 -0
- package/README.md +16 -8
- package/README_FOR_AI.md +11 -6
- package/ROADMAP.md +9 -2
- package/docs/COMMANDS.md.template +9 -2
- package/package.json +2 -1
- package/specs/quiver-v26-0121-smoke-hardening/SPEC.md +2 -2
- package/specs/quiver-v26-0121-smoke-hardening/STATUS.md +5 -5
- package/specs/quiver-v27-reliability-ai-workflow-hardening/AUDIT_V24_V25_V26.md +67 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/COMMAND_CONTRACTS.md +125 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/COVERAGE_MATRIX.md +74 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/EVIDENCE_REPORT.md +179 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/EXECUTION_PLAN.md +71 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/SPEC.md +176 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/STATUS.md +37 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/pr.md +132 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/CLOSURE_BRIEF.md +36 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/EXECUTION_BRIEF.md +56 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/slice.json +75 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/CLOSURE_BRIEF.md +37 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/EXECUTION_BRIEF.md +54 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/slice.json +79 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/CLOSURE_BRIEF.md +34 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/EXECUTION_BRIEF.md +54 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/slice.json +75 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/CLOSURE_BRIEF.md +36 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/EXECUTION_BRIEF.md +55 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/slice.json +78 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/CLOSURE_BRIEF.md +31 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/EXECUTION_BRIEF.md +55 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/slice.json +77 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/CLOSURE_BRIEF.md +31 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/EXECUTION_BRIEF.md +55 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/slice.json +84 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/CLOSURE_BRIEF.md +32 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/EXECUTION_BRIEF.md +57 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/slice.json +99 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/CLOSURE_BRIEF.md +31 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/EXECUTION_BRIEF.md +57 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/slice.json +88 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/CLOSURE_BRIEF.md +31 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/EXECUTION_BRIEF.md +56 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/slice.json +85 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/CLOSURE_BRIEF.md +32 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/EXECUTION_BRIEF.md +56 -0
- package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/slice.json +91 -0
- package/src/create-quiver/commands/ai.js +84 -9
- package/src/create-quiver/commands/flow.js +52 -4
- package/src/create-quiver/commands/graph.js +7 -7
- package/src/create-quiver/commands/plan.js +6 -15
- package/src/create-quiver/commands/spec.js +282 -0
- package/src/create-quiver/index.js +83 -21
- package/src/create-quiver/lib/agent-profiles.js +15 -3
- package/src/create-quiver/lib/ai/artifacts.js +318 -0
- package/src/create-quiver/lib/ai/execution-plan.js +9 -0
- package/src/create-quiver/lib/ai/executor.js +3 -2
- package/src/create-quiver/lib/ai/export-state.js +242 -97
- package/src/create-quiver/lib/ai/github.js +80 -3
- package/src/create-quiver/lib/ai/plan-review.js +2 -0
- package/src/create-quiver/lib/ai/spec-generator.js +72 -13
- package/src/create-quiver/lib/ai/spec-templates.js +72 -12
- package/src/create-quiver/lib/analyze.js +2 -2
- package/src/create-quiver/lib/approvals.js +14 -2
- package/src/create-quiver/lib/doctor.js +79 -0
- package/src/create-quiver/lib/git.js +40 -1
- package/src/create-quiver/lib/handoff.js +43 -1
- package/src/create-quiver/lib/init-docs.js +11 -7
- package/src/create-quiver/lib/init-layout.js +1 -0
- package/src/create-quiver/lib/lifecycle.js +52 -3
- package/src/create-quiver/lib/locks.js +134 -0
- package/src/create-quiver/lib/package-safety.js +7 -0
- package/src/create-quiver/lib/paths.js +74 -0
- package/src/create-quiver/lib/project-scan.js +74 -0
- package/src/create-quiver/lib/project-state-resolver.js +236 -0
- package/src/create-quiver/lib/readiness.js +48 -7
- package/src/create-quiver/lib/scope.js +2 -1
- package/src/create-quiver/lib/slice.js +8 -4
- package/src/create-quiver/lib/spec-worktrees.js +121 -38
- package/src/create-quiver/lib/statuses.js +115 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
|
|
6
|
+
const { quiverInternalPaths } = require('./init-layout');
|
|
7
|
+
|
|
8
|
+
function formatError(message) {
|
|
9
|
+
return `create-quiver: ${message}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toRelativePosix(root, filePath) {
|
|
13
|
+
return path.relative(root, filePath).split(path.sep).join('/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sanitizeLockName(value) {
|
|
17
|
+
return String(value || '')
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
20
|
+
.replace(/^-+|-+$/g, '') || 'operation';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lockPath(projectRoot, lockName) {
|
|
24
|
+
return path.join(quiverInternalPaths(projectRoot).locksDir, `${sanitizeLockName(lockName)}.lock`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readLock(projectRoot, lockName) {
|
|
28
|
+
const filePath = lockPath(projectRoot, lockName);
|
|
29
|
+
if (!fs.existsSync(filePath)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
34
|
+
} catch {
|
|
35
|
+
return {
|
|
36
|
+
schema_version: 1,
|
|
37
|
+
lock_name: sanitizeLockName(lockName),
|
|
38
|
+
command: 'unknown',
|
|
39
|
+
created_at: 'unknown',
|
|
40
|
+
pid: 'unknown',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function appendUniqueLine(filePath, line) {
|
|
46
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
47
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
48
|
+
const lines = current.split(/\r?\n/);
|
|
49
|
+
if (!lines.includes(line)) {
|
|
50
|
+
const prefix = current.endsWith('\n') || current.length === 0 ? current : `${current}\n`;
|
|
51
|
+
fs.writeFileSync(filePath, `${prefix}${line}\n`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensureQuiverStateIgnored(projectRoot) {
|
|
56
|
+
try {
|
|
57
|
+
const gitDir = execFileSync('git', ['rev-parse', '--absolute-git-dir'], {
|
|
58
|
+
cwd: projectRoot,
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
61
|
+
}).trim();
|
|
62
|
+
if (gitDir) {
|
|
63
|
+
appendUniqueLine(path.join(gitDir, 'info', 'exclude'), '.quiver/');
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Non-git fixtures can still use filesystem locks.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function acquireLock(projectRoot, lockName, options = {}) {
|
|
71
|
+
const filePath = lockPath(projectRoot, lockName);
|
|
72
|
+
const payload = {
|
|
73
|
+
schema_version: 1,
|
|
74
|
+
lock_name: sanitizeLockName(lockName),
|
|
75
|
+
pid: process.pid,
|
|
76
|
+
hostname: os.hostname(),
|
|
77
|
+
command: options.command || 'unknown',
|
|
78
|
+
created_at: (options.now || new Date()).toISOString(),
|
|
79
|
+
metadata: options.metadata || {},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
ensureQuiverStateIgnored(projectRoot);
|
|
83
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx' });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code === 'EEXIST') {
|
|
89
|
+
const existing = readLock(projectRoot, lockName);
|
|
90
|
+
throw new Error(formatError(`operation is locked: ${toRelativePosix(projectRoot, filePath)}\nLock owner: pid=${existing?.pid || 'unknown'} command=${existing?.command || 'unknown'} created_at=${existing?.created_at || 'unknown'}\nIf this process is gone, inspect the lock and remove it intentionally.`));
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
filePath,
|
|
97
|
+
lock: payload,
|
|
98
|
+
lockName: sanitizeLockName(lockName),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function releaseLock(handle) {
|
|
103
|
+
if (handle?.filePath && fs.existsSync(handle.filePath)) {
|
|
104
|
+
fs.rmSync(handle.filePath);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function withLockSync(projectRoot, lockName, options, callback) {
|
|
109
|
+
const handle = acquireLock(projectRoot, lockName, options);
|
|
110
|
+
try {
|
|
111
|
+
return callback(handle);
|
|
112
|
+
} finally {
|
|
113
|
+
releaseLock(handle);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function withLock(projectRoot, lockName, options, callback) {
|
|
118
|
+
const handle = acquireLock(projectRoot, lockName, options);
|
|
119
|
+
try {
|
|
120
|
+
return await callback(handle);
|
|
121
|
+
} finally {
|
|
122
|
+
releaseLock(handle);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
acquireLock,
|
|
128
|
+
lockPath,
|
|
129
|
+
readLock,
|
|
130
|
+
releaseLock,
|
|
131
|
+
sanitizeLockName,
|
|
132
|
+
withLock,
|
|
133
|
+
withLockSync,
|
|
134
|
+
};
|
|
@@ -13,6 +13,12 @@ const SAFETY_RULES = [
|
|
|
13
13
|
return /(^|\/)\.npmrc$/.test(relativePath) || /(^|\/)\.npm(\/|$)/.test(relativePath);
|
|
14
14
|
},
|
|
15
15
|
},
|
|
16
|
+
{
|
|
17
|
+
code: 'ai-raw-artifact',
|
|
18
|
+
match(relativePath) {
|
|
19
|
+
return /(^|\/)\.quiver\/runs\/[^/]+\/raw(\/|$)/.test(relativePath);
|
|
20
|
+
},
|
|
21
|
+
},
|
|
16
22
|
{
|
|
17
23
|
code: 'ai-tool-state',
|
|
18
24
|
match(relativePath) {
|
|
@@ -80,6 +86,7 @@ function collectPackageSafetyViolations(paths) {
|
|
|
80
86
|
code: rule.code,
|
|
81
87
|
path: normalizedPath,
|
|
82
88
|
});
|
|
89
|
+
break;
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
function formatError(message) {
|
|
5
|
+
return `create-quiver: ${message}`;
|
|
6
|
+
}
|
|
2
7
|
|
|
3
8
|
function resolveTargetRoot(cwd, targetDir, pathLib = path) {
|
|
4
9
|
return pathLib.resolve(cwd, targetDir);
|
|
@@ -71,10 +76,79 @@ function specRelativePathFromPath(filePath, pathLib = path) {
|
|
|
71
76
|
return parts.slice(specIndex).join('/');
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
function realpathOrResolve(filePath, pathLib = path) {
|
|
80
|
+
try {
|
|
81
|
+
return pathLib.resolve(fs.realpathSync(filePath));
|
|
82
|
+
} catch {
|
|
83
|
+
return pathLib.resolve(normalizeGitBashDrivePath(filePath, pathLib));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isPathInsideRoot(root, target, pathLib = path) {
|
|
88
|
+
const rootPath = realpathOrResolve(root, pathLib);
|
|
89
|
+
const targetPath = realpathOrResolve(target, pathLib);
|
|
90
|
+
const windowsPath = pathLib === path.win32 || process.platform === 'win32';
|
|
91
|
+
const comparableRoot = windowsPath ? rootPath.toLowerCase() : rootPath;
|
|
92
|
+
const comparableTarget = windowsPath ? targetPath.toLowerCase() : targetPath;
|
|
93
|
+
|
|
94
|
+
if (comparableTarget === comparableRoot) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const relative = pathLib.relative(comparableRoot, comparableTarget);
|
|
99
|
+
return Boolean(relative && !relative.startsWith('..') && !pathLib.isAbsolute(relative));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertPathInsideRoot(root, target, label = 'path', pathLib = path) {
|
|
103
|
+
if (!isPathInsideRoot(root, target, pathLib)) {
|
|
104
|
+
throw new Error(formatError(`${label} must stay inside the project root: ${toPosixPath(target, pathLib)}`));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getProjectRelativePathIssue(filePath, pathLib = path) {
|
|
109
|
+
const original = String(filePath || '').trim();
|
|
110
|
+
if (!original) {
|
|
111
|
+
return 'empty-path';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (/^file:/i.test(original)) {
|
|
115
|
+
return 'file-url';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const normalized = toPosixPath(normalizeGitBashDrivePath(original, pathLib), pathLib);
|
|
119
|
+
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) || pathLib.isAbsolute(original)) {
|
|
120
|
+
return 'absolute-path';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const segments = normalized.split('/').filter(Boolean);
|
|
124
|
+
if (segments.some((segment) => segment === '..')) {
|
|
125
|
+
return 'path-traversal';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateProjectRelativePath(filePath, fieldName = 'path', pathLib = path) {
|
|
132
|
+
const issue = getProjectRelativePathIssue(filePath, pathLib);
|
|
133
|
+
if (issue) {
|
|
134
|
+
throw new Error(formatError(`${fieldName} must be a project-relative path without traversal (got ${String(filePath || '<empty>')}; issue=${issue}).`));
|
|
135
|
+
}
|
|
136
|
+
return toPosixPath(normalizeGitBashDrivePath(String(filePath).trim(), pathLib), pathLib);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateProjectRelativePaths(paths, fieldName = 'paths', pathLib = path) {
|
|
140
|
+
return (Array.isArray(paths) ? paths : []).map((filePath) => validateProjectRelativePath(filePath, fieldName, pathLib));
|
|
141
|
+
}
|
|
142
|
+
|
|
74
143
|
module.exports = {
|
|
144
|
+
assertPathInsideRoot,
|
|
145
|
+
getProjectRelativePathIssue,
|
|
146
|
+
isPathInsideRoot,
|
|
75
147
|
normalizeGitBashDrivePath,
|
|
76
148
|
relativePosixPath,
|
|
77
149
|
resolveTargetRoot,
|
|
78
150
|
specRelativePathFromPath,
|
|
79
151
|
toPosixPath,
|
|
152
|
+
validateProjectRelativePath,
|
|
153
|
+
validateProjectRelativePaths,
|
|
80
154
|
};
|
|
@@ -49,6 +49,79 @@ function readProjectScanArtifact(projectRoot) {
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function statIso(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.statSync(filePath).mtime.toISOString();
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readProjectScanStatus(projectRoot) {
|
|
61
|
+
const { currentScanPath, legacyScanPath, projectMapPath } = projectScanPaths(projectRoot);
|
|
62
|
+
const projectMapExists = fs.existsSync(projectMapPath);
|
|
63
|
+
let artifact = null;
|
|
64
|
+
let artifactError = '';
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
artifact = readProjectScanArtifact(projectRoot);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
artifactError = error.message;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const scanPath = artifact?.path || (fs.existsSync(currentScanPath) ? currentScanPath : fs.existsSync(legacyScanPath) ? legacyScanPath : '');
|
|
73
|
+
const source = artifact?.source || (artifactError ? 'invalid' : 'missing');
|
|
74
|
+
const scanUpdatedAt = scanPath ? statIso(scanPath) : null;
|
|
75
|
+
const projectMapUpdatedAt = projectMapExists ? statIso(projectMapPath) : null;
|
|
76
|
+
const stale = Boolean(
|
|
77
|
+
scanUpdatedAt
|
|
78
|
+
&& projectMapUpdatedAt
|
|
79
|
+
&& Date.parse(projectMapUpdatedAt) + 1000 < Date.parse(scanUpdatedAt),
|
|
80
|
+
);
|
|
81
|
+
let status = 'missing';
|
|
82
|
+
|
|
83
|
+
if (artifactError) {
|
|
84
|
+
status = 'invalid';
|
|
85
|
+
} else if (artifact && projectMapExists && stale) {
|
|
86
|
+
status = 'stale';
|
|
87
|
+
} else if (artifact && projectMapExists && source === 'current') {
|
|
88
|
+
status = 'fresh';
|
|
89
|
+
} else if (artifact && projectMapExists && source === 'legacy') {
|
|
90
|
+
status = 'legacy';
|
|
91
|
+
} else if (artifact || projectMapExists) {
|
|
92
|
+
status = 'partial';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let summary;
|
|
96
|
+
if (status === 'fresh') {
|
|
97
|
+
summary = `${artifact.relativePath} (current, updated ${scanUpdatedAt})`;
|
|
98
|
+
} else if (status === 'legacy') {
|
|
99
|
+
summary = `${artifact.relativePath} (legacy scan, updated ${scanUpdatedAt})`;
|
|
100
|
+
} else if (status === 'stale') {
|
|
101
|
+
summary = `${artifact.relativePath} newer than docs/PROJECT_MAP.md; run analyze to refresh visible context`;
|
|
102
|
+
} else if (status === 'partial' && artifact && !projectMapExists) {
|
|
103
|
+
summary = `${artifact.relativePath} exists but docs/PROJECT_MAP.md is missing`;
|
|
104
|
+
} else if (status === 'partial' && !artifact && projectMapExists) {
|
|
105
|
+
summary = `docs/PROJECT_MAP.md exists but no scan artifact was found`;
|
|
106
|
+
} else if (status === 'invalid') {
|
|
107
|
+
summary = `scan artifact is invalid: ${artifactError}`;
|
|
108
|
+
} else {
|
|
109
|
+
summary = 'missing analysis artifacts; run npx create-quiver analyze';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
artifactPath: artifact?.relativePath || (scanPath ? toRelativeScanPath(projectRoot, scanPath) : null),
|
|
114
|
+
error: artifactError || null,
|
|
115
|
+
projectMapPath: projectMapExists ? PROJECT_MAP_RELATIVE_PATH : null,
|
|
116
|
+
projectMapUpdatedAt,
|
|
117
|
+
scanUpdatedAt,
|
|
118
|
+
source,
|
|
119
|
+
status,
|
|
120
|
+
stale,
|
|
121
|
+
summary,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
52
125
|
function hasProjectScanArtifact(projectRoot) {
|
|
53
126
|
const { currentScanPath, legacyScanPath } = projectScanPaths(projectRoot);
|
|
54
127
|
return fs.existsSync(currentScanPath) || fs.existsSync(legacyScanPath);
|
|
@@ -61,6 +134,7 @@ module.exports = {
|
|
|
61
134
|
hasProjectScanArtifact,
|
|
62
135
|
projectScanPaths,
|
|
63
136
|
readProjectScanArtifact,
|
|
137
|
+
readProjectScanStatus,
|
|
64
138
|
toRelativeScanPath,
|
|
65
139
|
writeProjectScanJson,
|
|
66
140
|
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SliceGraphError,
|
|
5
|
+
buildGraph,
|
|
6
|
+
computeLevels,
|
|
7
|
+
detectFileConflicts,
|
|
8
|
+
inferDependencies,
|
|
9
|
+
readAllSlices,
|
|
10
|
+
readSlicesForSpec,
|
|
11
|
+
topoSort,
|
|
12
|
+
} = require('./slice-graph');
|
|
13
|
+
const {
|
|
14
|
+
CANONICAL_STATUSES,
|
|
15
|
+
isBlockedStatus,
|
|
16
|
+
isCompletedStatus,
|
|
17
|
+
normalizeStatus,
|
|
18
|
+
} = require('./statuses');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SLICE_STATUS = 'planned';
|
|
21
|
+
const DEFAULT_SPEC_STATUS = 'planned';
|
|
22
|
+
const DEFAULT_RUN_STATUS = 'draft';
|
|
23
|
+
const DEFAULT_AGENT_STATUS = 'idle';
|
|
24
|
+
|
|
25
|
+
const CLOSED_SLICE_STATUSES = new Set(['completed', 'skipped']);
|
|
26
|
+
const HISTORY_CLOSED_SLICE_STATUSES = new Set(['skipped']);
|
|
27
|
+
|
|
28
|
+
function toPosix(relativePath) {
|
|
29
|
+
return String(relativePath || '').split(path.sep).join('/');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareRefs(left, right) {
|
|
33
|
+
return String(left || '').localeCompare(String(right || ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeSliceRecord(slice) {
|
|
37
|
+
const rawStatus = String(slice?.status || slice?.json?.status || 'draft').trim() || 'draft';
|
|
38
|
+
const canonicalStatus = normalizeStatus('slice', rawStatus, DEFAULT_SLICE_STATUS);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
...slice,
|
|
42
|
+
raw_status: rawStatus,
|
|
43
|
+
canonical_status: canonicalStatus,
|
|
44
|
+
status: rawStatus,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readResolverSlices(projectRoot, specSlug = '') {
|
|
49
|
+
const targetSpec = String(specSlug || '').trim();
|
|
50
|
+
const slices = targetSpec ? readSlicesForSpec(projectRoot, targetSpec) : readAllSlices(projectRoot);
|
|
51
|
+
return slices.map(normalizeSliceRecord);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeBuildGraph(slices, allowGraphErrors) {
|
|
55
|
+
try {
|
|
56
|
+
const graph = buildGraph(slices);
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
nodes: graph.nodes.map(normalizeSliceRecord),
|
|
60
|
+
edges: graph.edges,
|
|
61
|
+
cycles: graph.cycles,
|
|
62
|
+
error: null,
|
|
63
|
+
};
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (!allowGraphErrors || !(error instanceof SliceGraphError)) {
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
nodes: inferDependencies(slices).map(normalizeSliceRecord),
|
|
72
|
+
edges: [],
|
|
73
|
+
cycles: [],
|
|
74
|
+
error: {
|
|
75
|
+
code: error.code,
|
|
76
|
+
message: error.message,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveProjectState(projectRoot, options = {}) {
|
|
83
|
+
const specSlug = options.specSlug ? String(options.specSlug).trim() : '';
|
|
84
|
+
const rawSlices = readResolverSlices(projectRoot, specSlug);
|
|
85
|
+
const graph = safeBuildGraph(rawSlices, options.allowGraphErrors === true);
|
|
86
|
+
const orderedSlices = graph.ok ? topoSort(graph).map(normalizeSliceRecord) : graph.nodes.slice().sort((left, right) => compareRefs(left.ref, right.ref));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
graph,
|
|
90
|
+
orderedSlices,
|
|
91
|
+
projectRoot,
|
|
92
|
+
rawSlices,
|
|
93
|
+
specSlug,
|
|
94
|
+
specs: groupSlicesBySpec(graph.nodes),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function filterSlicesForExecution(slices, options = {}) {
|
|
99
|
+
const includeCompleted = options.includeCompleted === true;
|
|
100
|
+
const excluded = includeCompleted ? HISTORY_CLOSED_SLICE_STATUSES : CLOSED_SLICE_STATUSES;
|
|
101
|
+
|
|
102
|
+
return (Array.isArray(slices) ? slices : [])
|
|
103
|
+
.filter((slice) => !excluded.has(normalizeStatus('slice', slice?.canonical_status || slice?.status, DEFAULT_SLICE_STATUS)))
|
|
104
|
+
.sort((left, right) => compareRefs(left.ref, right.ref));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function progressForSlice(slice) {
|
|
108
|
+
const explicit = Number(slice?.json?.progress);
|
|
109
|
+
if (Number.isFinite(explicit)) {
|
|
110
|
+
return Math.max(0, Math.min(100, explicit));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const status = normalizeStatus('slice', slice?.canonical_status || slice?.status, DEFAULT_SLICE_STATUS);
|
|
114
|
+
if (status === 'completed') {
|
|
115
|
+
return 100;
|
|
116
|
+
}
|
|
117
|
+
if (status === 'in-progress' || status === 'review') {
|
|
118
|
+
return 50;
|
|
119
|
+
}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function summarizeSliceProgress(items) {
|
|
124
|
+
const slices = Array.isArray(items) ? items : [];
|
|
125
|
+
const total = slices.length;
|
|
126
|
+
const completed = slices.filter((item) => isCompletedStatus('slice', item.canonical_status || item.status)).length;
|
|
127
|
+
const blocked = slices.filter((item) => isBlockedStatus('slice', item.canonical_status || item.status, item)).length;
|
|
128
|
+
const open = Math.max(0, total - completed);
|
|
129
|
+
const percent = total === 0 ? 0 : Math.round((completed / total) * 100);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
total,
|
|
133
|
+
completed,
|
|
134
|
+
open,
|
|
135
|
+
blocked,
|
|
136
|
+
percent,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function statusForSpec(specSlices) {
|
|
141
|
+
const slices = Array.isArray(specSlices) ? specSlices : [];
|
|
142
|
+
if (slices.length === 0) {
|
|
143
|
+
return 'draft';
|
|
144
|
+
}
|
|
145
|
+
if (slices.some((slice) => isBlockedStatus('slice', slice.canonical_status || slice.status, slice))) {
|
|
146
|
+
return 'blocked';
|
|
147
|
+
}
|
|
148
|
+
if (slices.every((slice) => isCompletedStatus('slice', slice.canonical_status || slice.status))) {
|
|
149
|
+
return 'done';
|
|
150
|
+
}
|
|
151
|
+
if (slices.some((slice) => progressForSlice(slice) > 0)) {
|
|
152
|
+
return 'in-progress';
|
|
153
|
+
}
|
|
154
|
+
return DEFAULT_SPEC_STATUS;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function groupSlicesBySpec(slices) {
|
|
158
|
+
const groups = new Map();
|
|
159
|
+
|
|
160
|
+
for (const slice of Array.isArray(slices) ? slices : []) {
|
|
161
|
+
const key = `${slice.specFamily || 'specs'}/${slice.specSlug || ''}`;
|
|
162
|
+
if (!groups.has(key)) {
|
|
163
|
+
groups.set(key, []);
|
|
164
|
+
}
|
|
165
|
+
groups.get(key).push(slice);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return Array.from(groups.entries())
|
|
169
|
+
.map(([key, specSlices]) => {
|
|
170
|
+
const [specFamily, specSlug] = key.split('/');
|
|
171
|
+
const ordered = specSlices.slice().sort((left, right) => compareRefs(left.ref, right.ref));
|
|
172
|
+
const status = statusForSpec(ordered);
|
|
173
|
+
return {
|
|
174
|
+
canonical_status: normalizeStatus('spec', status, DEFAULT_SPEC_STATUS),
|
|
175
|
+
specFamily,
|
|
176
|
+
specSlug,
|
|
177
|
+
status,
|
|
178
|
+
slices: ordered,
|
|
179
|
+
};
|
|
180
|
+
})
|
|
181
|
+
.sort((left, right) => left.specSlug.localeCompare(right.specSlug));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function summarizeGraph(graph) {
|
|
185
|
+
if (!graph?.ok) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
edges: [],
|
|
189
|
+
levels: [],
|
|
190
|
+
conflicts: [],
|
|
191
|
+
error: graph?.error || null,
|
|
192
|
+
nodes: Array.isArray(graph?.nodes) ? graph.nodes : [],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const levels = computeLevels(graph).map((level, index) => ({
|
|
197
|
+
level: index,
|
|
198
|
+
slices: level.map((slice) => slice.ref),
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
edges: graph.edges.map((edge) => ({ from: edge.from, to: edge.to })),
|
|
204
|
+
levels,
|
|
205
|
+
conflicts: detectFileConflicts(graph.nodes).map((conflict) => ({
|
|
206
|
+
files: conflict.files,
|
|
207
|
+
slices: conflict.slices,
|
|
208
|
+
})),
|
|
209
|
+
error: null,
|
|
210
|
+
nodes: graph.nodes,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function relativeProjectPath(projectRoot, filePath) {
|
|
215
|
+
return toPosix(path.relative(projectRoot, filePath));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
CANONICAL_STATUSES,
|
|
220
|
+
DEFAULT_AGENT_STATUS,
|
|
221
|
+
DEFAULT_RUN_STATUS,
|
|
222
|
+
DEFAULT_SLICE_STATUS,
|
|
223
|
+
DEFAULT_SPEC_STATUS,
|
|
224
|
+
filterSlicesForExecution,
|
|
225
|
+
groupSlicesBySpec,
|
|
226
|
+
isBlockedStatus,
|
|
227
|
+
isCompletedStatus,
|
|
228
|
+
normalizeStatus,
|
|
229
|
+
progressForSlice,
|
|
230
|
+
relativeProjectPath,
|
|
231
|
+
resolveProjectState,
|
|
232
|
+
summarizeGraph,
|
|
233
|
+
summarizeSliceProgress,
|
|
234
|
+
toPosix,
|
|
235
|
+
};
|
|
236
|
+
|
|
@@ -3,7 +3,8 @@ const path = require('path');
|
|
|
3
3
|
const { catFileExists, currentBranch, hasLocalBranch, hasRemoteBranch, mergeBaseIsAncestor, revListCount, runGit, statusPorcelain, worktreeList } = require('./git');
|
|
4
4
|
const { parseJsonWithComments } = require('./json');
|
|
5
5
|
const { buildGraph, normalizeDeclaredDependencies, readAllSlices, SliceGraphError, topoSort } = require('./slice-graph');
|
|
6
|
-
const { resolveSliceContext, toAlias } = require('./slice');
|
|
6
|
+
const { resolveSliceContext, toAlias, validateSliceMetaForStart } = require('./slice');
|
|
7
|
+
const { validateProjectRelativePaths } = require('./paths');
|
|
7
8
|
|
|
8
9
|
function ensureExists(filePath, message) {
|
|
9
10
|
if (!fs.existsSync(filePath)) {
|
|
@@ -111,6 +112,13 @@ function validateLocalSliceArtifacts(repoRoot, slice) {
|
|
|
111
112
|
throw new Error('create-quiver: slice.json.files contiene entradas invalidas.');
|
|
112
113
|
}
|
|
113
114
|
console.log('PASS: slice.json declara archivos de alcance.');
|
|
115
|
+
|
|
116
|
+
validateSliceMetaForStart(slice);
|
|
117
|
+
console.log('PASS: slice.json declara metadata git compatible con start-slice.');
|
|
118
|
+
|
|
119
|
+
validateProjectRelativePaths(slice.files, 'slice.json files/allowed_write_paths');
|
|
120
|
+
validateProjectRelativePaths(slice.expectedReadPaths, 'slice.json expected_read_paths');
|
|
121
|
+
console.log('PASS: slice.json declara rutas relativas seguras dentro del proyecto.');
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
function baseRecoveryMessage(remote, baseBranch) {
|
|
@@ -219,6 +227,11 @@ function validateDeclaredDependencyContract(repoRoot, slice) {
|
|
|
219
227
|
}
|
|
220
228
|
}
|
|
221
229
|
|
|
230
|
+
function localCheckSummary() {
|
|
231
|
+
console.log('INFO: Modo local: checks ejecutados: spec docs, briefs, metadata git, scope declarado, rutas seguras, dependencias y gate.');
|
|
232
|
+
console.log('INFO: Modo local: checks omitidos: existencia en base remota/local y overlap contra worktrees activos.');
|
|
233
|
+
}
|
|
234
|
+
|
|
222
235
|
function checkSliceReadiness(sliceInput, options = {}) {
|
|
223
236
|
const gate = options.gate || 'execution';
|
|
224
237
|
const localMode = options.local === true;
|
|
@@ -262,6 +275,9 @@ function checkSliceReadiness(sliceInput, options = {}) {
|
|
|
262
275
|
}
|
|
263
276
|
|
|
264
277
|
validateDeclaredDependencyContract(repoRoot, slice);
|
|
278
|
+
if (localMode) {
|
|
279
|
+
localCheckSummary();
|
|
280
|
+
}
|
|
265
281
|
|
|
266
282
|
switch (gate) {
|
|
267
283
|
case 'ready':
|
|
@@ -371,22 +387,47 @@ function checkPrReadiness(sliceInput) {
|
|
|
371
387
|
|
|
372
388
|
function checkScope(sliceInput, options = {}) {
|
|
373
389
|
const strict = options.strict === true;
|
|
390
|
+
const remote = options.remote || 'origin';
|
|
374
391
|
const repoRoot = runGit(['rev-parse', '--show-toplevel'], process.cwd());
|
|
375
392
|
const slice = resolveSliceContext(repoRoot, sliceInput);
|
|
376
393
|
const declared = slice.files;
|
|
394
|
+
validateProjectRelativePaths(declared, 'slice scope path');
|
|
395
|
+
|
|
396
|
+
const explicitBaseBranch = typeof options.baseBranch === 'string' ? options.baseBranch.trim() : '';
|
|
397
|
+
const candidateBaseBranches = Array.from(new Set([
|
|
398
|
+
explicitBaseBranch,
|
|
399
|
+
slice.baseBranch,
|
|
400
|
+
'main',
|
|
401
|
+
'develop',
|
|
402
|
+
'master',
|
|
403
|
+
].filter(Boolean)));
|
|
404
|
+
|
|
405
|
+
let baseRef = '';
|
|
406
|
+
let baseSource = '';
|
|
407
|
+
for (const candidate of candidateBaseBranches) {
|
|
408
|
+
if (hasRemoteBranch(repoRoot, candidate, remote)) {
|
|
409
|
+
baseRef = `${remote}/${candidate}`;
|
|
410
|
+
baseSource = explicitBaseBranch === candidate ? '--base' : candidate === slice.baseBranch ? 'slice.git.base_branch' : 'fallback';
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
if (hasLocalBranch(repoRoot, candidate)) {
|
|
414
|
+
baseRef = candidate;
|
|
415
|
+
baseSource = explicitBaseBranch === candidate ? '--base' : candidate === slice.baseBranch ? 'slice.git.base_branch' : 'fallback';
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
377
419
|
|
|
378
420
|
let touchedRaw = '';
|
|
379
|
-
if (
|
|
380
|
-
touchedRaw = runGit(['diff', '--name-only',
|
|
381
|
-
|
|
382
|
-
touchedRaw = runGit(['diff', '--name-only', 'develop...HEAD'], repoRoot);
|
|
421
|
+
if (baseRef) {
|
|
422
|
+
touchedRaw = runGit(['diff', '--name-only', `${baseRef}...HEAD`], repoRoot);
|
|
423
|
+
console.log(`INFO: check-scope base: ${baseRef} (${baseSource}).`);
|
|
383
424
|
} else {
|
|
384
|
-
console.log(
|
|
425
|
+
console.log(`WARN: No se encontro base para check-scope. Probadas: ${candidateBaseBranches.join(', ')}. Usa --base <branch> o configura git.base_branch en slice.json.`);
|
|
385
426
|
return;
|
|
386
427
|
}
|
|
387
428
|
|
|
388
429
|
if (!touchedRaw) {
|
|
389
|
-
console.log(
|
|
430
|
+
console.log(`WARN: No se encontraron archivos modificados respecto de ${baseRef}.`);
|
|
390
431
|
return;
|
|
391
432
|
}
|
|
392
433
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { statusPorcelain } = require('./git');
|
|
2
2
|
const { normalizeContextPath } = require('./ai/safety');
|
|
3
3
|
const { checkScope } = require('./readiness');
|
|
4
|
+
const { validateProjectRelativePaths } = require('./paths');
|
|
4
5
|
|
|
5
6
|
class ScopeValidationError extends Error {
|
|
6
7
|
constructor(code, message, details = {}) {
|
|
@@ -118,7 +119,7 @@ function diffWorktreeSnapshots(beforeSnapshot, afterSnapshot) {
|
|
|
118
119
|
function validateScopeSnapshot({ allowedFiles = [], beforeSnapshot, afterSnapshot, strict = true } = {}) {
|
|
119
120
|
const normalizedAllowedFiles = Array.from(new Set(
|
|
120
121
|
Array.isArray(allowedFiles)
|
|
121
|
-
? allowedFiles.map(normalizeScopePath).filter(Boolean)
|
|
122
|
+
? validateProjectRelativePaths(allowedFiles, 'allowed scope path').map(normalizeScopePath).filter(Boolean)
|
|
122
123
|
: [],
|
|
123
124
|
));
|
|
124
125
|
const changedFiles = diffWorktreeSnapshots(beforeSnapshot, afterSnapshot);
|