@urielsh/prodify 0.1.1 → 0.1.3

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 (44) hide show
  1. package/.prodify/contracts/architecture.contract.json +9 -1
  2. package/.prodify/contracts/diagnose.contract.json +9 -1
  3. package/.prodify/contracts/plan.contract.json +9 -1
  4. package/.prodify/contracts/refactor.contract.json +13 -2
  5. package/.prodify/contracts/understand.contract.json +9 -1
  6. package/.prodify/contracts/validate.contract.json +11 -2
  7. package/.prodify/contracts-src/refactor.contract.md +7 -0
  8. package/.prodify/contracts-src/validate.contract.md +2 -0
  9. package/README.md +4 -2
  10. package/assets/presets/default/canonical/contracts-src/refactor.contract.md +7 -0
  11. package/assets/presets/default/canonical/contracts-src/validate.contract.md +2 -0
  12. package/dist/commands/setup-agent.js +3 -0
  13. package/dist/contracts/compiled-schema.js +10 -1
  14. package/dist/contracts/source-schema.js +42 -1
  15. package/dist/core/agent-setup.js +116 -1
  16. package/dist/core/diff-validator.js +183 -0
  17. package/dist/core/plan-units.js +64 -0
  18. package/dist/core/repo-root.js +17 -8
  19. package/dist/core/state.js +6 -0
  20. package/dist/core/status.js +14 -0
  21. package/dist/core/validation.js +87 -9
  22. package/dist/scoring/model.js +94 -213
  23. package/dist/scoring/scoring-engine.js +158 -0
  24. package/docs/diff-validator-design.md +44 -0
  25. package/docs/impact-scoring-design.md +38 -0
  26. package/package.json +1 -1
  27. package/src/commands/setup-agent.ts +3 -0
  28. package/src/contracts/compiled-schema.ts +10 -1
  29. package/src/contracts/source-schema.ts +51 -1
  30. package/src/core/agent-setup.ts +126 -2
  31. package/src/core/diff-validator.ts +230 -0
  32. package/src/core/plan-units.ts +82 -0
  33. package/src/core/repo-root.ts +21 -8
  34. package/src/core/state.ts +6 -0
  35. package/src/core/status.ts +17 -0
  36. package/src/core/validation.ts +136 -15
  37. package/src/scoring/model.ts +101 -250
  38. package/src/scoring/scoring-engine.ts +194 -0
  39. package/src/types.ts +55 -0
  40. package/tests/integration/cli-flows.test.js +19 -0
  41. package/tests/unit/agent-setup.test.js +9 -3
  42. package/tests/unit/diff-validator.test.js +28 -0
  43. package/tests/unit/scoring.test.js +42 -1
  44. package/tests/unit/validation.test.js +79 -1
@@ -40,6 +40,48 @@ function asOptionalStringArray(value: unknown, fieldName: string): string[] {
40
40
  return asStringArray(value, fieldName);
41
41
  }
42
42
 
43
+ function asOptionalNonNegativeInteger(value: unknown, fieldName: string, fallback = 0): number {
44
+ if (value === undefined || value === null || value === '') {
45
+ return fallback;
46
+ }
47
+
48
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
49
+ throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a non-negative integer.`, {
50
+ code: 'CONTRACT_SCHEMA_INVALID'
51
+ });
52
+ }
53
+
54
+ return value;
55
+ }
56
+
57
+ function asOptionalNonNegativeNumber(value: unknown, fieldName: string, fallback = 0): number {
58
+ if (value === undefined || value === null || value === '') {
59
+ return fallback;
60
+ }
61
+
62
+ if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
63
+ throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a non-negative number.`, {
64
+ code: 'CONTRACT_SCHEMA_INVALID'
65
+ });
66
+ }
67
+
68
+ return value;
69
+ }
70
+
71
+ function asOptionalBoolean(value: unknown, fieldName: string, fallback = false): boolean {
72
+ if (value === undefined || value === null || value === '') {
73
+ return fallback;
74
+ }
75
+
76
+ if (typeof value !== 'boolean') {
77
+ throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a boolean.`, {
78
+ code: 'CONTRACT_SCHEMA_INVALID'
79
+ });
80
+ }
81
+
82
+ return value;
83
+ }
84
+
43
85
  function asStage(value: unknown): FlowStage {
44
86
  const stage = asString(value, 'stage') as FlowStage;
45
87
  if (!CONTRACT_STAGE_NAMES.includes(stage)) {
@@ -136,6 +178,14 @@ export function normalizeSourceContractDocument(options: {
136
178
  : [],
137
179
  policy_rules: asStringArray(document.frontmatter.policy_rules, 'policy_rules'),
138
180
  success_criteria: asStringArray(document.frontmatter.success_criteria, 'success_criteria'),
139
- skill_routing: normalizeStageSkillRouting(document.frontmatter.skill_routing)
181
+ skill_routing: normalizeStageSkillRouting(document.frontmatter.skill_routing),
182
+ diff_validation_rules: {
183
+ minimum_files_modified: asOptionalNonNegativeInteger(document.frontmatter.minimum_files_modified, 'minimum_files_modified'),
184
+ minimum_lines_changed: asOptionalNonNegativeInteger(document.frontmatter.minimum_lines_changed, 'minimum_lines_changed'),
185
+ must_create_files: asOptionalBoolean(document.frontmatter.must_create_files, 'must_create_files'),
186
+ required_structural_changes: asOptionalStringArray(document.frontmatter.required_structural_changes, 'required_structural_changes')
187
+ },
188
+ min_impact_score: asOptionalNonNegativeNumber(document.frontmatter.min_impact_score, 'min_impact_score'),
189
+ enforce_plan_units: asOptionalBoolean(document.frontmatter.enforce_plan_units, 'enforce_plan_units')
140
190
  };
141
191
  }
@@ -10,6 +10,126 @@ import type { GlobalAgentSetupState, RuntimeProfileName } from '../types.js';
10
10
  export const GLOBAL_AGENT_SETUP_SCHEMA_VERSION = '1';
11
11
  export const PRODIFY_RUNTIME_COMMANDS = ['$prodify-init', '$prodify-execute', '$prodify-resume'] as const;
12
12
 
13
+ function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): string {
14
+ const explicit = env.CODEX_HOME?.trim();
15
+ if (explicit) {
16
+ return path.resolve(explicit);
17
+ }
18
+
19
+ return path.join(os.homedir(), '.codex');
20
+ }
21
+
22
+ function resolveCodexSkillsRoot(env: NodeJS.ProcessEnv = process.env): string {
23
+ return path.join(resolveCodexHome(env), 'skills');
24
+ }
25
+
26
+ function renderCodexSkill(name: 'prodify-init' | 'prodify-execute' | 'prodify-resume'): string {
27
+ if (name === 'prodify-init') {
28
+ return `---
29
+ name: "prodify-init"
30
+ description: "Bootstrap Prodify inside the current repository."
31
+ metadata:
32
+ short-description: "Bootstrap Prodify inside the current repository."
33
+ ---
34
+
35
+ <codex_skill_adapter>
36
+ ## A. Skill Invocation
37
+ - This skill is invoked by mentioning \`$prodify-init\`.
38
+ - Ignore trailing arguments unless the repository-specific runtime instructions require them.
39
+ </codex_skill_adapter>
40
+
41
+ # prodify-init
42
+
43
+ Use this runtime bridge to bootstrap Prodify inside the current repository.
44
+
45
+ Load and follow, in this order:
46
+ - \`.prodify/AGENTS.md\`
47
+ - \`.prodify/runtime-commands.md\`
48
+ - \`.prodify/state.json\`
49
+
50
+ Keep the runtime anchored to \`.prodify/\`.
51
+ Do not substitute compatibility files for the canonical \`.prodify/AGENTS.md\` entrypoint.
52
+
53
+ Available runtime commands:
54
+ - \`$prodify-init\`
55
+ - \`$prodify-execute\`
56
+ - \`$prodify-execute --auto\`
57
+ - \`$prodify-resume\`
58
+ `;
59
+ }
60
+
61
+ if (name === 'prodify-execute') {
62
+ return `---
63
+ name: "prodify-execute"
64
+ description: "Execute the next Prodify workflow stage inside the current repository."
65
+ metadata:
66
+ short-description: "Execute the next Prodify workflow stage."
67
+ ---
68
+
69
+ <codex_skill_adapter>
70
+ ## A. Skill Invocation
71
+ - This skill is invoked by mentioning \`$prodify-execute\`.
72
+ - Treat all user text after \`$prodify-execute\` as \`{{PRODIFY_EXECUTE_ARGS}}\`.
73
+ - If no arguments are present, treat \`{{PRODIFY_EXECUTE_ARGS}}\` as empty.
74
+ </codex_skill_adapter>
75
+
76
+ # prodify-execute
77
+
78
+ Use this runtime bridge to execute the next Prodify workflow stage.
79
+
80
+ Load and follow, in this order:
81
+ - \`.prodify/runtime-commands.md\`
82
+ - \`.prodify/state.json\`
83
+ - \`.prodify/AGENTS.md\`
84
+
85
+ Interpret \`{{PRODIFY_EXECUTE_ARGS}}\` as the runtime command arguments.
86
+ - empty: run \`$prodify-execute\`
87
+ - \`--auto\`: run \`$prodify-execute --auto\`
88
+
89
+ Keep all execution state, artifacts, contracts, and validation anchored to \`.prodify/\`.
90
+ `;
91
+ }
92
+
93
+ return `---
94
+ name: "prodify-resume"
95
+ description: "Resume a paused Prodify run from saved runtime state."
96
+ metadata:
97
+ short-description: "Resume a paused Prodify run."
98
+ ---
99
+
100
+ <codex_skill_adapter>
101
+ ## A. Skill Invocation
102
+ - This skill is invoked by mentioning \`$prodify-resume\`.
103
+ - Treat trailing arguments as optional runtime-specific hints only when the repository guidance explicitly supports them.
104
+ </codex_skill_adapter>
105
+
106
+ # prodify-resume
107
+
108
+ Use this runtime bridge to resume Prodify from saved runtime state.
109
+
110
+ Load and follow, in this order:
111
+ - \`.prodify/runtime-commands.md\`
112
+ - \`.prodify/state.json\`
113
+ - \`.prodify/AGENTS.md\`
114
+
115
+ Resume from the current state recorded under \`.prodify/state.json\`.
116
+ Preserve validation checkpoints and stop clearly if the state is corrupt or non-resumable.
117
+ `;
118
+ }
119
+
120
+ async function installCodexRuntimeCommands(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
121
+ const skillsRoot = resolveCodexSkillsRoot(env);
122
+ const installedFiles: string[] = [];
123
+
124
+ for (const command of ['prodify-init', 'prodify-execute', 'prodify-resume'] as const) {
125
+ const skillPath = path.join(skillsRoot, command, 'SKILL.md');
126
+ await writeFileEnsuringDir(skillPath, renderCodexSkill(command));
127
+ installedFiles.push(skillPath);
128
+ }
129
+
130
+ return installedFiles;
131
+ }
132
+
13
133
  function asRecord(value: unknown): Record<string, unknown> {
14
134
  return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
15
135
  }
@@ -116,7 +236,7 @@ export async function setupAgentIntegration(
116
236
  now?: string;
117
237
  env?: NodeJS.ProcessEnv;
118
238
  } = {}
119
- ): Promise<{ statePath: string; configuredAgents: RuntimeProfileName[]; alreadyConfigured: boolean }> {
239
+ ): Promise<{ statePath: string; configuredAgents: RuntimeProfileName[]; alreadyConfigured: boolean; installedPaths: string[] }> {
120
240
  const profile = getRuntimeProfile(agent);
121
241
  if (!profile) {
122
242
  throw new ProdifyError('setup-agent requires <codex|claude|copilot|opencode>.', {
@@ -138,10 +258,14 @@ export async function setupAgentIntegration(
138
258
  commands: [...PRODIFY_RUNTIME_COMMANDS]
139
259
  };
140
260
 
261
+ const installedPaths = profile.name === 'codex'
262
+ ? await installCodexRuntimeCommands(env)
263
+ : [];
141
264
  const statePath = await writeGlobalAgentSetupState(nextState, { env });
142
265
  return {
143
266
  statePath,
144
267
  configuredAgents: listConfiguredAgents(nextState),
145
- alreadyConfigured
268
+ alreadyConfigured,
269
+ installedPaths
146
270
  };
147
271
  }
@@ -0,0 +1,230 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { listFilesRecursive, pathExists, writeFileEnsuringDir } from './fs.js';
5
+ import { normalizeRepoRelativePath, resolveRepoPath } from './paths.js';
6
+ import type { DiffResult } from '../types.js';
7
+
8
+ const DIFF_SNAPSHOT_SCHEMA_VERSION = '1';
9
+ const BASELINE_SNAPSHOT_PATH = '.prodify/metrics/refactor-baseline.snapshot.json';
10
+ const TRACKED_PREFIXES = ['src/', 'tests/', 'assets/'] as const;
11
+ const TRACKED_FILE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.py', '.cs', '.css', '.scss', '.html']);
12
+ const LAYER_DIRECTORY_NAMES = new Set(['application', 'domain', 'services', 'service', 'modules', 'module', 'adapters', 'infrastructure', 'core']);
13
+
14
+ interface SnapshotFile {
15
+ path: string;
16
+ content: string;
17
+ }
18
+
19
+ interface RepoSnapshot {
20
+ schema_version: string;
21
+ files: SnapshotFile[];
22
+ }
23
+
24
+ function isTrackedPath(relativePath: string): boolean {
25
+ const normalized = normalizeRepoRelativePath(relativePath);
26
+ if (!TRACKED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
27
+ return false;
28
+ }
29
+
30
+ return TRACKED_FILE_EXTENSIONS.has(path.extname(normalized));
31
+ }
32
+
33
+ function normalizeWhitespace(content: string): string {
34
+ return content
35
+ .replace(/\s+/g, '')
36
+ .trim();
37
+ }
38
+
39
+ function toMap(snapshot: RepoSnapshot): Map<string, SnapshotFile> {
40
+ return new Map(snapshot.files.map((file) => [file.path, file]));
41
+ }
42
+
43
+ function diffLines(before: string, after: string): { added: number; removed: number } {
44
+ const beforeLines = before.replace(/\r\n/g, '\n').split('\n');
45
+ const afterLines = after.replace(/\r\n/g, '\n').split('\n');
46
+ const rows = beforeLines.length;
47
+ const cols = afterLines.length;
48
+ const dp = Array.from({ length: rows + 1 }, () => Array<number>(cols + 1).fill(0));
49
+
50
+ for (let row = rows - 1; row >= 0; row -= 1) {
51
+ for (let col = cols - 1; col >= 0; col -= 1) {
52
+ if (beforeLines[row] === afterLines[col]) {
53
+ dp[row][col] = dp[row + 1][col + 1] + 1;
54
+ } else {
55
+ dp[row][col] = Math.max(dp[row + 1][col], dp[row][col + 1]);
56
+ }
57
+ }
58
+ }
59
+
60
+ const common = dp[0][0];
61
+ return {
62
+ added: Math.max(0, afterLines.length - common),
63
+ removed: Math.max(0, beforeLines.length - common)
64
+ };
65
+ }
66
+
67
+ function collectDirectories(paths: string[]): string[] {
68
+ return [...new Set(paths
69
+ .map((relativePath) => path.posix.dirname(relativePath))
70
+ .filter((directory) => directory !== '.' && directory !== ''))]
71
+ .sort((left, right) => left.localeCompare(right));
72
+ }
73
+
74
+ function detectStructuralChanges(options: {
75
+ addedPaths: string[];
76
+ modifiedPaths: string[];
77
+ beforeMap: Map<string, SnapshotFile>;
78
+ afterMap: Map<string, SnapshotFile>;
79
+ removedLineCounts: Map<string, number>;
80
+ }): DiffResult['structuralChanges'] {
81
+ const newDirectories = collectDirectories(options.addedPaths);
82
+ const newLayers = newDirectories.filter((directory) => LAYER_DIRECTORY_NAMES.has(path.posix.basename(directory)));
83
+ const filesWithReducedResponsibility = options.modifiedPaths
84
+ .filter((relativePath) => (options.removedLineCounts.get(relativePath) ?? 0) > 0)
85
+ .sort((left, right) => left.localeCompare(right));
86
+ const newModules = options.addedPaths
87
+ .filter((relativePath) => relativePath.startsWith('src/'))
88
+ .sort((left, right) => left.localeCompare(right));
89
+
90
+ const flags = new Set<string>();
91
+ if (newDirectories.length > 0) {
92
+ flags.add('new-directories');
93
+ }
94
+ if (newLayers.length > 0) {
95
+ flags.add('new-layer-directories');
96
+ flags.add('module-boundary-created');
97
+ }
98
+ if (newModules.length > 0) {
99
+ flags.add('new-modules');
100
+ flags.add('module-boundary-created');
101
+ }
102
+ if (filesWithReducedResponsibility.length > 0) {
103
+ flags.add('responsibility-reduced');
104
+ }
105
+
106
+ return {
107
+ new_directories: newDirectories,
108
+ new_layer_directories: newLayers,
109
+ files_with_reduced_responsibility: filesWithReducedResponsibility,
110
+ new_modules: newModules,
111
+ structural_change_flags: [...flags].sort((left, right) => left.localeCompare(right))
112
+ };
113
+ }
114
+
115
+ function serializeSnapshot(snapshot: RepoSnapshot): string {
116
+ return `${JSON.stringify(snapshot, null, 2)}\n`;
117
+ }
118
+
119
+ export async function captureRepoSnapshot(repoRoot: string): Promise<RepoSnapshot> {
120
+ const repoFiles = await listFilesRecursive(repoRoot);
121
+ const files: SnapshotFile[] = [];
122
+
123
+ for (const file of repoFiles) {
124
+ if (!isTrackedPath(file.relativePath)) {
125
+ continue;
126
+ }
127
+
128
+ files.push({
129
+ path: normalizeRepoRelativePath(file.relativePath),
130
+ content: await fs.readFile(file.fullPath, 'utf8')
131
+ });
132
+ }
133
+
134
+ files.sort((left, right) => left.path.localeCompare(right.path));
135
+ return {
136
+ schema_version: DIFF_SNAPSHOT_SCHEMA_VERSION,
137
+ files
138
+ };
139
+ }
140
+
141
+ export async function writeRefactorBaselineSnapshot(repoRoot: string): Promise<RepoSnapshot> {
142
+ const snapshot = await captureRepoSnapshot(repoRoot);
143
+ await writeFileEnsuringDir(resolveRepoPath(repoRoot, BASELINE_SNAPSHOT_PATH), serializeSnapshot(snapshot));
144
+ return snapshot;
145
+ }
146
+
147
+ export async function readRefactorBaselineSnapshot(repoRoot: string): Promise<RepoSnapshot | null> {
148
+ const baselinePath = resolveRepoPath(repoRoot, BASELINE_SNAPSHOT_PATH);
149
+ if (!(await pathExists(baselinePath))) {
150
+ return null;
151
+ }
152
+
153
+ return JSON.parse(await fs.readFile(baselinePath, 'utf8')) as RepoSnapshot;
154
+ }
155
+
156
+ export function diffSnapshots(before: RepoSnapshot, after: RepoSnapshot): DiffResult {
157
+ const beforeMap = toMap(before);
158
+ const afterMap = toMap(after);
159
+ const allPaths = [...new Set([...beforeMap.keys(), ...afterMap.keys()])].sort((left, right) => left.localeCompare(right));
160
+ const modifiedPaths: string[] = [];
161
+ const addedPaths: string[] = [];
162
+ const deletedPaths: string[] = [];
163
+ const formattingOnlyPaths: string[] = [];
164
+ let linesAdded = 0;
165
+ let linesRemoved = 0;
166
+ const removedLineCounts = new Map<string, number>();
167
+
168
+ for (const relativePath of allPaths) {
169
+ const beforeFile = beforeMap.get(relativePath);
170
+ const afterFile = afterMap.get(relativePath);
171
+
172
+ if (!beforeFile && afterFile) {
173
+ addedPaths.push(relativePath);
174
+ linesAdded += afterFile.content.replace(/\r\n/g, '\n').split('\n').length;
175
+ continue;
176
+ }
177
+
178
+ if (beforeFile && !afterFile) {
179
+ deletedPaths.push(relativePath);
180
+ const removed = beforeFile.content.replace(/\r\n/g, '\n').split('\n').length;
181
+ linesRemoved += removed;
182
+ removedLineCounts.set(relativePath, removed);
183
+ continue;
184
+ }
185
+
186
+ if (!beforeFile || !afterFile || beforeFile.content === afterFile.content) {
187
+ continue;
188
+ }
189
+
190
+ modifiedPaths.push(relativePath);
191
+ if (normalizeWhitespace(beforeFile.content) === normalizeWhitespace(afterFile.content)) {
192
+ formattingOnlyPaths.push(relativePath);
193
+ continue;
194
+ }
195
+
196
+ const lineDiff = diffLines(beforeFile.content, afterFile.content);
197
+ linesAdded += lineDiff.added;
198
+ linesRemoved += lineDiff.removed;
199
+ removedLineCounts.set(relativePath, lineDiff.removed);
200
+ }
201
+
202
+ return {
203
+ filesModified: modifiedPaths.length,
204
+ filesAdded: addedPaths.length,
205
+ filesDeleted: deletedPaths.length,
206
+ linesAdded,
207
+ linesRemoved,
208
+ modifiedPaths,
209
+ addedPaths,
210
+ deletedPaths,
211
+ formattingOnlyPaths,
212
+ structuralChanges: detectStructuralChanges({
213
+ addedPaths,
214
+ modifiedPaths,
215
+ beforeMap,
216
+ afterMap,
217
+ removedLineCounts
218
+ })
219
+ };
220
+ }
221
+
222
+ export async function diffAgainstRefactorBaseline(repoRoot: string): Promise<DiffResult | null> {
223
+ const baseline = await readRefactorBaselineSnapshot(repoRoot);
224
+ if (!baseline) {
225
+ return null;
226
+ }
227
+
228
+ const current = await captureRepoSnapshot(repoRoot);
229
+ return diffSnapshots(baseline, current);
230
+ }
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+
3
+ import { resolveCanonicalPath } from './paths.js';
4
+
5
+ export interface PlanUnit {
6
+ id: string;
7
+ description: string;
8
+ }
9
+
10
+ function extractSection(markdown: string, heading: string): string {
11
+ const normalized = markdown.replace(/\r\n/g, '\n');
12
+ const match = new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s+|\\Z)`, 'm').exec(normalized);
13
+ return match?.[1]?.trim() ?? '';
14
+ }
15
+
16
+ function normalizePlanUnits(section: string): PlanUnit[] {
17
+ const lines = section.split('\n');
18
+ const units: PlanUnit[] = [];
19
+ let currentId: string | null = null;
20
+ let currentDescription = '';
21
+
22
+ for (const rawLine of lines) {
23
+ const line = rawLine.trim();
24
+ const idMatch = /^-\s+Step ID:\s+(.+)$/.exec(line);
25
+ if (idMatch) {
26
+ if (currentId) {
27
+ units.push({
28
+ id: currentId,
29
+ description: currentDescription
30
+ });
31
+ }
32
+
33
+ currentId = idMatch[1].trim();
34
+ currentDescription = '';
35
+ continue;
36
+ }
37
+
38
+ if (!currentId) {
39
+ continue;
40
+ }
41
+
42
+ const descriptionMatch = /^-\s+Description:\s+(.+)$/.exec(line);
43
+ if (descriptionMatch) {
44
+ currentDescription = descriptionMatch[1].trim();
45
+ }
46
+ }
47
+
48
+ if (currentId) {
49
+ units.push({
50
+ id: currentId,
51
+ description: currentDescription
52
+ });
53
+ }
54
+
55
+ return units;
56
+ }
57
+
58
+ export async function readPlanUnits(repoRoot: string): Promise<PlanUnit[]> {
59
+ const planPath = resolveCanonicalPath(repoRoot, '.prodify/artifacts/04-plan.md');
60
+ const markdown = await fs.readFile(planPath, 'utf8');
61
+ return normalizePlanUnits(extractSection(markdown, 'Step Breakdown'));
62
+ }
63
+
64
+ export async function readSelectedRefactorStep(repoRoot: string): Promise<PlanUnit | null> {
65
+ const refactorPath = resolveCanonicalPath(repoRoot, '.prodify/artifacts/05-refactor.md');
66
+ const markdown = await fs.readFile(refactorPath, 'utf8');
67
+ const section = extractSection(markdown, 'Selected Step');
68
+ if (!section) {
69
+ return null;
70
+ }
71
+
72
+ const id = /-\s+Step ID:\s+(.+)/.exec(section)?.[1]?.trim() ?? null;
73
+ const description = /-\s+Description:\s+(.+)/.exec(section)?.[1]?.trim() ?? '';
74
+ if (!id) {
75
+ return null;
76
+ }
77
+
78
+ return {
79
+ id,
80
+ description
81
+ };
82
+ }
@@ -56,16 +56,29 @@ export async function resolveRepoRoot(options: ResolveRepoRootOptions = {}): Pro
56
56
  return explicitRepo;
57
57
  }
58
58
 
59
- const prodifyRoot = await searchUpwards(cwd, async (candidate) => directoryHas(candidate, '.prodify'));
60
- if (prodifyRoot) {
61
- return prodifyRoot;
62
- }
59
+ let current = cwd;
60
+
61
+ while (true) {
62
+ const hasProdify = await directoryHas(current, '.prodify');
63
+ if (hasProdify) {
64
+ return current;
65
+ }
66
+
67
+ const hasGit = await directoryHas(current, '.git');
68
+ if (hasGit) {
69
+ if (allowBootstrap) {
70
+ return current;
71
+ }
63
72
 
64
- if (allowBootstrap) {
65
- const gitRoot = await searchUpwards(cwd, async (candidate) => directoryHas(candidate, '.git'));
66
- if (gitRoot) {
67
- return gitRoot;
73
+ break;
68
74
  }
75
+
76
+ const parent = path.dirname(current);
77
+ if (parent === current) {
78
+ break;
79
+ }
80
+
81
+ current = parent;
69
82
  }
70
83
 
71
84
  throw new ProdifyError('Could not resolve repository root from the current working directory.', {
package/src/core/state.ts CHANGED
@@ -3,6 +3,8 @@ import fs from 'node:fs/promises';
3
3
  import { ProdifyError } from './errors.js';
4
4
  import { pathExists, writeFileEnsuringDir } from './fs.js';
5
5
  import { isRuntimeProfileName, resolveCanonicalPath } from './paths.js';
6
+ import { writeRefactorBaselineSnapshot } from './diff-validator.js';
7
+ import { syncScoreArtifactsForRuntimeState } from '../scoring/model.js';
6
8
  import type {
7
9
  ExecutionMode,
8
10
  FlowStage,
@@ -261,4 +263,8 @@ export async function readRuntimeState(
261
263
  export async function writeRuntimeState(repoRoot: string, state: ProdifyState): Promise<void> {
262
264
  const statePath = resolveCanonicalPath(repoRoot, '.prodify/state.json');
263
265
  await writeFileEnsuringDir(statePath, serializeRuntimeState(state));
266
+ if (state.runtime.current_state === 'refactor_pending') {
267
+ await writeRefactorBaselineSnapshot(repoRoot);
268
+ }
269
+ await syncScoreArtifactsForRuntimeState(repoRoot, state);
264
270
  }
@@ -12,8 +12,10 @@ import { readRuntimeState, RUNTIME_STATUS } from './state.js';
12
12
  import { inspectVersionStatus } from './version-checks.js';
13
13
  import { buildBootstrapPrompt, hasManualBootstrapGuidance } from './prompt-builder.js';
14
14
  import { getRuntimeProfile } from './targets.js';
15
+ import { readScoreDelta } from '../scoring/model.js';
15
16
  import type {
16
17
  RuntimeProfileName,
18
+ ScoreDelta,
17
19
  RuntimeStateBlock,
18
20
  StatusReport,
19
21
  VersionInspection,
@@ -164,6 +166,16 @@ function describeStageValidation(report: StatusReport): string {
164
166
  : `failed at ${runtime.last_validation.stage}`;
165
167
  }
166
168
 
169
+ function describeImpactScore(scoreDelta: ScoreDelta | null): string {
170
+ if (!scoreDelta) {
171
+ return 'not available';
172
+ }
173
+
174
+ const threshold = scoreDelta.min_impact_score !== undefined ? `, threshold=${scoreDelta.min_impact_score}` : '';
175
+ const verdict = scoreDelta.passed === undefined ? '' : `, passed=${scoreDelta.passed}`;
176
+ return `${scoreDelta.baseline_score} -> ${scoreDelta.final_score} (delta ${scoreDelta.delta}${threshold}${verdict})`;
177
+ }
178
+
167
179
  async function checkManualBootstrapGuidance(repoRoot: string): Promise<boolean> {
168
180
  const agentsPath = resolveCanonicalPath(repoRoot, '.prodify/AGENTS.md');
169
181
  if (!(await pathExists(agentsPath))) {
@@ -264,6 +276,7 @@ export async function inspectRepositoryStatus(
264
276
  bootstrapProfile,
265
277
  bootstrapPrompt,
266
278
  stageSkillResolution: null,
279
+ scoreDelta: null,
267
280
  recommendedNextAction: 'prodify init',
268
281
  presetMetadata: preset.metadata
269
282
  };
@@ -281,6 +294,7 @@ export async function inspectRepositoryStatus(
281
294
  let runtimeState = null;
282
295
  let runtimeStateError = null;
283
296
  let stageSkillResolution = null;
297
+ let scoreDelta = null;
284
298
 
285
299
  try {
286
300
  runtimeState = await readRuntimeState(repoRoot, {
@@ -291,6 +305,7 @@ export async function inspectRepositoryStatus(
291
305
  }
292
306
 
293
307
  const manualBootstrapReady = await checkManualBootstrapGuidance(repoRoot);
308
+ scoreDelta = await readScoreDelta(repoRoot);
294
309
  const canonicalOk = missingPaths.length === 0;
295
310
  if (canonicalOk && contractInventory.ok) {
296
311
  const skillStage = runtimeState?.runtime.current_stage
@@ -328,6 +343,7 @@ export async function inspectRepositoryStatus(
328
343
  bootstrapProfile,
329
344
  bootstrapPrompt,
330
345
  stageSkillResolution,
346
+ scoreDelta,
331
347
  recommendedNextAction: deriveNextAction({
332
348
  initialized,
333
349
  canonicalOk,
@@ -357,6 +373,7 @@ export function renderStatusReport(report: StatusReport): string {
357
373
  `Skills active: ${describeActiveSkills(report)}`,
358
374
  `Execution state: ${describeRuntime(report.runtimeState?.runtime ?? null)}`,
359
375
  `Stage validation: ${describeStageValidation(report)}`,
376
+ `Impact score: ${describeImpactScore(report.scoreDelta)}`,
360
377
  `Manual bootstrap: ${report.manualBootstrapReady ? 'ready' : 'repair .prodify/AGENTS.md guidance'}`,
361
378
  `Bootstrap profile: ${report.bootstrapProfile}`,
362
379
  `Bootstrap prompt: ${report.bootstrapPrompt}`,