dotmd-cli 0.68.0 → 0.70.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/README.md +144 -964
- package/bin/dotmd.mjs +241 -197
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -87
- package/src/bulk-tag.mjs +7 -7
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +12 -5
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +2 -0
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +531 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +17 -12
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +554 -282
- package/src/lint.mjs +57 -9
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +138 -32
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +17 -8
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +14 -6
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/lint.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { buildIndex, collectDocFiles } from './index.mjs';
|
|
|
6
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
7
|
import { runMLX, checkUvAvailable } from './ai.mjs';
|
|
8
8
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
9
|
+
import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
|
|
9
10
|
|
|
10
11
|
const KEY_RENAMES = {
|
|
11
12
|
nextStep: 'next_step',
|
|
@@ -32,8 +33,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
32
33
|
|
|
33
34
|
// Missing type (fixable — infer from root: plans → 'plan', else 'doc')
|
|
34
35
|
if (!asString(parsed.type)) {
|
|
35
|
-
const
|
|
36
|
-
const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
|
|
36
|
+
const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
|
|
37
37
|
const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
|
|
38
38
|
// If the root label contains 'plan' (e.g. 'docs/plans'), default to plan type
|
|
39
39
|
const inferredType = rootLabel.includes('plan') ? 'plan' : 'doc';
|
|
@@ -73,12 +73,26 @@ export function runLint(argv, config, opts = {}) {
|
|
|
73
73
|
// existing plural array happens at apply-time so the message reflects
|
|
74
74
|
// just what's being introduced from the singular form.
|
|
75
75
|
for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
const rawVal = parsed[singular];
|
|
77
|
+
const val = asString(rawVal);
|
|
78
|
+
if (val) {
|
|
79
|
+
// Inline value (`surface: foo`) → migrate into the plural array.
|
|
80
|
+
const values = val.includes(',')
|
|
81
|
+
? val.split(',').map(s => s.trim()).filter(Boolean)
|
|
82
|
+
: [val];
|
|
83
|
+
fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
|
|
84
|
+
} else if (singular in parsed && (!Array.isArray(rawVal) || rawVal.length === 0)) {
|
|
85
|
+
// Empty deprecated key (`surface:` with nothing after it, which the
|
|
86
|
+
// parser yields as `[]`) — usually sitting right above a populated
|
|
87
|
+
// `surfaces:`. There's no value to migrate; the deprecation warning
|
|
88
|
+
// just wants the dead line gone. `validate` treats the empty `[]` as
|
|
89
|
+
// present (so it warns), while the old `asString` gate here saw it as
|
|
90
|
+
// absent and skipped — the exact no-op reported in issue #17. Emit a
|
|
91
|
+
// drop fix so `lint --fix` does what the warning advertises. A
|
|
92
|
+
// populated block-form singular (rawVal.length > 0) is left untouched:
|
|
93
|
+
// too rare to risk mis-editing.
|
|
94
|
+
fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
|
|
95
|
+
}
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
// Trailing whitespace in values
|
|
@@ -102,8 +116,22 @@ export function runLint(argv, config, opts = {}) {
|
|
|
102
116
|
// Also get non-fixable issues from index, excluding issues we can already fix
|
|
103
117
|
const index = buildIndex(config);
|
|
104
118
|
const fixablePaths = new Set(fixable.map(f => f.repoPath));
|
|
119
|
+
// Singular-key deprecation warnings whose fix `lint --fix` will actually make.
|
|
120
|
+
// Without this, those warnings landed in BOTH the "fixable" preview and the
|
|
121
|
+
// "non-fixable" list, so the header claimed "N non-fixable" while every line
|
|
122
|
+
// told you to run `lint --fix` (issue #17, item 8).
|
|
123
|
+
const fixableSingularKeys = new Set();
|
|
124
|
+
for (const { repoPath, fixes } of fixable) {
|
|
125
|
+
for (const f of fixes) {
|
|
126
|
+
if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
|
|
127
|
+
fixableSingularKeys.add(`${repoPath}::${f.field}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
105
131
|
const nonFixable = [...index.errors, ...index.warnings].filter(issue => {
|
|
106
132
|
if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
|
|
133
|
+
const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
|
|
134
|
+
if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
|
|
107
135
|
return true;
|
|
108
136
|
});
|
|
109
137
|
|
|
@@ -120,6 +148,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
120
148
|
process.stdout.write(dim(` missing status (fixable via AI)\n`));
|
|
121
149
|
} else if (f.type === 'singular-to-plural') {
|
|
122
150
|
process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]\n`));
|
|
151
|
+
} else if (f.type === 'drop-singular') {
|
|
152
|
+
process.stdout.write(dim(` remove deprecated \`${f.field}:\` (empty; \`${f.pluralKey}:\` is the live key)\n`));
|
|
123
153
|
} else if (f.type === 'eof') {
|
|
124
154
|
process.stdout.write(dim(` missing newline at end of file\n`));
|
|
125
155
|
} else if (f.type === 'add') {
|
|
@@ -146,6 +176,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
// Fix mode
|
|
179
|
+
authorizeManagedSweep(fixable.map(item => item.filePath), config, { kind: 'Lint fix source' });
|
|
149
180
|
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
150
181
|
let totalFixes = 0;
|
|
151
182
|
|
|
@@ -155,6 +186,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
155
186
|
let needsEofFix = false;
|
|
156
187
|
const trimFixes = [];
|
|
157
188
|
const singularToPlural = [];
|
|
189
|
+
const dropSingular = [];
|
|
158
190
|
|
|
159
191
|
for (const f of fixes) {
|
|
160
192
|
if (f.type === 'rename-key') {
|
|
@@ -165,6 +197,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
165
197
|
trimFixes.push(f);
|
|
166
198
|
} else if (f.type === 'singular-to-plural') {
|
|
167
199
|
singularToPlural.push(f);
|
|
200
|
+
} else if (f.type === 'drop-singular') {
|
|
201
|
+
dropSingular.push(f);
|
|
168
202
|
} else {
|
|
169
203
|
updates[f.field] = f.newValue;
|
|
170
204
|
}
|
|
@@ -212,6 +246,19 @@ export function runLint(argv, config, opts = {}) {
|
|
|
212
246
|
writeFileSync(filePath, raw, 'utf8');
|
|
213
247
|
}
|
|
214
248
|
|
|
249
|
+
// Drop empty deprecated singular keys. The value is known-empty (the fix
|
|
250
|
+
// is only emitted for `[]`/`''`), so this matches the bare `key:` line
|
|
251
|
+
// exactly — never a line carrying content — and removes it.
|
|
252
|
+
for (const ds of dropSingular) {
|
|
253
|
+
let raw = readFileSync(filePath, 'utf8');
|
|
254
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
255
|
+
const newFm = fm
|
|
256
|
+
.replace(new RegExp(`^${escapeRegex(ds.field)}:[ \\t]*$`, 'm'), '')
|
|
257
|
+
.replace(/\n{2,}/g, '\n');
|
|
258
|
+
raw = replaceFrontmatter(raw, newFm.trim());
|
|
259
|
+
writeFileSync(filePath, raw, 'utf8');
|
|
260
|
+
}
|
|
261
|
+
|
|
215
262
|
// Apply key renames and trim fixes via raw string manipulation
|
|
216
263
|
if (keyRenames.length > 0 || trimFixes.length > 0) {
|
|
217
264
|
let raw = readFileSync(filePath, 'utf8');
|
|
@@ -259,6 +306,8 @@ export function runLint(argv, config, opts = {}) {
|
|
|
259
306
|
}
|
|
260
307
|
} else if (f.type === 'singular-to-plural') {
|
|
261
308
|
process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]`)}\n`);
|
|
309
|
+
} else if (f.type === 'drop-singular') {
|
|
310
|
+
process.stdout.write(`${prefix} ${dim(`removed deprecated \`${f.field}:\` (empty)`)}\n`);
|
|
262
311
|
} else if (f.type === 'add') {
|
|
263
312
|
process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
|
|
264
313
|
} else {
|
|
@@ -274,4 +323,3 @@ export function runLint(argv, config, opts = {}) {
|
|
|
274
323
|
|
|
275
324
|
process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
|
|
276
325
|
}
|
|
277
|
-
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// These checks are authorization preflights, not filesystem transactions. A
|
|
5
|
+
// concurrent actor can still replace a checked ancestor before the later write
|
|
6
|
+
// (TOCTOU); eliminating that residual boundary requires descriptor-relative IO.
|
|
7
|
+
|
|
8
|
+
function contains(parent, child) {
|
|
9
|
+
const rel = path.relative(parent, child);
|
|
10
|
+
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sameNode(a, b) {
|
|
14
|
+
try {
|
|
15
|
+
const left = statSync(a, { bigint: true });
|
|
16
|
+
const right = statSync(b, { bigint: true });
|
|
17
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// realpath on case-insensitive macOS volumes may preserve the caller's casing.
|
|
22
|
+
// Prove an alias by inode before treating it as contained; lowercase string
|
|
23
|
+
// comparison alone would be unsafe on case-sensitive volumes.
|
|
24
|
+
function aliasContainment(parent, child) {
|
|
25
|
+
if (contains(parent, child)) return { contained: true, relative: path.relative(parent, child) };
|
|
26
|
+
const parentParts = path.resolve(parent).split(path.sep).filter(Boolean);
|
|
27
|
+
const childParts = path.resolve(child).split(path.sep).filter(Boolean);
|
|
28
|
+
if (childParts.length < parentParts.length) return { contained: false, relative: null };
|
|
29
|
+
const childPrefix = `${path.parse(path.resolve(child)).root}${childParts.slice(0, parentParts.length).join(path.sep)}`;
|
|
30
|
+
if (!sameNode(parent, childPrefix)) return { contained: false, relative: null };
|
|
31
|
+
return { contained: true, relative: childParts.slice(parentParts.length).join(path.sep) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function lexicalRootsFor(config) {
|
|
35
|
+
return (config.docsRoots ?? [config.docsRoot]).map(configuredPath => ({
|
|
36
|
+
configuredPath,
|
|
37
|
+
lexicalPath: path.resolve(configuredPath),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rootsFor(config) {
|
|
42
|
+
return lexicalRootsFor(config).map(root => {
|
|
43
|
+
const { configuredPath, lexicalPath } = root;
|
|
44
|
+
let canonicalPath;
|
|
45
|
+
try {
|
|
46
|
+
canonicalPath = realpathSync(lexicalPath);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new Error(`Configured docs root cannot be resolved: ${lexicalPath} (${err.message})`);
|
|
49
|
+
}
|
|
50
|
+
return { configuredPath, lexicalPath, canonicalPath };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rootsMessage(roots) {
|
|
55
|
+
return roots.map(root => root.lexicalPath).join(', ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function markdown(pathname, kind, roots) {
|
|
59
|
+
if (!pathname.endsWith('.md')) {
|
|
60
|
+
throw new Error(`${kind} must be a Markdown file ending in .md: ${pathname}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function nearestExistingAncestor(input, roots, kind) {
|
|
65
|
+
let current = input;
|
|
66
|
+
while (!existsSync(current)) {
|
|
67
|
+
try {
|
|
68
|
+
// existsSync is false for dangling symlinks, but lstat still sees them.
|
|
69
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
70
|
+
throw new Error(`dangling symlink: ${current}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') {
|
|
74
|
+
throw new Error(`${kind} has an unsafe existing ancestor: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const parent = path.dirname(current);
|
|
78
|
+
if (parent === current) break;
|
|
79
|
+
current = parent;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return realpathSync(current);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new Error(`${kind} ancestor cannot be resolved: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function lexicalOwner(input, roots) {
|
|
89
|
+
return roots
|
|
90
|
+
.filter(root => contains(root.lexicalPath, input))
|
|
91
|
+
.sort((a, b) => b.lexicalPath.length - a.lexicalPath.length)[0] ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findLexicalDocsRoot(input, config) {
|
|
95
|
+
return lexicalOwner(path.resolve(input), lexicalRootsFor(config))?.lexicalPath ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function authorizeManagedSource(input, config, { kind = 'Managed mutation source' } = {}) {
|
|
99
|
+
const roots = rootsFor(config);
|
|
100
|
+
const lexicalPath = path.resolve(input);
|
|
101
|
+
markdown(lexicalPath, kind, roots);
|
|
102
|
+
|
|
103
|
+
if (!existsSync(lexicalPath)) {
|
|
104
|
+
throw new Error(`${kind} does not exist: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
105
|
+
}
|
|
106
|
+
if (lstatSync(lexicalPath).isSymbolicLink()) {
|
|
107
|
+
throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let canonicalPath;
|
|
111
|
+
try {
|
|
112
|
+
canonicalPath = realpathSync(lexicalPath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new Error(`${kind} cannot be resolved: ${lexicalPath} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
115
|
+
}
|
|
116
|
+
if (!statSync(canonicalPath).isFile()) {
|
|
117
|
+
throw new Error(`${kind} is not a file: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lexical = lexicalOwner(lexicalPath, roots);
|
|
121
|
+
if (lexical && !aliasContainment(lexical.canonicalPath, canonicalPath).contained) {
|
|
122
|
+
throw new Error(`${kind} is lexically owned by ${lexical.lexicalPath} but resolves outside that root: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
123
|
+
}
|
|
124
|
+
const root = lexical
|
|
125
|
+
?? roots.filter(candidate => aliasContainment(candidate.canonicalPath, canonicalPath).contained)
|
|
126
|
+
.sort((a, b) => b.canonicalPath.length - a.canonicalPath.length)[0];
|
|
127
|
+
if (!root) {
|
|
128
|
+
throw new Error(`${kind} resolves outside configured docs roots: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Keep operations on the configured root spelling (important when the root
|
|
132
|
+
// itself is a symlink), while normalizing OS-level aliases such as /var ->
|
|
133
|
+
// /private/var back through the owning root.
|
|
134
|
+
const managedPath = lexical
|
|
135
|
+
? lexicalPath
|
|
136
|
+
: path.join(root.lexicalPath, aliasContainment(root.canonicalPath, canonicalPath).relative);
|
|
137
|
+
return { path: managedPath, canonicalPath, root };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination' } = {}) {
|
|
141
|
+
const roots = rootsFor(config);
|
|
142
|
+
const lexicalPath = path.resolve(input);
|
|
143
|
+
markdown(lexicalPath, kind, roots);
|
|
144
|
+
|
|
145
|
+
const root = requiredRoot
|
|
146
|
+
? roots.find(candidate => candidate.lexicalPath === requiredRoot.lexicalPath && candidate.canonicalPath === requiredRoot.canonicalPath)
|
|
147
|
+
: lexicalOwner(lexicalPath, roots);
|
|
148
|
+
if (!root || !contains(root.lexicalPath, lexicalPath)) {
|
|
149
|
+
const ownership = requiredRoot ? `Owning docs root: ${requiredRoot.lexicalPath}\n` : '';
|
|
150
|
+
throw new Error(`${kind} is lexically outside its configured docs root: ${lexicalPath}\n${ownership}Configured docs roots: ${rootsMessage(roots)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ancestor = nearestExistingAncestor(lexicalPath, roots, kind);
|
|
154
|
+
if (!aliasContainment(root.canonicalPath, ancestor).contained) {
|
|
155
|
+
throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nOwning docs root: ${root.lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
|
|
156
|
+
}
|
|
157
|
+
return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null, root };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function authorizeManagedMove(source, destination, config, options = {}) {
|
|
161
|
+
const authorizedSource = authorizeManagedSource(source, config, options);
|
|
162
|
+
const authorizedDestination = authorizeManagedDestination(destination, config, {
|
|
163
|
+
...options,
|
|
164
|
+
kind: options.destinationKind ?? 'Managed mutation destination',
|
|
165
|
+
root: authorizedSource.root,
|
|
166
|
+
});
|
|
167
|
+
return { source: authorizedSource, destination: authorizedDestination };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function authorizeManagedSweep(files, config, { kind = 'Managed mutation sweep' } = {}) {
|
|
171
|
+
return files.map(file => authorizeManagedSource(file, config, { kind }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function authorizeRepoGeneratedPath(input, config, { kind = 'Repository-generated destination' } = {}) {
|
|
175
|
+
const lexicalPath = path.resolve(input);
|
|
176
|
+
const repoRoot = path.resolve(config.repoRoot);
|
|
177
|
+
let canonicalRepo;
|
|
178
|
+
try { canonicalRepo = realpathSync(repoRoot); }
|
|
179
|
+
catch (err) { throw new Error(`Repository root cannot be resolved: ${repoRoot} (${err.message})`); }
|
|
180
|
+
|
|
181
|
+
if (!contains(repoRoot, lexicalPath)) {
|
|
182
|
+
throw new Error(`${kind} is outside the repository: ${lexicalPath}\nRepository root: ${repoRoot}`);
|
|
183
|
+
}
|
|
184
|
+
const ancestor = nearestExistingAncestor(lexicalPath, [{ lexicalPath: repoRoot }], kind);
|
|
185
|
+
if (!contains(canonicalRepo, ancestor)) {
|
|
186
|
+
throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nRepository root: ${repoRoot}`);
|
|
187
|
+
}
|
|
188
|
+
if (existsSync(lexicalPath) && lstatSync(lexicalPath).isSymbolicLink()) {
|
|
189
|
+
throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nRepository root: ${repoRoot}`);
|
|
190
|
+
}
|
|
191
|
+
return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null };
|
|
192
|
+
}
|
package/src/migrate-prompts.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { getGitFirstAdded } from './git.mjs';
|
|
|
6
6
|
import { bold, green, dim } from './color.mjs';
|
|
7
7
|
import { readFileSync as rfs } from 'node:fs';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
9
10
|
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const pkg = JSON.parse(rfs(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -124,6 +125,7 @@ export function runMigratePrompts(argv, config, opts = {}) {
|
|
|
124
125
|
} else {
|
|
125
126
|
files = findPromptCandidates(config);
|
|
126
127
|
}
|
|
128
|
+
authorizeManagedSweep(files, config, { kind: 'Prompt migration source' });
|
|
127
129
|
|
|
128
130
|
const results = [];
|
|
129
131
|
let touched = 0;
|
package/src/migrate-template.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
|
4
4
|
import { asString, toRepoPath, nowIso } from './util.mjs';
|
|
5
5
|
import { collectDocFiles } from './index.mjs';
|
|
6
6
|
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
8
|
|
|
8
9
|
const HEADING_RENAMES = [
|
|
9
10
|
{ from: /^##\s+Open questions\s*$/gm, to: '## Open Questions' },
|
|
@@ -117,6 +118,7 @@ export function runMigrateTemplate(argv, config, opts = {}) {
|
|
|
117
118
|
files = collectDocFiles(config);
|
|
118
119
|
if (!includeArchived) files = files.filter(f => !isInArchive(f, config));
|
|
119
120
|
}
|
|
121
|
+
authorizeManagedSweep(files, config, { kind: 'Template migration source' });
|
|
120
122
|
|
|
121
123
|
const results = [];
|
|
122
124
|
let totalChanges = 0;
|
package/src/migrate.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { asString, toRepoPath, resolveDocPath, die } from './util.mjs';
|
|
|
5
5
|
import { collectDocFiles } from './index.mjs';
|
|
6
6
|
import { updateFrontmatter } from './lifecycle.mjs';
|
|
7
7
|
import { bold, green, dim } from './color.mjs';
|
|
8
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
8
9
|
|
|
9
10
|
export function runMigrate(argv, config, opts = {}) {
|
|
10
11
|
const { dryRun } = opts;
|
|
@@ -38,7 +39,11 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
38
39
|
matched.push(filePath);
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
41
|
-
const
|
|
42
|
+
const normalizedInput = input.replaceAll('\\', '/');
|
|
43
|
+
const hits = allFiles.filter(f => {
|
|
44
|
+
const repoPath = toRepoPath(f, config.repoRoot);
|
|
45
|
+
return repoPath.includes(normalizedInput) || path.basename(f).includes(input);
|
|
46
|
+
});
|
|
42
47
|
if (hits.length === 0) {
|
|
43
48
|
unresolved.push(input);
|
|
44
49
|
} else {
|
|
@@ -52,6 +57,7 @@ export function runMigrate(argv, config, opts = {}) {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
const matches = [];
|
|
60
|
+
authorizeManagedSweep(fileFilter ? [...fileFilter] : allFiles, config, { kind: 'Migration source' });
|
|
55
61
|
|
|
56
62
|
for (const filePath of allFiles) {
|
|
57
63
|
if (fileFilter && !fileFilter.has(filePath)) continue;
|
package/src/new.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync,
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
@@ -6,6 +6,8 @@ import { green, dim, bold } from './color.mjs';
|
|
|
6
6
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
7
7
|
import { regenIndex } from './lifecycle.mjs';
|
|
8
8
|
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
9
|
+
import { authorizeManagedDestination } from './managed-path.mjs';
|
|
10
|
+
import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
9
11
|
|
|
10
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
13
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -281,6 +283,18 @@ export function readBodyInput(source) {
|
|
|
281
283
|
return source;
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
export function readPipedBodyInput() {
|
|
287
|
+
try {
|
|
288
|
+
const stat = fstatSync(0);
|
|
289
|
+
const isWindowsPipe = process.platform === 'win32' && !process.stdin.isTTY;
|
|
290
|
+
if (stat.isFIFO() || stat.isFile() || stat.isSocket() || isWindowsPipe) {
|
|
291
|
+
const piped = readFileSync(0, 'utf8');
|
|
292
|
+
return piped.length > 0 ? piped : null;
|
|
293
|
+
}
|
|
294
|
+
} catch { /* stdin not introspectable */ }
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
284
298
|
// Slug/title helpers shared by name resolution and runlist child generation.
|
|
285
299
|
export function slugify(s) {
|
|
286
300
|
return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
@@ -289,6 +303,48 @@ export function titleize(s) {
|
|
|
289
303
|
return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
290
304
|
}
|
|
291
305
|
|
|
306
|
+
export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
|
|
307
|
+
const template = resolveTemplate('prompt', config);
|
|
308
|
+
const typeStatuses = config.typeStatuses?.get('prompt');
|
|
309
|
+
const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
|
|
310
|
+
? template.defaultStatus
|
|
311
|
+
: ([...(typeStatuses ?? [])][0] ?? 'pending');
|
|
312
|
+
if (!bodyInput?.trim()) die('`prompt` template requires a body.');
|
|
313
|
+
const slug = slugify(path.basename(name, '.md'));
|
|
314
|
+
const title = titleize(path.basename(name, '.md'));
|
|
315
|
+
let targetRoot = config.docsRoot;
|
|
316
|
+
let routed = false;
|
|
317
|
+
if (template.targetRoot) {
|
|
318
|
+
const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
|
|
319
|
+
if (match) { targetRoot = match; routed = true; }
|
|
320
|
+
}
|
|
321
|
+
const baseDir = template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
|
|
322
|
+
const filePath = path.join(baseDir, `${slug}.md`);
|
|
323
|
+
authorizeManagedDestination(filePath, config, { kind: 'Baton prompt destination' });
|
|
324
|
+
if (dryRun) return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: null };
|
|
325
|
+
|
|
326
|
+
const today = nowIso();
|
|
327
|
+
const split = splitBodyFrontmatter(bodyInput);
|
|
328
|
+
const body = split.frontmatter ? split.body : bodyInput;
|
|
329
|
+
const ctx = { status, title, today, bodyInput: body };
|
|
330
|
+
let content;
|
|
331
|
+
if (typeof template === 'function') {
|
|
332
|
+
content = template(name, ctx);
|
|
333
|
+
if (plan) {
|
|
334
|
+
const end = content.indexOf('\n---\n', 4);
|
|
335
|
+
if (!content.startsWith('---\n') || end === -1) throw new Error('Custom prompt template must return frontmatter for baton plan binding.');
|
|
336
|
+
const fm = mergeBodyFrontmatter(content.slice(4, end), { plan }, 'prompt');
|
|
337
|
+
content = `---\n${fm}${content.slice(end)}`;
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
let fm = template.frontmatter(status, today, ctx);
|
|
341
|
+
if (split.frontmatter) fm = mergeBodyFrontmatter(fm, split.frontmatter, 'prompt');
|
|
342
|
+
if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
|
|
343
|
+
content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
|
|
344
|
+
}
|
|
345
|
+
return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
|
|
346
|
+
}
|
|
347
|
+
|
|
292
348
|
// Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
|
|
293
349
|
// `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
|
|
294
350
|
// 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
|
|
@@ -642,16 +698,11 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
642
698
|
// delivers stdin as an AF_UNIX socket). Probe this even for templates that
|
|
643
699
|
// don't accept bodies so the fail-fast guard below can reject accidental
|
|
644
700
|
// heredoc/input instead of silently scaffolding without it.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
bodyInput = piped;
|
|
651
|
-
bodyInputSource = 'piped stdin';
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
} catch { /* stdin not introspectable — skip auto-consume */ }
|
|
701
|
+
const piped = readPipedBodyInput();
|
|
702
|
+
if (piped !== null) {
|
|
703
|
+
bodyInput = piped;
|
|
704
|
+
bodyInputSource = 'piped stdin';
|
|
705
|
+
}
|
|
655
706
|
}
|
|
656
707
|
|
|
657
708
|
// If the body input has a leading `---…---` frontmatter block, lift its keys
|
|
@@ -757,6 +808,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
757
808
|
const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
|
|
758
809
|
const filePath = path.join(baseDir, slug + '.md');
|
|
759
810
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
811
|
+
const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
|
|
760
812
|
|
|
761
813
|
if (existsSync(filePath)) {
|
|
762
814
|
die(`File already exists: ${repoPath}`);
|
|
@@ -766,40 +818,13 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
766
818
|
|
|
767
819
|
// Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
|
|
768
820
|
const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
|
|
775
|
-
const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
|
|
776
|
-
if (typeof template === 'function') {
|
|
777
|
-
content = template(name, tmplCtx);
|
|
778
|
-
} else {
|
|
779
|
-
let fm = template.frontmatter(status, today, tmplCtx);
|
|
780
|
-
if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
|
|
781
|
-
// Inject the hub-shape frontmatter (runlist array / coordination marker)
|
|
782
|
-
// on top of the standard plan scaffold, then swap in a purpose-built body.
|
|
783
|
-
if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
|
|
784
|
-
if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
|
|
785
|
-
if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
|
|
786
|
-
let body;
|
|
787
|
-
// A full authored body (own `## Section` headings) wins over every variant
|
|
788
|
-
// skeleton too — otherwise the whole document gets nested in the builder's
|
|
789
|
-
// single slot and the skeleton is appended below it (duplicate Scope /
|
|
790
|
-
// Ranked queue / Version History). The default plan body applies the same
|
|
791
|
-
// shortcut inside template.body, so only the variant branches need it here.
|
|
792
|
-
const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
|
|
793
|
-
const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
|
|
794
|
-
if (authored !== null) body = authored;
|
|
795
|
-
else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
|
|
796
|
-
else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
|
|
797
|
-
else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
|
|
798
|
-
else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
|
|
799
|
-
else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
|
|
800
|
-
else body = template.body(docTitle, tmplCtx);
|
|
801
|
-
content = `---\n${fm}\n---\n${body}`;
|
|
821
|
+
for (const child of runlistChildren) {
|
|
822
|
+
authorizeManagedDestination(path.join(baseDir, child.file), config, {
|
|
823
|
+
root: destinationAuthorization.root,
|
|
824
|
+
kind: 'Runlist child destination',
|
|
825
|
+
});
|
|
802
826
|
}
|
|
827
|
+
const childStatus = effective.has('planned') ? 'planned' : status;
|
|
803
828
|
|
|
804
829
|
// When the project has >1 root and `--root` was omitted, surface the choice
|
|
805
830
|
// so agents can see that an alternative root was available. Cheap visibility
|
|
@@ -820,34 +845,81 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
820
845
|
: isLite ? ' (lite plan)'
|
|
821
846
|
: isAudit ? ' (audit plan)'
|
|
822
847
|
: '';
|
|
848
|
+
const isCustomTemplate = Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName);
|
|
823
849
|
|
|
824
850
|
if (dryRun) {
|
|
825
|
-
|
|
851
|
+
if (isCustomTemplate) {
|
|
852
|
+
process.stdout.write(`${dim('[dry-run]')} Target: ${repoPath}\n`);
|
|
853
|
+
process.stdout.write(`${dim('[dry-run]')} Custom template rendering skipped; preview cannot confirm creation will succeed.\n`);
|
|
854
|
+
} else {
|
|
855
|
+
process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
|
|
856
|
+
}
|
|
826
857
|
process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
|
|
827
858
|
for (const c of runlistChildren) {
|
|
828
|
-
|
|
859
|
+
const childPath = path.join(baseDir, c.file);
|
|
860
|
+
const action = existsSync(childPath) ? 'Would leave existing child unchanged' : 'Would create child';
|
|
861
|
+
process.stdout.write(`${dim('[dry-run]')} ${action}: ${toRepoPath(childPath, config.repoRoot)}\n`);
|
|
829
862
|
}
|
|
830
863
|
if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
|
|
831
864
|
return;
|
|
832
865
|
}
|
|
833
866
|
|
|
867
|
+
// Generate content only for a real create. Custom template functions are
|
|
868
|
+
// user code and may have side effects, so previews stop before invoking them.
|
|
869
|
+
let content;
|
|
870
|
+
const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
|
|
871
|
+
const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
|
|
872
|
+
const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
|
|
873
|
+
if (typeof template === 'function') {
|
|
874
|
+
content = template(name, tmplCtx);
|
|
875
|
+
} else {
|
|
876
|
+
let fm = template.frontmatter(status, today, tmplCtx);
|
|
877
|
+
if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
|
|
878
|
+
if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
|
|
879
|
+
if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
|
|
880
|
+
if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
|
|
881
|
+
let body;
|
|
882
|
+
const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
|
|
883
|
+
const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
|
|
884
|
+
if (authored !== null) body = authored;
|
|
885
|
+
else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
|
|
886
|
+
else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
|
|
887
|
+
else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
|
|
888
|
+
else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
|
|
889
|
+
else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
|
|
890
|
+
else body = template.body(docTitle, tmplCtx);
|
|
891
|
+
content = `---\n${fm}\n---\n${body}`;
|
|
892
|
+
}
|
|
893
|
+
|
|
834
894
|
// Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
|
|
835
895
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
836
896
|
|
|
837
|
-
writeFileSync(filePath, content, 'utf8');
|
|
838
|
-
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
|
|
839
|
-
if (rootHint) process.stdout.write(dim(rootHint));
|
|
840
|
-
|
|
841
|
-
// Scaffold runlist child stubs. An existing child file is never clobbered.
|
|
842
897
|
const childPaths = [];
|
|
898
|
+
const creations = [{ path: filePath, content }];
|
|
843
899
|
for (const c of runlistChildren) {
|
|
844
900
|
const childPath = path.join(baseDir, c.file);
|
|
845
901
|
if (existsSync(childPath)) {
|
|
846
902
|
warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
|
|
847
903
|
continue;
|
|
848
904
|
}
|
|
849
|
-
|
|
905
|
+
creations.push({ path: childPath, content: runlistChildContent(c.title, slug, docTitle, childStatus, today) });
|
|
850
906
|
childPaths.push(childPath);
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
if (runlistChildren.length > 0) mutateFileSet({ creations }, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
910
|
+
else createFileExclusive(filePath, content, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
911
|
+
} catch (err) {
|
|
912
|
+
if (err instanceof MutationConflictError) die(`Could not create runlist atomically because a destination already exists: ${err.message}`);
|
|
913
|
+
throw err;
|
|
914
|
+
}
|
|
915
|
+
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
|
|
916
|
+
if (rootHint) process.stdout.write(dim(rootHint));
|
|
917
|
+
|
|
918
|
+
// Existing children discovered before the transaction are consistently left
|
|
919
|
+
// unchanged; absent children and the hub publish or roll back together.
|
|
920
|
+
for (const c of runlistChildren) {
|
|
921
|
+
const childPath = path.join(baseDir, c.file);
|
|
922
|
+
if (!childPaths.includes(childPath)) continue;
|
|
851
923
|
process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
|
|
852
924
|
}
|
|
853
925
|
|
|
@@ -873,15 +945,24 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
873
945
|
}
|
|
874
946
|
} catch { /* git absent / not a repo — skip the note */ }
|
|
875
947
|
|
|
876
|
-
regenIndex(config);
|
|
948
|
+
const indexRegenerated = !opts.deferIndex ? regenIndex(config) : false;
|
|
877
949
|
|
|
878
950
|
if (showFiles) {
|
|
879
951
|
const touched = [filePath, ...childPaths];
|
|
880
|
-
if (config.indexPath) touched.push(config.indexPath);
|
|
952
|
+
if (config.indexPath && !opts.deferIndex) touched.push(config.indexPath);
|
|
881
953
|
emitFilesFooter(touched, config);
|
|
882
954
|
}
|
|
883
955
|
|
|
884
956
|
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
957
|
+
const sessionLocal = typeName === 'prompt';
|
|
958
|
+
return {
|
|
959
|
+
operation: 'new',
|
|
960
|
+
repositoryFiles: sessionLocal ? [] : [repoPath, ...childPaths.map(item => toRepoPath(item, config.repoRoot))],
|
|
961
|
+
sessionFiles: sessionLocal ? [repoPath] : [],
|
|
962
|
+
generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
963
|
+
deferredGeneratedFiles: config.indexPath && opts.deferIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
964
|
+
path: repoPath,
|
|
965
|
+
};
|
|
885
966
|
}
|
|
886
967
|
|
|
887
968
|
function resolveTemplate(name, config) {
|