@foundation0/api 1.0.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/projects.ts ADDED
@@ -0,0 +1,2690 @@
1
+ import fs from 'node:fs/promises'
2
+ import fsSync from 'node:fs'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import { setActiveLink } from './agents'
6
+ import {
7
+ parseTaskGraphFromMarkdown as parseTaskGraphFromMarkdownScript,
8
+ type TaskGraphParseResult,
9
+ type TaskGraphTask,
10
+ } from './taskgraph-parser'
11
+ import {
12
+ createGitServiceApi,
13
+ resolveProjectRepoIdentity,
14
+ type GitServiceApi,
15
+ type GitServiceApiExecutionResult,
16
+ type GitServiceApiMethod,
17
+ syncIssueDependencies as syncIssueDependenciesViaApi,
18
+ } from './git'
19
+
20
+ type ProjectGenerateOptions = {
21
+ input?: string
22
+ out?: string
23
+ dryRun: boolean
24
+ manifest: boolean
25
+ prune: boolean
26
+ verbose: boolean
27
+ }
28
+
29
+ export type ProjectGenerateResult = {
30
+ projectName: string
31
+ inputPath: string
32
+ outRoot: string
33
+ files: number
34
+ created: number
35
+ updated: number
36
+ unchanged: number
37
+ dryRun: boolean
38
+ }
39
+
40
+ type MonolithFile = {
41
+ relPath: string
42
+ content: string
43
+ sha256: string
44
+ bytes: number
45
+ meta?: {
46
+ spec_id?: string
47
+ title?: string
48
+ source?: string
49
+ source_version?: string
50
+ source_anchor?: string
51
+ }
52
+ }
53
+
54
+ type ProjectSyncTaskResult = {
55
+ projectName: string
56
+ tracksPath: string
57
+ owner: string
58
+ repo: string
59
+ totalTasks: number
60
+ created: number
61
+ updated: number
62
+ skipped: number
63
+ dryRun: boolean
64
+ truncated: boolean
65
+ }
66
+
67
+ type ProjectClearIssuesOptions = {
68
+ owner?: string
69
+ repo?: string
70
+ state: 'open' | 'closed' | 'all'
71
+ dryRun: boolean
72
+ force: boolean
73
+ verbose: boolean
74
+ }
75
+
76
+ type ProjectClearIssuesResult = {
77
+ projectName: string
78
+ owner: string
79
+ repo: string
80
+ state: 'open' | 'closed' | 'all'
81
+ total: number
82
+ deleted: number
83
+ dryRun: boolean
84
+ }
85
+
86
+ type ProjectSyncTaskOptions = {
87
+ tracks?: string
88
+ owner?: string
89
+ repo?: string
90
+ labels: string[]
91
+ prefix: string
92
+ dryRun: boolean
93
+ max?: number
94
+ maxBodyLength?: number
95
+ requestTimeoutMs?: number
96
+ throttleMs?: number
97
+ skipDependencies?: boolean
98
+ verbose?: boolean
99
+ }
100
+
101
+ type GitTaskIssueState = 'open' | 'closed' | 'all'
102
+
103
+ type ProjectGitTaskQueryOptions = {
104
+ owner?: string
105
+ repo?: string
106
+ state?: GitTaskIssueState
107
+ taskOnly?: boolean
108
+ }
109
+
110
+ type ProjectWriteGitTaskOptions = {
111
+ owner?: string
112
+ repo?: string
113
+ createIfMissing?: boolean
114
+ title?: string
115
+ body?: string
116
+ labels?: string[]
117
+ state?: 'open' | 'closed'
118
+ taskDependencies?: string[]
119
+ taskSignature?: string
120
+ }
121
+
122
+ export type ProjectGitTaskRecord = {
123
+ number: number
124
+ title: string
125
+ taskId: string | null
126
+ taskSignature: string | null
127
+ body: string
128
+ state: string
129
+ labels: string[]
130
+ taskDependenciesFromPayload: string[]
131
+ taskDependenciesFromBody: string[]
132
+ }
133
+
134
+ export type ProjectFetchGitTasksResult = {
135
+ projectName: string
136
+ owner: string
137
+ repo: string
138
+ state: GitTaskIssueState
139
+ taskOnly: boolean
140
+ total: number
141
+ tasks: ProjectGitTaskRecord[]
142
+ }
143
+
144
+ export type ProjectReadGitTaskResult = {
145
+ projectName: string
146
+ owner: string
147
+ repo: string
148
+ issueNumber: number
149
+ issue: ProjectGitTaskRecord
150
+ foundBy: 'number' | 'taskId'
151
+ searchScope: GitTaskIssueState
152
+ }
153
+
154
+ export type ProjectWriteGitTaskResult = {
155
+ projectName: string
156
+ owner: string
157
+ repo: string
158
+ action: 'created' | 'updated'
159
+ issueNumber: number
160
+ issue: ProjectGitTaskRecord
161
+ }
162
+
163
+ const CLI_NAME = 'f0'
164
+ const VERSION_RE = 'v?\\d+(?:\\.\\d+){2,}'
165
+ const VERSION_RE_CORE = `(?:${VERSION_RE})`
166
+ const ACTIVE_EXT_PRIORITY = ['.md', '.json', '.yaml', '.yml', '.ts', '.js', '.txt']
167
+ const PROJECT_DOC_SUBDIRS = ['docs', 'spec'] as const
168
+ const PROJECT_ARCHIVE_DIR_NAME = '_archive'
169
+ const ACTIVE_DOC_EXTENSION_MARKER_RE = /^(.+)\.active\.([^.]+)$/i
170
+ const VERSIONED_DOC_RE = new RegExp(`^(.+)\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
171
+ const TASK_HASH_LENGTH = 8
172
+ const ISSUE_LIST_PAGE_SIZE = 100
173
+ const resolveGiteaHost = (): string => {
174
+ const host = process.env.GITEA_HOST ?? process.env.F0_GITEA_HOST
175
+ if (!host) {
176
+ throw new Error('No Gitea host configured. Set GITEA_HOST or F0_GITEA_HOST.')
177
+ }
178
+ return host
179
+ }
180
+ const TASK_ID_RE = /^TASK-[0-9]{3}[A-Za-z0-9]*$/
181
+ const TASK_TITLE_RE = /^\[([^\]]+)\]\s+(TASK-[0-9]{3}[A-Za-z0-9]*)\s*(?:\s*\[([0-9a-fA-F]{8})\])?/
182
+ const DEPENDENCY_TOKEN_RE = /TASK-[0-9]{3}[A-Za-z0-9]*/g
183
+ const DEFAULT_TRACKS_CANDIDATES = [
184
+ path.join('spec', 'tracks.md'),
185
+ path.join('spec', '07_roadmap', 'tracks.md'),
186
+ ] as const
187
+ const PROJECTS_DIRECTORY = 'projects'
188
+
189
+ type ProjectDocsCandidateKind = 'active' | 'plain' | 'versioned'
190
+
191
+ type ProjectDocsCandidate = {
192
+ sourcePath: string
193
+ kind: ProjectDocsCandidateKind
194
+ version: string | null
195
+ }
196
+
197
+ function resolveProjectsRoot(processRoot: string): string {
198
+ return path.join(processRoot, PROJECTS_DIRECTORY)
199
+ }
200
+
201
+ export function usage(): string {
202
+ return `Usage:\n ${CLI_NAME} projects <project-name> --generate-spec\n ${CLI_NAME} projects <project-name> <file-path> --set-active\n ${CLI_NAME} projects --list\n ${CLI_NAME} projects <project-name> sync-tasks\n ${CLI_NAME} projects <project-name> clear-issues\n\n` +
203
+ `Examples:\n ${CLI_NAME} projects index --generate-spec\n ${CLI_NAME} projects index /implementation-plan.v0.0.1 --set-active\n ${CLI_NAME} projects index /implementation-plan --set-active --latest\n ${CLI_NAME} projects index sync-tasks\n ${CLI_NAME} projects index sync-tasks --verbose\n ${CLI_NAME} projects index sync-tasks --request-timeout-ms 60000 --throttle-ms 250\n ${CLI_NAME} projects index sync-tasks --skip-dependencies\n ${CLI_NAME} projects index clear-issues --dry-run\n ${CLI_NAME} projects index clear-issues --force\n ${CLI_NAME} projects index clear-issues --state all --force\n ${CLI_NAME} projects --list\n\n` +
204
+ `file-path is relative to the project root (leading slash required).\n` +
205
+ `Sync-tasks flags: --verbose --max --max-body --request-timeout-ms --throttle-ms --skip-dependencies --owner --repo --label --prefix --tracks.\n` +
206
+ `Use --verbose with clear-issues to print each deletion.\n` +
207
+ `Use --latest to resolve /file-name to the latest version.\n` +
208
+ `The active file created is [file].active.<ext>.\n`
209
+ }
210
+
211
+ export function resolveProjectRoot(projectName: string, processRoot: string = process.cwd()): string {
212
+ if (!projectName || projectName.trim().length === 0) {
213
+ throw new Error('project-name is required.')
214
+ }
215
+
216
+ const normalized = projectName.trim().replace(/[\\/]+/g, path.sep)
217
+ if (normalized === '.' || normalized === '..' || normalized.includes(path.sep + '..') || normalized.includes('..')) {
218
+ throw new Error('project-name may not include path traversal.')
219
+ }
220
+
221
+ const projectRoot = path.join(resolveProjectsRoot(processRoot), normalized)
222
+ if (!existsDir(projectRoot)) {
223
+ throw new Error(`Project folder not found: ${projectRoot}`)
224
+ }
225
+
226
+ return projectRoot
227
+ }
228
+
229
+ export function listProjects(processRoot: string = process.cwd()): string[] {
230
+ const projectsRoot = resolveProjectsRoot(processRoot)
231
+ if (!existsDir(projectsRoot)) {
232
+ return []
233
+ }
234
+
235
+ return fsSync
236
+ .readdirSync(projectsRoot, { withFileTypes: true })
237
+ .filter((entry) => entry.isDirectory())
238
+ .filter((entry) => {
239
+ const docsPath = path.join(projectsRoot, entry.name, 'docs')
240
+ return existsDir(docsPath)
241
+ })
242
+ .map((entry) => entry.name)
243
+ .sort((a, b) => a.localeCompare(b))
244
+ }
245
+
246
+ export function listProjectDocs(projectName: string, processRoot: string = process.cwd()): string[] {
247
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
248
+ const candidates = buildProjectDocsCatalog(projectRoot)
249
+ return Array.from(candidates.keys()).sort((a, b) => a.localeCompare(b))
250
+ }
251
+
252
+ export async function readProjectDoc(
253
+ projectName: string,
254
+ requestPath: string,
255
+ processRoot: string = process.cwd()
256
+ ): Promise<string> {
257
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
258
+ const catalog = buildProjectDocsCatalog(projectRoot)
259
+ const normalized = normalizeProjectDocRequest(requestPath)
260
+ const sourcePath = resolveProjectDocPath(catalog, normalized)
261
+
262
+ if (!sourcePath) {
263
+ throw new Error(`Doc file not found: ${requestPath}`)
264
+ }
265
+
266
+ const content = await readTextIfExists(sourcePath)
267
+ if (content === null) {
268
+ throw new Error(`Doc file not found: ${requestPath}`)
269
+ }
270
+
271
+ return content
272
+ }
273
+
274
+ type ProjectVersionedFileSpec = {
275
+ parts: string[]
276
+ base: string
277
+ version: string | null
278
+ ext: string | null
279
+ }
280
+
281
+ function buildProjectDocsCatalog(projectRoot: string): Map<string, ProjectDocsCandidate> {
282
+ const catalog = new Map<string, ProjectDocsCandidate>()
283
+
284
+ for (const section of PROJECT_DOC_SUBDIRS) {
285
+ const sectionRoot = path.join(projectRoot, section)
286
+ if (!existsDir(sectionRoot)) {
287
+ continue
288
+ }
289
+ collectProjectDocs(sectionRoot, section, '', catalog)
290
+ }
291
+
292
+ return catalog
293
+ }
294
+
295
+ function collectProjectDocs(
296
+ baseDir: string,
297
+ section: string,
298
+ relativeDir: string,
299
+ catalog: Map<string, ProjectDocsCandidate>
300
+ ): void {
301
+ const entries = fsSync.readdirSync(baseDir, { withFileTypes: true })
302
+
303
+ for (const entry of entries) {
304
+ if (entry.name.toLowerCase() === PROJECT_ARCHIVE_DIR_NAME) {
305
+ continue
306
+ }
307
+
308
+ const childAbsolute = path.join(baseDir, entry.name)
309
+ if (entry.isDirectory()) {
310
+ const nextRelative = relativeDir ? `${relativeDir}/${entry.name}` : entry.name
311
+ collectProjectDocs(childAbsolute, section, nextRelative, catalog)
312
+ continue
313
+ }
314
+
315
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
316
+ continue
317
+ }
318
+
319
+ const isActiveCandidate = entry.isSymbolicLink()
320
+ const simpleName = simplifyDocsFileName(entry.name, isActiveCandidate)
321
+ if (!simpleName) {
322
+ continue
323
+ }
324
+
325
+ const logicalPath = relativeDir
326
+ ? `${section}/${relativeDir}/${simpleName}`
327
+ : `${section}/${simpleName}`
328
+
329
+ const candidate = buildDocsCandidate(entry.name, childAbsolute, isActiveCandidate)
330
+ const existing = catalog.get(logicalPath)
331
+ if (!existing || shouldReplaceDocsCandidate(existing, candidate)) {
332
+ catalog.set(logicalPath, candidate)
333
+ }
334
+ }
335
+ }
336
+
337
+ function buildDocsCandidate(fileName: string, sourcePath: string, isSymlink: boolean): ProjectDocsCandidate {
338
+ const activeMatch = isSymlink ? fileName.match(ACTIVE_DOC_EXTENSION_MARKER_RE) : null
339
+ if (activeMatch) {
340
+ return {
341
+ sourcePath,
342
+ kind: 'active',
343
+ version: null,
344
+ }
345
+ }
346
+
347
+ const versionedMatch = fileName.match(VERSIONED_DOC_RE)
348
+ if (versionedMatch) {
349
+ return {
350
+ sourcePath,
351
+ kind: 'versioned',
352
+ version: versionedMatch[2],
353
+ }
354
+ }
355
+
356
+ return {
357
+ sourcePath,
358
+ kind: 'plain',
359
+ version: null,
360
+ }
361
+ }
362
+
363
+ function simplifyDocsFileName(fileName: string, isSymlink: boolean): string | null {
364
+ const activeMatch = isSymlink ? fileName.match(ACTIVE_DOC_EXTENSION_MARKER_RE) : null
365
+ if (activeMatch) {
366
+ return `${activeMatch[1]}.${activeMatch[2]}`
367
+ }
368
+
369
+ const versionedMatch = fileName.match(VERSIONED_DOC_RE)
370
+ if (versionedMatch) {
371
+ return `${versionedMatch[1]}.${versionedMatch[3]}`
372
+ }
373
+
374
+ return fileName
375
+ }
376
+
377
+ function docsCandidatePriority(kind: ProjectDocsCandidateKind): number {
378
+ if (kind === 'active') return 0
379
+ if (kind === 'plain') return 1
380
+ return 2
381
+ }
382
+
383
+ function shouldReplaceDocsCandidate(
384
+ current: ProjectDocsCandidate,
385
+ incoming: ProjectDocsCandidate
386
+ ): boolean {
387
+ const currentPriority = docsCandidatePriority(current.kind)
388
+ const incomingPriority = docsCandidatePriority(incoming.kind)
389
+
390
+ if (incomingPriority < currentPriority) return true
391
+ if (incomingPriority > currentPriority) return false
392
+
393
+ if (incoming.kind === 'versioned' && current.kind === 'versioned') {
394
+ if (!incoming.version || !current.version) {
395
+ return incoming.sourcePath.localeCompare(current.sourcePath) > 0
396
+ }
397
+
398
+ const versionCompare = compareVersions(current.version, incoming.version)
399
+ if (versionCompare < 0) return true
400
+ if (versionCompare === 0) {
401
+ return incoming.sourcePath.localeCompare(current.sourcePath) > 0
402
+ }
403
+ }
404
+
405
+ return false
406
+ }
407
+
408
+ function normalizeProjectDocRequest(requestPath: string): string {
409
+ const normalized = requestPath
410
+ .replace(/\\/g, '/')
411
+ .trim()
412
+
413
+ if (!normalized) {
414
+ throw new Error('doc-path is required.')
415
+ }
416
+
417
+ const trimmed = normalized.startsWith('/') ? normalized.slice(1) : normalized
418
+
419
+ const parts = trimmed.split('/').filter(Boolean)
420
+ if (parts.length === 0) {
421
+ throw new Error('doc-path is required.')
422
+ }
423
+
424
+ if (parts.some((part) => part === '.' || part === '..')) {
425
+ throw new Error(`Invalid doc-path: ${requestPath}`)
426
+ }
427
+
428
+ return trimmed
429
+ }
430
+
431
+ function resolveProjectDocPath(
432
+ catalog: Map<string, ProjectDocsCandidate>,
433
+ requestPath: string
434
+ ): string | null {
435
+ const exact = catalog.get(requestPath)
436
+ if (exact) {
437
+ return exact.sourcePath
438
+ }
439
+
440
+ const matches = Array.from(catalog).filter(([
441
+ candidatePath,
442
+ ]) => candidatePath === requestPath || candidatePath.endsWith(`/${requestPath}`))
443
+
444
+ if (matches.length === 1) {
445
+ return matches[0]?.[1]?.sourcePath ?? null
446
+ }
447
+
448
+ if (matches.length > 1) {
449
+ throw new Error(`Ambiguous doc-path: ${requestPath}`)
450
+ }
451
+
452
+ return null
453
+ }
454
+
455
+ export interface ProjectSetActiveResult {
456
+ projectName: string
457
+ sourceFile: string
458
+ activeFile: string
459
+ matchedFile: string
460
+ mode: 'symlink'
461
+ }
462
+
463
+ type PlanResolveOptions = {
464
+ requireActive?: boolean
465
+ }
466
+
467
+ export function resolveImplementationPlan(
468
+ projectRoot: string,
469
+ inputFile?: string,
470
+ options: PlanResolveOptions = {}
471
+ ): string {
472
+ const docsRoot = path.join(projectRoot, 'docs')
473
+ if (!existsDir(docsRoot)) {
474
+ throw new Error(`docs directory not found for project: ${projectRoot}`)
475
+ }
476
+
477
+ if (inputFile) {
478
+ const explicit = path.isAbsolute(inputFile) ? inputFile : path.join(docsRoot, inputFile)
479
+ const resolved = path.resolve(explicit)
480
+ if (!isSubPath(docsRoot, resolved)) {
481
+ throw new Error(`Input plan path must be within docs directory: ${inputFile}`)
482
+ }
483
+ if (!existsFile(explicit)) {
484
+ throw new Error(`Input plan file not found: ${explicit}`)
485
+ }
486
+ return resolved
487
+ }
488
+
489
+ const files = fsSync.readdirSync(docsRoot, { withFileTypes: true }).map((entry) => entry.name)
490
+
491
+ const active = files.find((file) => /^implementation[-_]?plan\.active\.[^.]+$/i.test(file))
492
+ if (active) {
493
+ return path.join(docsRoot, active)
494
+ }
495
+
496
+ if (options.requireActive) {
497
+ throw new Error(
498
+ 'No active implementation plan file found: expected docs/implementation-plan.active.md (or .json/.txt/etc).'
499
+ )
500
+ }
501
+
502
+ const versioned = files.filter((file) => new RegExp(`^implementation[-_]plan\\.${VERSION_RE}\\.[^.]+$`, 'i').test(file))
503
+ if (versioned.length === 1) {
504
+ return path.join(docsRoot, versioned[0])
505
+ }
506
+ if (versioned.length > 1) {
507
+ throw new Error(
508
+ `Multiple versioned implementation plan files found: ${versioned.join(', ')}. Please use --input <path>.`
509
+ )
510
+ }
511
+
512
+ throw new Error('No implementation plan file found. Expected docs/implementation-plan.active.md or docs/implementation-plan.<ver>.md')
513
+ }
514
+
515
+ export async function generateSpec(
516
+ projectName: string,
517
+ options: Partial<ProjectGenerateOptions> = {},
518
+ processRoot: string = process.cwd()
519
+ ): Promise<ProjectGenerateResult> {
520
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
521
+ const opts: ProjectGenerateOptions = {
522
+ input: options.input,
523
+ out: options.out,
524
+ dryRun: options.dryRun ?? false,
525
+ manifest: options.manifest ?? true,
526
+ prune: options.prune ?? false,
527
+ verbose: options.verbose ?? false,
528
+ }
529
+
530
+ const inputAbs = path.resolve(
531
+ resolveImplementationPlan(projectRoot, opts.input, { requireActive: !opts.input })
532
+ )
533
+ const outRoot = opts.out ? path.resolve(projectRoot, opts.out) : projectRoot
534
+
535
+ const inputContents = await readTextIfExists(inputAbs)
536
+ if (inputContents === null) {
537
+ throw new Error(`Input monolith file not found: ${inputAbs}`)
538
+ }
539
+
540
+ if (inputContents.trim().length === 0) {
541
+ throw new Error(`Input monolith is empty: ${inputAbs}`)
542
+ }
543
+
544
+ const files = parseMonolith(inputContents)
545
+ files.sort((a, b) => a.relPath.localeCompare(b.relPath))
546
+
547
+ const manifestRel = 'spec/.spec-manifest.json'
548
+ const manifestAbs = path.resolve(outRoot, manifestRel)
549
+
550
+ if (opts.prune && !opts.dryRun) {
551
+ const previous = await loadPreviousManifest(manifestAbs)
552
+ if (previous?.files) {
553
+ const now = new Set(files.map((file) => file.relPath))
554
+ const toDelete = previous.files
555
+ .map((entry) => entry?.path)
556
+ .filter((value): value is string => Boolean(value))
557
+ .map((entryPath) => normalizeRelPath(entryPath))
558
+ .filter((entryPath) => !now.has(entryPath))
559
+
560
+ for (const relPath of toDelete) {
561
+ const absolute = path.resolve(outRoot, relPath)
562
+ assertWithinOutRoot(outRoot, absolute)
563
+ try {
564
+ await fs.rm(absolute, { force: true })
565
+ if (opts.verbose) {
566
+ // eslint-disable-next-line no-console
567
+ console.log(`pruned ${relPath}`)
568
+ }
569
+ } catch {
570
+ // ignore
571
+ }
572
+ }
573
+ }
574
+ }
575
+
576
+ let created = 0
577
+ let updated = 0
578
+ let unchanged = 0
579
+
580
+ for (const file of files) {
581
+ const outPath = path.resolve(outRoot, file.relPath)
582
+ assertWithinOutRoot(outRoot, outPath)
583
+
584
+ if (opts.dryRun) {
585
+ // eslint-disable-next-line no-console
586
+ console.log(`[dry-run] would write ${file.relPath} (${file.bytes} bytes)`)
587
+ continue
588
+ }
589
+
590
+ await fs.mkdir(path.dirname(outPath), { recursive: true })
591
+ const status = await writeIfChanged(outPath, file.content)
592
+ if (status === 'created') created += 1
593
+ if (status === 'updated') updated += 1
594
+ if (status === 'unchanged') unchanged += 1
595
+
596
+ if (opts.verbose) {
597
+ // eslint-disable-next-line no-console
598
+ console.log(`${status.padEnd(9)} ${file.relPath}`)
599
+ }
600
+ }
601
+
602
+ if (opts.manifest && !opts.dryRun) {
603
+ const manifest = {
604
+ generated_at: new Date().toISOString(),
605
+ source_monolith: path.relative(process.cwd(), inputAbs) || inputAbs,
606
+ out_root: path.relative(process.cwd(), outRoot) || outRoot,
607
+ file_count: files.length,
608
+ total_bytes: files.reduce((sum, file) => sum + file.bytes, 0),
609
+ files: files.map((file) => ({
610
+ path: file.relPath,
611
+ sha256: file.sha256,
612
+ bytes: file.bytes,
613
+ spec_id: file.meta?.spec_id ?? null,
614
+ title: file.meta?.title ?? null,
615
+ source_anchor: file.meta?.source_anchor ?? null,
616
+ })),
617
+ }
618
+
619
+ await fs.mkdir(path.dirname(manifestAbs), { recursive: true })
620
+ await fs.writeFile(manifestAbs, JSON.stringify(manifest, null, 2) + '\n')
621
+ }
622
+
623
+ return {
624
+ projectName,
625
+ inputPath: inputAbs,
626
+ outRoot,
627
+ files: files.length,
628
+ created,
629
+ updated,
630
+ unchanged,
631
+ dryRun: opts.dryRun,
632
+ }
633
+ }
634
+
635
+ function parseGenerateArgs(argv: string[]): {
636
+ command: 'generate-spec' | 'list'
637
+ setActive: boolean
638
+ target?: string
639
+ latest: boolean
640
+ options: Partial<ProjectGenerateOptions>
641
+ requestedGenerateSpec: boolean
642
+ unknownFlags: string[]
643
+ } {
644
+ const result: {
645
+ command: 'generate-spec' | 'list'
646
+ setActive: boolean
647
+ target?: string
648
+ latest: boolean
649
+ options: Partial<ProjectGenerateOptions>
650
+ requestedGenerateSpec: boolean
651
+ unknownFlags: string[]
652
+ } = {
653
+ options: {},
654
+ requestedGenerateSpec: false,
655
+ unknownFlags: [],
656
+ command: 'generate-spec',
657
+ setActive: false,
658
+ latest: false,
659
+ }
660
+
661
+ for (let i = 0; i < argv.length; i += 1) {
662
+ const arg = argv[i]
663
+
664
+ if (!arg.startsWith('--')) {
665
+ if (!result.target) {
666
+ result.target = arg
667
+ } else {
668
+ result.unknownFlags.push(arg)
669
+ }
670
+ continue
671
+ }
672
+
673
+ if (arg === '--generate-spec') {
674
+ result.command = 'generate-spec'
675
+ result.requestedGenerateSpec = true
676
+ continue
677
+ }
678
+ if (arg === '--set-active') {
679
+ result.setActive = true
680
+ continue
681
+ }
682
+ if (arg === '--latest') {
683
+ result.latest = true
684
+ continue
685
+ }
686
+
687
+ if (arg === '--list') {
688
+ result.command = 'list'
689
+ continue
690
+ }
691
+
692
+ if (arg === '--input') {
693
+ const value = argv[i + 1]
694
+ if (!value) {
695
+ throw new Error('Missing value for --input')
696
+ }
697
+ result.options.input = value
698
+ i += 1
699
+ continue
700
+ }
701
+
702
+ if (arg === '--out') {
703
+ const value = argv[i + 1]
704
+ if (!value) {
705
+ throw new Error('Missing value for --out')
706
+ }
707
+ result.options.out = value
708
+ i += 1
709
+ continue
710
+ }
711
+
712
+ if (arg === '--dry-run') {
713
+ result.options.dryRun = true
714
+ continue
715
+ }
716
+
717
+ if (arg === '--prune') {
718
+ result.options.prune = true
719
+ continue
720
+ }
721
+
722
+ if (arg === '--manifest') {
723
+ result.options.manifest = true
724
+ continue
725
+ }
726
+
727
+ if (arg === '--no-manifest') {
728
+ result.options.manifest = false
729
+ continue
730
+ }
731
+
732
+ if (arg === '--verbose') {
733
+ result.options.verbose = true
734
+ continue
735
+ }
736
+
737
+ if (arg === '--') {
738
+ continue
739
+ }
740
+
741
+ result.unknownFlags.push(arg)
742
+ }
743
+
744
+ return result
745
+ }
746
+
747
+ function parseSyncTasksArgs(argv: string[]): {
748
+ options: Omit<ProjectSyncTaskOptions, 'tracks'>
749
+ tracks?: string
750
+ unknownFlags: string[]
751
+ } {
752
+ const options: Omit<ProjectSyncTaskOptions, 'tracks'> = {
753
+ labels: [],
754
+ prefix: '[TASK]',
755
+ dryRun: false,
756
+ requestTimeoutMs: 60_000,
757
+ throttleMs: 0,
758
+ skipDependencies: false,
759
+ verbose: false,
760
+ }
761
+ const result: {
762
+ options: Omit<ProjectSyncTaskOptions, 'tracks'>
763
+ tracks?: string
764
+ unknownFlags: string[]
765
+ } = {
766
+ options,
767
+ unknownFlags: [],
768
+ }
769
+
770
+ for (let i = 0; i < argv.length; i += 1) {
771
+ const arg = argv[i]
772
+
773
+ if (!arg.startsWith('--')) {
774
+ if (!result.tracks && !arg.startsWith('sync-tasks')) {
775
+ result.tracks = arg
776
+ } else {
777
+ result.unknownFlags.push(arg)
778
+ }
779
+ continue
780
+ }
781
+
782
+ if (arg === '--dry-run') {
783
+ options.dryRun = true
784
+ continue
785
+ }
786
+
787
+ if (arg === '--owner') {
788
+ const value = argv[i + 1]
789
+ if (!value) {
790
+ throw new Error('Missing value for --owner')
791
+ }
792
+ options.owner = value
793
+ i += 1
794
+ continue
795
+ }
796
+
797
+ if (arg === '--repo') {
798
+ const value = argv[i + 1]
799
+ if (!value) {
800
+ throw new Error('Missing value for --repo')
801
+ }
802
+ options.repo = value
803
+ i += 1
804
+ continue
805
+ }
806
+
807
+ if (arg === '--label') {
808
+ const value = argv[i + 1]
809
+ if (!value) {
810
+ throw new Error('Missing value for --label')
811
+ }
812
+ options.labels.push(value)
813
+ i += 1
814
+ continue
815
+ }
816
+
817
+ if (arg === '--prefix') {
818
+ const value = argv[i + 1]
819
+ if (!value) {
820
+ throw new Error('Missing value for --prefix')
821
+ }
822
+ options.prefix = value
823
+ i += 1
824
+ continue
825
+ }
826
+
827
+ if (arg === '--max-body') {
828
+ const value = Number(argv[i + 1])
829
+ if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) {
830
+ throw new Error(`Invalid --max-body value: ${argv[i + 1]}`)
831
+ }
832
+ options.maxBodyLength = value
833
+ i += 1
834
+ continue
835
+ }
836
+
837
+ if (arg === '--max') {
838
+ const value = Number(argv[i + 1])
839
+ if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) {
840
+ throw new Error(`Invalid --max value: ${argv[i + 1]}`)
841
+ }
842
+ options.max = value
843
+ i += 1
844
+ continue
845
+ }
846
+
847
+ if (arg === '--request-timeout-ms') {
848
+ const value = Number(argv[i + 1])
849
+ if (!Number.isFinite(value) || value <= 0 || Math.floor(value) !== value) {
850
+ throw new Error(`Invalid --request-timeout-ms value: ${argv[i + 1]}`)
851
+ }
852
+ options.requestTimeoutMs = value
853
+ i += 1
854
+ continue
855
+ }
856
+
857
+ if (arg === '--throttle-ms') {
858
+ const value = Number(argv[i + 1])
859
+ if (!Number.isFinite(value) || value < 0 || Math.floor(value) !== value) {
860
+ throw new Error(`Invalid --throttle-ms value: ${argv[i + 1]}`)
861
+ }
862
+ options.throttleMs = value
863
+ i += 1
864
+ continue
865
+ }
866
+
867
+ if (arg === '--skip-dependencies') {
868
+ options.skipDependencies = true
869
+ continue
870
+ }
871
+
872
+ if (arg === '--tracks') {
873
+ const value = argv[i + 1]
874
+ if (!value) {
875
+ throw new Error('Missing value for --tracks')
876
+ }
877
+ result.tracks = value
878
+ i += 1
879
+ continue
880
+ }
881
+
882
+ if (arg === '--verbose') {
883
+ options.verbose = true
884
+ continue
885
+ }
886
+
887
+ result.unknownFlags.push(arg)
888
+ }
889
+
890
+ return result
891
+ }
892
+
893
+ function parseClearIssuesArgs(argv: string[]): {
894
+ options: ProjectClearIssuesOptions
895
+ unknownFlags: string[]
896
+ } {
897
+ const options: ProjectClearIssuesOptions = {
898
+ state: 'all',
899
+ dryRun: false,
900
+ force: false,
901
+ verbose: false,
902
+ }
903
+
904
+ const result: {
905
+ options: ProjectClearIssuesOptions
906
+ unknownFlags: string[]
907
+ } = {
908
+ options,
909
+ unknownFlags: [],
910
+ }
911
+
912
+ for (let i = 0; i < argv.length; i += 1) {
913
+ const arg = argv[i]
914
+
915
+ if (arg === '--dry-run') {
916
+ options.dryRun = true
917
+ continue
918
+ }
919
+
920
+ if (arg === '--force') {
921
+ options.force = true
922
+ continue
923
+ }
924
+
925
+ if (arg === '--verbose') {
926
+ options.verbose = true
927
+ continue
928
+ }
929
+
930
+ if (arg === '--state') {
931
+ const value = argv[i + 1]
932
+ if (!value) {
933
+ throw new Error('Missing value for --state')
934
+ }
935
+ if (value !== 'open' && value !== 'closed' && value !== 'all') {
936
+ throw new Error(`Invalid value for --state: ${value}`)
937
+ }
938
+ options.state = value
939
+ i += 1
940
+ continue
941
+ }
942
+
943
+ if (arg === '--owner') {
944
+ const value = argv[i + 1]
945
+ if (!value) {
946
+ throw new Error('Missing value for --owner')
947
+ }
948
+ options.owner = value
949
+ i += 1
950
+ continue
951
+ }
952
+
953
+ if (arg === '--repo') {
954
+ const value = argv[i + 1]
955
+ if (!value) {
956
+ throw new Error('Missing value for --repo')
957
+ }
958
+ options.repo = value
959
+ i += 1
960
+ continue
961
+ }
962
+
963
+ if (arg.startsWith('--')) {
964
+ result.unknownFlags.push(arg)
965
+ continue
966
+ }
967
+
968
+ result.unknownFlags.push(arg)
969
+ }
970
+
971
+ return result
972
+ }
973
+
974
+ function resolveGitIdentityFromProject(projectRoot: string): { owner: string; repo: string } {
975
+ const resolved = resolveProjectRepoIdentity(projectRoot)
976
+ if (!resolved) {
977
+ throw new Error(
978
+ `Could not resolve project git identity from ${path.join(projectRoot, 'code', '.git', 'config')} or ${path.join(projectRoot, '.git', 'config')}`
979
+ )
980
+ }
981
+ return resolved
982
+ }
983
+
984
+ function resolveTracksPath(projectRoot: string, input?: string): string {
985
+ if (input) {
986
+ if (path.isAbsolute(input)) {
987
+ if (!existsFile(input)) {
988
+ throw new Error(`tracks file not found: ${input}`)
989
+ }
990
+ return input
991
+ }
992
+
993
+ const relative = input.startsWith('/') || input.startsWith('\\') ? input.slice(1) : input
994
+ const candidate = path.join(projectRoot, relative)
995
+ if (!existsFile(candidate)) {
996
+ throw new Error(`tracks file not found: ${input}`)
997
+ }
998
+ return candidate
999
+ }
1000
+
1001
+ for (const candidate of DEFAULT_TRACKS_CANDIDATES) {
1002
+ const absolute = path.join(projectRoot, candidate)
1003
+ if (existsFile(absolute)) {
1004
+ return absolute
1005
+ }
1006
+ }
1007
+
1008
+ throw new Error(
1009
+ `Could not locate tracks file. Checked: ${DEFAULT_TRACKS_CANDIDATES.map((candidate) => path.join(projectRoot, candidate)).join(', ')}`
1010
+ )
1011
+ }
1012
+
1013
+ function normalizeTaskDependencies(value: string): string[] {
1014
+ const normalized = value
1015
+ .split(',')
1016
+ .map((raw) => raw.trim())
1017
+ .filter(Boolean)
1018
+ .filter((raw) => TASK_ID_RE.test(raw))
1019
+ const seen = new Set<string>()
1020
+ const deduped: string[] = []
1021
+
1022
+ for (const item of normalized) {
1023
+ const canonical = item.toUpperCase()
1024
+ if (seen.has(canonical)) {
1025
+ continue
1026
+ }
1027
+ seen.add(canonical)
1028
+ deduped.push(canonical)
1029
+ }
1030
+
1031
+ return deduped
1032
+ }
1033
+
1034
+ function parseDependencyCell(value: string): string[] {
1035
+ const found = value.match(DEPENDENCY_TOKEN_RE)
1036
+ if (!found) {
1037
+ return []
1038
+ }
1039
+ return normalizeTaskDependencies(found.join(','))
1040
+ }
1041
+
1042
+ function parseTaskGraphFromMarkdownForProjects(
1043
+ markdown: string,
1044
+ log?: (message: string) => void
1045
+ ): TaskGraphParseResult {
1046
+ try {
1047
+ log?.('Parsing tracks using script taskgraph parser.')
1048
+ return parseTaskGraphFromMarkdownScript(markdown)
1049
+ } catch {
1050
+ log?.('Script taskgraph parser did not match; trying table fallback parser.')
1051
+ const rows = parseTaskGraphFromTableRows(markdown)
1052
+ if (rows.length === 0) {
1053
+ throw new Error('No parseable taskgraph found in markdown.')
1054
+ }
1055
+ return buildTaskGraphFromRows(rows)
1056
+ }
1057
+ }
1058
+
1059
+ function parseTaskGraphFromTableRows(markdown: string): Array<{ id: string; desc: string; deps: string[]; lineNo: number }> {
1060
+ const rows: Array<{ id: string; desc: string; deps: string[]; lineNo: number }> = []
1061
+ const lines = markdown.replace(/\r/g, '').split('\n')
1062
+ let tableHasHeader = false
1063
+ let depIndex = -1
1064
+
1065
+ for (let i = 0; i < lines.length; i += 1) {
1066
+ const line = lines[i].trim()
1067
+ if (!line.startsWith('|') || !line.endsWith('|')) {
1068
+ if (tableHasHeader && line.length === 0) {
1069
+ tableHasHeader = false
1070
+ depIndex = -1
1071
+ }
1072
+ continue
1073
+ }
1074
+
1075
+ const cells = line
1076
+ .slice(1, -1)
1077
+ .split('|')
1078
+ .map((value) => value.trim())
1079
+ .filter((value) => value.length > 0)
1080
+
1081
+ if (cells.length < 3) {
1082
+ continue
1083
+ }
1084
+
1085
+ const isSeparator = cells.every((cell) => /^:?[-\s]+:?$/.test(cell))
1086
+ if (isSeparator) {
1087
+ continue
1088
+ }
1089
+
1090
+ const hasTaskHeader = cells[0].toUpperCase().includes('TASK') && cells[0].toUpperCase().includes('ID')
1091
+ const hasDescriptionHeader = cells[1].toUpperCase().includes('DESCRIPTION')
1092
+
1093
+ if (hasTaskHeader && hasDescriptionHeader) {
1094
+ tableHasHeader = true
1095
+ const depsHeaderIndex = cells.findIndex((cell) => cell.toUpperCase().includes('DEPEND'))
1096
+ depIndex = depsHeaderIndex >= 0 ? depsHeaderIndex : -1
1097
+ continue
1098
+ }
1099
+
1100
+ if (!tableHasHeader) {
1101
+ continue
1102
+ }
1103
+
1104
+ const taskId = cells[0].trim().toUpperCase()
1105
+ if (!TASK_ID_RE.test(taskId)) {
1106
+ continue
1107
+ }
1108
+
1109
+ const desc = cells[1]?.trim() ?? ''
1110
+ const depCell = depIndex >= 0 ? cells[depIndex] ?? '' : ''
1111
+ const dependencies = parseDependencyCell(depCell)
1112
+
1113
+ rows.push({ id: taskId, desc, deps: dependencies, lineNo: i + 1 })
1114
+ }
1115
+
1116
+ return rows
1117
+ }
1118
+
1119
+ function buildTaskGraphFromRows(rows: Array<{ id: string; desc: string; deps: string[]; lineNo: number }>): TaskGraphParseResult {
1120
+ const byId = new Map<string, TaskGraphTask>()
1121
+
1122
+ for (const row of rows) {
1123
+ if (!row.desc) {
1124
+ throw new Error(`Empty description in tracks table at line ${row.lineNo}`)
1125
+ }
1126
+ if (TASK_ID_RE.test(row.id) === false) {
1127
+ continue
1128
+ }
1129
+ const normalizedDeps = row.deps.filter((dependency) => dependency !== row.id)
1130
+ if (normalizedDeps.length !== row.deps.length) {
1131
+ throw new Error(`Task '${row.id}' cannot depend on itself`)
1132
+ }
1133
+ if (new Set(normalizedDeps).size !== normalizedDeps.length) {
1134
+ throw new Error(`Duplicate dependencies for '${row.id}' at line ${row.lineNo}`)
1135
+ }
1136
+
1137
+ const task: TaskGraphTask = {
1138
+ id: row.id as TaskGraphTask['id'],
1139
+ desc: row.desc,
1140
+ deps: normalizedDeps as TaskGraphTask['deps'],
1141
+ }
1142
+ byId.set(task.id, task)
1143
+ }
1144
+
1145
+ const tasks = [...byId.values()]
1146
+
1147
+ const inDeg = new Map<string, number>()
1148
+ const adj = new Map<string, string[]>()
1149
+ for (const task of tasks) {
1150
+ const effectiveDeps = task.deps.filter((dependency) => byId.has(dependency))
1151
+ inDeg.set(task.id, effectiveDeps.length)
1152
+ adj.set(task.id, [])
1153
+ }
1154
+
1155
+ for (const task of tasks) {
1156
+ for (const dep of task.deps.filter((dependency) => byId.has(dependency))) {
1157
+ adj.get(dep)?.push(task.id)
1158
+ }
1159
+ }
1160
+
1161
+ const ready = [...inDeg.entries()]
1162
+ .filter(([, degree]) => degree === 0)
1163
+ .map(([id]) => id)
1164
+ .sort()
1165
+
1166
+ const topo: string[] = []
1167
+ while (ready.length > 0) {
1168
+ const id = ready.shift()
1169
+ if (!id) {
1170
+ break
1171
+ }
1172
+
1173
+ topo.push(id)
1174
+ for (const downstream of adj.get(id) ?? []) {
1175
+ const degree = (inDeg.get(downstream) ?? 0) - 1
1176
+ inDeg.set(downstream, degree)
1177
+ if (degree === 0) {
1178
+ ready.push(downstream)
1179
+ ready.sort()
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ if (topo.length !== tasks.length) {
1185
+ const remaining = [...inDeg.entries()].filter(([, degree]) => degree > 0).map(([id]) => id).sort()
1186
+ throw new Error(`Dependency cycle detected among: ${remaining.join(',')}`)
1187
+ }
1188
+
1189
+ const byIdObject: Record<string, TaskGraphTask> = {}
1190
+ for (const [id, task] of byId.entries()) {
1191
+ byIdObject[id] = task
1192
+ }
1193
+
1194
+ return {
1195
+ version: 'TASKGRAPH-V1',
1196
+ tasks,
1197
+ byId: byIdObject,
1198
+ topo,
1199
+ }
1200
+ }
1201
+
1202
+ function computeTaskHash(task: TaskGraphTask): string {
1203
+ const payload = `${task.id}|${task.desc}|${task.deps.join(',')}`
1204
+ return crypto
1205
+ .createHash('sha256')
1206
+ .update(payload, 'utf8')
1207
+ .digest('hex')
1208
+ .slice(0, TASK_HASH_LENGTH)
1209
+ }
1210
+
1211
+ function buildIssuePayloadFromTask(
1212
+ task: TaskGraphTask,
1213
+ index: number,
1214
+ total: number,
1215
+ options: Pick<ProjectSyncTaskOptions, 'prefix' | 'maxBodyLength' | 'labels'>
1216
+ ) {
1217
+ const shortHash = computeTaskHash(task)
1218
+ const deps = task.deps.length === 0 ? 'None' : task.deps.join(', ')
1219
+ const body = [
1220
+ `# ${task.id}`,
1221
+ '',
1222
+ `- Topological index: ${index + 1} / ${total}`,
1223
+ `- Dependencies: ${deps}`,
1224
+ '',
1225
+ '## Description',
1226
+ task.desc,
1227
+ ].join('\n')
1228
+
1229
+ return {
1230
+ title: `${options.prefix} ${task.id} [${shortHash}]`,
1231
+ body: options.maxBodyLength ? body.slice(0, options.maxBodyLength) : body,
1232
+ labels: [...new Set(options.labels)],
1233
+ task_id: task.id,
1234
+ task_dependencies: task.deps,
1235
+ task_signature: shortHash,
1236
+ }
1237
+ }
1238
+
1239
+ function resolveIssueApiMethod(api: GitServiceApi, feature: 'create' | 'edit' | 'list' | 'delete' | 'view'): GitServiceApiMethod {
1240
+ const issuesNamespace = api['issue']
1241
+ if (!issuesNamespace || typeof issuesNamespace !== 'object') {
1242
+ throw new Error('Git service API does not expose issue namespace')
1243
+ }
1244
+
1245
+ const method = (issuesNamespace as {
1246
+ create?: unknown
1247
+ edit?: unknown
1248
+ list?: unknown
1249
+ delete?: unknown
1250
+ view?: unknown
1251
+ })[feature]
1252
+ if (typeof method !== 'function') {
1253
+ throw new Error(`Git service API does not expose issue.${feature}`)
1254
+ }
1255
+
1256
+ return method as GitServiceApiMethod
1257
+ }
1258
+
1259
+ type ProjectGitTaskIdentifier = {
1260
+ type: 'number' | 'taskId'
1261
+ value: number | string
1262
+ }
1263
+
1264
+ function parseGitTaskReference(taskRef: string): ProjectGitTaskIdentifier {
1265
+ const trimmed = taskRef.trim()
1266
+ if (!trimmed) {
1267
+ throw new Error('Task reference is required.')
1268
+ }
1269
+
1270
+ const numeric = trimmed.match(/^([1-9]\d*)$/)
1271
+ if (numeric?.[1]) {
1272
+ return { type: 'number', value: Number(numeric[1]) }
1273
+ }
1274
+
1275
+ const taskId = trimmed.toUpperCase()
1276
+ if (!TASK_ID_RE.test(taskId)) {
1277
+ throw new Error(`Invalid task reference: ${taskRef}`)
1278
+ }
1279
+
1280
+ return { type: 'taskId', value: taskId }
1281
+ }
1282
+
1283
+ function normalizeIssueLabels(raw: unknown): string[] {
1284
+ if (!Array.isArray(raw)) {
1285
+ return []
1286
+ }
1287
+
1288
+ const labels = raw
1289
+ .map((item) => {
1290
+ if (typeof item === 'string') return item.trim()
1291
+ if (item && typeof item === 'object' && 'name' in item && typeof (item as { name?: unknown }).name === 'string') {
1292
+ return (item as { name: string }).name.trim()
1293
+ }
1294
+
1295
+ return ''
1296
+ })
1297
+ .filter((value): value is string => value.length > 0)
1298
+
1299
+ return [...new Set(labels)]
1300
+ }
1301
+
1302
+ function parseTaskDependencyList(raw: unknown): string[] {
1303
+ if (Array.isArray(raw)) {
1304
+ return [...new Set(
1305
+ raw
1306
+ .map((item) => (typeof item === 'string' ? item.trim().toUpperCase() : ''))
1307
+ .filter((value): value is string => value.length > 0 && TASK_ID_RE.test(value)),
1308
+ )]
1309
+ }
1310
+
1311
+ if (typeof raw === 'string') {
1312
+ return [...new Set(
1313
+ normalizeTaskDependencies(raw).map((value) => value.toUpperCase()).filter((value) => TASK_ID_RE.test(value)),
1314
+ )]
1315
+ }
1316
+
1317
+ return []
1318
+ }
1319
+
1320
+ function normalizeTaskId(value: unknown): string | null {
1321
+ if (typeof value !== 'string') {
1322
+ return null
1323
+ }
1324
+
1325
+ const normalized = value.trim().toUpperCase()
1326
+ return TASK_ID_RE.test(normalized) ? normalized : null
1327
+ }
1328
+
1329
+ function normalizeTaskSignature(value: unknown): string | null {
1330
+ if (typeof value !== 'string') {
1331
+ return null
1332
+ }
1333
+
1334
+ const normalized = value.trim()
1335
+ return /^[0-9a-fA-F]{8}$/.test(normalized) ? normalized.toUpperCase() : null
1336
+ }
1337
+
1338
+ function buildGitTaskPayload(ref: ProjectGitTaskIdentifier, options: ProjectWriteGitTaskOptions): Record<string, unknown> {
1339
+ const payload: Record<string, unknown> = {}
1340
+
1341
+ if (typeof options.title === 'string') payload.title = options.title
1342
+ if (typeof options.body === 'string') payload.body = options.body
1343
+ if (options.labels) payload.labels = options.labels
1344
+ if (options.state) payload.state = options.state
1345
+ if (options.taskDependencies) payload.task_dependencies = options.taskDependencies
1346
+ if (options.taskSignature) payload.task_signature = options.taskSignature
1347
+
1348
+ if (ref.type === 'taskId' && payload.title === undefined) {
1349
+ payload.title = `[TASK] ${ref.value}`
1350
+ }
1351
+
1352
+ return payload
1353
+ }
1354
+
1355
+ function toProjectGitTaskRecord(issue: GitIssueLike): ProjectGitTaskRecord | null {
1356
+ const raw = issue as Record<string, unknown>
1357
+
1358
+ const issueNumber = Number(raw.number)
1359
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null
1360
+
1361
+ const title = typeof raw.title === 'string' ? raw.title : `Task #${issueNumber}`
1362
+ const body = typeof raw.body === 'string' ? raw.body : ''
1363
+ const parsedTitle = parseIssueTaskIdFromTitle(title)
1364
+ const parsedBody = body ? parseIssueTaskIdFromBody(body) : null
1365
+ const taskIdFromPayload = normalizeTaskId(raw.task_id)
1366
+ const signatureFromPayload = normalizeTaskSignature(raw.task_signature)
1367
+
1368
+ return {
1369
+ number: issueNumber,
1370
+ title,
1371
+ taskId: taskIdFromPayload ?? parsedTitle?.taskId ?? parsedBody?.taskId ?? null,
1372
+ taskSignature: signatureFromPayload ?? parsedTitle?.hash ?? parsedBody?.hash ?? null,
1373
+ body,
1374
+ state: typeof raw.state === 'string' ? raw.state : 'unknown',
1375
+ labels: normalizeIssueLabels(raw.labels),
1376
+ taskDependenciesFromPayload: parseTaskDependencyList(raw.task_dependencies),
1377
+ taskDependenciesFromBody: body ? parseIssueDependenciesFromBody(body) : [],
1378
+ }
1379
+ }
1380
+
1381
+ function resolveProjectGitRepository(
1382
+ projectName: string,
1383
+ processRoot: string,
1384
+ options: { owner?: string; repo?: string },
1385
+ ): { owner: string; repo: string } {
1386
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
1387
+ const resolved = resolveGitIdentityFromProject(projectRoot)
1388
+ const fallbackOwner = process.env.GITEA_TEST_OWNER || process.env.GITEA_OWNER
1389
+ const fallbackRepo = process.env.GITEA_TEST_REPO || process.env.GITEA_REPO
1390
+
1391
+ const owner = options.owner ?? resolved?.owner ?? fallbackOwner
1392
+ const repo = options.repo ?? resolved?.repo ?? fallbackRepo
1393
+
1394
+ if (!owner || !repo) {
1395
+ throw new Error(
1396
+ `Could not resolve project git identity from ${path.join(projectRoot, 'code', '.git', 'config')} or ${path.join(projectRoot, '.git', 'config')} and no owner/repo defaults are configured. Pass --owner and --repo (or set GITEA_TEST_OWNER and GITEA_TEST_REPO).`
1397
+ )
1398
+ }
1399
+
1400
+ return {
1401
+ owner,
1402
+ repo,
1403
+ }
1404
+ }
1405
+
1406
+ function parseIssueTaskIdFromTitle(title: string): { taskId: string; hash?: string } | null {
1407
+ const normalizedTitle = title.trim().toUpperCase()
1408
+ const match = normalizedTitle.match(TASK_TITLE_RE)
1409
+ if (match && match[1] && match[2]) {
1410
+ return {
1411
+ taskId: match[2],
1412
+ hash: match[3],
1413
+ }
1414
+ }
1415
+
1416
+ const fallbackMatch = normalizedTitle.match(/^(?:\[[^\]]+\]\s*)?(TASK-[0-9]{3}[A-Za-z0-9]*)/)
1417
+ if (!fallbackMatch?.[1]) {
1418
+ return null
1419
+ }
1420
+
1421
+ return {
1422
+ taskId: fallbackMatch[1],
1423
+ }
1424
+ }
1425
+
1426
+ function parseIssueTaskIdFromBody(body: string): { taskId: string; hash?: string } | null {
1427
+ const firstLine = body
1428
+ .replace(/\r/g, '')
1429
+ .split('\n')
1430
+ .map((line) => line.trim())
1431
+ .find((line) => line.length > 0)
1432
+
1433
+ if (!firstLine) {
1434
+ return null
1435
+ }
1436
+
1437
+ const normalized = firstLine.toUpperCase().replace(/^#\s*/, '')
1438
+ const match = normalized.match(/^(TASK-[0-9]{3}[A-Za-z0-9]*)(?:\s+\[([0-9A-F]{8})\])?/)
1439
+ if (!match?.[1]) {
1440
+ return null
1441
+ }
1442
+
1443
+ return {
1444
+ taskId: match[1],
1445
+ hash: match[2],
1446
+ }
1447
+ }
1448
+
1449
+ function parseIssueDependenciesFromBody(body: string): string[] {
1450
+ const match = body.match(/^\s*-\s*Dependencies:\s*(.+)$/im)
1451
+ if (!match || !match[1]) {
1452
+ return []
1453
+ }
1454
+
1455
+ const text = match[1].trim()
1456
+ if (!text || /^none$/i.test(text)) {
1457
+ return []
1458
+ }
1459
+
1460
+ return normalizeTaskDependencies(text.replace(/`/g, ','))
1461
+ }
1462
+
1463
+ type GitIssueLike = {
1464
+ title?: unknown
1465
+ number?: unknown
1466
+ task_id?: unknown
1467
+ task_signature?: unknown
1468
+ state?: unknown
1469
+ labels?: unknown
1470
+ task_dependencies?: unknown
1471
+ body?: unknown
1472
+ }
1473
+
1474
+ async function fetchAllIssues(
1475
+ issueList: GitServiceApiMethod,
1476
+ owner: string,
1477
+ repo: string,
1478
+ state: 'open' | 'closed' | 'all',
1479
+ projectName: string,
1480
+ log?: (message: string) => void
1481
+ ): Promise<GitIssueLike[]> {
1482
+ const allIssues: GitIssueLike[] = []
1483
+ let page = 1
1484
+
1485
+ while (true) {
1486
+ log?.(`Listing ${state} issues page ${page} for ${owner}/${repo}...`)
1487
+ const listResult = await issueList(owner, repo, {
1488
+ state,
1489
+ limit: ISSUE_LIST_PAGE_SIZE,
1490
+ query: { page },
1491
+ })
1492
+
1493
+ if (!listResult.ok) {
1494
+ throw new Error(
1495
+ `Failed to list ${state} issues for ${owner}/${repo}: status=${listResult.status} url=${listResult.request.url}`
1496
+ )
1497
+ }
1498
+
1499
+ if (!Array.isArray(listResult.body)) {
1500
+ throw new Error(`Expected array from issue.list for ${projectName} (page ${page})`)
1501
+ }
1502
+
1503
+ const batch = listResult.body as GitIssueLike[]
1504
+ allIssues.push(...batch)
1505
+ log?.(`Loaded page ${page} with ${batch.length} ${state} issues for ${owner}/${repo}.`)
1506
+
1507
+ if (batch.length === 0) {
1508
+ break
1509
+ }
1510
+ page += 1
1511
+ }
1512
+
1513
+ log?.(`Loaded ${allIssues.length} ${state} issues in ${owner}/${repo} across pages.`)
1514
+ return allIssues
1515
+ }
1516
+
1517
+ function issueDependenciesMatches(taskDeps: string[], issue: ProjectSyncIssueRecord): boolean {
1518
+ const fromBody = issue.dependenciesFromBody
1519
+ if (fromBody) {
1520
+ return areDependencyListsEqual(fromBody, taskDeps)
1521
+ }
1522
+
1523
+ if (issue.dependenciesFromPayload) {
1524
+ return areDependencyListsEqual(issue.dependenciesFromPayload, taskDeps)
1525
+ }
1526
+
1527
+ return taskDeps.length === 0
1528
+ }
1529
+
1530
+ function areDependencyListsEqual(left: string[], right: string[]): boolean {
1531
+ const normalize = (value: string[]) => {
1532
+ const uniq = [...new Set(value.map((item) => item.toUpperCase()))].sort()
1533
+ return uniq.join(',')
1534
+ }
1535
+
1536
+ return normalize(left) === normalize(right)
1537
+ }
1538
+
1539
+ type ProjectSyncIssueRecord = {
1540
+ number: number
1541
+ title: string
1542
+ hash: string
1543
+ rawHash: string | null
1544
+ state: string
1545
+ dependenciesFromPayload: string[] | null
1546
+ dependenciesFromBody: string[] | null
1547
+ }
1548
+
1549
+ function buildIssueNumberFromPayload(payload: unknown): number | null {
1550
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
1551
+ return null
1552
+ }
1553
+
1554
+ const number = Number((payload as { number?: unknown }).number)
1555
+ if (Number.isInteger(number) && number > 0) {
1556
+ return number
1557
+ }
1558
+ return null
1559
+ }
1560
+
1561
+ function indexExistingTaskIssues(rawBody: unknown, projectName: string): Map<string, ProjectSyncIssueRecord[]> {
1562
+ if (!Array.isArray(rawBody)) {
1563
+ throw new Error(`Expected array from issue.list for ${projectName}`)
1564
+ }
1565
+
1566
+ const byTask = new Map<string, ProjectSyncIssueRecord[]>()
1567
+ for (const issue of rawBody as Array<Record<string, unknown>>) {
1568
+ const parsedIssue = toProjectGitTaskRecord(issue)
1569
+ if (!parsedIssue || !parsedIssue.taskId) {
1570
+ continue
1571
+ }
1572
+
1573
+ const taskId = parsedIssue.taskId
1574
+ const issueHash = parsedIssue.taskSignature
1575
+
1576
+ const existing = byTask.get(taskId) ?? []
1577
+ existing.push({
1578
+ number: parsedIssue.number,
1579
+ title: parsedIssue.title,
1580
+ hash: issueHash ?? '',
1581
+ rawHash: issueHash ?? null,
1582
+ state: parsedIssue.state,
1583
+ dependenciesFromPayload: parsedIssue.taskDependenciesFromPayload.length === 0 ? null : parsedIssue.taskDependenciesFromPayload,
1584
+ dependenciesFromBody: parsedIssue.body ? parsedIssue.taskDependenciesFromBody : null,
1585
+ })
1586
+ byTask.set(taskId, existing)
1587
+ }
1588
+
1589
+ for (const [, existing] of byTask) {
1590
+ existing.sort((left, right) => {
1591
+ const leftOpen = left.state === 'open'
1592
+ const rightOpen = right.state === 'open'
1593
+
1594
+ if (leftOpen && !rightOpen) return -1
1595
+ if (!leftOpen && rightOpen) return 1
1596
+ return 0
1597
+ })
1598
+ }
1599
+
1600
+ return byTask
1601
+ }
1602
+
1603
+ function hasSyncMatch(existing: ProjectSyncIssueRecord, task: TaskGraphTask): boolean {
1604
+ const expectedHash = computeTaskHash(task)
1605
+ if (!existing.rawHash || existing.rawHash.toLowerCase() !== expectedHash.toLowerCase()) {
1606
+ return false
1607
+ }
1608
+
1609
+ return issueDependenciesMatches(task.deps, existing)
1610
+ }
1611
+
1612
+ function pickTaskIssueMatch(
1613
+ issues: ProjectSyncIssueRecord[],
1614
+ task: TaskGraphTask
1615
+ ): { issue: ProjectSyncIssueRecord; isMatch: boolean } | null {
1616
+ const matched = issues.find((issue) => hasSyncMatch(issue, task))
1617
+ if (matched) {
1618
+ return { issue: matched, isMatch: true }
1619
+ }
1620
+
1621
+ if (issues.length === 0) {
1622
+ return null
1623
+ }
1624
+
1625
+ const openIssue = issues.find((issue) => issue.state === 'open')
1626
+ return { issue: openIssue ?? issues[0], isMatch: false }
1627
+ }
1628
+
1629
+ function pickBestMatchFromTasks(issues: ProjectGitTaskRecord[]): ProjectGitTaskRecord | null {
1630
+ if (issues.length === 0) {
1631
+ return null
1632
+ }
1633
+
1634
+ const openIssue = issues.find((issue) => issue.state === 'open')
1635
+ if (openIssue) {
1636
+ return openIssue
1637
+ }
1638
+
1639
+ return issues.sort((a, b) => a.number - b.number)[0] ?? null
1640
+ }
1641
+
1642
+ export async function fetchGitTasks(
1643
+ projectName: string,
1644
+ options: Partial<ProjectGitTaskQueryOptions> = {},
1645
+ processRoot: string = process.cwd()
1646
+ ): Promise<ProjectFetchGitTasksResult> {
1647
+ const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
1648
+ const state: GitTaskIssueState = options.state ?? 'open'
1649
+ const taskOnly = options.taskOnly ?? true
1650
+
1651
+ const git = createGitServiceApi({
1652
+ config: {
1653
+ platform: 'GITEA',
1654
+ giteaHost: resolveGiteaHost(),
1655
+ giteaToken: process.env.GITEA_TOKEN,
1656
+ },
1657
+ defaultOwner: owner,
1658
+ defaultRepo: repo,
1659
+ })
1660
+ const issueList = resolveIssueApiMethod(git, 'list')
1661
+ const issues = await fetchAllIssues(issueList, owner, repo, state, projectName)
1662
+ const tasks = issues
1663
+ .map((issue) => toProjectGitTaskRecord(issue))
1664
+ .filter((issue): issue is ProjectGitTaskRecord => {
1665
+ if (!issue) return false
1666
+ return !taskOnly || Boolean(issue.taskId)
1667
+ })
1668
+
1669
+ return {
1670
+ projectName,
1671
+ owner,
1672
+ repo,
1673
+ state,
1674
+ taskOnly,
1675
+ total: tasks.length,
1676
+ tasks,
1677
+ }
1678
+ }
1679
+
1680
+ export async function readGitTask(
1681
+ projectName: string,
1682
+ taskRef: string,
1683
+ options: Partial<ProjectGitTaskQueryOptions> = {},
1684
+ processRoot: string = process.cwd()
1685
+ ): Promise<ProjectReadGitTaskResult> {
1686
+ const parsedRef = parseGitTaskReference(taskRef)
1687
+ const state: GitTaskIssueState = options.state ?? 'all'
1688
+ const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
1689
+
1690
+ const git = createGitServiceApi({
1691
+ config: {
1692
+ platform: 'GITEA',
1693
+ giteaHost: resolveGiteaHost(),
1694
+ giteaToken: process.env.GITEA_TOKEN,
1695
+ },
1696
+ defaultOwner: owner,
1697
+ defaultRepo: repo,
1698
+ })
1699
+
1700
+ if (parsedRef.type === 'number') {
1701
+ const issueView = resolveIssueApiMethod(git, 'view')
1702
+ const result = await issueView(owner, repo, parsedRef.value) as GitServiceApiExecutionResult<GitIssueLike>
1703
+
1704
+ if (!result.ok) {
1705
+ throw new Error(`Failed to read issue #${parsedRef.value} for ${owner}/${repo}: status=${result.status} url=${result.request.url}`)
1706
+ }
1707
+
1708
+ const issue = toProjectGitTaskRecord(result.body)
1709
+ if (!issue) {
1710
+ throw new Error(`Read issue payload invalid for ${owner}/${repo} issue #${parsedRef.value}`)
1711
+ }
1712
+
1713
+ return {
1714
+ projectName,
1715
+ owner,
1716
+ repo,
1717
+ issueNumber: issue.number,
1718
+ issue,
1719
+ foundBy: 'number',
1720
+ searchScope: state,
1721
+ }
1722
+ }
1723
+
1724
+ const issueList = resolveIssueApiMethod(git, 'list')
1725
+ const issues = await fetchAllIssues(issueList, owner, repo, state, projectName)
1726
+ const match = issues
1727
+ .map((issue) => toProjectGitTaskRecord(issue))
1728
+ .find((task) => task?.taskId === parsedRef.value)
1729
+
1730
+ if (!match) {
1731
+ throw new Error(`Task ${parsedRef.value} not found in ${owner}/${repo}`)
1732
+ }
1733
+
1734
+ return {
1735
+ projectName,
1736
+ owner,
1737
+ repo,
1738
+ issueNumber: match.number,
1739
+ issue: match,
1740
+ foundBy: 'taskId',
1741
+ searchScope: state,
1742
+ }
1743
+ }
1744
+
1745
+ export async function writeGitTask(
1746
+ projectName: string,
1747
+ taskRef: string,
1748
+ options: Partial<ProjectWriteGitTaskOptions> = {},
1749
+ processRoot: string = process.cwd()
1750
+ ): Promise<ProjectWriteGitTaskResult> {
1751
+ const parsedRef = parseGitTaskReference(taskRef)
1752
+ const { owner, repo } = resolveProjectGitRepository(projectName, processRoot, options)
1753
+ const payload = buildGitTaskPayload(parsedRef, options as ProjectWriteGitTaskOptions)
1754
+
1755
+ if (Object.keys(payload).length === 0) {
1756
+ throw new Error('Nothing to write. Provide at least one writable field (title, body, labels, state, taskDependencies, or taskSignature).')
1757
+ }
1758
+
1759
+ const git = createGitServiceApi({
1760
+ config: {
1761
+ platform: 'GITEA',
1762
+ giteaHost: resolveGiteaHost(),
1763
+ giteaToken: process.env.GITEA_TOKEN,
1764
+ },
1765
+ defaultOwner: owner,
1766
+ defaultRepo: repo,
1767
+ })
1768
+
1769
+ const issueList = resolveIssueApiMethod(git, 'list')
1770
+ const issueEdit = resolveIssueApiMethod(git, 'edit')
1771
+ const issueCreate = resolveIssueApiMethod(git, 'create')
1772
+
1773
+ let issueNumber: number | null = null
1774
+ if (parsedRef.type === 'number') {
1775
+ issueNumber = parsedRef.value
1776
+ } else {
1777
+ const issues = await fetchAllIssues(issueList, owner, repo, 'all', projectName)
1778
+ const matchingIssues = issues
1779
+ .map((issue) => toProjectGitTaskRecord(issue))
1780
+ .filter((task): task is ProjectGitTaskRecord => Boolean(task?.taskId === parsedRef.value))
1781
+ const match = pickBestMatchFromTasks(matchingIssues)
1782
+
1783
+ if (match) {
1784
+ issueNumber = match.number
1785
+ } else if (!options.createIfMissing) {
1786
+ throw new Error(`Task ${parsedRef.value} not found for ${owner}/${repo}. Pass createIfMissing:true to create it.`)
1787
+ }
1788
+ }
1789
+
1790
+ if (!issueNumber) {
1791
+ const createResult = await issueCreate(owner, repo, payload)
1792
+ if (!createResult.ok) {
1793
+ throw new Error(`Failed to create task in ${owner}/${repo}: status=${createResult.status} url=${createResult.request.url}`)
1794
+ }
1795
+
1796
+ const issue = toProjectGitTaskRecord(createResult.body as GitIssueLike)
1797
+ if (!issue) {
1798
+ throw new Error(`Create task response invalid for ${owner}/${repo}`)
1799
+ }
1800
+
1801
+ return {
1802
+ projectName,
1803
+ owner,
1804
+ repo,
1805
+ action: 'created',
1806
+ issueNumber: issue.number,
1807
+ issue,
1808
+ }
1809
+ }
1810
+
1811
+ const updateResult = await issueEdit(owner, repo, issueNumber, payload)
1812
+ if (!updateResult.ok) {
1813
+ throw new Error(`Failed to update issue #${issueNumber} for ${owner}/${repo}: status=${updateResult.status} url=${updateResult.request.url}`)
1814
+ }
1815
+
1816
+ const issue = toProjectGitTaskRecord(updateResult.body as GitIssueLike)
1817
+ if (!issue) {
1818
+ throw new Error(`Update task response invalid for ${owner}/${repo} issue #${issueNumber}`)
1819
+ }
1820
+
1821
+ return {
1822
+ projectName,
1823
+ owner,
1824
+ repo,
1825
+ action: 'updated',
1826
+ issueNumber: issue.number,
1827
+ issue,
1828
+ }
1829
+ }
1830
+
1831
+ export async function syncTasks(
1832
+ projectName: string,
1833
+ options: Partial<ProjectSyncTaskOptions> = {},
1834
+ processRoot: string = process.cwd()
1835
+ ): Promise<ProjectSyncTaskResult> {
1836
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
1837
+ const tracksPath = resolveTracksPath(projectRoot, options.tracks)
1838
+ const resolved = options.owner && options.repo
1839
+ ? { owner: options.owner, repo: options.repo }
1840
+ : resolveGitIdentityFromProject(projectRoot)
1841
+
1842
+ const owner = options.owner ?? resolved.owner
1843
+ const repo = options.repo ?? resolved.repo
1844
+ const syncOptions: ProjectSyncTaskOptions = {
1845
+ labels: options.labels ?? [],
1846
+ prefix: options.prefix ?? '[TASK]',
1847
+ dryRun: options.dryRun ?? false,
1848
+ max: options.max,
1849
+ maxBodyLength: options.maxBodyLength,
1850
+ requestTimeoutMs: options.requestTimeoutMs,
1851
+ throttleMs: options.throttleMs,
1852
+ skipDependencies: options.skipDependencies,
1853
+ owner,
1854
+ repo,
1855
+ verbose: options.verbose ?? false,
1856
+ }
1857
+ const log = syncOptions.verbose
1858
+ ? (message: string) => {
1859
+ process.stderr.write(`[f0 projects ${projectName}] ${message}\n`)
1860
+ }
1861
+ : null
1862
+
1863
+ const previousTimeoutEnv = process.env.F0_GIT_REQUEST_TIMEOUT_MS
1864
+ if (syncOptions.requestTimeoutMs) {
1865
+ process.env.F0_GIT_REQUEST_TIMEOUT_MS = String(syncOptions.requestTimeoutMs)
1866
+ }
1867
+
1868
+ log?.(`Resolved project root: ${projectRoot}`)
1869
+ log?.(`Resolved tracks file: ${tracksPath}`)
1870
+ log?.(`Using issue destination: ${owner}/${repo}`)
1871
+ log?.(`HTTP request timeout: ${syncOptions.requestTimeoutMs ?? 'default'}`)
1872
+ log?.(`HTTP throttle: ${syncOptions.throttleMs ?? 0}ms`)
1873
+ log?.(`Dependency sync: ${syncOptions.skipDependencies ? 'skipped' : 'enabled'}`)
1874
+ const markdown = await fs.readFile(tracksPath, 'utf8')
1875
+ log?.(`Loaded tracks content (${markdown.length} chars).`)
1876
+ const graph = parseTaskGraphFromMarkdownForProjects(markdown, (message) => log?.(message))
1877
+ const topo = graph.topo
1878
+ const total = graph.topo.length
1879
+ const ordered = topo.map((taskId) => graph.byId[taskId]).filter((value): value is TaskGraphTask => Boolean(value))
1880
+ const selected = syncOptions.max ? ordered.slice(0, syncOptions.max) : ordered
1881
+ log?.(`Parsed ${total} tasks from tracks. ${selected.length} selected for sync.`)
1882
+ if (selected.length === 0) {
1883
+ log?.('No tasks selected for sync. Nothing to do.')
1884
+ }
1885
+
1886
+ const truncated = syncOptions.max ? total > selected.length : false
1887
+
1888
+ const git = createGitServiceApi({
1889
+ config: {
1890
+ platform: 'GITEA',
1891
+ giteaHost: resolveGiteaHost(),
1892
+ giteaToken: process.env.GITEA_TOKEN,
1893
+ },
1894
+ defaultOwner: owner,
1895
+ defaultRepo: repo,
1896
+ ...(syncOptions.requestTimeoutMs ? { requestTimeoutMs: syncOptions.requestTimeoutMs } : {}),
1897
+ ...(log ? { log: (message: string) => log(message) } : {}),
1898
+ })
1899
+
1900
+ const issueList = resolveIssueApiMethod(git, 'list')
1901
+ const issueCreate = resolveIssueApiMethod(git, 'create')
1902
+ const issueEdit = resolveIssueApiMethod(git, 'edit')
1903
+
1904
+ const allIssues = await fetchAllIssues(issueList, owner, repo, 'all', projectName, log)
1905
+ const existing = indexExistingTaskIssues(allIssues, projectName)
1906
+ const indexedIssueCount = [...existing.values()].reduce((acc, bucket) => acc + bucket.length, 0)
1907
+ log?.(`Loaded ${indexedIssueCount} task-like issues in ${owner}/${repo}.`)
1908
+ let created = 0
1909
+ let updated = 0
1910
+ let unchanged = 0
1911
+ const issueNumbersByTask = new Map<string, number>()
1912
+ const host = resolveGiteaHost()
1913
+ const token = process.env.GITEA_TOKEN
1914
+ const dependencyStats = {
1915
+ added: 0,
1916
+ removed: 0,
1917
+ unchanged: 0,
1918
+ }
1919
+
1920
+ const sleep = (ms: number) =>
1921
+ new Promise<void>((resolve) => setTimeout(resolve, ms))
1922
+
1923
+ for (let i = 0; i < selected.length; i += 1) {
1924
+ const task = selected[i]
1925
+ const marker = `[${i + 1}/${selected.length}] ${task.id}`
1926
+ const payload = buildIssuePayloadFromTask(task, i, selected.length, syncOptions)
1927
+ const byId = existing.get(task.id)
1928
+ const selection = byId ? pickTaskIssueMatch(byId, task) : null
1929
+ const targetIssue = selection?.issue
1930
+
1931
+ if (selection?.isMatch) {
1932
+ issueNumbersByTask.set(task.id, targetIssue.number)
1933
+ unchanged += 1
1934
+ log?.(`${marker} unchanged`)
1935
+ continue
1936
+ }
1937
+
1938
+ if (selection) {
1939
+ issueNumbersByTask.set(task.id, targetIssue.number)
1940
+ updated += 1
1941
+ log?.(`${marker} updating #${targetIssue.number}`)
1942
+ if (!syncOptions.dryRun) {
1943
+ log?.(`${marker} sending update request...`)
1944
+ const updatedResult = await issueEdit(owner, repo, targetIssue.number, {
1945
+ data: {
1946
+ title: payload.title,
1947
+ body: payload.body,
1948
+ labels: payload.labels,
1949
+ task_id: payload.task_id,
1950
+ task_dependencies: payload.task_dependencies,
1951
+ },
1952
+ }) as GitServiceApiExecutionResult
1953
+
1954
+ if (!updatedResult.ok) {
1955
+ throw new Error(
1956
+ `Failed to update issue ${targetIssue.number} for ${owner}/${repo}: status=${updatedResult.status} url=${updatedResult.request.url}`
1957
+ )
1958
+ }
1959
+
1960
+ if (syncOptions.throttleMs && syncOptions.throttleMs > 0) {
1961
+ await sleep(syncOptions.throttleMs)
1962
+ }
1963
+ }
1964
+ continue
1965
+ }
1966
+
1967
+ log?.(`${marker} creating new task issue`)
1968
+ if (!syncOptions.dryRun) {
1969
+ let createdResult: GitServiceApiExecutionResult
1970
+ try {
1971
+ log?.(`${marker} sending create request...`)
1972
+ createdResult = await issueCreate(owner, repo, {
1973
+ data: {
1974
+ title: payload.title,
1975
+ body: payload.body,
1976
+ labels: payload.labels,
1977
+ task_id: payload.task_id,
1978
+ task_dependencies: payload.task_dependencies,
1979
+ },
1980
+ }) as GitServiceApiExecutionResult
1981
+ } catch (error) {
1982
+ const message = error instanceof Error ? error.message : String(error)
1983
+ throw new Error(`Failed to create issue for ${task.id} in ${owner}/${repo}: ${message}`)
1984
+ }
1985
+
1986
+ if (!createdResult.ok) {
1987
+ const bodySummary = (() => {
1988
+ try {
1989
+ if (typeof createdResult.body === 'string') {
1990
+ return createdResult.body.slice(0, 500)
1991
+ }
1992
+ return JSON.stringify(createdResult.body)?.slice(0, 500) ?? String(createdResult.body)
1993
+ } catch {
1994
+ return String(createdResult.body)
1995
+ }
1996
+ })()
1997
+ throw new Error(
1998
+ `Failed to create issue for ${task.id} in ${owner}/${repo}: status=${createdResult.status} url=${createdResult.request.url} body=${bodySummary}`
1999
+ )
2000
+ }
2001
+
2002
+ const issueNumber = buildIssueNumberFromPayload(createdResult.body)
2003
+ if (issueNumber === null) {
2004
+ throw new Error(
2005
+ `Created issue response for ${task.id} in ${owner}/${repo} is invalid: ${JSON.stringify(createdResult.body)}`
2006
+ )
2007
+ }
2008
+ issueNumbersByTask.set(task.id, issueNumber)
2009
+
2010
+ log?.(`${marker} created #${issueNumber}`)
2011
+
2012
+ if (syncOptions.throttleMs && syncOptions.throttleMs > 0) {
2013
+ await sleep(syncOptions.throttleMs)
2014
+ }
2015
+ } else {
2016
+ issueNumbersByTask.set(task.id, 0)
2017
+ }
2018
+ created += 1
2019
+ }
2020
+
2021
+ if (syncOptions.skipDependencies) {
2022
+ if (previousTimeoutEnv === undefined) {
2023
+ delete process.env.F0_GIT_REQUEST_TIMEOUT_MS
2024
+ } else {
2025
+ process.env.F0_GIT_REQUEST_TIMEOUT_MS = previousTimeoutEnv
2026
+ }
2027
+
2028
+ return {
2029
+ projectName,
2030
+ tracksPath,
2031
+ owner,
2032
+ repo,
2033
+ totalTasks: total,
2034
+ created,
2035
+ updated,
2036
+ skipped: unchanged,
2037
+ dryRun: syncOptions.dryRun,
2038
+ truncated,
2039
+ }
2040
+ }
2041
+
2042
+ for (let i = 0; i < selected.length; i += 1) {
2043
+ const task = selected[i]
2044
+ const issueNumber = issueNumbersByTask.get(task.id)
2045
+ if (!issueNumber) {
2046
+ log?.(`[${i + 1}/${selected.length}] ${task.id} has no issue number; skipping dependency sync`)
2047
+ continue
2048
+ }
2049
+
2050
+ const missingDependencies = task.deps.filter((dep) => !issueNumbersByTask.has(dep))
2051
+ if (missingDependencies.length > 0) {
2052
+ log?.(`Issue #${issueNumber} (task ${task.id}) skipped dependency(s): ${missingDependencies.join(', ')} not synced/open`)
2053
+ }
2054
+
2055
+ const desiredDependencyIssues = task.deps
2056
+ .map((dep) => issueNumbersByTask.get(dep))
2057
+ .filter((value): value is number => value !== undefined && value > 0)
2058
+
2059
+ log?.(`Issue #${issueNumber}: syncing dependencies (${desiredDependencyIssues.length} desired)...`)
2060
+ const syncResult = await syncIssueDependenciesViaApi(
2061
+ owner,
2062
+ repo,
2063
+ issueNumber,
2064
+ desiredDependencyIssues,
2065
+ host,
2066
+ token,
2067
+ syncOptions.dryRun,
2068
+ log
2069
+ )
2070
+ dependencyStats.added += syncResult.added
2071
+ dependencyStats.removed += syncResult.removed
2072
+ dependencyStats.unchanged += syncResult.unchanged
2073
+
2074
+ if (syncOptions.throttleMs && syncOptions.throttleMs > 0) {
2075
+ await sleep(syncOptions.throttleMs)
2076
+ }
2077
+ }
2078
+
2079
+ if (dependencyStats.added > 0 || dependencyStats.removed > 0) {
2080
+ log?.(`Dependency sync summary: +${dependencyStats.added} -${dependencyStats.removed}`)
2081
+ }
2082
+
2083
+ if (previousTimeoutEnv === undefined) {
2084
+ delete process.env.F0_GIT_REQUEST_TIMEOUT_MS
2085
+ } else {
2086
+ process.env.F0_GIT_REQUEST_TIMEOUT_MS = previousTimeoutEnv
2087
+ }
2088
+
2089
+ return {
2090
+ projectName,
2091
+ tracksPath,
2092
+ owner,
2093
+ repo,
2094
+ totalTasks: total,
2095
+ created,
2096
+ updated,
2097
+ skipped: unchanged,
2098
+ dryRun: syncOptions.dryRun,
2099
+ truncated,
2100
+ }
2101
+ }
2102
+
2103
+ export async function clearIssues(
2104
+ projectName: string,
2105
+ options: Partial<ProjectClearIssuesOptions> = {},
2106
+ processRoot: string = process.cwd()
2107
+ ): Promise<ProjectClearIssuesResult> {
2108
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
2109
+ const resolved = options.owner && options.repo
2110
+ ? { owner: options.owner, repo: options.repo }
2111
+ : resolveGitIdentityFromProject(projectRoot)
2112
+
2113
+ const owner = options.owner ?? resolved.owner
2114
+ const repo = options.repo ?? resolved.repo
2115
+ const clearOptions: ProjectClearIssuesOptions = {
2116
+ state: options.state ?? 'all',
2117
+ dryRun: options.dryRun ?? false,
2118
+ force: options.force ?? false,
2119
+ verbose: options.verbose ?? false,
2120
+ owner,
2121
+ repo,
2122
+ }
2123
+
2124
+ if (!clearOptions.dryRun && !clearOptions.force) {
2125
+ throw new Error('Refusing to delete issues without --force. Re-run with --dry-run for preview.')
2126
+ }
2127
+
2128
+ const log = clearOptions.verbose
2129
+ ? (message: string) => {
2130
+ process.stderr.write(`[f0 projects ${projectName}] ${message}\n`)
2131
+ }
2132
+ : null
2133
+
2134
+ log?.(`Resolved project root: ${projectRoot}`)
2135
+ log?.(`Using issue destination: ${owner}/${repo}`)
2136
+
2137
+ const git = createGitServiceApi({
2138
+ config: {
2139
+ platform: 'GITEA',
2140
+ giteaHost: resolveGiteaHost(),
2141
+ giteaToken: process.env.GITEA_TOKEN,
2142
+ },
2143
+ defaultOwner: owner,
2144
+ defaultRepo: repo,
2145
+ })
2146
+
2147
+ const issueList = resolveIssueApiMethod(git, 'list')
2148
+ const issueDelete = resolveIssueApiMethod(git, 'delete')
2149
+
2150
+ const requestedStates: Array<'open' | 'closed' | 'all'> = clearOptions.state === 'all'
2151
+ ? ['open', 'closed']
2152
+ : [clearOptions.state]
2153
+
2154
+ const issueBatches = await Promise.all(
2155
+ requestedStates.map((state) => fetchAllIssues(issueList, owner, repo, state, projectName, log))
2156
+ )
2157
+ const issueByNumber = new Map<number, GitIssueLike>()
2158
+
2159
+ for (const batch of issueBatches) {
2160
+ for (const issue of batch) {
2161
+ const number = Number((issue as Record<string, unknown>).number)
2162
+ if (!Number.isFinite(number) || number <= 0) {
2163
+ continue
2164
+ }
2165
+ issueByNumber.set(number, issue)
2166
+ }
2167
+ }
2168
+
2169
+ const issues = Array.from(issueByNumber.values())
2170
+ log?.(`Found ${issues.length} issues to process.`)
2171
+
2172
+ let deleted = 0
2173
+ for (const issue of issues) {
2174
+ const issueNumber = Number((issue as Record<string, unknown>).number)
2175
+ if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
2176
+ continue
2177
+ }
2178
+
2179
+ log?.(`Deleting #${issueNumber}`)
2180
+ if (clearOptions.dryRun) {
2181
+ continue
2182
+ }
2183
+
2184
+ const deleteResult = await issueDelete(owner, repo, issueNumber, { yes: true }) as GitServiceApiExecutionResult
2185
+ if (!deleteResult.ok) {
2186
+ throw new Error(
2187
+ `Failed to delete issue ${issueNumber} for ${owner}/${repo}: status=${deleteResult.status} url=${deleteResult.request.url}`
2188
+ )
2189
+ }
2190
+
2191
+ deleted += 1
2192
+ }
2193
+
2194
+ return {
2195
+ projectName,
2196
+ owner,
2197
+ repo,
2198
+ state: clearOptions.state,
2199
+ total: issues.length,
2200
+ deleted,
2201
+ dryRun: clearOptions.dryRun,
2202
+ }
2203
+ }
2204
+
2205
+ export async function main(argv: string[], processRoot: string = process.cwd()): Promise<string> {
2206
+ if (
2207
+ argv.length === 0 ||
2208
+ argv.includes('--help') ||
2209
+ argv.includes('-h')
2210
+ ) {
2211
+ return usage()
2212
+ }
2213
+
2214
+ const [scope, maybeProject, maybeTarget, ...rest] = argv
2215
+ const listMode = scope === 'projects' && maybeProject === '--list'
2216
+
2217
+ if (scope !== 'projects') {
2218
+ throw new Error('Expected command `projects` as first positional argument.')
2219
+ }
2220
+
2221
+ if (listMode) {
2222
+ const unknownFlags = rest.filter(Boolean)
2223
+ if (unknownFlags.length > 0) {
2224
+ throw new Error(`Unknown flags: ${unknownFlags.join(', ')}`)
2225
+ }
2226
+ return listProjects(processRoot).join('\n')
2227
+ }
2228
+
2229
+ const projectName = maybeProject
2230
+ if (!projectName) {
2231
+ throw new Error('Missing required arguments: <project-name>.')
2232
+ }
2233
+
2234
+ if (maybeTarget === 'sync-tasks') {
2235
+ const parsedSync = parseSyncTasksArgs(rest)
2236
+ if (parsedSync.unknownFlags.length > 0) {
2237
+ throw new Error(`Unknown flags: ${parsedSync.unknownFlags.join(', ')}`)
2238
+ }
2239
+
2240
+ const result = await syncTasks(projectName, { ...parsedSync.options, tracks: parsedSync.tracks }, processRoot)
2241
+ const dryRun = result.dryRun ? ' [dry-run]' : ''
2242
+ const truncated = result.truncated ? ' (truncated)' : ''
2243
+ return `${CLI_NAME}: synced ${result.totalTasks} tasks for ${result.projectName}${dryRun} from ${path.relative(processRoot, result.tracksPath)}${truncated} (created ${result.created}, updated ${result.updated}, skipped ${result.skipped})`
2244
+ }
2245
+
2246
+ if (maybeTarget === 'clear-issues') {
2247
+ const parsedClear = parseClearIssuesArgs(rest)
2248
+ if (parsedClear.unknownFlags.length > 0) {
2249
+ throw new Error(`Unknown flags: ${parsedClear.unknownFlags.join(', ')}`)
2250
+ }
2251
+
2252
+ const result = await clearIssues(projectName, parsedClear.options, processRoot)
2253
+ const dryRun = result.dryRun ? ' [dry-run]' : ''
2254
+ return `${CLI_NAME}: clear-issues for ${result.projectName}${dryRun} (${result.owner}/${result.repo}, state=${result.state}) total=${result.total}, deleted=${result.deleted}`
2255
+ }
2256
+
2257
+ const parsed = parseGenerateArgs(maybeTarget ? [maybeTarget, ...rest] : rest)
2258
+ if (parsed.command === 'list') {
2259
+ throw new Error('`--list` can only be used without a project name.')
2260
+ }
2261
+
2262
+ if (parsed.setActive) {
2263
+ if (parsed.requestedGenerateSpec) {
2264
+ throw new Error('`--set-active` cannot be combined with `--generate-spec`.')
2265
+ }
2266
+
2267
+ if (parsed.target === undefined) {
2268
+ throw new Error('Missing required arguments: <project-name> and <file-path>.')
2269
+ }
2270
+ if (parsed.unknownFlags.length > 0) {
2271
+ throw new Error(`Unknown flags: ${parsed.unknownFlags.join(', ')}`)
2272
+ }
2273
+ const optionFlags = Object.keys(parsed.options)
2274
+ if (optionFlags.length > 0) {
2275
+ throw new Error(`Unknown flags: --${optionFlags.join(', --')}`)
2276
+ }
2277
+
2278
+ const result = setActive(projectName, parsed.target, processRoot, { latest: parsed.latest })
2279
+ const matchedFileName = path.basename(result.sourceFile).toLowerCase()
2280
+ const isImplementationPlan = /^implementation[-_]?plan(?:\.[^.]+)*$/i.test(matchedFileName)
2281
+ if (!isImplementationPlan) {
2282
+ return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})`
2283
+ }
2284
+
2285
+ const generated = await generateSpec(projectName, { input: result.sourceFile }, processRoot)
2286
+ const dryRun = generated.dryRun ? ' [dry-run]' : ''
2287
+ return `[${CLI_NAME}] set active: ${result.matchedFile} -> ${path.basename(result.activeFile)} (${result.mode})` +
2288
+ `\n${CLI_NAME}: generated ${generated.files} spec files for ${generated.projectName}${dryRun} from ${path.relative(processRoot, generated.inputPath)}`
2289
+ }
2290
+
2291
+ if (!parsed.requestedGenerateSpec) {
2292
+ throw new Error('`--generate-spec` is required.')
2293
+ }
2294
+ if (parsed.target !== undefined) {
2295
+ throw new Error(`Unknown argument: ${parsed.target}`)
2296
+ }
2297
+
2298
+ if (parsed.unknownFlags.length > 0) {
2299
+ throw new Error(`Unknown flags: ${parsed.unknownFlags.join(', ')}`)
2300
+ }
2301
+
2302
+ const result = await generateSpec(projectName, parsed.options, processRoot)
2303
+ const dryRun = result.dryRun ? ' [dry-run]' : ''
2304
+
2305
+ return `${CLI_NAME}: generated ${result.files} spec files for ${result.projectName}${dryRun} from ${path.relative(processRoot, result.inputPath)}`
2306
+ }
2307
+
2308
+ export function parseProjectTargetSpec(spec: string): ProjectVersionedFileSpec {
2309
+ if (!spec || typeof spec !== 'string') {
2310
+ throw new Error('file-path is required.')
2311
+ }
2312
+
2313
+ const normalized = spec.replace(/\\/g, '/').trim()
2314
+ if (!normalized.startsWith('/')) {
2315
+ throw new Error('file-path must start with a leading slash, e.g. /implementation-plan.v0.0.1')
2316
+ }
2317
+
2318
+ const body = normalized.slice(1).replace(/^docs\//, '')
2319
+ if (!body) {
2320
+ throw new Error('file-path is empty.')
2321
+ }
2322
+
2323
+ const parts = body.split('/').filter(Boolean)
2324
+ if (parts.length === 0) {
2325
+ throw new Error('file-path must include a file name, for example /implementation-plan.v0.0.1')
2326
+ }
2327
+
2328
+ const file = parts.pop()
2329
+ if (!file) {
2330
+ throw new Error('file-path must include a file name, for example /implementation-plan.v0.0.1')
2331
+ }
2332
+
2333
+ const withExt = file.match(new RegExp(`^(.+)\\.(${VERSION_RE})\\.([A-Za-z][A-Za-z0-9_-]*)$`))
2334
+ const withoutExt = file.match(new RegExp(`^(.+)\\.(${VERSION_RE})$`))
2335
+ if (withExt) {
2336
+ return {
2337
+ parts,
2338
+ base: withExt[1],
2339
+ version: withExt[2],
2340
+ ext: withExt[3],
2341
+ }
2342
+ }
2343
+ if (withoutExt) {
2344
+ return {
2345
+ parts,
2346
+ base: withoutExt[1],
2347
+ version: withoutExt[2],
2348
+ ext: null,
2349
+ }
2350
+ }
2351
+
2352
+ return {
2353
+ parts,
2354
+ base: file,
2355
+ version: null,
2356
+ ext: null,
2357
+ }
2358
+ }
2359
+
2360
+ function parseVersion(version: string): number[] {
2361
+ const clean = version.replace(/^v/, '')
2362
+ return clean.split('.').filter(Boolean).map((part) => Number.parseInt(part, 10))
2363
+ }
2364
+
2365
+ function compareVersions(left: string, right: string): number {
2366
+ const a = parseVersion(left)
2367
+ const b = parseVersion(right)
2368
+ const size = Math.max(a.length, b.length)
2369
+
2370
+ for (let i = 0; i < size; i += 1) {
2371
+ const leftValue = a[i] ?? 0
2372
+ const rightValue = b[i] ?? 0
2373
+ if (leftValue > rightValue) return 1
2374
+ if (leftValue < rightValue) return -1
2375
+ }
2376
+
2377
+ return 0
2378
+ }
2379
+
2380
+ function escapeRegExp(value: string): string {
2381
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
2382
+ }
2383
+
2384
+ function findLatestVersionedFile(targetDir: string, base: string, ext: string | null): string | null {
2385
+ const fileEntries = fsSync
2386
+ .readdirSync(targetDir, { withFileTypes: true })
2387
+ .filter((entry) => entry.isFile())
2388
+ .map((entry) => entry.name)
2389
+
2390
+ const safeBase = escapeRegExp(base)
2391
+ const regex = new RegExp(`^${safeBase}\\.(${VERSION_RE_CORE})\\.([^.]+)$`)
2392
+ const matches = fileEntries
2393
+ .map((entry) => {
2394
+ const match = entry.match(regex)
2395
+ if (!match) return null
2396
+
2397
+ const version = match[1]
2398
+ const detectedExt = `.${match[2]}`
2399
+ if (ext && detectedExt !== `.${ext}`) {
2400
+ return null
2401
+ }
2402
+
2403
+ return { entry, version, detectedExt }
2404
+ })
2405
+ .filter((value): value is { entry: string; version: string; detectedExt: string } => value !== null)
2406
+
2407
+ if (matches.length === 0) return null
2408
+
2409
+ matches.sort((a, b) => compareVersions(a.version, b.version))
2410
+ const latestVersion = matches[matches.length - 1].version
2411
+ const sameVersion = matches
2412
+ .filter((item) => compareVersions(item.version, latestVersion) === 0)
2413
+ .map((item) => item.entry)
2414
+ .sort()
2415
+
2416
+ if (sameVersion.length <= 1) {
2417
+ return sameVersion[0]
2418
+ }
2419
+
2420
+ for (const extName of ACTIVE_EXT_PRIORITY) {
2421
+ const preferred = sameVersion.find((entryName) => entryName.endsWith(extName))
2422
+ if (preferred) {
2423
+ return preferred
2424
+ }
2425
+ }
2426
+
2427
+ return sameVersion.sort()[0]
2428
+ }
2429
+
2430
+ export function resolveProjectTargetFile(
2431
+ targetDir: string,
2432
+ spec: ProjectVersionedFileSpec,
2433
+ options: { latest?: boolean } = {}
2434
+ ): string | null {
2435
+ const entries = fsSync
2436
+ .readdirSync(targetDir, { withFileTypes: true })
2437
+ .filter((entry) => entry.isFile())
2438
+ .map((entry) => entry.name)
2439
+
2440
+ if (!spec.version) {
2441
+ if (!options.latest) {
2442
+ return null
2443
+ }
2444
+ return findLatestVersionedFile(targetDir, spec.base, null)
2445
+ }
2446
+
2447
+ if (spec.ext) {
2448
+ const exact = `${spec.base}.${spec.version}.${spec.ext}`
2449
+ if (entries.includes(exact)) {
2450
+ return exact
2451
+ }
2452
+ return null
2453
+ }
2454
+
2455
+ const prefix = `${spec.base}.${spec.version}.`
2456
+ const candidates = entries.filter((entry) => entry.startsWith(prefix))
2457
+ if (candidates.length === 0) {
2458
+ return null
2459
+ }
2460
+
2461
+ for (const ext of ACTIVE_EXT_PRIORITY) {
2462
+ const matched = candidates.find((entry) => entry.endsWith(ext))
2463
+ if (matched) {
2464
+ return matched
2465
+ }
2466
+ }
2467
+
2468
+ return candidates.sort()[0]
2469
+ }
2470
+
2471
+ export function setActive(
2472
+ projectName: string,
2473
+ target: string,
2474
+ processRoot: string = process.cwd(),
2475
+ options: { latest?: boolean } = {}
2476
+ ): ProjectSetActiveResult {
2477
+ const projectRoot = resolveProjectRoot(projectName, processRoot)
2478
+ const docsRoot = path.join(projectRoot, 'docs')
2479
+ const spec = parseProjectTargetSpec(target)
2480
+ const targetDir = path.join(docsRoot, ...spec.parts)
2481
+ if (!existsDir(docsRoot)) {
2482
+ throw new Error(`Docs directory not found for project: ${projectRoot}`)
2483
+ }
2484
+
2485
+ if (!existsDir(targetDir)) {
2486
+ throw new Error(`Target folder not found: ${targetDir}`)
2487
+ }
2488
+
2489
+ const matchedFile = resolveProjectTargetFile(targetDir, spec, options)
2490
+ if (!matchedFile && !spec.version && options.latest) {
2491
+ throw new Error(`No files found for /${spec.parts.join('/')}/${spec.base}`)
2492
+ }
2493
+ if (!matchedFile) {
2494
+ if (!spec.version && !options.latest) {
2495
+ throw new Error(
2496
+ `Version missing for /${spec.parts.join('/')}/${spec.base}. Use --latest to select the latest version.`
2497
+ )
2498
+ }
2499
+ const extensionHint = spec.ext ? `.${spec.ext}` : ' with any extension'
2500
+ throw new Error(
2501
+ `Versioned file not found for /${spec.parts.join('/')}/${spec.base}.${spec.version}${extensionHint}`
2502
+ )
2503
+ }
2504
+
2505
+ const sourceFile = path.join(targetDir, matchedFile)
2506
+ const activeFile = path.join(targetDir, `${spec.base}.active${path.extname(matchedFile)}`)
2507
+ const mode = setActiveLink(sourceFile, activeFile)
2508
+
2509
+ return {
2510
+ projectName,
2511
+ sourceFile,
2512
+ activeFile,
2513
+ matchedFile,
2514
+ mode,
2515
+ }
2516
+ }
2517
+
2518
+ function parseMonolith(monolith: string): MonolithFile[] {
2519
+ const hasBeginMarker = /BEGIN(?:\s|_)FILE:/.test(monolith)
2520
+ const hasEndMarker = /END(?:\s|_)FILE:/.test(monolith)
2521
+ if (!hasBeginMarker || !hasEndMarker) {
2522
+ throw new Error(
2523
+ 'Monolith does not appear to contain BEGIN/END markers (expected BEGIN FILE/END FILE or BEGIN_FILE/END_FILE).'
2524
+ )
2525
+ }
2526
+
2527
+ const re =
2528
+ /<!--\s*BEGIN(?:\s|_)FILE:\s*(.+?)\s*-->\s*\r?\n([\s\S]*?)\r?\n<!--\s*END(?:\s|_)FILE:\s*\1\s*-->/g
2529
+
2530
+ const entries: MonolithFile[] = []
2531
+ const seen = new Set<string>()
2532
+
2533
+ let match: RegExpExecArray | null
2534
+ while ((match = re.exec(monolith)) !== null) {
2535
+ const relPath = normalizeRelPath(match[1])
2536
+
2537
+ if (seen.has(relPath)) {
2538
+ throw new Error(`Duplicate file path in monolith: ${relPath}`)
2539
+ }
2540
+
2541
+ const raw = match[2]
2542
+ const content = raw.endsWith('\n') ? raw : `${raw}\n`
2543
+ const bytes = Buffer.byteLength(content, 'utf8')
2544
+ const sha256 = sha256Hex(content)
2545
+
2546
+ entries.push({
2547
+ relPath,
2548
+ content,
2549
+ sha256,
2550
+ bytes,
2551
+ meta: extractTopMetadataBlock(content),
2552
+ })
2553
+
2554
+ seen.add(relPath)
2555
+ }
2556
+
2557
+ if (entries.length === 0) {
2558
+ throw new Error('No files found. Expected matching BEGIN/END FILE markers in monolith.')
2559
+ }
2560
+
2561
+ return entries
2562
+ }
2563
+
2564
+ function normalizeRelPath(relPath: string): string {
2565
+ const p = relPath.trim()
2566
+ if (p.length === 0) {
2567
+ throw new Error('Empty path in BEGIN FILE marker')
2568
+ }
2569
+ if (path.isAbsolute(p)) {
2570
+ throw new Error(`Absolute paths are not allowed: ${p}`)
2571
+ }
2572
+
2573
+ const normalized = p.replace(/\\/g, '/')
2574
+ const parts = normalized.split('/')
2575
+ for (const part of parts) {
2576
+ if (part === '..') {
2577
+ throw new Error(`Path traversal is not allowed: ${p}`)
2578
+ }
2579
+ if (part === '') {
2580
+ throw new Error(`Empty path segment is not allowed: ${p}`)
2581
+ }
2582
+ }
2583
+
2584
+ return normalized
2585
+ }
2586
+
2587
+ function extractTopMetadataBlock(content: string): MonolithFile['meta'] {
2588
+ const match = content.match(/^\s*<!--\s*\n([\s\S]*?)\n-->\s*\n/m)
2589
+ if (!match) return undefined
2590
+
2591
+ const lines = match[1]
2592
+ const meta: NonNullable<MonolithFile['meta']> = {}
2593
+
2594
+ for (const line of lines.split(/\r?\n/)) {
2595
+ const parsed = line.match(/^\s*([a-zA-Z0-9_]+)\s*:\s*(.*?)\s*$/)
2596
+ if (!parsed) continue
2597
+
2598
+ const key = parsed[1]
2599
+ const value = parsed[2]
2600
+ if (key === 'spec_id') meta.spec_id = value
2601
+ if (key === 'title') meta.title = value
2602
+ if (key === 'source') meta.source = value
2603
+ if (key === 'source_version') meta.source_version = value
2604
+ if (key === 'source_anchor') meta.source_anchor = value
2605
+ }
2606
+
2607
+ return Object.keys(meta).length === 0 ? undefined : meta
2608
+ }
2609
+
2610
+ async function readTextIfExists(filePath: string): Promise<string | null> {
2611
+ try {
2612
+ return await fs.readFile(filePath, 'utf8')
2613
+ } catch (error: any) {
2614
+ if (error?.code === 'ENOENT') {
2615
+ return null
2616
+ }
2617
+ throw error
2618
+ }
2619
+ }
2620
+
2621
+ async function writeIfChanged(filePath: string, content: string): Promise<'created' | 'updated' | 'unchanged'> {
2622
+ const existing = await readTextIfExists(filePath)
2623
+ if (existing === null) {
2624
+ await fs.writeFile(filePath, content, 'utf8')
2625
+ return 'created'
2626
+ }
2627
+
2628
+ if (existing === content) return 'unchanged'
2629
+ await fs.writeFile(filePath, content, 'utf8')
2630
+ return 'updated'
2631
+ }
2632
+
2633
+ async function loadPreviousManifest(manifestAbs: string): Promise<{ files: Array<{ path: string }> } | null> {
2634
+ const manifestRaw = await readTextIfExists(manifestAbs)
2635
+ if (!manifestRaw) return null
2636
+
2637
+ try {
2638
+ const parsed = JSON.parse(manifestRaw)
2639
+ if (!parsed || typeof parsed !== 'object') return null
2640
+ if (!Array.isArray((parsed as any).files)) return null
2641
+ return parsed as { files: Array<{ path: string }> }
2642
+ } catch {
2643
+ return null
2644
+ }
2645
+ }
2646
+
2647
+ function sha256Hex(value: string): string {
2648
+ return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
2649
+ }
2650
+
2651
+ function assertWithinOutRoot(outRoot: string, candidate: string): void {
2652
+ const rel = path.relative(path.resolve(outRoot), path.resolve(candidate))
2653
+ if (rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..')) {
2654
+ return
2655
+ }
2656
+ throw new Error(`Refusing to write outside project root. outRoot=${outRoot} candidate=${candidate}`)
2657
+ }
2658
+
2659
+ function isSubPath(parent: string, child: string): boolean {
2660
+ const resolvedParent = path.resolve(parent)
2661
+ const resolvedChild = path.resolve(child)
2662
+
2663
+ const parentRoot = path.parse(resolvedParent).root.toLowerCase()
2664
+ const childRoot = path.parse(resolvedChild).root.toLowerCase()
2665
+ if (parentRoot !== childRoot) {
2666
+ return false
2667
+ }
2668
+
2669
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
2670
+ if (relative === '' || relative === '.') {
2671
+ return true
2672
+ }
2673
+ return !relative.startsWith(`..${path.sep}`) && relative !== '..'
2674
+ }
2675
+
2676
+ function existsDir(input: string): boolean {
2677
+ try {
2678
+ return fsSync.statSync(input).isDirectory()
2679
+ } catch {
2680
+ return false
2681
+ }
2682
+ }
2683
+
2684
+ function existsFile(input: string): boolean {
2685
+ try {
2686
+ return fsSync.statSync(input).isFile()
2687
+ } catch {
2688
+ return false
2689
+ }
2690
+ }