File size: 8,140 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
"use strict";
/**
 * Core Utilities β€” Shared low-level utility primitives
 *
 * ADR-857 rollout phase 2c: extracted from core.cts (issue #877).
 * Owns POSIX path normalization, sub-repo/subdirectory scanning,
 * phase file stats, slug/one-liner/plan-id helpers, and time-ago.
 * Behaviour is preserved byte-for-behaviour from the prior location;
 * only the module boundary moved. core.cjs re-exports every public symbol
 * here under its own `export =` object so existing consumers are unaffected.
 *
 * New imports should pull core-utils helpers from core-utils.cjs directly.
 *
 * Dependencies (leaf modules only β€” no core.cjs, no loadConfig):
 *   - node:fs / node:path (stdlib)
 *   - ./phase-id.cjs       (comparePhaseNum, used by readSubdirectories)
 *   - ./planning-workspace.cjs (findContextMdIn, used by getPhaseFileStats)
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_fs_1 = __importDefault(require("node:fs"));
const node_path_1 = __importDefault(require("node:path"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const phaseIdModule = require("./phase-id.cjs");
const { comparePhaseNum } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const planningWorkspace = require("./planning-workspace.cjs");
const { findContextMdIn } = planningWorkspace;
// ─── Path helpers ────────────────────────────────────────────────────────────
/** Normalize a relative path to always use forward slashes (cross-platform). */
function toPosixPath(p) {
    return p.split(node_path_1.default.sep).join('/');
}
/**
 * Scan immediate child directories for separate git repos.
 * Returns a sorted array of directory names that have their own `.git`.
 * Excludes hidden directories and node_modules.
 */
function detectSubRepos(cwd) {
    const results = [];
    try {
        const entries = node_fs_1.default.readdirSync(cwd, { withFileTypes: true });
        for (const entry of entries) {
            if (!entry.isDirectory())
                continue;
            if (entry.name.startsWith('.') || entry.name === 'node_modules')
                continue;
            const gitPath = node_path_1.default.join(cwd, entry.name, '.git');
            try {
                if (node_fs_1.default.existsSync(gitPath)) {
                    results.push(entry.name);
                }
            }
            catch { /* ignore */ }
        }
    }
    catch { /* ignore */ }
    return results.sort();
}
// ─── Summary body helpers ─────────────────────────────────────────────────
/**
 * Extract a one-liner from the summary body when it's not in frontmatter.
 */
function extractOneLinerFromBody(content) {
    if (!content)
        return null;
    const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
    const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
    const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
    if (!match)
        return null;
    const boldInner = match[1].trim();
    const afterBold = match[2];
    if (/:\s*$/.test(boldInner)) {
        const prose = afterBold.trim();
        return prose.length > 0 ? prose : null;
    }
    return boldInner.length > 0 ? boldInner : null;
}
// ─── Misc utilities ───────────────────────────────────────────────────────────
function pathExistsInternal(cwd, targetPath) {
    const fullPath = node_path_1.default.isAbsolute(targetPath) ? targetPath : node_path_1.default.join(cwd, targetPath);
    try {
        node_fs_1.default.statSync(fullPath);
        return true;
    }
    catch {
        return false;
    }
}
function generateSlugInternal(text) {
    if (!text)
        return null;
    return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
}
// ─── Phase file helpers ──────────────────────────────────────────────────────
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
function filterPlanFiles(files) {
    return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
}
/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
function filterSummaryFiles(files) {
    return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
}
/**
 * Read a phase directory and return counts/flags for common file types.
 */
function getPhaseFileStats(phaseDir) {
    const files = node_fs_1.default.readdirSync(phaseDir);
    return {
        plans: filterPlanFiles(files),
        summaries: filterSummaryFiles(files),
        hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
        hasContext: findContextMdIn(files) !== null,
        hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
        hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
    };
}
/**
 * Read immediate child directories from a path.
 * Returns [] if the path doesn't exist or can't be read.
 * Pass sort=true to apply comparePhaseNum ordering.
 */
function readSubdirectories(dirPath, sort = false) {
    try {
        const entries = node_fs_1.default.readdirSync(dirPath, { withFileTypes: true });
        const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
        return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
    }
    catch {
        return [];
    }
}
/**
 * Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
 */
function timeAgo(date) {
    const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
    if (seconds < 5)
        return 'just now';
    if (seconds < 60)
        return `${seconds} seconds ago`;
    const minutes = Math.floor(seconds / 60);
    if (minutes === 1)
        return '1 minute ago';
    if (minutes < 60)
        return `${minutes} minutes ago`;
    const hours = Math.floor(minutes / 60);
    if (hours === 1)
        return '1 hour ago';
    if (hours < 24)
        return `${hours} hours ago`;
    const days = Math.floor(hours / 24);
    if (days === 1)
        return '1 day ago';
    if (days < 30)
        return `${days} days ago`;
    const months = Math.floor(days / 30);
    if (months === 1)
        return '1 month ago';
    if (months < 12)
        return `${months} months ago`;
    const years = Math.floor(days / 365);
    if (years === 1)
        return '1 year ago';
    return `${years} years ago`;
}
// ─── Plan ID helpers ─────────────────────────────────────────────────────────
/**
 * Extract the canonical plan ID from a filename.
 * Private to the core cluster β€” exported so core.cjs:searchPhaseInDir can
 * import it from this leaf without circular dependency, but NOT re-exported
 * from core.cjs's public `export =` block.
 */
function extractCanonicalPlanId(filename) {
    const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
    const parts = base.split('-').filter(Boolean);
    const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
    const phaseIdx = parts.findIndex(p => tokenRe.test(p));
    if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
        return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
    }
    return base;
}
module.exports = {
    toPosixPath,
    detectSubRepos,
    extractOneLinerFromBody,
    pathExistsInternal,
    generateSlugInternal,
    filterPlanFiles,
    filterSummaryFiles,
    getPhaseFileStats,
    readSubdirectories,
    timeAgo,
    extractCanonicalPlanId,
};