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.
Files changed (79) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +16 -8
  3. package/README_FOR_AI.md +11 -6
  4. package/ROADMAP.md +9 -2
  5. package/docs/COMMANDS.md.template +9 -2
  6. package/package.json +2 -1
  7. package/specs/quiver-v26-0121-smoke-hardening/SPEC.md +2 -2
  8. package/specs/quiver-v26-0121-smoke-hardening/STATUS.md +5 -5
  9. package/specs/quiver-v27-reliability-ai-workflow-hardening/AUDIT_V24_V25_V26.md +67 -0
  10. package/specs/quiver-v27-reliability-ai-workflow-hardening/COMMAND_CONTRACTS.md +125 -0
  11. package/specs/quiver-v27-reliability-ai-workflow-hardening/COVERAGE_MATRIX.md +74 -0
  12. package/specs/quiver-v27-reliability-ai-workflow-hardening/EVIDENCE_REPORT.md +179 -0
  13. package/specs/quiver-v27-reliability-ai-workflow-hardening/EXECUTION_PLAN.md +71 -0
  14. package/specs/quiver-v27-reliability-ai-workflow-hardening/SPEC.md +176 -0
  15. package/specs/quiver-v27-reliability-ai-workflow-hardening/STATUS.md +37 -0
  16. package/specs/quiver-v27-reliability-ai-workflow-hardening/pr.md +132 -0
  17. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/CLOSURE_BRIEF.md +36 -0
  18. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/EXECUTION_BRIEF.md +56 -0
  19. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-00-docs-audit-coverage-and-contracts/slice.json +75 -0
  20. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/CLOSURE_BRIEF.md +37 -0
  21. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/EXECUTION_BRIEF.md +54 -0
  22. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-01-core-state-resolver-and-canonical-statuses/slice.json +79 -0
  23. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/CLOSURE_BRIEF.md +34 -0
  24. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/EXECUTION_BRIEF.md +54 -0
  25. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-02-json-export-contract-and-machine-output/slice.json +75 -0
  26. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/CLOSURE_BRIEF.md +36 -0
  27. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/EXECUTION_BRIEF.md +55 -0
  28. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-03-approved-plan-to-spec-create/slice.json +78 -0
  29. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/CLOSURE_BRIEF.md +31 -0
  30. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/EXECUTION_BRIEF.md +55 -0
  31. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-04-ai-artifact-storage-redaction-and-token-compaction/slice.json +77 -0
  32. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/CLOSURE_BRIEF.md +31 -0
  33. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/EXECUTION_BRIEF.md +55 -0
  34. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-05-worktree-lifecycle-locks-and-recovery/slice.json +84 -0
  35. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/CLOSURE_BRIEF.md +32 -0
  36. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/EXECUTION_BRIEF.md +57 -0
  37. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-06-validation-gates-and-scope-safety/slice.json +99 -0
  38. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/CLOSURE_BRIEF.md +31 -0
  39. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/EXECUTION_BRIEF.md +57 -0
  40. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-07-context-analysis-and-doctor-flow/slice.json +88 -0
  41. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/CLOSURE_BRIEF.md +31 -0
  42. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/EXECUTION_BRIEF.md +56 -0
  43. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-08-cross-platform-help-auth-and-dx/slice.json +85 -0
  44. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/CLOSURE_BRIEF.md +32 -0
  45. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/EXECUTION_BRIEF.md +56 -0
  46. package/specs/quiver-v27-reliability-ai-workflow-hardening/slices/slice-09-fixtures-smoke-docs-and-release-readiness/slice.json +91 -0
  47. package/src/create-quiver/commands/ai.js +84 -9
  48. package/src/create-quiver/commands/flow.js +52 -4
  49. package/src/create-quiver/commands/graph.js +7 -7
  50. package/src/create-quiver/commands/plan.js +6 -15
  51. package/src/create-quiver/commands/spec.js +282 -0
  52. package/src/create-quiver/index.js +83 -21
  53. package/src/create-quiver/lib/agent-profiles.js +15 -3
  54. package/src/create-quiver/lib/ai/artifacts.js +318 -0
  55. package/src/create-quiver/lib/ai/execution-plan.js +9 -0
  56. package/src/create-quiver/lib/ai/executor.js +3 -2
  57. package/src/create-quiver/lib/ai/export-state.js +242 -97
  58. package/src/create-quiver/lib/ai/github.js +80 -3
  59. package/src/create-quiver/lib/ai/plan-review.js +2 -0
  60. package/src/create-quiver/lib/ai/spec-generator.js +72 -13
  61. package/src/create-quiver/lib/ai/spec-templates.js +72 -12
  62. package/src/create-quiver/lib/analyze.js +2 -2
  63. package/src/create-quiver/lib/approvals.js +14 -2
  64. package/src/create-quiver/lib/doctor.js +79 -0
  65. package/src/create-quiver/lib/git.js +40 -1
  66. package/src/create-quiver/lib/handoff.js +43 -1
  67. package/src/create-quiver/lib/init-docs.js +11 -7
  68. package/src/create-quiver/lib/init-layout.js +1 -0
  69. package/src/create-quiver/lib/lifecycle.js +52 -3
  70. package/src/create-quiver/lib/locks.js +134 -0
  71. package/src/create-quiver/lib/package-safety.js +7 -0
  72. package/src/create-quiver/lib/paths.js +74 -0
  73. package/src/create-quiver/lib/project-scan.js +74 -0
  74. package/src/create-quiver/lib/project-state-resolver.js +236 -0
  75. package/src/create-quiver/lib/readiness.js +48 -7
  76. package/src/create-quiver/lib/scope.js +2 -1
  77. package/src/create-quiver/lib/slice.js +8 -4
  78. package/src/create-quiver/lib/spec-worktrees.js +121 -38
  79. 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 (hasRemoteBranch(repoRoot, 'develop')) {
380
- touchedRaw = runGit(['diff', '--name-only', 'origin/develop...HEAD'], repoRoot);
381
- } else if (hasLocalBranch(repoRoot, 'develop')) {
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('WARN: No se encontro rama origin/develop ni develop. Saltando check de scope.');
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('WARN: No se encontraron archivos modificados respecto de develop.');
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);