filemayor 4.0.7 → 4.0.8

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/core/config.js CHANGED
@@ -532,18 +532,78 @@ security:
532
532
  `;
533
533
  }
534
534
 
535
+ /**
536
+ * Generate a PARA-method .filemayor.yml template.
537
+ * PARA (Projects, Areas, Resources, Archives) is a method by Tiago Forte;
538
+ * FileMayor automates it — this preset tunes the actionability sorter.
539
+ * @returns {string} YAML config template
540
+ */
541
+ function generateParaTemplate() {
542
+ return `# ═══════════════════════════════════════════════════════════
543
+ # FileMayor Configuration — PARA preset
544
+ # PARA = Projects · Areas · Resources · Archives (method by Tiago Forte).
545
+ # FileMayor automates it by sorting files on actionability signals.
546
+ # Run: filemayor para . (preview the plan)
547
+ # filemayor apply (execute, fully reversible)
548
+ # ═══════════════════════════════════════════════════════════
549
+
550
+ version: 1
551
+
552
+ # ─── PARA Actionability Sorter ─────────────────────────────
553
+ para:
554
+ # Files not modified in this many months are moved to Archives.
555
+ archiveAfterMonths: 12
556
+
557
+ # Files modified within this window count as active → Projects.
558
+ activeWithinMonths: 3
559
+
560
+ # Where files with no clear signal land: projects|areas|resources|archives
561
+ defaultBucket: resources
562
+
563
+ # Keep the top-level subfolder name as a grouping inside each bucket.
564
+ preserveSubfolders: true
565
+
566
+ # Bucket folder names (rename or localize freely).
567
+ folders:
568
+ projects: Projects
569
+ areas: Areas
570
+ resources: Resources
571
+ archives: Archives
572
+
573
+ # Folder/name words that signal an ongoing responsibility (Areas).
574
+ areaKeywords: [finance, finances, tax, taxes, invoice, invoices, receipt, insurance, health, medical, home, car, contract, contracts, legal, admin, hr, utilities]
575
+
576
+ # File categories that are reference material by nature (Resources).
577
+ resourceCategories: [books, fonts, design]
578
+
579
+ # Folder/name words that signal reference material (Resources).
580
+ resourceKeywords: [reference, template, templates, manual, manuals, docs, research, notes, inspiration, assets, library, guide, guides]
581
+
582
+ # Folder/name words that signal active efforts (Projects).
583
+ projectKeywords: [project, projects, client, launch, campaign, sprint, deliverable, proposal, draft]
584
+
585
+ # ─── Security ──────────────────────────────────────────────
586
+ security:
587
+ confirmDestructive: true
588
+ journalEnabled: true
589
+ journalPath: .filemayor-journal.json
590
+ `;
591
+ }
592
+
535
593
  /**
536
594
  * Create a config file in the specified directory
537
595
  * @param {string} dir - Directory to create config in
538
596
  * @param {string} filename - Config filename
597
+ * @param {string} preset - Template preset: 'default' | 'para'
539
598
  * @returns {string} Path to created file
540
599
  */
541
- function createConfigFile(dir = process.cwd(), filename = '.filemayor.yml') {
600
+ function createConfigFile(dir = process.cwd(), filename = '.filemayor.yml', preset = 'default') {
542
601
  const configPath = path.join(dir, filename);
543
602
  if (fs.existsSync(configPath)) {
544
603
  throw new Error(`Config file already exists: ${configPath}`);
545
604
  }
546
- fs.writeFileSync(configPath, generateTemplate(), 'utf8');
605
+ const template = preset === 'para' ? generateParaTemplate() : generateTemplate();
606
+ fs.writeFileSync(configPath, template, 'utf8');
547
607
  return configPath;
548
608
  }
549
609
 
@@ -558,5 +618,6 @@ module.exports = {
558
618
  expandVariables,
559
619
  parseSimpleYaml,
560
620
  generateTemplate,
621
+ generateParaTemplate,
561
622
  createConfigFile
562
623
  };
@@ -0,0 +1,370 @@
1
+ /**
2
+ * ═══════════════════════════════════════════════════════════════════
3
+ * FILEMAYOR — PARA ENGINE
4
+ * Deterministic actionability sorter implementing Tiago Forte's PARA
5
+ * method (Projects · Areas · Resources · Archives).
6
+ *
7
+ * PARA organizes by *actionability*, not by file type. A pure semantic
8
+ * judgement (is this an active Project or an ongoing Area?) needs a
9
+ * human or an LLM — so this engine is deliberately honest: it sorts on
10
+ * the signals it can actually read (recency, category, folder names,
11
+ * project substrates) and labels every move with the reason it chose.
12
+ * Where it cannot tell, it says so (warnings) rather than guessing
13
+ * confidently. Semantic refinement is what the AI path is for.
14
+ *
15
+ * Output shape matches the Curative Plan consumed by ApplyEngine:
16
+ * { narrative, confidence, warnings, plan: [{ source, destination,
17
+ * reason, bucket }], summary }
18
+ * ═══════════════════════════════════════════════════════════════════
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const path = require('path');
24
+ const { scan } = require('../scanner');
25
+ const { findNearestSubstrate } = require('../security');
26
+
27
+ // ─── PARA Defaults ────────────────────────────────────────────────
28
+ // Mirrors config.para in cli/core/config.js. Anything passed to the
29
+ // constructor overrides these per-field.
30
+ const DEFAULT_PARA = {
31
+ archiveAfterMonths: 12, // not modified in ≥ this many months → Archives
32
+ activeWithinMonths: 3, // modified within this window → Projects (active work)
33
+ defaultBucket: 'resources', // where files with no actionability signal land
34
+ preserveSubfolders: true, // keep the top-level subfolder name as a grouping
35
+ folders: {
36
+ projects: 'Projects',
37
+ areas: 'Areas',
38
+ resources: 'Resources',
39
+ archives: 'Archives',
40
+ },
41
+ // Ongoing responsibilities you maintain indefinitely (Areas).
42
+ areaKeywords: [
43
+ 'finance', 'finances', 'financial', 'tax', 'taxes', 'invoice', 'invoices',
44
+ 'receipt', 'receipts', 'banking', 'payroll', 'payslip', 'insurance',
45
+ 'health', 'medical', 'fitness', 'home', 'house', 'car', 'vehicle',
46
+ 'contract', 'contracts', 'legal', 'admin', 'hr', 'utilities', 'pet', 'pets',
47
+ ],
48
+ // File categories that are reference material by nature (Resources).
49
+ resourceCategories: ['books', 'fonts', 'design'],
50
+ // Folder names that signal reference/interest material (Resources).
51
+ resourceKeywords: [
52
+ 'reference', 'references', 'template', 'templates', 'manual', 'manuals',
53
+ 'docs', 'documentation', 'research', 'notes', 'inspiration', 'assets',
54
+ 'library', 'resource', 'resources', 'guide', 'guides', 'cheatsheet',
55
+ ],
56
+ // Folder/name signals for active efforts with an end (Projects).
57
+ projectKeywords: [
58
+ 'project', 'projects', 'client', 'clients', 'launch', 'campaign',
59
+ 'sprint', 'deliverable', 'proposal', 'wip', 'draft', 'milestone',
60
+ ],
61
+ };
62
+
63
+ const MONTH_MS = 1000 * 60 * 60 * 24 * 30.44; // average month
64
+
65
+ class PARAEngine {
66
+ /**
67
+ * @param {Object} paraConfig - Overrides for DEFAULT_PARA (typically config.para)
68
+ */
69
+ constructor(paraConfig = {}) {
70
+ this.config = {
71
+ ...DEFAULT_PARA,
72
+ ...paraConfig,
73
+ folders: { ...DEFAULT_PARA.folders, ...(paraConfig.folders || {}) },
74
+ };
75
+ // The four bucket folder names, lowercased, for idempotency checks.
76
+ this._bucketNames = new Set(
77
+ Object.values(this.config.folders).map(f => f.toLowerCase())
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Generate a PARA reorganisation plan for a directory (read-only).
83
+ * @param {string} dirPath - Directory to organise
84
+ * @param {Object} [options]
85
+ * @param {number} [options.now] - Reference timestamp (ms) — injectable for tests
86
+ * @returns {Object} Curative-plan-shaped result
87
+ */
88
+ plan(dirPath, options = {}) {
89
+ const root = path.resolve(dirPath);
90
+ const now = options.now || Date.now();
91
+
92
+ const scanResult = scan(root, { ...(options.scanOptions || {}), includeDirectories: false });
93
+
94
+ const plan = [];
95
+ const counts = { projects: 0, areas: 0, resources: 0, archives: 0 };
96
+ const seenDest = new Set();
97
+ const movedSubstrates = new Set();
98
+ let noSignalCount = 0;
99
+ let alreadySorted = 0;
100
+
101
+ for (const file of scanResult.files) {
102
+ const rel = path.relative(root, file.path);
103
+ const segments = rel.split(path.sep);
104
+
105
+ // Idempotency: skip anything already living under a PARA bucket.
106
+ if (segments.length > 1 && this._bucketNames.has(segments[0].toLowerCase())) {
107
+ alreadySorted++;
108
+ continue;
109
+ }
110
+
111
+ // Project substrates (.git, package.json, .als, …) move as one
112
+ // atomic unit into Projects — never shredded file-by-file.
113
+ const substrate = findNearestSubstrate(file.path);
114
+ if (substrate) {
115
+ const substrateRoot = path.resolve(substrate);
116
+ // A substrate that IS the target dir can't be PARA-organised.
117
+ if (substrateRoot === root) continue;
118
+ if (movedSubstrates.has(substrateRoot)) continue;
119
+ movedSubstrates.add(substrateRoot);
120
+
121
+ const dest = this._dest(root, 'projects', [path.basename(substrateRoot)]);
122
+ plan.push({
123
+ source: substrateRoot,
124
+ destination: dest,
125
+ bucket: 'projects',
126
+ reason: `Active project (contains ${path.basename(substrate) === path.basename(substrateRoot) ? 'a project marker' : 'project files'}) — moved intact. COLLECTION_MOVE`,
127
+ });
128
+ counts.projects++;
129
+ continue;
130
+ }
131
+
132
+ const { bucket, reason, noSignal } = this._classify(file, rel, now);
133
+ if (noSignal) noSignalCount++;
134
+ counts[bucket]++;
135
+
136
+ // Preserve the top-level subfolder as a grouping inside the bucket.
137
+ const sub = (this.config.preserveSubfolders && segments.length > 1)
138
+ ? [segments[0]]
139
+ : [];
140
+ let dest = this._dest(root, bucket, [...sub, file.name]);
141
+
142
+ // Avoid two sources colliding on one destination within the plan.
143
+ if (seenDest.has(dest.toLowerCase())) {
144
+ const parsed = path.parse(dest);
145
+ dest = path.join(parsed.dir, `${parsed.name}_${counts[bucket]}${parsed.ext}`);
146
+ }
147
+ seenDest.add(dest.toLowerCase());
148
+
149
+ plan.push({ source: file.path, destination: dest, bucket, reason });
150
+ }
151
+
152
+ const warnings = [];
153
+ if (noSignalCount > 0) {
154
+ warnings.push(
155
+ `${noSignalCount} file(s) had no clear actionability signal and were placed in ` +
156
+ `${this.config.folders[this.config.defaultBucket]} for you to review.`
157
+ );
158
+ }
159
+ if (alreadySorted > 0) {
160
+ warnings.push(`${alreadySorted} file(s) were already inside a PARA folder and left untouched.`);
161
+ }
162
+
163
+ return {
164
+ mode: 'deterministic',
165
+ method: 'PARA',
166
+ narrative: this._narrative(counts, plan.length),
167
+ confidence: 100, // deterministic — every move is rule-derived
168
+ warnings,
169
+ plan,
170
+ summary: {
171
+ totalMoves: plan.length,
172
+ buckets: counts,
173
+ scanned: scanResult.files.length,
174
+ },
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Create and populate the four PARA folders with teaching READMEs.
180
+ * Idempotent — existing folders/READMEs are left in place.
181
+ * @param {string} dirPath - Where to scaffold
182
+ * @returns {{ created: string[], folders: Object }}
183
+ */
184
+ scaffold(dirPath) {
185
+ const fs = require('fs');
186
+ const root = path.resolve(dirPath);
187
+ const created = [];
188
+
189
+ for (const key of ['projects', 'areas', 'resources', 'archives']) {
190
+ const folder = path.join(root, this.config.folders[key]);
191
+ if (!fs.existsSync(folder)) {
192
+ fs.mkdirSync(folder, { recursive: true });
193
+ created.push(folder);
194
+ }
195
+ const readme = path.join(folder, 'README.md');
196
+ if (!fs.existsSync(readme)) {
197
+ fs.writeFileSync(readme, PARA_READMES[key], 'utf8');
198
+ created.push(readme);
199
+ }
200
+ }
201
+
202
+ return { created, folders: this.config.folders };
203
+ }
204
+
205
+ // ─── Internals ────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Decide a file's PARA bucket from the signals we can actually read.
209
+ *
210
+ * Order is deliberate: EXPLICIT INTENT first (file category and the
211
+ * folder names the user themselves chose), then INFERRED TIME signals
212
+ * as the fallback for loose, unfiled files. Rationale — if you already
213
+ * filed something under finances/ or templates/, honour that meaning
214
+ * regardless of age; don't fragment a maintained folder by date. The
215
+ * "stale → Archives" automation then targets exactly the loose junk
216
+ * where it's most valuable. Every branch states its reason.
217
+ */
218
+ _classify(file, rel, now) {
219
+ const hay = rel.toLowerCase();
220
+ const cat = file.category || 'other';
221
+ const ageMonths = this._ageMonths(file, now);
222
+
223
+ // ── Explicit intent ──────────────────────────────────────
224
+ // 1. Reference by nature — books, fonts, design assets.
225
+ if (this.config.resourceCategories.includes(cat)) {
226
+ return { bucket: 'resources', reason: `Reference material (${cat})` };
227
+ }
228
+
229
+ // 2. Ongoing responsibility — a maintained Area (finances/, health/…).
230
+ const areaHit = this._keywordHit(hay, this.config.areaKeywords);
231
+ if (areaHit) {
232
+ return { bucket: 'areas', reason: `Ongoing responsibility (matched "${areaHit}")` };
233
+ }
234
+
235
+ // 3. Reference by folder name (reference/, templates/, …).
236
+ const resourceHit = this._keywordHit(hay, this.config.resourceKeywords);
237
+ if (resourceHit) {
238
+ return { bucket: 'resources', reason: `Reference material (matched "${resourceHit}")` };
239
+ }
240
+
241
+ // 4. Explicit active-effort signal (client-*, *-launch, project/…).
242
+ const projectHit = this._keywordHit(hay, this.config.projectKeywords);
243
+ if (projectHit) {
244
+ return { bucket: 'projects', reason: `Active project (matched "${projectHit}")` };
245
+ }
246
+
247
+ // ── Inferred time signals (loose, unfiled files only) ─────
248
+ // 5. Inactive — not modified in a long time → Archives. The single
249
+ // most automatable PARA decision, and the one humans avoid.
250
+ if (ageMonths !== null && ageMonths >= this.config.archiveAfterMonths) {
251
+ return { bucket: 'archives', reason: `Not modified in ${Math.round(ageMonths)} months` };
252
+ }
253
+
254
+ // 6. Recently active loose work → Projects.
255
+ if (ageMonths !== null && ageMonths <= this.config.activeWithinMonths) {
256
+ return { bucket: 'projects', reason: `Recently active (modified ${Math.round(ageMonths)} month(s) ago)` };
257
+ }
258
+
259
+ // 7. No clear actionability signal — honest fallback, flagged for review.
260
+ const fallback = this.config.defaultBucket;
261
+ return {
262
+ bucket: fallback,
263
+ reason: 'No clear actionability signal — placed here for review',
264
+ noSignal: true,
265
+ };
266
+ }
267
+
268
+ _ageMonths(file, now) {
269
+ const t = this._mtime(file);
270
+ if (t === null) return null;
271
+ return (now - t) / MONTH_MS;
272
+ }
273
+
274
+ _mtime(file) {
275
+ const m = file.modified || file.created;
276
+ if (!m) return null;
277
+ const t = (m instanceof Date) ? m.getTime() : new Date(m).getTime();
278
+ return Number.isFinite(t) ? t : null;
279
+ }
280
+
281
+ _keywordHit(haystack, keywords) {
282
+ for (const kw of keywords) {
283
+ // Word-ish boundary so "car" doesn't match "scary".
284
+ const re = new RegExp(`(^|[^a-z0-9])${escapeRe(kw)}([^a-z0-9]|$)`, 'i');
285
+ if (re.test(haystack)) return kw;
286
+ }
287
+ return null;
288
+ }
289
+
290
+ _dest(root, bucketKey, parts) {
291
+ return path.join(root, this.config.folders[bucketKey], ...parts);
292
+ }
293
+
294
+ _narrative(counts, total) {
295
+ if (total === 0) return 'Nothing to do — this folder is already PARA-organised or empty.';
296
+ const bits = [];
297
+ if (counts.projects) bits.push(`${counts.projects} to Projects`);
298
+ if (counts.areas) bits.push(`${counts.areas} to Areas`);
299
+ if (counts.resources) bits.push(`${counts.resources} to Resources`);
300
+ if (counts.archives) bits.push(`${counts.archives} to Archives`);
301
+ return `PARA plan: ${bits.join(', ')}. Every move is reversible after apply.`;
302
+ }
303
+ }
304
+
305
+ function escapeRe(s) {
306
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
+ }
308
+
309
+ // ─── Teaching READMEs (the democratization layer) ─────────────────
310
+ // Plain-English so a first-timer who has never heard of PARA gets it.
311
+ const PARA_READMES = {
312
+ projects: `# Projects
313
+
314
+ **Active efforts with a goal and an end.** One folder per project.
315
+
316
+ A project is anything you are *currently* working toward finishing:
317
+ a trip you're planning, a deliverable due next month, a room you're
318
+ renovating. When a project is done, move its folder to \`Archives\`.
319
+
320
+ FileMayor places recently-modified files and anything that looks like
321
+ active work here. You stay in control — review before applying.
322
+
323
+ _PARA is a method created by Tiago Forte (Building a Second Brain).
324
+ FileMayor automates it; it is not affiliated with or endorsed by him._
325
+ `,
326
+ areas: `# Areas
327
+
328
+ **Ongoing responsibilities you maintain — with no end date.**
329
+
330
+ Areas are the standing parts of your life and work: Finances, Health,
331
+ Home, a job role, a car you own. There's no "done" — you just keep them
332
+ in good order. One folder per area (e.g. \`Finances\`, \`Health\`).
333
+
334
+ FileMayor routes files whose names or folders match common areas
335
+ (taxes, invoices, insurance, health…) here.
336
+
337
+ _PARA method by Tiago Forte. FileMayor automates it; not affiliated._
338
+ `,
339
+ resources: `# Resources
340
+
341
+ **Topics and reference material you may want later.**
342
+
343
+ Things that aren't tied to a current project or a responsibility, but
344
+ are worth keeping: reference docs, templates, books, fonts, design
345
+ assets, inspiration. One folder per topic.
346
+
347
+ FileMayor places reference-type files (books, fonts, design) and
348
+ anything in \`reference/\`, \`templates/\`, etc. here.
349
+
350
+ _PARA method by Tiago Forte. FileMayor automates it; not affiliated._
351
+ `,
352
+ archives: `# Archives
353
+
354
+ **Inactive items from the other three categories.**
355
+
356
+ Nothing is deleted — Archives is cold storage. When a project finishes
357
+ or a resource stops being useful, it moves here, out of the way but
358
+ recoverable.
359
+
360
+ FileMayor automatically moves files you haven't touched in a long time
361
+ (12+ months by default) here. This is the chore PARA users hate doing
362
+ by hand — so FileMayor does it for you.
363
+
364
+ _PARA method by Tiago Forte. FileMayor automates it; not affiliated._
365
+ `,
366
+ };
367
+
368
+ module.exports = PARAEngine;
369
+ module.exports.DEFAULT_PARA = DEFAULT_PARA;
370
+ module.exports.PARA_READMES = PARA_READMES;
package/core/index.js CHANGED
@@ -27,6 +27,7 @@ const CureEngine = require('./engine/cure-engine');
27
27
  const ApplyEngine = require('./engine/apply-engine');
28
28
  const PreviewEngine = require('./engine/preview-engine');
29
29
  const DedupeEngine = require('./engine/dedupe-engine');
30
+ const PARAEngine = require('./engine/para-engine');
30
31
  const MetadataStore = require('./metadata-store');
31
32
  const strategist = require('./ai/strategist');
32
33
  const sentry = require('./ai/sentry');
@@ -119,6 +120,7 @@ module.exports = {
119
120
  ApplyEngine,
120
121
  PreviewEngine,
121
122
  DedupeEngine,
123
+ PARAEngine,
122
124
  MetadataStore,
123
125
 
124
126
  // Hardened Runtime
package/index.js CHANGED
@@ -51,13 +51,14 @@ const { formatBytes } = require('./core/scanner');
51
51
  const { getCategories } = require('./core/categories');
52
52
  const { checkPermissions } = require('./core/security');
53
53
  const { activateLicense, deactivateLicense, getLicenseInfo, checkProFeature, checkBulkLimit } = require('./core/license');
54
- const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine } = require('./core/index');
54
+ const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine, PARAEngine } = require('./core/index');
55
55
  const { ping: telemetryPing } = require('./core/telemetry');
56
56
 
57
57
  const { c, banner, Spinner, success, error, warn, info } = reporter;
58
58
 
59
59
  // ─── Version ──────────────────────────────────────────────────────
60
- const VERSION = '4.0.5';
60
+ // Derived from package.json so it can never drift out of sync.
61
+ const VERSION = require('./package.json').version;
61
62
 
62
63
  // ─── Path Helpers ─────────────────────────────────────────────────
63
64
 
@@ -117,6 +118,10 @@ function parseArgs(argv) {
117
118
  args.flags.depth = parseInt(raw[++i], 10);
118
119
  } else if (arg === '--naming' && raw[i + 1]) {
119
120
  args.flags.naming = raw[++i];
121
+ } else if (arg === '--archive-after' && raw[i + 1]) {
122
+ args.flags.archiveAfter = parseInt(raw[++i], 10);
123
+ } else if (arg === '--para') {
124
+ args.flags.para = true;
120
125
  } else if (arg === '--output' && raw[i + 1]) {
121
126
  args.flags.outputDir = raw[++i];
122
127
  } else if (arg === '--config' && raw[i + 1]) {
@@ -182,9 +187,10 @@ ${banner()}
182
187
  ${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
183
188
  ${c('yellow', 'analyze')} ${c('dim', '<path>')} Deep analysis (duplicates, bloat, savings)
184
189
  ${c('yellow', 'organize')} ${c('dim', '<path>')} Organize files into categories
190
+ ${c('yellow', 'para')} ${c('dim', '<path>')} Sort by actionability — PARA method (then apply)
185
191
  ${c('yellow', 'clean')} ${c('dim', '<path>')} Find and remove junk files
186
192
  ${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory for changes
187
- ${c('yellow', 'init')} Create .filemayor.yml config
193
+ ${c('yellow', 'init')} ${c('dim', '[--para]')} Create .filemayor.yml (--para scaffolds PARA folders)
188
194
  ${c('yellow', 'undo')} ${c('dim', '<path>')} Undo last organization
189
195
  ${c('yellow', 'info')} System info and version
190
196
  ${c('yellow', 'license')} ${c('dim', '<action>')} Manage license (activate|status|deactivate)
@@ -201,6 +207,8 @@ ${banner()}
201
207
  ${c('dim', '--config <path>')} Custom config file
202
208
  ${c('dim', '--depth <n>')} Max recursion depth
203
209
  ${c('dim', '--naming <type>')} Naming: original|category_prefix|date_prefix|clean
210
+ ${c('dim', '--archive-after <n>')} PARA: months of inactivity before a file → Archives
211
+ ${c('dim', '--para')} init: scaffold PARA folders + preset config
204
212
  ${c('dim', '--duplicates <s>')} Duplicates: rename|skip|overwrite
205
213
  ${c('dim', '--extensions <e>')} Filter by extensions (comma-separated)
206
214
  ${c('dim', '--output <path>')} Output directory for organized files
@@ -214,6 +222,8 @@ ${banner()}
214
222
  ${c('dim', '$')} filemayor scan ~/Downloads
215
223
  ${c('dim', '$')} filemayor organize ~/Downloads --dry-run
216
224
  ${c('dim', '$')} filemayor organize ~/Downloads --naming category_prefix
225
+ ${c('dim', '$')} filemayor init --para ~/Documents
226
+ ${c('dim', '$')} filemayor para ~/Documents --archive-after 18
217
227
  ${c('dim', '$')} filemayor clean /var/tmp --yes
218
228
  ${c('dim', '$')} filemayor scan . --json | jq '.files | length'
219
229
  ${c('dim', '$')} filemayor watch ~/Downloads
@@ -454,6 +464,23 @@ async function cmdWatch(target, flags, config) {
454
464
 
455
465
  async function cmdInit(flags) {
456
466
  try {
467
+ if (flags.para) {
468
+ // PARA preset: write a tuned config AND scaffold the four folders
469
+ // with teaching READMEs so a first-timer understands the method.
470
+ const configPath = createConfigFile(process.cwd(), '.filemayor.yml', 'para');
471
+ console.log(success(`Created ${c('cyan', configPath)}`));
472
+
473
+ const engine = new PARAEngine();
474
+ const { created, folders } = engine.scaffold(process.cwd());
475
+ console.log(success(`Scaffolded PARA folders: ${c('cyan', Object.values(folders).join(', '))}`));
476
+ if (created.length) {
477
+ console.log(c('dim', ` ${created.length} item(s) created (folders + READMEs)`));
478
+ }
479
+ console.log('');
480
+ console.log(c('dim', ' Next: ') + c('cyan', 'filemayor para .') + c('dim', ' then ') + c('cyan', 'filemayor apply'));
481
+ return;
482
+ }
483
+
457
484
  const configPath = createConfigFile(process.cwd());
458
485
  console.log(success(`Created ${c('cyan', configPath)}`));
459
486
  console.log(c('dim', ' Edit this file to customize FileMayor settings'));
@@ -463,6 +490,55 @@ async function cmdInit(flags) {
463
490
  }
464
491
  }
465
492
 
493
+ // ─── PARA: sort by actionability (Projects/Areas/Resources/Archives) ──
494
+ async function cmdPara(target, flags, config) {
495
+ const targetPath = path.resolve(target || '.');
496
+
497
+ // Per-run override for the archive threshold.
498
+ const paraConfig = { ...(config.para || {}) };
499
+ if (Number.isFinite(flags.archiveAfter)) {
500
+ paraConfig.archiveAfterMonths = flags.archiveAfter;
501
+ }
502
+
503
+ const spinner = new Spinner(`Sorting ${c('cyan', targetPath)} by actionability (PARA)...`);
504
+ if (!flags.quiet) spinner.start();
505
+
506
+ try {
507
+ const engine = new PARAEngine(paraConfig);
508
+ const result = engine.plan(targetPath);
509
+ spinner.stop();
510
+
511
+ if (flags.format === 'json') {
512
+ console.log(JSON.stringify(result, null, 2));
513
+ return;
514
+ }
515
+
516
+ // Cache the plan so `filemayor apply` can execute it (same path as cure).
517
+ const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
518
+ fs.writeFileSync(cachePath, JSON.stringify(result), 'utf8');
519
+
520
+ console.log(reporter.formatCureReport(result));
521
+
522
+ // PARA-specific breakdown — the bucket is the whole point.
523
+ const b = result.summary.buckets;
524
+ console.log(c('bold', ' Into PARA buckets:'));
525
+ console.log(` ${c('cyan', 'Projects')} ${b.projects} ${c('cyan', 'Areas')} ${b.areas} ${c('cyan', 'Resources')} ${b.resources} ${c('cyan', 'Archives')} ${b.archives}`);
526
+ for (const wn of (result.warnings || [])) {
527
+ console.log(c('dim', ` ⚠ ${wn}`));
528
+ }
529
+
530
+ if (result.plan.length === 0) {
531
+ console.log(info('Nothing to reorganise — run with a messier folder, or it is already PARA-sorted.'));
532
+ } else {
533
+ console.log('');
534
+ console.log(c('dim', ' Review the plan above, then run ') + c('cyan', 'filemayor apply') + c('dim', ' to execute (reversible with ') + c('cyan', 'filemayor undo') + c('dim', ').'));
535
+ }
536
+ } catch (err) {
537
+ spinner.fail(err.message);
538
+ process.exit(1);
539
+ }
540
+ }
541
+
466
542
  async function cmdUndo(target, flags) {
467
543
  const { JOURNAL_PATH, rollback: fsRollback } = require('./core/fs-abstraction');
468
544
 
@@ -837,6 +913,11 @@ async function main() {
837
913
  await cmdOrganize(args.target, args.flags, config);
838
914
  break;
839
915
 
916
+ case 'para':
917
+ case 'pm':
918
+ await cmdPara(args.target, args.flags, config);
919
+ break;
920
+
840
921
  case 'clean':
841
922
  case 'cl':
842
923
  case 'c':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filemayor",
3
- "version": "4.0.7",
3
+ "version": "4.0.8",
4
4
  "description": "FileMayor — Your intelligent filesystem clerk. CLI + Desktop + PWA.",
5
5
  "main": "index.js",
6
6
  "bin": {