create-pmos 0.131.0 → 0.133.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/data-freshness.md +40 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/calendar-cli.js +84 -0
- package/workspace/lib/facts-async.js +144 -35
- package/workspace/lib/freshness.js +167 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/offloop.js +94 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +2 -0
- package/workspace/lib/routes-board.js +3 -1
- package/workspace/lib/routes-my-work.js +27 -9
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/routes-sources.js +64 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-shell.js +4 -1
- package/workspace/public/pm/components.css +313 -0
- package/workspace/public/pm/dest-settings.js +10 -1
- package/workspace/public/pm/freshness-line.js +123 -0
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +41 -4
- package/workspace/skills/calendar-sync.skill +30 -0
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
- package/workspace/test-lists/workspace-data-freshness.json +6 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* migrate-layout.js — READ-ONLY dry-run planner for moving a tenant (product DOCS) repo to the PMOS portfolio layout.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/migrate-layout.js plan <tenantRoot> [--domain general] [--out <manifest.json>]
|
|
6
|
+
* node scripts/migrate-layout.js verify <manifest.json> <tenantRoot>
|
|
7
|
+
*
|
|
8
|
+
* THE HARD RULE: this tool never writes into the tenant. It reads files, and it runs `git log` (a read) for revision counts
|
|
9
|
+
* and dates. `--out` is refused when it resolves inside the tenant. There is no `apply` and no `undo` on purpose: see
|
|
10
|
+
* scripts/migrate-layout-APPLY-DESIGN.md for the apply step that is recommended and what the PM approves before it runs.
|
|
11
|
+
*
|
|
12
|
+
* Target layout (prototype/pmos-console-handoff/STRUCTURE.md): products/<domain>/{inbox, ledgers, reports, knowledge,
|
|
13
|
+
* decisions, strategy, health, initiatives/<slug>/{initiative.md, docs/}}. Paths in `to` are relative to the repo root after
|
|
14
|
+
* migration. `keep`, `sidecar` and `upgrade` entries do not move: their `to` is the same tenant-relative path.
|
|
15
|
+
*
|
|
16
|
+
* Classes: kit | record | document | attachment | code | other. Actions: move | keep | sidecar | inbox | upgrade.
|
|
17
|
+
* Ownership is never guessed from a file name: owner evidence is (a) a path an initiative record names in frontmatter or a
|
|
18
|
+
* markdown link, or (b) an `initiative_id` the document declares that resolves to a record. Anything else goes to
|
|
19
|
+
* products/<domain>/inbox/ with needs_pm true; a name match is recorded as `suggested_owner` only and is never acted on.
|
|
20
|
+
* A document body is never planned for rewrite. The only change to a .md is added frontmatter keys. Links that the moves
|
|
21
|
+
* break are LISTED with the rewrite that fixes them; applying a rewrite is a separate, PM-approved step.
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const cp = require('child_process');
|
|
27
|
+
|
|
28
|
+
const PMOS_ROOT = path.resolve(__dirname, '..');
|
|
29
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
30
|
+
const ATTACH_EXT = new Set(['.docx', '.doc', '.xlsx', '.xls', '.pptx', '.ppt', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.csv', '.gdoc', '.gsheet', '.gslides']);
|
|
31
|
+
const CODE_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.ts', '.sh', '.bash', '.zsh', '.rb', '.go']);
|
|
32
|
+
const DOC_EXT = new Set(['.md', '.markdown']);
|
|
33
|
+
const RESERVED = new Set(['index.md', 'log.md']);
|
|
34
|
+
const HASH_LIMIT = 50 * 1024 * 1024;
|
|
35
|
+
// What the vendored kit owns: replaced by `create-pmos` upgrade, never moved by hand. Paths are relative to the kit dir.
|
|
36
|
+
const KIT_RE = /^(AGENTS\.md|AGENTS-HUMAN\.md|KIT-VERSION|README\.md|SECURITY\.md|dashboard\.html|mods\/|skills\/|skills-native\/|scripts\/|templates\/|okf\/core\/|okf\/index\.md|okf\/log\.md|state\/skills\/|\.[^/]+)/;
|
|
37
|
+
|
|
38
|
+
const posix = (p) => p.split(path.sep).join('/');
|
|
39
|
+
const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
40
|
+
|
|
41
|
+
// ── frontmatter (a minimal top-level key: value reader, like scripts/frontmatter_lint.py) ───────────────────────────
|
|
42
|
+
function parseFrontmatter(text) {
|
|
43
|
+
const lines = text.split('\n');
|
|
44
|
+
if (!lines.length || lines[0].trim() !== '---') return { fm: null, multiline: [], end: -1 };
|
|
45
|
+
const fm = {}; const multiline = [];
|
|
46
|
+
let lastKey = null;
|
|
47
|
+
for (let i = 1; i < lines.length; i++) {
|
|
48
|
+
const l = lines[i];
|
|
49
|
+
if (l.trim() === '---') return { fm, multiline, end: i };
|
|
50
|
+
const m = /^([A-Za-z0-9_-]+):\s?(.*)$/.exec(l);
|
|
51
|
+
if (m) {
|
|
52
|
+
lastKey = m[1];
|
|
53
|
+
let v = m[2].trim();
|
|
54
|
+
if (/^(".*"|'.*')$/.test(v)) v = v.slice(1, -1);
|
|
55
|
+
if (!(m[1] in fm)) fm[m[1]] = v;
|
|
56
|
+
if (/^["'][^"']*$/.test(m[2].trim())) multiline.push(m[1]); // opened a quote and did not close it on this line
|
|
57
|
+
} else if (/^\s+\S/.test(l) && lastKey) {
|
|
58
|
+
if (!multiline.includes(lastKey)) multiline.push(lastKey); // an indented continuation: not one-line-per-key
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { fm: null, multiline: [], end: -1 }; // unterminated
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let REGISTRY = null;
|
|
65
|
+
function registry() {
|
|
66
|
+
if (REGISTRY) return REGISTRY;
|
|
67
|
+
try {
|
|
68
|
+
REGISTRY = new Set(fs.readFileSync(path.join(PMOS_ROOT, 'scripts', 'frontmatter_registry.txt'), 'utf8')
|
|
69
|
+
.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')));
|
|
70
|
+
} catch { REGISTRY = new Set(['Concept', 'ADR', 'Template', 'Playbook', 'Reference', 'Initiative', 'PRD', 'Story', 'Rubric', 'OKR', 'Radar', 'Checklist', 'Calibration', 'Research', 'Design', 'Prototype', 'Handover', 'Decision', 'Principle', 'Debt', 'Discovery', 'Guide']); }
|
|
71
|
+
return REGISTRY;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A value the file declares in its first 30 lines: `key: v` or a prose `- key: v` (the kit's `## Frontmatter` / `## Identity`). */
|
|
75
|
+
/** Tags from folder words, only when obvious: the parent folder names of a nested file, minus generic ones. */
|
|
76
|
+
function tagsFor(rel) {
|
|
77
|
+
const generic = new Set(['docs', 'doc', 'files', 'misc', 'other', 'tmp', 'new', 'old', 'src', 'pmos', 'planning', 'okf', 'state']);
|
|
78
|
+
const parts = path.posix.dirname(rel).split('/').filter((x) => x && x !== '.').map(slug).filter((x) => x && x.length > 2 && !generic.has(x) && !/^\d+$/.test(x));
|
|
79
|
+
return parts.length ? '[' + parts.slice(-2).join(', ') + ']' : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function declared(text, key) {
|
|
83
|
+
const re = new RegExp('^[-\\s]*' + key + ':\\s*(.+?)\\s*$', 'i');
|
|
84
|
+
for (const line of text.split('\n').slice(0, 30)) {
|
|
85
|
+
const m = re.exec(line);
|
|
86
|
+
if (m) return m[1].trim().replace(/^["']|["']$/g, '');
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Type from the path, after backfill_frontmatter.py's infer_type, applied to the tenant's logical path. */
|
|
92
|
+
function inferType(lp, category) {
|
|
93
|
+
const b = path.posix.basename(lp).toLowerCase();
|
|
94
|
+
if (/^planning\/evals\//.test(lp)) return /calibration/.test(b) ? 'Calibration' : /rubric/.test(b) ? 'Rubric' : 'Reference';
|
|
95
|
+
for (const [pre, ty] of [['planning/prd/', 'PRD'], ['planning/research/', 'Research'], ['planning/design/', 'Design'], ['planning/radar/', 'Radar'], ['planning/okrs/', 'OKR']]) {
|
|
96
|
+
if (lp.startsWith(pre)) return ty;
|
|
97
|
+
}
|
|
98
|
+
if (lp.startsWith('prototype/')) return 'Prototype';
|
|
99
|
+
if (/^okf\/core\/concepts\//.test(lp)) return 'Concept';
|
|
100
|
+
if (/^planning\/out-of-scope\//.test(lp)) return 'Decision';
|
|
101
|
+
if (/^state\/initiatives\//.test(lp) || /^planning\/initiatives\//.test(lp)) return 'Initiative';
|
|
102
|
+
if (category === 'prd') return 'PRD';
|
|
103
|
+
if (category === 'knowledge') return 'Concept';
|
|
104
|
+
if (category === 'report') return 'Reference';
|
|
105
|
+
return 'Reference';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ISO = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
109
|
+
const humanize = (rel) => { const s = path.posix.basename(rel).replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim(); return s.charAt(0).toUpperCase() + s.slice(1); };
|
|
110
|
+
|
|
111
|
+
/** Frontmatter keys to ADD (never change). Returns {add, notes}. Reuses the backfill logic: declared > git date > file mtime. */
|
|
112
|
+
function planFrontmatter({ lp, text, fm, git, rel, mtimeIso, category, isRecord }) {
|
|
113
|
+
const add = {}; const notes = [];
|
|
114
|
+
const reg = registry();
|
|
115
|
+
const have = fm || {};
|
|
116
|
+
if (!have.type) {
|
|
117
|
+
let dt = declared(text, 'type');
|
|
118
|
+
if (dt && !reg.has(dt)) { notes.push(`declared type '${dt}' not in registry; inferred`); dt = null; }
|
|
119
|
+
add.type = dt || inferType(lp, category);
|
|
120
|
+
} else if (isRecord && have.type && !reg.has(have.type)) {
|
|
121
|
+
notes.push(`frontmatter type '${have.type}' is the work lane, not an OKF type (not in registry); decision needed: keep the lane key and add an OKF key, or rename`);
|
|
122
|
+
} else if (!reg.has(have.type)) {
|
|
123
|
+
notes.push(`type '${have.type}' not in the registry`);
|
|
124
|
+
}
|
|
125
|
+
if (!have.title) {
|
|
126
|
+
let title = null;
|
|
127
|
+
for (const line of text.split('\n')) { const m = /^#\s+(.+?)\s*$/.exec(line); if (m) { title = m[1]; break; } }
|
|
128
|
+
if (!title) title = declared(text, 'title');
|
|
129
|
+
if (!title) { title = humanize(rel); notes.push('title from file name (no H1)'); }
|
|
130
|
+
add.title = title;
|
|
131
|
+
}
|
|
132
|
+
if (!have.timestamp) {
|
|
133
|
+
let ts = have.created || have.date || declared(text, 'timestamp') || declared(text, 'date');
|
|
134
|
+
if (!(ts && ISO.test(ts))) ts = null;
|
|
135
|
+
if (!ts && git.dates.get(rel)) ts = git.dates.get(rel);
|
|
136
|
+
if (!ts) { ts = mtimeIso; notes.push('timestamp from file mtime (no declared or git date)'); }
|
|
137
|
+
add.timestamp = ts;
|
|
138
|
+
}
|
|
139
|
+
return { add, notes };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── reading the repo ────────────────────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
const BUILD_DIRS = new Set(['dist', 'build', 'out', 'target', 'coverage', '__pycache__', '.next', '.cache', 'vendor']);
|
|
144
|
+
/** All files under root. In connect mode (`skipped` given) hidden entries, build output and over-size files are left out and reported. */
|
|
145
|
+
function walk(root, skipped, maxBytes, skipFn, skippedByRule) {
|
|
146
|
+
const out = [];
|
|
147
|
+
(function rec(dir) {
|
|
148
|
+
let ents;
|
|
149
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
150
|
+
ents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
151
|
+
for (const e of ents) {
|
|
152
|
+
const full = path.join(dir, e.name);
|
|
153
|
+
const rel = posix(path.relative(root, full));
|
|
154
|
+
if (SKIP_DIRS.has(e.name) && e.isDirectory()) continue;
|
|
155
|
+
// an optional hard limit from the caller (an onboarding brief's doNotRead/exclude): the entry is never opened, not even stat'ed for a hash
|
|
156
|
+
if (skipFn) { const why = skipFn(rel, e.isDirectory()); if (why) { if (skippedByRule) skippedByRule.push({ path: rel + (e.isDirectory() ? '/' : ''), reason: why, files: e.isDirectory() ? countNames(full) : 1 }); continue; } }
|
|
157
|
+
if (skipped) {
|
|
158
|
+
if (e.name.startsWith('.')) { skipped.push({ path: rel + (e.isDirectory() ? '/' : ''), reason: 'hidden' }); continue; }
|
|
159
|
+
if (e.isDirectory() && BUILD_DIRS.has(e.name)) { skipped.push({ path: rel + '/', reason: 'build output or vendored dependencies' }); continue; }
|
|
160
|
+
}
|
|
161
|
+
if (e.isDirectory()) rec(full);
|
|
162
|
+
else {
|
|
163
|
+
if (skipped && !e.isSymbolicLink()) { let sz = 0; try { sz = fs.statSync(full).size; } catch { /* keep 0 */ } if (sz > maxBytes) { skipped.push({ path: rel, reason: `over the size limit (${sz} bytes > ${maxBytes})` }); continue; } }
|
|
164
|
+
out.push(rel); // files and symlinks (never followed)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
})(root);
|
|
168
|
+
return out.sort();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Count the file names under a folder without opening any file (a directory listing only). */
|
|
172
|
+
function countNames(dir) { let n = 0; try { for (const e of fs.readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? countNames(path.join(dir, e.name)) : 1; } catch { /* unreadable */ } return n; }
|
|
173
|
+
|
|
174
|
+
/** A folder is a PMOS tenant when it holds a vendored kit or PMOS records; anything else is foreign and is connected. */
|
|
175
|
+
function detectMode(root) {
|
|
176
|
+
const has = (p) => fs.existsSync(path.join(root, p));
|
|
177
|
+
return has('pmos/KIT-VERSION') || has('pmos/state') || has('planning/initiatives') || has('planning/runs') || has('state/initiatives') ? 'tenant' : 'connect';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function hashFile(full, st) {
|
|
181
|
+
if (!st.isFile() || st.size > HASH_LIMIT) return null;
|
|
182
|
+
return crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One `git log` pass: commit count and last commit date per file. Read-only; empty maps when there is no history. */
|
|
186
|
+
function gitFacts(root) {
|
|
187
|
+
const counts = new Map(); const dates = new Map();
|
|
188
|
+
if (!fs.existsSync(path.join(root, '.git'))) return { counts, dates, available: false };
|
|
189
|
+
try {
|
|
190
|
+
const out = cp.execFileSync('git', ['-C', root, '--no-optional-locks', 'log', '--no-renames', '--format=%x01%cs', '--name-only'],
|
|
191
|
+
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
192
|
+
let date = null;
|
|
193
|
+
for (const line of out.split('\n')) {
|
|
194
|
+
if (line.startsWith('\x01')) { date = line.slice(1); continue; }
|
|
195
|
+
if (!line) continue;
|
|
196
|
+
counts.set(line, (counts.get(line) || 0) + 1);
|
|
197
|
+
if (!dates.has(line)) dates.set(line, date); // newest first
|
|
198
|
+
}
|
|
199
|
+
return { counts, dates, available: true };
|
|
200
|
+
} catch { return { counts, dates, available: false }; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function detectKitDir(root, files) {
|
|
204
|
+
if (files.includes('pmos/KIT-VERSION')) return 'pmos';
|
|
205
|
+
if (files.includes('pmos/AGENTS.md') && files.some((f) => f.startsWith('pmos/skills/'))) return 'pmos';
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function domainFor(root, explicit) {
|
|
210
|
+
if (explicit) return explicit;
|
|
211
|
+
try {
|
|
212
|
+
const y = fs.readFileSync(path.join(root, 'portfolio.yaml'), 'utf8');
|
|
213
|
+
const base = path.basename(root);
|
|
214
|
+
const m = new RegExp('^\\s+' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':\\s*([A-Za-z0-9_-]+)', 'm').exec(y);
|
|
215
|
+
if (m) return m[1];
|
|
216
|
+
} catch { /* no portfolio.yaml */ }
|
|
217
|
+
return 'general';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── links ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
function stripCode(text) {
|
|
222
|
+
// blank out fenced code and inline code so a link shown as an example is not counted (positions are kept)
|
|
223
|
+
return text.replace(/```[\s\S]*?```/g, (m) => m.replace(/[^\n]/g, ' ')).replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
|
|
224
|
+
}
|
|
225
|
+
const EXTERNAL = /^([a-z][a-z0-9+.-]*:|#|\/\/)/i;
|
|
226
|
+
function safeDecode(s) { try { return decodeURI(s); } catch { return s; } }
|
|
227
|
+
|
|
228
|
+
/** Every markdown link, reference definition and wiki link in a document. Positions are not needed: rewrites are by text. */
|
|
229
|
+
function extractLinks(text) {
|
|
230
|
+
const t = stripCode(text);
|
|
231
|
+
const links = [];
|
|
232
|
+
const add = (kind, raw) => { links.push({ kind, raw }); };
|
|
233
|
+
for (const m of t.matchAll(/\]\(\s*<([^>]+)>\s*(?:"[^"]*")?\s*\)/g)) add('md', m[1]);
|
|
234
|
+
for (const m of t.matchAll(/\]\(\s*([^)\s<>]+)(?:\s+"[^"]*")?\s*\)/g)) add('md', m[1]);
|
|
235
|
+
for (const m of t.matchAll(/^\s*\[[^\]\n]+\]:\s*<?(\S+?)>?(?:\s+"[^"]*")?\s*$/gm)) add('md', m[1]);
|
|
236
|
+
for (const m of t.matchAll(/\[\[([^\]|#\n]+)(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]/g)) add('wiki', m[1].trim());
|
|
237
|
+
return links;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function splitHref(raw) {
|
|
241
|
+
const m = /^([^#?]*)((?:[?#].*)?)$/.exec(raw);
|
|
242
|
+
return { p: m ? m[1] : raw, tail: m ? m[2] : '' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── the planner ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
246
|
+
function plan(tenantRoot, opts = {}) {
|
|
247
|
+
const root = path.resolve(tenantRoot);
|
|
248
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`not a directory: ${root}`);
|
|
249
|
+
const domain = slug(domainFor(root, opts.domain)) || 'general';
|
|
250
|
+
const mode = opts.mode || detectMode(root);
|
|
251
|
+
const connect = mode === 'connect';
|
|
252
|
+
const strategy = opts.strategy || (connect ? 'in-place' : 'restructure');
|
|
253
|
+
if (!['connect', 'tenant'].includes(mode)) throw new Error(`unknown mode: ${mode}`);
|
|
254
|
+
if (!['in-place', 'restructure'].includes(strategy)) throw new Error(`unknown strategy: ${strategy}`);
|
|
255
|
+
const maxBytes = opts.maxBytes || 25 * 1024 * 1024;
|
|
256
|
+
const skipped = [];
|
|
257
|
+
const skippedByRule = [];
|
|
258
|
+
const files = walk(root, connect ? skipped : null, maxBytes, opts.skip || null, skippedByRule);
|
|
259
|
+
const fileSet = new Set(files);
|
|
260
|
+
const kitDir = detectKitDir(root, files);
|
|
261
|
+
const git = gitFacts(root);
|
|
262
|
+
const stat = new Map(); const text = new Map();
|
|
263
|
+
for (const f of files) {
|
|
264
|
+
const full = path.join(root, f);
|
|
265
|
+
const st = fs.lstatSync(full);
|
|
266
|
+
stat.set(f, st);
|
|
267
|
+
if (DOC_EXT.has(path.extname(f).toLowerCase()) && st.isFile()) text.set(f, fs.readFileSync(full, 'utf8'));
|
|
268
|
+
}
|
|
269
|
+
const kitRel = (f) => (kitDir && f.startsWith(kitDir + '/') ? f.slice(kitDir.length + 1) : null);
|
|
270
|
+
const isKit = (f) => { const k = kitRel(f); return k !== null && KIT_RE.test(k); };
|
|
271
|
+
const logical = (f) => { const k = kitRel(f); return k !== null ? k : f; };
|
|
272
|
+
|
|
273
|
+
// ── initiative records and what they name ──
|
|
274
|
+
const initiatives = new Map(); // id -> {rel, fm}
|
|
275
|
+
const isInitiativeRecord = (f) => {
|
|
276
|
+
const lp = logical(f);
|
|
277
|
+
return DOC_EXT.has(path.extname(f).toLowerCase()) && /^(state|planning)\/initiatives\/[^/]+\.md$/.test(lp)
|
|
278
|
+
&& !/^(README|index)\.md$/i.test(path.posix.basename(f)) && !isKit(f);
|
|
279
|
+
};
|
|
280
|
+
for (const f of files) {
|
|
281
|
+
if (!isInitiativeRecord(f)) continue;
|
|
282
|
+
const { fm } = parseFrontmatter(text.get(f) || '');
|
|
283
|
+
const id = (fm && fm.id) || path.posix.basename(f, '.md');
|
|
284
|
+
if (!initiatives.has(id)) initiatives.set(id, { rel: f, fm: fm || {} });
|
|
285
|
+
}
|
|
286
|
+
const resolveRef = (from, v) => {
|
|
287
|
+
const c = String(v).trim().replace(/^["']|["']$/g, '');
|
|
288
|
+
if (!c) return null;
|
|
289
|
+
const cand = [c.replace(/^\//, ''), path.posix.join(path.posix.dirname(from), c)];
|
|
290
|
+
if (kitDir) cand.push(path.posix.join(kitDir, c.replace(/^\//, '')));
|
|
291
|
+
for (const x of cand) { const n = path.posix.normalize(x); if (fileSet.has(n)) return n; }
|
|
292
|
+
return null;
|
|
293
|
+
};
|
|
294
|
+
const claims = new Map(); // file -> Set(initiative id), strong evidence (frontmatter path key or markdown link)
|
|
295
|
+
const mentions = new Map(); // file -> Set(initiative id), prose mention only (suggestion, never acted on)
|
|
296
|
+
const claim = (m, f, id) => { if (!m.has(f)) m.set(f, new Set()); m.get(f).add(id); };
|
|
297
|
+
for (const [id, { rel, fm }] of initiatives) {
|
|
298
|
+
const body = text.get(rel) || '';
|
|
299
|
+
const parsed = parseFrontmatter(body);
|
|
300
|
+
for (const v of Object.values(fm)) { const r = resolveRef(rel, v); if (r && r !== rel) claim(claims, r, id); }
|
|
301
|
+
for (const l of extractLinks(body)) {
|
|
302
|
+
if (l.kind !== 'md' || EXTERNAL.test(l.raw)) continue;
|
|
303
|
+
const r = resolveRef(rel, safeDecode(splitHref(l.raw).p));
|
|
304
|
+
if (r && r !== rel) claim(claims, r, id);
|
|
305
|
+
}
|
|
306
|
+
const after = parsed.end >= 0 ? body.split('\n').slice(parsed.end + 1).join('\n') : body;
|
|
307
|
+
for (const m of after.matchAll(/[\w./-]+\/[\w./-]+\.(?:md|docx|xlsx|pdf)/g)) {
|
|
308
|
+
const r = resolveRef(rel, m[0]);
|
|
309
|
+
if (r && r !== rel && !(claims.get(r) && claims.get(r).has(id))) claim(mentions, r, id);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const named = categorizeNamed(initiatives);
|
|
313
|
+
|
|
314
|
+
// ── classify every file ──
|
|
315
|
+
const entries = [];
|
|
316
|
+
const base = `products/${domain}`;
|
|
317
|
+
for (const f of files) {
|
|
318
|
+
const st = stat.get(f);
|
|
319
|
+
const e = classify(f, st);
|
|
320
|
+
e.size = st.size;
|
|
321
|
+
e.sha256 = hashFile(path.join(root, f), st);
|
|
322
|
+
entries.push(e);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function entry(from, cls, action, to, reason, confidence, needsPm, extra) {
|
|
326
|
+
return Object.assign({ from, class: cls, action, to, reason, confidence, needs_pm: !!needsPm }, extra || {});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function ownerOf(f, t) {
|
|
330
|
+
// evidence a, strong: a record names the path (frontmatter key or link)
|
|
331
|
+
const strong = claims.get(f) ? [...claims.get(f)] : [];
|
|
332
|
+
// evidence b: the document declares an initiative_id (frontmatter, or the kit's `## Frontmatter` / `## Identity` prose)
|
|
333
|
+
const parsed = parseFrontmatter(t || '');
|
|
334
|
+
const dId = (parsed.fm && parsed.fm.initiative_id) || declared(t || '', 'initiative_id');
|
|
335
|
+
const declaredId = dId && dId !== '<id>' ? dId : null;
|
|
336
|
+
const sugg = [];
|
|
337
|
+
if (declaredId && !initiatives.has(declaredId)) sugg.push({ id: declaredId, evidence: 'declared initiative_id does not resolve to a record' });
|
|
338
|
+
for (const id of (mentions.get(f) || [])) sugg.push({ id, evidence: 'named in prose by an initiative record' });
|
|
339
|
+
// a name match is a SUGGESTION only
|
|
340
|
+
const lpl = logical(f).toLowerCase();
|
|
341
|
+
for (const id of initiatives.keys()) {
|
|
342
|
+
if (lpl.split('/').includes(id.toLowerCase()) || path.posix.basename(lpl).startsWith(id.toLowerCase() + '-')) sugg.push({ id, evidence: 'file or folder name matches (a name alone is not ownership)' });
|
|
343
|
+
}
|
|
344
|
+
const ids = new Set(strong);
|
|
345
|
+
if (declaredId && initiatives.has(declaredId)) ids.add(declaredId);
|
|
346
|
+
if (ids.size === 1) return { owner: [...ids][0], evidence: strong.length ? 'named by an initiative record' + (declaredId && initiatives.has(declaredId) ? ' and declared in the file' : '') : 'declared initiative_id in the file' };
|
|
347
|
+
if (ids.size > 1) return { conflict: [...ids].sort(), suggested: [] };
|
|
348
|
+
const seen = new Set(); const uniq = sugg.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
|
|
349
|
+
return { suggested: uniq };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function classify(f, st) {
|
|
353
|
+
const ext = path.extname(f).toLowerCase();
|
|
354
|
+
const b = path.posix.basename(f);
|
|
355
|
+
const lp = logical(f);
|
|
356
|
+
if (st.isSymbolicLink()) return entry(f, 'other', 'keep', f, 'a symbolic link: never followed, left in place', 'high', false);
|
|
357
|
+
if (connect && /^(planning|okf|products)\//.test(f)) {
|
|
358
|
+
return entry(f, ext === '.jsonl' ? 'record' : 'document', 'keep', f, 'already follows PMOS conventions: kept as is, only the rest of the folder is converted', 'high', false, { notes: ['PMOS-convention folder'] });
|
|
359
|
+
}
|
|
360
|
+
if (st.isFile() && !ATTACH_EXT.has(ext) && DOC_EXT.has(ext) && (text.get(f) || '').includes('\0')) {
|
|
361
|
+
return entry(f, 'other', 'keep', f, 'named like a document but not readable as text: left alone', 'high', false, { notes: ['unreadable as text'] });
|
|
362
|
+
}
|
|
363
|
+
if (ext === '.md' && fileSet.has(f.slice(0, -3)) && (ATTACH_EXT.has(path.extname(f.slice(0, -3)).toLowerCase()) || /\.(txt|html?)$/i.test(f.slice(0, -3)))) {
|
|
364
|
+
return entry(f, 'document', 'keep', f, 'a sidecar note that already exists beside its file', 'high', false, { notes: ['existing sidecar'] });
|
|
365
|
+
}
|
|
366
|
+
if (connect && (ext === '.txt' || ext === '.html' || ext === '.htm')) return textSidecar(f, st, ext);
|
|
367
|
+
if (isKit(f)) return entry(f, 'kit', 'upgrade', f, `vendored kit (${kitDir}/): replaced by 'create-pmos' upgrade, never moved by hand`, 'high', false);
|
|
368
|
+
// code and tooling
|
|
369
|
+
if (CODE_EXT.has(ext) || /^(tools|\.github|\.claude)\//.test(f)) return entry(f, 'code', 'keep', f, 'tool, CI or agent configuration: stays where tooling expects it', 'high', false);
|
|
370
|
+
if (b === '.DS_Store') return entry(f, 'other', 'keep', f, 'operating-system noise: left alone (candidate for .gitignore)', 'high', false);
|
|
371
|
+
if (!f.includes('/') && (b === 'CLAUDE.md' || b === 'AGENTS.md' || b.startsWith('.'))) return entry(f, 'other', 'keep', f, 'repo-root agent instruction or dotfile: stays at the root', 'high', false);
|
|
372
|
+
if (f === 'portfolio.yaml') return entry(f, 'record', 'keep', f, 'repo-to-domain map: input to roots.json, which the apply step writes new; this file is not moved', 'high', false);
|
|
373
|
+
// attachments stay in place with a sidecar note
|
|
374
|
+
if (ATTACH_EXT.has(ext)) {
|
|
375
|
+
const sidecar = f + '.md';
|
|
376
|
+
const own = (claims.get(f) && claims.get(f).size === 1) ? [...claims.get(f)][0] : null;
|
|
377
|
+
const title = humanize(f);
|
|
378
|
+
const ts = git.dates.get(f) || st.mtime.toISOString().slice(0, 10);
|
|
379
|
+
const fmAdd = { type: 'Reference', title, timestamp: ts, attachment: b };
|
|
380
|
+
if (own) fmAdd.initiative_id = `${domain}/${own}`;
|
|
381
|
+
if (fileSet.has(sidecar)) return entry(f, 'attachment', 'keep', f, `${ext} attachment: its sidecar note already exists`, 'high', false, { owner: null, notes: ['sidecar present'] });
|
|
382
|
+
return entry(f, 'attachment', 'sidecar', f, `${ext} attachment: stays in place; a sidecar note carries the frontmatter`, 'high', false,
|
|
383
|
+
{ owner: own ? `${domain}/${own}` : null, sidecar_to: sidecar, sidecar_frontmatter: fmAdd });
|
|
384
|
+
}
|
|
385
|
+
// initiative records and ledgers
|
|
386
|
+
if (isInitiativeRecord(f)) {
|
|
387
|
+
const { fm, multiline } = parseFrontmatter(text.get(f) || '');
|
|
388
|
+
const id = (fm && fm.id) || path.posix.basename(f, '.md');
|
|
389
|
+
const fa = planFrontmatter({ lp, text: text.get(f) || '', fm, git, rel: f, mtimeIso: st.mtime.toISOString().slice(0, 10), category: null, isRecord: true });
|
|
390
|
+
return entry(f, 'record', 'move', `${base}/initiatives/${id}/initiative.md`, 'initiative record: becomes initiative.md in its own folder', 'high', false,
|
|
391
|
+
{ owner: `${domain}/${id}`, frontmatter_add: fa.add, multiline_frontmatter: multiline, notes: fa.notes });
|
|
392
|
+
}
|
|
393
|
+
if (/^state\/initiatives\/archive\//.test(lp)) {
|
|
394
|
+
return entry(f, 'record', 'inbox', `${base}/inbox/${lp}`, 'archived initiative record: its consolidated live record is not derivable from the file', 'medium', true, { notes: ['append-only history: keep the file whole'] });
|
|
395
|
+
}
|
|
396
|
+
if (/^planning\/(runs\/[^/]+\.jsonl|work-items\.jsonl)$/.test(lp) || (/^state\/.+\.jsonl$/.test(lp))) {
|
|
397
|
+
const rest = lp.replace(/^(state|planning)\//, '');
|
|
398
|
+
return entry(f, 'record', 'move', `${base}/ledgers/${rest}`, 'append-only ledger: moved whole (history preserved); splitting per initiative into work.jsonl is a later step', 'high', false, { notes: ['mixed-initiative ledger'] });
|
|
399
|
+
}
|
|
400
|
+
if (/^state\/(discoveries\.md)$/.test(lp)) return docRecord(f, lp, `${base}/ledgers/${b}`, 'run and friction log: a record, moved whole');
|
|
401
|
+
if (/^state\/reports\//.test(lp) && DOC_EXT.has(ext)) return docRecord(f, lp, `${base}/reports/${lp.slice('state/reports/'.length)}`, 'kit-generated report or report definition');
|
|
402
|
+
if (/^state\/live-notes\//.test(lp)) return entry(f, 'record', 'move', `${base}/ledgers/${lp.slice('state/'.length)}`, 'append-only live-notes ledger', 'high', false);
|
|
403
|
+
if (/^state\//.test(lp) && ext !== '.md') return entry(f, 'record', 'inbox', `${base}/inbox/${lp}`, 'state file of an unknown kind', 'low', true);
|
|
404
|
+
if (/^state\//.test(lp)) return docOrInbox(f, st, 'record');
|
|
405
|
+
// documents
|
|
406
|
+
if (DOC_EXT.has(ext)) return docOrInbox(f, st, 'document');
|
|
407
|
+
// everything else: data files, html
|
|
408
|
+
if ((ext === '.html' || ext === '.htm') && /^prototype\//.test(lp)) return entry(f, 'other', 'keep', f, 'prototype page: prototypes live in their own quarantined folder and never ship', 'high', false);
|
|
409
|
+
return entry(f, 'other', 'inbox', `${base}/inbox/${lp}`, `${ext || 'extensionless'} file with no known home`, 'low', true);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** .txt and .html cannot carry YAML frontmatter without changing the file, so a sidecar note carries it. */
|
|
413
|
+
function textSidecar(f, st, ext) {
|
|
414
|
+
let body = '';
|
|
415
|
+
try { body = fs.readFileSync(path.join(root, f), 'utf8'); } catch { /* unreadable */ }
|
|
416
|
+
if (body.includes('\0')) return entry(f, 'other', 'keep', f, 'not readable as text: left alone', 'high', false, { notes: ['unreadable as text'] });
|
|
417
|
+
let title = null;
|
|
418
|
+
if (ext !== '.txt') { const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(body) || /<h1[^>]*>([\s\S]*?)<\/h1>/i.exec(body); if (m) title = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); }
|
|
419
|
+
else title = (body.split('\n').find((l) => l.trim()) || '').trim().slice(0, 100) || null;
|
|
420
|
+
const fmAdd = { type: 'Reference', title: title || humanize(f), timestamp: git.dates.get(f) || st.mtime.toISOString().slice(0, 10) };
|
|
421
|
+
const tg = tagsFor(f); if (tg) fmAdd.tags = tg;
|
|
422
|
+
return entry(f, 'document', 'sidecar', f, `${ext} prose document: stays; a sidecar note carries the frontmatter (its body is never edited)`, 'high', false,
|
|
423
|
+
{ owner: null, sidecar_to: f + '.md', sidecar_frontmatter: fmAdd });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function docRecord(f, lp, to, reason) {
|
|
427
|
+
const t = text.get(f) || ''; const { fm, multiline } = parseFrontmatter(t);
|
|
428
|
+
const fa = planFrontmatter({ lp, text: t, fm, git, rel: f, mtimeIso: stat.get(f).mtime.toISOString().slice(0, 10), category: null, isRecord: false });
|
|
429
|
+
return entry(f, 'record', 'move', to, reason, 'high', false, { frontmatter_add: RESERVED.has(path.posix.basename(f)) ? {} : fa.add, multiline_frontmatter: multiline, notes: fa.notes });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function docOrInbox(f, st, cls) {
|
|
433
|
+
const t = text.get(f) || '';
|
|
434
|
+
const lp = logical(f);
|
|
435
|
+
const b = path.posix.basename(f);
|
|
436
|
+
const { fm, multiline } = parseFrontmatter(t);
|
|
437
|
+
const cat = require('../workspace/lib/doc-category.js').categorize({ rel: lp, fm: fm || {}, text: t, named });
|
|
438
|
+
const reserved = RESERVED.has(b);
|
|
439
|
+
const fa = planFrontmatter({ lp, text: t, fm, git, rel: f, mtimeIso: st.mtime.toISOString().slice(0, 10), category: cat.category, isRecord: false });
|
|
440
|
+
if (connect && !reserved && !(fm && fm.tags)) { const tg = tagsFor(f); if (tg) fa.add.tags = tg; }
|
|
441
|
+
const common = { category: cat.category, category_basis: cat.basis, multiline_frontmatter: multiline, notes: fa.notes, frontmatter_add: reserved ? {} : fa.add };
|
|
442
|
+
const own = ownerOf(f, t);
|
|
443
|
+
if (own.owner) {
|
|
444
|
+
const add = Object.assign({}, common.frontmatter_add);
|
|
445
|
+
const rev = git.counts.get(f);
|
|
446
|
+
if (!reserved && rev && !(fm && fm.rev)) add.rev = rev;
|
|
447
|
+
return entry(f, cls === 'record' ? 'record' : 'document', 'move', `${base}/initiatives/${own.owner}/docs/${b}`, `belongs to initiative ${own.owner}: ${own.evidence}`, 'high', false,
|
|
448
|
+
Object.assign({ owner: `${domain}/${own.owner}` }, common, { frontmatter_add: add }));
|
|
449
|
+
}
|
|
450
|
+
// domain-level homes, decided by a documented kit location, never by guessing
|
|
451
|
+
const dom = domainHome(lp, fm);
|
|
452
|
+
if (dom) return entry(f, 'document', 'move', `${base}/${dom.dir}`, dom.reason, 'high', false, Object.assign({ owner: null }, common));
|
|
453
|
+
const tos = `${base}/inbox/${lp}`;
|
|
454
|
+
const why = own.conflict ? `claimed by several initiatives (${own.conflict.join(', ')}): the PM decides`
|
|
455
|
+
: (own.suggested && own.suggested.length) ? 'no ownership evidence a tool can act on; a possible owner is suggested for the PM'
|
|
456
|
+
: 'no owner evidence (no record names it, no initiative_id): the PM decides';
|
|
457
|
+
return entry(f, cls === 'record' ? 'record' : 'document', 'inbox', tos, why, own.suggested && own.suggested.length ? 'medium' : 'low', true,
|
|
458
|
+
Object.assign({ owner: null, suggested_owner: (own.suggested || []).map((s) => ({ initiative: `${domain}/${s.id}`, evidence: s.evidence })) }, own.conflict ? { conflict: own.conflict } : {}, common,
|
|
459
|
+
privateHint(lp, t) ? { sensitivity_review: privateHint(lp, t) } : {}));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function privateHint(lp, t) {
|
|
463
|
+
if (/^briefs\//.test(lp)) return 'daily briefs cite the PM\'s calendar, mail and chat: some of this may belong in me/, not in a shared domain';
|
|
464
|
+
if (/(gmail|slack|calendar|inbox)\b/i.test(t.slice(0, 600)) && /^(briefs|notes)\//.test(lp)) return 'cites personal sources';
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function domainHome(lp, fm) {
|
|
469
|
+
let m;
|
|
470
|
+
if (lp === 'planning/okrs/health-budgets.md') return { dir: 'health/health-budgets.md', reason: 'health budgets (kit location planning/okrs/health-budgets.md)' };
|
|
471
|
+
if ((m = /^planning\/okrs\/(.+)$/.exec(lp))) return { dir: `strategy/${m[1]}`, reason: 'OKRs live under strategy/ (kit location planning/okrs/)' };
|
|
472
|
+
if ((m = /^okf\/product\/(.+)$/.exec(lp))) return { dir: `knowledge/${m[1]}`, reason: "the tenant's own OKF bundle (okf/product/) is the domain's knowledge/" };
|
|
473
|
+
if ((m = /^planning\/out-of-scope\/(.+)$/.exec(lp))) return { dir: `decisions/out-of-scope/${m[1]}`, reason: 'prior rejections are decisions (kit location planning/out-of-scope/)' };
|
|
474
|
+
if ((m = /^reports\/(.+)$/.exec(lp))) return { dir: `reports/${m[1]}`, reason: 'a report in the reports/ folder' };
|
|
475
|
+
if (fm && /^(Decision|ADR)$/.test(fm.type || '')) return { dir: `decisions/${path.posix.basename(lp)}`, reason: `declared type ${fm.type}` };
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── collisions: refuse to plan a clobber ──
|
|
480
|
+
const taken = new Map(); // target path -> source
|
|
481
|
+
for (const e of entries) if (e.action === 'keep' || e.action === 'upgrade' || e.action === 'sidecar') taken.set(e.to, e.from);
|
|
482
|
+
for (const e of entries) if (e.sidecar_to) taken.set(e.sidecar_to, e.from + ' (sidecar)');
|
|
483
|
+
for (const e of entries) {
|
|
484
|
+
if (e.action !== 'move' && e.action !== 'inbox') continue;
|
|
485
|
+
let t = e.to;
|
|
486
|
+
if (taken.has(t) || fileSet.has(t) && t !== e.from) {
|
|
487
|
+
const rival = taken.get(t) || t;
|
|
488
|
+
const ext = path.posix.extname(t); const stem = t.slice(0, t.length - ext.length);
|
|
489
|
+
let n = 2; while (taken.has(`${stem}-${n}${ext}`) || fileSet.has(`${stem}-${n}${ext}`)) n++;
|
|
490
|
+
e.collision = true; e.collides_with = rival; e.wanted_to = t;
|
|
491
|
+
e.to = `${stem}-${n}${ext}`;
|
|
492
|
+
e.needs_pm = true;
|
|
493
|
+
e.reason += `; target name was taken by ${rival}, suffixed -${n}`;
|
|
494
|
+
t = e.to;
|
|
495
|
+
}
|
|
496
|
+
taken.set(t, e.from);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── links ──
|
|
500
|
+
const dest = new Map(entries.map((e) => [e.from, e.to]));
|
|
501
|
+
const kitByFrom = new Map(entries.map((e) => [e.from, e.class === 'kit']));
|
|
502
|
+
const linkIssues = []; const alreadyBroken = []; let linksChecked = 0;
|
|
503
|
+
const stems = new Map();
|
|
504
|
+
for (const f of files) if (DOC_EXT.has(path.extname(f).toLowerCase())) { const s = path.posix.basename(f).replace(/\.[^.]+$/, ''); if (!stems.has(s)) stems.set(s, []); stems.get(s).push(f); }
|
|
505
|
+
|
|
506
|
+
const bases = ['', ...(kitDir ? [kitDir + '/', kitDir + '/okf/'] : [])];
|
|
507
|
+
for (const f of files) {
|
|
508
|
+
if (!text.has(f)) continue;
|
|
509
|
+
const srcNew = dest.get(f);
|
|
510
|
+
const srcKit = kitByFrom.get(f);
|
|
511
|
+
const body = text.get(f);
|
|
512
|
+
const seen = new Set();
|
|
513
|
+
const note = (iss) => { const k = iss.kind + '|' + iss.href; if (seen.has(k)) return; seen.add(k); linkIssues.push(Object.assign({ file: f, file_to: srcNew, source_is_kit: !!srcKit }, iss)); };
|
|
514
|
+
for (const l of extractLinks(body)) {
|
|
515
|
+
linksChecked++;
|
|
516
|
+
if (l.kind === 'wiki') {
|
|
517
|
+
let target = null;
|
|
518
|
+
const w = l.raw.replace(/\.md$/, '');
|
|
519
|
+
if (w.includes('/')) { for (const c of [w + '.md', w, ...(kitDir ? [kitDir + '/' + w + '.md'] : [])]) if (fileSet.has(c)) { target = c; break; } }
|
|
520
|
+
else if (stems.has(w) && stems.get(w).length === 1) target = stems.get(w)[0];
|
|
521
|
+
if (!target) { alreadyBroken.push({ file: f, href: l.raw, kind: 'wiki' }); continue; }
|
|
522
|
+
const tNew = dest.get(target);
|
|
523
|
+
if (tNew === target) continue;
|
|
524
|
+
if (w.includes('/')) note({ kind: 'wiki', href: l.raw, target, target_to: tNew, rewrite: tNew.replace(/\.md$/, '') });
|
|
525
|
+
else { const ns = path.posix.basename(tNew).replace(/\.[^.]+$/, ''); if (ns !== w) note({ kind: 'wiki', href: l.raw, target, target_to: tNew, rewrite: ns }); }
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
if (EXTERNAL.test(l.raw)) continue;
|
|
529
|
+
const { p, tail } = splitHref(l.raw);
|
|
530
|
+
if (!p) continue;
|
|
531
|
+
const dec = safeDecode(p);
|
|
532
|
+
const abs = dec.startsWith('/');
|
|
533
|
+
let target = null; let usedBase = '';
|
|
534
|
+
if (abs) { for (const bs of bases) { const c = path.posix.normalize(bs + dec.slice(1)); if (fileSet.has(c)) { target = c; usedBase = bs; break; } } }
|
|
535
|
+
else { const c = path.posix.normalize(path.posix.join(path.posix.dirname(f), dec)); if (fileSet.has(c)) target = c; }
|
|
536
|
+
if (!target) { alreadyBroken.push({ file: f, href: l.raw, kind: 'md' }); continue; }
|
|
537
|
+
const tNew = dest.get(target);
|
|
538
|
+
let newHref;
|
|
539
|
+
if (abs) {
|
|
540
|
+
// an absolute link stays absolute when its target is still under the same base; otherwise it becomes relative
|
|
541
|
+
newHref = tNew.startsWith(usedBase) ? '/' + tNew.slice(usedBase.length) : path.posix.relative(path.posix.dirname(srcNew), tNew);
|
|
542
|
+
} else {
|
|
543
|
+
newHref = path.posix.relative(path.posix.dirname(srcNew), tNew);
|
|
544
|
+
}
|
|
545
|
+
if (newHref === null) { note({ kind: 'md', href: l.raw, target, target_to: tNew, rewrite: null, note: 'the target leaves the bundle this absolute link is rooted in: choose a form by hand' }); continue; }
|
|
546
|
+
const enc = /%[0-9A-Fa-f]{2}/.test(p) ? encodeURI(newHref) : newHref;
|
|
547
|
+
if (safeDecode(enc) === dec) continue; // still resolves to the same text
|
|
548
|
+
note({ kind: 'md', href: l.raw, target, target_to: tNew, rewrite: enc + tail });
|
|
549
|
+
}
|
|
550
|
+
// frontmatter path values and bare path mentions in prose
|
|
551
|
+
const parsed = parseFrontmatter(body);
|
|
552
|
+
const fmLines = parsed.end >= 0 ? body.split('\n').slice(0, parsed.end + 1) : [];
|
|
553
|
+
const consumed = new Set();
|
|
554
|
+
for (const ln of fmLines) {
|
|
555
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.+)$/.exec(ln);
|
|
556
|
+
if (!m) continue;
|
|
557
|
+
const v = m[2].trim().replace(/^["']|["']$/g, '');
|
|
558
|
+
const r = fileSet.has(v) ? v : (fileSet.has(v.replace(/^\//, '')) ? v.replace(/^\//, '') : null);
|
|
559
|
+
if (r && dest.get(r) !== r) { note({ kind: 'frontmatter-path', href: v, key: m[1], target: r, target_to: dest.get(r), rewrite: dest.get(r) }); consumed.add(v); }
|
|
560
|
+
}
|
|
561
|
+
const rest = parsed.end >= 0 ? body.split('\n').slice(parsed.end + 1).join('\n') : body;
|
|
562
|
+
for (const m of rest.matchAll(/[\w./-]+\/[\w./-]+\.[A-Za-z0-9]+/g)) {
|
|
563
|
+
const tok = m[0]; const before = rest.slice(Math.max(0, m.index - 2), m.index);
|
|
564
|
+
if (before === '](' || before.endsWith('[[') || consumed.has(tok)) continue;
|
|
565
|
+
const r = fileSet.has(tok) ? tok : null;
|
|
566
|
+
if (r && dest.get(r) !== r) note({ kind: 'path-mention', href: tok, target: r, target_to: dest.get(r), rewrite: dest.get(r) });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── strategies: the plan above is the restructure plan; in-place keeps every file where it is ──
|
|
571
|
+
const rewritable = (i) => i.rewrite !== null && i.rewrite !== undefined && !i.source_is_kit;
|
|
572
|
+
const restructureLinks = linkIssues.filter((i) => i.kind !== 'path-mention' && rewritable(i)).length;
|
|
573
|
+
const restructureMentions = linkIssues.filter((i) => i.kind === 'path-mention' && rewritable(i)).length;
|
|
574
|
+
const restructureCounts = {
|
|
575
|
+
moves: entries.filter((e) => e.action === 'move').length,
|
|
576
|
+
inbox: entries.filter((e) => e.action === 'inbox').length,
|
|
577
|
+
needs_pm: entries.filter((e) => e.needs_pm).length,
|
|
578
|
+
collisions: entries.filter((e) => e.collision).length,
|
|
579
|
+
links_to_rewrite: restructureLinks, path_mentions_to_rewrite: restructureMentions,
|
|
580
|
+
};
|
|
581
|
+
const sidecars = entries.filter((e) => e.action === 'sidecar').length;
|
|
582
|
+
const fmAdds = entries.filter((e) => e.frontmatter_add && Object.keys(e.frontmatter_add).length).length;
|
|
583
|
+
if (strategy === 'in-place') {
|
|
584
|
+
for (const e of entries) {
|
|
585
|
+
if (e.action !== 'move' && e.action !== 'inbox') continue;
|
|
586
|
+
e.restructure = { action: e.action, to: e.to, needs_pm: e.needs_pm, collision: !!e.collision };
|
|
587
|
+
e.action = 'keep'; e.to = e.from; e.needs_pm = false; delete e.collision; delete e.collides_with; delete e.wanted_to;
|
|
588
|
+
e.reason = 'in-place: stays where it is; frontmatter is added. (restructure would: ' + e.restructure.action + ' to ' + e.restructure.to + ')';
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const inPlace = strategy === 'in-place';
|
|
592
|
+
const keptLinks = inPlace ? [] : linkIssues;
|
|
593
|
+
let outEntries = entries; let outLinks = keptLinks; let outBroken = alreadyBroken;
|
|
594
|
+
|
|
595
|
+
// ── incremental re-scan: only new or changed files, compared by content hash with the previous manifest ──
|
|
596
|
+
let incremental = null;
|
|
597
|
+
if (opts.since) {
|
|
598
|
+
const prev = new Map((opts.since.entries || []).map((e) => [e.from, e.sha256]));
|
|
599
|
+
const isNew = []; const isChanged = []; let unchanged = 0;
|
|
600
|
+
for (const e of entries) {
|
|
601
|
+
if (!prev.has(e.from)) isNew.push(e.from);
|
|
602
|
+
else if (prev.get(e.from) !== e.sha256) isChanged.push(e.from);
|
|
603
|
+
else unchanged++;
|
|
604
|
+
}
|
|
605
|
+
const listed = new Set([...isNew, ...isChanged]);
|
|
606
|
+
const nowSet = new Set(entries.map((e) => e.from));
|
|
607
|
+
incremental = { new: isNew.length, changed: isChanged.length, unchanged, removed: [...prev.keys()].filter((k) => !nowSet.has(k)).sort(), new_files: isNew, changed_files: isChanged };
|
|
608
|
+
outEntries = entries.filter((e) => listed.has(e.from));
|
|
609
|
+
outLinks = keptLinks.filter((i) => listed.has(i.file));
|
|
610
|
+
outBroken = alreadyBroken.filter((i) => listed.has(i.file));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ── summary ──
|
|
614
|
+
const countBy = (k) => outEntries.reduce((a, e) => { a[e[k]] = (a[e[k]] || 0) + 1; return a; }, {});
|
|
615
|
+
const outRewritable = (i) => rewritable(i);
|
|
616
|
+
const summary = {
|
|
617
|
+
files: outEntries.length,
|
|
618
|
+
by_class: countBy('class'),
|
|
619
|
+
by_action: countBy('action'),
|
|
620
|
+
needs_pm: outEntries.filter((e) => e.needs_pm).length,
|
|
621
|
+
collisions: outEntries.filter((e) => e.collision).length,
|
|
622
|
+
links_checked: linksChecked,
|
|
623
|
+
links_to_rewrite: outLinks.filter((i) => i.kind !== 'path-mention' && outRewritable(i)).length,
|
|
624
|
+
path_mentions_to_rewrite: outLinks.filter((i) => i.kind === 'path-mention' && outRewritable(i)).length,
|
|
625
|
+
links_needing_manual_choice: outLinks.filter((i) => i.rewrite === null && !i.source_is_kit).length,
|
|
626
|
+
links_from_kit_files: outLinks.filter((i) => i.source_is_kit).length,
|
|
627
|
+
links_already_broken: outBroken.length,
|
|
628
|
+
multiline_frontmatter_files: outEntries.filter((e) => e.multiline_frontmatter && e.multiline_frontmatter.length).length,
|
|
629
|
+
initiatives: initiatives.size,
|
|
630
|
+
kit_dir: kitDir,
|
|
631
|
+
mode, strategy,
|
|
632
|
+
skipped: skipped.length,
|
|
633
|
+
strategies: {
|
|
634
|
+
'in-place': { moves: 0, frontmatter_additions: fmAdds, sidecars, links_to_rewrite: 0, needs_pm: 0 },
|
|
635
|
+
restructure: Object.assign({ frontmatter_additions: fmAdds, sidecars }, restructureCounts),
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
const registration = {
|
|
639
|
+
applied: false,
|
|
640
|
+
note: 'Proposed only. A registration writes to the PM\'s own state and to roots.json; the planner never performs it. The PM applies it after approving this manifest.',
|
|
641
|
+
roots_json_entry: { name: domain, kind: 'domain', path: root, layout: inPlace ? 'in-place' : 'portfolio-v1', private: false },
|
|
642
|
+
workspace_registration: { action: 'register-workspace', root, name: path.basename(root), domain, writes: 'the workspace registry in the per-user state dir' },
|
|
643
|
+
};
|
|
644
|
+
return {
|
|
645
|
+
schema: 'pmos-migrate-layout/2',
|
|
646
|
+
tenant: root, tenant_name: path.basename(root), domain, mode, strategy, git_history: git.available,
|
|
647
|
+
summary, entries: outEntries, links: outLinks, already_broken_links: outBroken,
|
|
648
|
+
skipped, skipped_by_rule: skippedByRule, registration, incremental,
|
|
649
|
+
initiatives: [...initiatives.keys()].sort(),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function categorizeNamed(initiatives) {
|
|
654
|
+
const dc = require('../workspace/lib/doc-category.js');
|
|
655
|
+
return dc.namedFromRecords([...initiatives.values()].map((v) => ({ rel: v.rel, fm: v.fm })));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── manifest.md ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
659
|
+
function renderReport(m, today) {
|
|
660
|
+
const s = m.summary;
|
|
661
|
+
const L = [];
|
|
662
|
+
const row = (...c) => '| ' + c.join(' | ') + ' |';
|
|
663
|
+
L.push('---', 'type: Reference', `title: "Layout migration plan: ${m.tenant_name}"`, `timestamp: ${today || new Date().toISOString().slice(0, 10)}`, '---', '');
|
|
664
|
+
L.push(`# Layout migration plan: ${m.tenant_name}`, '');
|
|
665
|
+
L.push('A dry-run plan. Nothing was written into the tenant. The apply step is not built; see `scripts/migrate-layout-APPLY-DESIGN.md`.', '');
|
|
666
|
+
L.push(`Tenant: \`${m.tenant}\`. Domain: \`${m.domain}\`. Kit dir: \`${s.kit_dir || 'none'}\`. Git history read: ${m.git_history ? 'yes' : 'no'}.`, '');
|
|
667
|
+
L.push(`Mode: \`${m.mode}\`. Strategy shown: \`${m.strategy}\`. \`in-place\` leaves every file where it is and only adds frontmatter and sidecars. \`restructure\` moves files into products/${m.domain}/.`, '');
|
|
668
|
+
if (s.strategies) {
|
|
669
|
+
const a = s.strategies['in-place']; const r = s.strategies.restructure;
|
|
670
|
+
L.push('## Strategies side by side', '', row('Measure', 'in-place', 'restructure'), row('---', '---', '---'));
|
|
671
|
+
L.push(row('file moves', a.moves, r.moves), row('moved to inbox', 0, r.inbox), row('frontmatter additions', a.frontmatter_additions, r.frontmatter_additions), row('sidecar notes', a.sidecars, r.sidecars));
|
|
672
|
+
L.push(row('needing PM review', a.needs_pm, r.needs_pm), row('name collisions', 0, r.collisions), row('links to rewrite', a.links_to_rewrite, r.links_to_rewrite), row('bare path mentions to rewrite', 0, r.path_mentions_to_rewrite), '');
|
|
673
|
+
}
|
|
674
|
+
if (m.incremental) L.push('## Incremental re-scan', '', `Only new or changed files are listed: ${m.incremental.new} new, ${m.incremental.changed} changed, ${m.incremental.unchanged} unchanged, ${m.incremental.removed.length} removed since the previous manifest.`, '');
|
|
675
|
+
if (m.registration) L.push('## Proposed registration (NOT performed)', '', m.registration.note, '', '```json', JSON.stringify({ roots_json_entry: m.registration.roots_json_entry, workspace_registration: m.registration.workspace_registration }, null, 2), '```', '');
|
|
676
|
+
if (m.skipped && m.skipped.length) L.push(`## Skipped (${m.skipped.length})`, '', ...m.skipped.slice(0, 60).map((x) => '- `' + x.path + '`: ' + x.reason), '');
|
|
677
|
+
L.push('## Summary', '', row('Measure', 'Count'), row('---', '---'));
|
|
678
|
+
L.push(row('files', s.files));
|
|
679
|
+
for (const [k, v] of Object.entries(s.by_class)) L.push(row('class ' + k, v));
|
|
680
|
+
for (const [k, v] of Object.entries(s.by_action)) L.push(row('action ' + k, v));
|
|
681
|
+
L.push(row('needing PM review', s.needs_pm), row('name collisions (suffixed)', s.collisions), row('initiative records', s.initiatives));
|
|
682
|
+
L.push(row('links that would break (rewrite listed)', s.links_to_rewrite), row('bare path mentions that would go stale', s.path_mentions_to_rewrite));
|
|
683
|
+
L.push(row('links needing a manual choice', s.links_needing_manual_choice), row('links found inside kit files (kit upgrade owns them)', s.links_from_kit_files));
|
|
684
|
+
L.push(row('links already broken before any move', s.links_already_broken), row('files with multi-line frontmatter (not one key per line)', s.multiline_frontmatter_files), '');
|
|
685
|
+
const nonKit = m.entries.filter((e) => e.class !== 'kit');
|
|
686
|
+
L.push('## Kit (upgrade, never moved by hand)', '');
|
|
687
|
+
const kit = m.entries.filter((e) => e.class === 'kit');
|
|
688
|
+
const kitTop = {}; for (const e of kit) { const t = e.from.split('/').slice(0, 2).join('/'); kitTop[t] = (kitTop[t] || 0) + 1; }
|
|
689
|
+
for (const [k, v] of Object.entries(kitTop).sort()) L.push(`- \`${k}\`: ${v} files`);
|
|
690
|
+
L.push('');
|
|
691
|
+
L.push('## Planned moves by destination', '');
|
|
692
|
+
const dests = {};
|
|
693
|
+
for (const e of nonKit.filter((x) => x.action === 'move' || x.action === 'inbox')) { const d = e.to.split('/').slice(0, 4).join('/'); dests[d] = (dests[d] || 0) + 1; }
|
|
694
|
+
for (const [k, v] of Object.entries(dests).sort()) L.push(`- \`${k}\`: ${v}`);
|
|
695
|
+
L.push('');
|
|
696
|
+
const review = nonKit.filter((e) => e.needs_pm);
|
|
697
|
+
L.push(`## PM review queue (${review.length})`, '');
|
|
698
|
+
L.push(row('From', 'Proposed', 'Why', 'Suggested owner'), row('---', '---', '---', '---'));
|
|
699
|
+
for (const e of review) L.push(row('`' + e.from + '`', '`' + e.to + '`', (e.collision ? 'COLLISION. ' : '') + e.reason.replace(/\|/g, '/'), (e.suggested_owner || []).map((x) => x.initiative).join(', ') || ''));
|
|
700
|
+
L.push('');
|
|
701
|
+
const priv = nonKit.filter((e) => e.sensitivity_review);
|
|
702
|
+
if (priv.length) { L.push(`## Possible personal content (${priv.length})`, '', priv[0].sensitivity_review, '', ...priv.slice(0, 20).map((e) => '- `' + e.from + '`'), priv.length > 20 ? `- ... and ${priv.length - 20} more` : '', ''); }
|
|
703
|
+
const coll = nonKit.filter((e) => e.collision);
|
|
704
|
+
if (coll.length) { L.push(`## Collisions (${coll.length})`, '', row('From', 'Wanted', 'Planned', 'Taken by'), row('---', '---', '---', '---')); for (const e of coll) L.push(row('`' + e.from + '`', '`' + e.wanted_to + '`', '`' + e.to + '`', '`' + e.collides_with + '`')); L.push(''); }
|
|
705
|
+
const lk = m.links.filter((i) => !i.source_is_kit);
|
|
706
|
+
L.push(`## Links and references to rewrite (${lk.length})`, '', 'Each row lists the exact text in the file and the text that fixes it. A rewrite edits a body, so it is a separate commit the PM approves; the move commit leaves bodies byte-identical.', '');
|
|
707
|
+
L.push(row('File (now)', 'Kind', 'Current', 'Rewrite to'), row('---', '---', '---', '---'));
|
|
708
|
+
for (const i of lk.slice(0, 400)) L.push(row('`' + i.file + '`', i.kind, '`' + i.href + '`', i.rewrite === null ? 'manual: ' + (i.note || '') : '`' + i.rewrite + '`'));
|
|
709
|
+
if (lk.length > 400) L.push('', `... ${lk.length - 400} more in the JSON manifest.`);
|
|
710
|
+
L.push('');
|
|
711
|
+
const fmw = nonKit.filter((e) => e.multiline_frontmatter && e.multiline_frontmatter.length);
|
|
712
|
+
if (fmw.length) { L.push(`## Frontmatter that is not one key per line (${fmw.length})`, '', 'OKF v0.2 wants keys on one line. The planner adds keys only; it does not reflow these.', '', ...fmw.slice(0, 40).map((e) => '- `' + e.from + '`: ' + e.multiline_frontmatter.join(', ')), ''); }
|
|
713
|
+
const notes = nonKit.filter((e) => e.notes && e.notes.some((n) => /work lane|not in the registry/.test(n)));
|
|
714
|
+
if (notes.length) { L.push(`## Frontmatter type conflicts (${notes.length})`, '', ...notes.slice(0, 40).map((e) => '- `' + e.from + '`: ' + e.notes.filter((n) => /work lane|not in the registry/.test(n)).join('; ')), ''); }
|
|
715
|
+
return L.join('\n') + '\n';
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── verify ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
719
|
+
function verify(manifest, tenantRoot) {
|
|
720
|
+
const root = path.resolve(tenantRoot);
|
|
721
|
+
const drift = [];
|
|
722
|
+
const files = walk(root, manifest.mode === 'connect' ? [] : null, 25 * 1024 * 1024 * 100);
|
|
723
|
+
const fileSet = new Set(files);
|
|
724
|
+
const known = new Set(manifest.entries.map((e) => e.from));
|
|
725
|
+
for (const e of manifest.entries) {
|
|
726
|
+
if (!fileSet.has(e.from)) { drift.push({ type: 'missing', path: e.from, detail: 'in the plan, not in the repo' }); continue; }
|
|
727
|
+
const st = fs.lstatSync(path.join(root, e.from));
|
|
728
|
+
if (e.sha256 && st.isFile() && st.size <= HASH_LIMIT && hashFile(path.join(root, e.from), st) !== e.sha256) drift.push({ type: 'changed', path: e.from, detail: 'content differs from the planned hash' });
|
|
729
|
+
if ((e.action === 'move' || e.action === 'inbox') && e.to !== e.from && fileSet.has(e.to)) drift.push({ type: 'target-taken', path: e.to, detail: `planned target for ${e.from} now exists` });
|
|
730
|
+
if (e.sidecar_to && fileSet.has(e.sidecar_to)) drift.push({ type: 'target-taken', path: e.sidecar_to, detail: `sidecar for ${e.from} now exists` });
|
|
731
|
+
}
|
|
732
|
+
if (!manifest.incremental) for (const f of files) if (!known.has(f)) drift.push({ type: 'new-file', path: f, detail: 'not in the plan' });
|
|
733
|
+
return { ok: drift.length === 0, drift, checked: manifest.entries.length };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
737
|
+
function insideDir(child, parent) { const r = path.relative(parent, child); return r === '' || (!r.startsWith('..') && !path.isAbsolute(r)); }
|
|
738
|
+
|
|
739
|
+
function main(argv) {
|
|
740
|
+
const [cmd, ...rest] = argv;
|
|
741
|
+
const flags = {}; const pos = [];
|
|
742
|
+
for (let i = 0; i < rest.length; i++) { if (rest[i].startsWith('--')) { flags[rest[i].slice(2)] = rest[i + 1]; i++; } else pos.push(rest[i]); }
|
|
743
|
+
if (cmd === 'plan' && pos[0]) {
|
|
744
|
+
const root = path.resolve(pos[0]);
|
|
745
|
+
const since = flags.since ? JSON.parse(fs.readFileSync(path.resolve(flags.since), 'utf8')) : null;
|
|
746
|
+
const m = plan(root, { domain: flags.domain, mode: flags.mode, strategy: flags.strategy, since, maxBytes: flags['max-mb'] ? Number(flags['max-mb']) * 1024 * 1024 : undefined });
|
|
747
|
+
if (flags.out) {
|
|
748
|
+
const out = path.resolve(flags.out);
|
|
749
|
+
let realRoot = root; try { realRoot = fs.realpathSync(root); } catch { /* keep */ }
|
|
750
|
+
if (insideDir(out, root) || insideDir(out, realRoot) || insideDir(path.join(fs.existsSync(path.dirname(out)) ? fs.realpathSync(path.dirname(out)) : path.dirname(out), path.basename(out)), realRoot)) {
|
|
751
|
+
process.stderr.write(`refused: --out is inside the tenant repo (${root}). This tool never writes into a tenant.\n`); return 2;
|
|
752
|
+
}
|
|
753
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
754
|
+
fs.writeFileSync(out, JSON.stringify(m, null, 2) + '\n');
|
|
755
|
+
const md = out.replace(/\.json$/i, '') + (/\.json$/i.test(out) ? '.md' : '.manifest.md');
|
|
756
|
+
fs.writeFileSync(md, renderReport(m));
|
|
757
|
+
process.stdout.write(`wrote ${out}\nwrote ${md}\n`);
|
|
758
|
+
}
|
|
759
|
+
process.stdout.write(JSON.stringify(m.summary, null, 2) + '\n');
|
|
760
|
+
return 0;
|
|
761
|
+
}
|
|
762
|
+
if (cmd === 'verify' && pos[0] && pos[1]) {
|
|
763
|
+
const manifest = JSON.parse(fs.readFileSync(path.resolve(pos[0]), 'utf8'));
|
|
764
|
+
const r = verify(manifest, pos[1]);
|
|
765
|
+
if (r.ok) { process.stdout.write(`clean: ${r.checked} planned entries match the repo\n`); return 0; }
|
|
766
|
+
process.stdout.write(`DRIFT: ${r.drift.length} difference(s)\n`);
|
|
767
|
+
for (const d of r.drift) process.stdout.write(` ${d.type.padEnd(12)} ${d.path} (${d.detail})\n`);
|
|
768
|
+
return 1;
|
|
769
|
+
}
|
|
770
|
+
process.stderr.write('usage:\n node scripts/migrate-layout.js plan <folder> [--domain general] [--mode connect|tenant] [--strategy in-place|restructure] [--since <manifest.json>] [--max-mb 25] [--out <manifest.json>]\n node scripts/migrate-layout.js verify <manifest.json> <tenantRoot>\n(no apply, no undo: see scripts/migrate-layout-APPLY-DESIGN.md)\n');
|
|
771
|
+
return 2;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
module.exports = { plan, verify, renderReport, extractLinks, parseFrontmatter };
|
|
775
|
+
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|