@chenguangyao/devflow-kit 0.1.43
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/CHANGELOG.md +232 -0
- package/LICENSE +21 -0
- package/README.md +539 -0
- package/bin/devflow.js +9 -0
- package/docs/RFC-001-devflow-kit.md +617 -0
- package/docs/RFC-002-workflow-kernel.md +134 -0
- package/docs/enterprise-integration-supplement.md +274 -0
- package/docs/internal-gitlab-setup.md +426 -0
- package/docs/marketplace-skills.md +231 -0
- package/docs/migration-from-arb.md +232 -0
- package/docs/tooling-overview.md +774 -0
- package/docs/workflow-orchestration.md +695 -0
- package/docs/workflow-ui-prototype.html +271 -0
- package/package.json +52 -0
- package/schemas/config.schema.json +51 -0
- package/schemas/delta.schema.json +22 -0
- package/schemas/state.schema.json +130 -0
- package/schemas/status-surface.schema.json +197 -0
- package/schemas/workflow-confirmation-surface.schema.json +70 -0
- package/schemas/workflow-picker.schema.json +94 -0
- package/scripts/postinstall.js +101 -0
- package/scripts/render-workflow-ui-prototype.js +271 -0
- package/skills/apply/SKILL.md +313 -0
- package/skills/apply/references/discipline-checklist.md +145 -0
- package/skills/apply/references/subagent-implementer-prompt.md +113 -0
- package/skills/apply/references/subagent-orchestration.md +150 -0
- package/skills/apply/references/subagent-reviewer-prompt.md +180 -0
- package/skills/apply/references/tdd-loop.md +287 -0
- package/skills/apply/references/when-plan-is-wrong.md +279 -0
- package/skills/apply/references/worktree-swarm.md +292 -0
- package/skills/archive/SKILL.md +229 -0
- package/skills/archive/references/conflict-resolution.md +336 -0
- package/skills/archive/references/knowledge-deposit.md +381 -0
- package/skills/archive/references/spec-merge.md +365 -0
- package/skills/brainstorm/SKILL.md +123 -0
- package/skills/brainstorm/references/proposal-template.md +244 -0
- package/skills/brainstorm/references/question-catalog.md +168 -0
- package/skills/brainstorm/references/session-template.md +184 -0
- package/skills/ci-fix/SKILL.md +63 -0
- package/skills/ci-fix/references/loop.md +25 -0
- package/skills/code-review/SKILL.md +279 -0
- package/skills/code-review/references/escalation-playbook.md +192 -0
- package/skills/code-review/references/language-cheatsheets/go.md +175 -0
- package/skills/code-review/references/language-cheatsheets/java-spring-mybatis.md +246 -0
- package/skills/code-review/references/language-cheatsheets/python.md +170 -0
- package/skills/code-review/references/language-cheatsheets/vue.md +199 -0
- package/skills/code-review/references/output-template.md +275 -0
- package/skills/code-review/references/review-checklist.md +251 -0
- package/skills/complexity-grading/SKILL.md +259 -0
- package/skills/deliver/SKILL.md +271 -0
- package/skills/deliver/references/delivery-modes.md +299 -0
- package/skills/deliver/references/notify.md +359 -0
- package/skills/deliver/references/pr-description.md +319 -0
- package/skills/dependency-upgrade/SKILL.md +57 -0
- package/skills/dependency-upgrade/references/risk-matrix.md +38 -0
- package/skills/df-orchestrator/SKILL.md +407 -0
- package/skills/df-orchestrator/references/complexity-grading.md +177 -0
- package/skills/df-orchestrator/references/escalation-matrix.md +191 -0
- package/skills/df-orchestrator/references/routing-rules.md +290 -0
- package/skills/df-orchestrator/references/workflow-state-machine.md +208 -0
- package/skills/frontend-quality/SKILL.md +61 -0
- package/skills/frontend-quality/references/checklist.md +35 -0
- package/skills/handoff-resume/SKILL.md +59 -0
- package/skills/handoff-resume/references/handoff-template.md +54 -0
- package/skills/plan/SKILL.md +166 -0
- package/skills/plan/references/task-breakdown.md +207 -0
- package/skills/plan/references/task-sequencing.md +143 -0
- package/skills/plan/references/task-template.md +248 -0
- package/skills/requirement-analysis/SKILL.md +499 -0
- package/skills/requirement-analysis/references/acceptance-criteria.md +183 -0
- package/skills/requirement-analysis/references/code-recon.md +151 -0
- package/skills/requirement-analysis/references/edge-case-catalog.md +164 -0
- package/skills/requirement-analysis/references/requirement-template.md +339 -0
- package/skills/requirement-analysis/references/scope-negotiation.md +162 -0
- package/skills/security-hardening/SKILL.md +60 -0
- package/skills/security-hardening/references/checklist.md +42 -0
- package/skills/tech-spec/SKILL.md +388 -0
- package/skills/tech-spec/references/api-contract-design.md +172 -0
- package/skills/tech-spec/references/decision-records.md +110 -0
- package/skills/tech-spec/references/design-template.md +301 -0
- package/skills/tech-spec/references/rollout-and-rollback.md +203 -0
- package/skills/tech-spec/references/spec-delta-conventions.md +250 -0
- package/skills/tech-spec/references/transaction-patterns.md +212 -0
- package/skills/test-spec/SKILL.md +219 -0
- package/skills/test-spec/references/coverage-strategy.md +218 -0
- package/skills/test-spec/references/edge-case-to-test.md +143 -0
- package/skills/test-spec/references/test-case-template.md +276 -0
- package/skills/verify/SKILL.md +232 -0
- package/skills/verify/references/nfr-verification.md +292 -0
- package/skills/verify/references/report-templates.md +510 -0
- package/skills/verify/references/self-test-guide.md +240 -0
- package/skills/verify/references/verify-rollback-map.md +247 -0
- package/src/cli/commands/_helpers.js +108 -0
- package/src/cli/commands/_submit.js +718 -0
- package/src/cli/commands/apply.js +198 -0
- package/src/cli/commands/archive.js +180 -0
- package/src/cli/commands/checkpoint.js +113 -0
- package/src/cli/commands/deliver.js +377 -0
- package/src/cli/commands/deploy.js +504 -0
- package/src/cli/commands/design.js +158 -0
- package/src/cli/commands/disable.js +21 -0
- package/src/cli/commands/doctor.js +178 -0
- package/src/cli/commands/enable.js +21 -0
- package/src/cli/commands/flow.js +645 -0
- package/src/cli/commands/help.js +93 -0
- package/src/cli/commands/ingest.js +602 -0
- package/src/cli/commands/init.js +341 -0
- package/src/cli/commands/knowledge.js +523 -0
- package/src/cli/commands/logs.js +43 -0
- package/src/cli/commands/new.js +202 -0
- package/src/cli/commands/plan.js +49 -0
- package/src/cli/commands/propose.js +27 -0
- package/src/cli/commands/provider.js +698 -0
- package/src/cli/commands/report.js +143 -0
- package/src/cli/commands/requirement.js +227 -0
- package/src/cli/commands/review.js +301 -0
- package/src/cli/commands/skills.js +457 -0
- package/src/cli/commands/status.js +925 -0
- package/src/cli/commands/switch.js +27 -0
- package/src/cli/commands/sync.js +47 -0
- package/src/cli/commands/test.js +366 -0
- package/src/cli/commands/uninstall.js +32 -0
- package/src/cli/commands/update.js +74 -0
- package/src/cli/commands/verify.js +354 -0
- package/src/cli/commands/worktree.js +78 -0
- package/src/cli/index.js +72 -0
- package/src/cli/parse-args.js +102 -0
- package/src/core/autodetect.js +271 -0
- package/src/core/change.js +208 -0
- package/src/core/checkpoint.js +217 -0
- package/src/core/config.js +60 -0
- package/src/core/delta.js +290 -0
- package/src/core/markers.js +59 -0
- package/src/core/paths.js +173 -0
- package/src/core/plan-tasks.js +36 -0
- package/src/core/project-routing.js +285 -0
- package/src/core/projects.js +200 -0
- package/src/core/state.js +200 -0
- package/src/core/workflow-check.js +177 -0
- package/src/core/workflow-init.js +34 -0
- package/src/core/workflow-picker.js +154 -0
- package/src/core/workflow-policy.js +119 -0
- package/src/core/workflow-suggest.js +181 -0
- package/src/core/workflow-verify.js +88 -0
- package/src/core/workflow.js +433 -0
- package/src/core/worktree.js +241 -0
- package/src/knowledge/categories.js +107 -0
- package/src/knowledge/classify.js +125 -0
- package/src/knowledge/deposit.js +414 -0
- package/src/knowledge/migrate.js +149 -0
- package/src/knowledge/mr.js +219 -0
- package/src/knowledge/query.js +131 -0
- package/src/knowledge/registry.js +151 -0
- package/src/knowledge/sync.js +179 -0
- package/src/providers/base.js +74 -0
- package/src/providers/drivers/api-yapi.js +78 -0
- package/src/providers/drivers/ci-jenkins.js +109 -0
- package/src/providers/drivers/intake-confluence.js +544 -0
- package/src/providers/drivers/kb-git.js +549 -0
- package/src/providers/drivers/kb-weknora.js +472 -0
- package/src/providers/drivers/notify-smtp.js +515 -0
- package/src/providers/drivers/observability-oss.js +43 -0
- package/src/providers/drivers/observability-sls.js +50 -0
- package/src/providers/lifecycle.js +135 -0
- package/src/providers/loader.js +132 -0
- package/src/providers/local.js +190 -0
- package/src/providers/userconfig.js +283 -0
- package/src/reports/aggregate.js +185 -0
- package/src/reports/coverage.js +163 -0
- package/src/reports/detect.js +143 -0
- package/src/reports/parse.js +236 -0
- package/src/templates/files/ci/github.yml +38 -0
- package/src/templates/files/ci/gitlab.yml +27 -0
- package/src/templates/files/design.md +63 -0
- package/src/templates/files/ide/devflow-workflow.md +58 -0
- package/src/templates/files/ide/project-overview-reference.md +1 -0
- package/src/templates/files/ide/project-overview.md +27 -0
- package/src/templates/files/knowledge-index.json +17 -0
- package/src/templates/files/knowledge.md +28 -0
- package/src/templates/files/meta.json +8 -0
- package/src/templates/files/plan.md +38 -0
- package/src/templates/files/proposal.md +33 -0
- package/src/templates/files/reports/contract-test.md +40 -0
- package/src/templates/files/reports/e2e-test.md +30 -0
- package/src/templates/files/reports/integration-test.md +36 -0
- package/src/templates/files/reports/joint-test.md +58 -0
- package/src/templates/files/reports/perf.md +24 -0
- package/src/templates/files/reports/regression.md +20 -0
- package/src/templates/files/reports/remote-test.md +55 -0
- package/src/templates/files/reports/self-test.md +43 -0
- package/src/templates/files/reports/smoke-test.md +22 -0
- package/src/templates/files/reports/unit-test.md +36 -0
- package/src/templates/files/requirement.md +51 -0
- package/src/templates/files/review.md +38 -0
- package/src/templates/files/tests.md +36 -0
- package/src/templates/files/verify.md +32 -0
- package/src/templates/index.js +21 -0
- package/src/utils/log.js +37 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const log = require('../../utils/log.js');
|
|
3
|
+
|
|
4
|
+
const TEXT = `
|
|
5
|
+
@chenguangyao/devflow-kit — independent dev workflow
|
|
6
|
+
command: devflow (short alias: dfk)
|
|
7
|
+
|
|
8
|
+
USAGE
|
|
9
|
+
devflow <command> [subcommand] [options] [args]
|
|
10
|
+
|
|
11
|
+
ENTRY POINTS
|
|
12
|
+
devflow ingest <url|issue-id|incident:id> primary: pull source material, draft proposal
|
|
13
|
+
[--project-root=<dir>[,<dir>]] use primary project git branch for the change slug
|
|
14
|
+
[--follow-links] [--max-links=10] also fetch one-level same-domain/local markdown links
|
|
15
|
+
devflow new <slug> fallback: brainstorm-driven start
|
|
16
|
+
devflow new <slug> --micro L0 path: create plan directly, skip requirement/design/archive
|
|
17
|
+
devflow new <slug> --level=L0 same as --micro
|
|
18
|
+
[--project-root=<dir>[,<dir>]] use primary project git branch for the change slug
|
|
19
|
+
|
|
20
|
+
LIFECYCLE
|
|
21
|
+
devflow propose | devflow requirement | devflow design | devflow plan | devflow apply
|
|
22
|
+
devflow review | devflow test <kind> | devflow verify | devflow deploy | devflow deliver | devflow archive
|
|
23
|
+
devflow requirement --project-root=<dir>[,<dir>] use/override explicitly selected project roots
|
|
24
|
+
devflow requirement --search-root=<dir> auto-route a requirement to projects under a multi-project root
|
|
25
|
+
|
|
26
|
+
SUBSYSTEMS
|
|
27
|
+
devflow provider <list|status|add|import|relogin|...>
|
|
28
|
+
devflow knowledge <init|query|deposit|sync|validate|migrate>
|
|
29
|
+
devflow skills install [--scope=user|project] [--target=...]
|
|
30
|
+
# --scope=user → copy once to ~/.devflow/skills/devflow-kit,
|
|
31
|
+
# then symlink ~/.cursor + ~/.claude + ~/.agents (all projects share)
|
|
32
|
+
# --scope=project → per-repo .cursor/skills + .claude/skills + .agents/skills (default)
|
|
33
|
+
# --copy can force user-scope back to physical copies
|
|
34
|
+
# all shipped skills are grouped under a single devflow-kit/ subfolder,
|
|
35
|
+
# so other skill collections (e.g. ~/.cursor/skills/superpowers/) stay untouched
|
|
36
|
+
devflow skills status # show where skills are installed (and their layout)
|
|
37
|
+
devflow skills migrate [--scope=user|project] # move legacy flat layout into devflow-kit/ subfolder
|
|
38
|
+
devflow skills uninstall [--scope=user|project] # also scrubs any stray flat-layout leftovers
|
|
39
|
+
devflow sync project [--check]
|
|
40
|
+
devflow sync yapi --file=<openapi.json|api.md> [--provider=api.yapi] [--dry-run]
|
|
41
|
+
sync API contract to YAPI through api provider
|
|
42
|
+
devflow logs query [--provider=observability.sls] [--query=...] [--trace-id=...] [--request-id=...]
|
|
43
|
+
query SLS/OSS logs for verify/self-test evidence
|
|
44
|
+
devflow switch project-overview --mode=<reference|supplement|generate>
|
|
45
|
+
devflow enable / devflow disable <feature>
|
|
46
|
+
|
|
47
|
+
OPS
|
|
48
|
+
devflow init [--ide=auto|full|minimal|none]
|
|
49
|
+
initialize devflow in current repo
|
|
50
|
+
(default --ide=auto: skip per-project IDE files
|
|
51
|
+
when ~/.cursor/skills/devflow-kit/ is installed)
|
|
52
|
+
devflow update refresh marker sections
|
|
53
|
+
devflow uninstall [--purge] remove devflow integration
|
|
54
|
+
devflow status current change overview
|
|
55
|
+
devflow status set-level <L0|L1|L2|L3> --reason="..." adjust level; L0 uses micro route
|
|
56
|
+
devflow status risk <add|resolve|accept|list> <type> --reason="..."
|
|
57
|
+
record/close risk signals such as frontend_change, dependency_change, security_sensitive, ci_failure
|
|
58
|
+
devflow doctor [--scope=...] [--fix] [--json] 7-dimension health check
|
|
59
|
+
devflow test remote --preflight --api-base-url=<url> --cmd="..."
|
|
60
|
+
remote API validation against a deployed test env
|
|
61
|
+
devflow test joint --preflight --backend-url=<url> --frontend-url=<url> --cmd="..."
|
|
62
|
+
frontend/backend joint test with browser-tool preflight
|
|
63
|
+
devflow test contract --cmd="..."
|
|
64
|
+
call-chain/API contract consistency validation
|
|
65
|
+
[--split-report] also write legacy reports/<kind>-test.md
|
|
66
|
+
devflow report compact [--remove-split]
|
|
67
|
+
merge existing split reports into reports/test-report.md
|
|
68
|
+
devflow flow <recommend|draft|suggest|add-step|disable-step|move-step|set-verify|diff|picker|apply-selection|card|check|confirm|explain>
|
|
69
|
+
draft and adjust the change workflow from builtin/project recipes
|
|
70
|
+
devflow deploy test --project=<name> trigger configured Jenkins test job
|
|
71
|
+
auto-prompts feature->test merge/push before Jenkins when run from a dev branch
|
|
72
|
+
[--merge-to=test] [--preflight] [--update-self-test]
|
|
73
|
+
non-interactive merge + local compile precheck; opt-in self-test refresh
|
|
74
|
+
devflow deliver --notify email submit body + one reports/test-report.md attachment
|
|
75
|
+
[--attach-design-plan] [--attach-docs] [--attach-reports]
|
|
76
|
+
opt in to design+plan, all docs, or raw report attachments
|
|
77
|
+
devflow checkpoint <show|add|resolve|cancel> [--json]
|
|
78
|
+
structured user decision cards / phase gates
|
|
79
|
+
devflow version | devflow help [topic]
|
|
80
|
+
|
|
81
|
+
LEARN MORE
|
|
82
|
+
See devflow/RFC-001-devflow-kit.md inside the repo, or run "devflow help <command>".
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
async function run({ topic } = {}) {
|
|
86
|
+
if (topic) {
|
|
87
|
+
log.raw(`(detailed help for "${topic}" not yet written; see RFC for now)`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
log.raw(TEXT);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { run };
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs/promises');
|
|
3
|
+
const fsSync = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
const log = require('../../utils/log.js');
|
|
7
|
+
const change = require('../../core/change.js');
|
|
8
|
+
const config = require('../../core/config.js');
|
|
9
|
+
const state = require('../../core/state.js');
|
|
10
|
+
const routing = require('../../core/project-routing.js');
|
|
11
|
+
const checkpoint = require('../../core/checkpoint.js');
|
|
12
|
+
const workflowInit = require('../../core/workflow-init.js');
|
|
13
|
+
const templates = require('../../templates/index.js');
|
|
14
|
+
const providers = require('../../providers/loader.js');
|
|
15
|
+
const lifecycle = require('../../providers/lifecycle.js');
|
|
16
|
+
const { printWorkflowSelectionGuidance } = require('./_helpers.js');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Multi-entry router: classifies the input and dispatches to the right provider.
|
|
20
|
+
* - URL (http/https/file) -> intake
|
|
21
|
+
* - PROJ-NUM (e.g. SCRUM-15803) -> issue
|
|
22
|
+
* - incident:<id> / alert:<id> -> ops/incident
|
|
23
|
+
* - else -> error
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
function classify(input) {
|
|
27
|
+
if (!input || typeof input !== 'string') return { kind: 'unknown' };
|
|
28
|
+
if (input.startsWith('http://') || input.startsWith('https://') || input.startsWith('file://')) {
|
|
29
|
+
return { kind: 'url', value: input };
|
|
30
|
+
}
|
|
31
|
+
if (/^[A-Z][A-Z0-9]+-\d+$/.test(input)) {
|
|
32
|
+
return { kind: 'issue', value: input };
|
|
33
|
+
}
|
|
34
|
+
if (/^(incident|alert):/i.test(input)) {
|
|
35
|
+
return { kind: 'incident', value: input.replace(/^[a-z]+:/i, '') };
|
|
36
|
+
}
|
|
37
|
+
if (input.endsWith('.md') || input.startsWith('./') || input.startsWith('../') || input.startsWith('/')) {
|
|
38
|
+
return { kind: 'url', value: input };
|
|
39
|
+
}
|
|
40
|
+
return { kind: 'unknown', value: input };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run({ positional = [], flags = {}, cwd, _selectProjectIndices } = {}) {
|
|
44
|
+
const root = cwd || process.cwd();
|
|
45
|
+
const input = positional[0];
|
|
46
|
+
if (!input) {
|
|
47
|
+
log.error('usage: devflow ingest <url|issue-id|incident:id>');
|
|
48
|
+
process.exitCode = 2;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { kind, value } = classify(input);
|
|
52
|
+
if (kind === 'unknown') {
|
|
53
|
+
log.error(`cannot classify input: ${input}`);
|
|
54
|
+
log.dim('hint: pass a URL, JIRA-style ID (e.g. SCRUM-15803), or "incident:<id>"');
|
|
55
|
+
process.exitCode = 2;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
log.info(`ingest type=${kind} value=${value}`);
|
|
60
|
+
|
|
61
|
+
let provider;
|
|
62
|
+
try {
|
|
63
|
+
provider = await lifecycle.loadOrAuth(root, { type: kind === 'issue' ? 'issue' : 'intake' });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log.error(e.message);
|
|
66
|
+
if (e.code === 'PROVIDER_NOT_INSTALLED') {
|
|
67
|
+
log.dim(`hint: run "devflow provider setup" to configure an intake provider`);
|
|
68
|
+
log.dim(` or: devflow provider add intake.confluence --type=intake --driver=confluence (built-in, no extra deps)`);
|
|
69
|
+
log.dim(` or: devflow provider add intake.confluence --type=intake --driver=confluence`);
|
|
70
|
+
}
|
|
71
|
+
process.exitCode = 2;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let fetched;
|
|
76
|
+
try {
|
|
77
|
+
fetched = await lifecycle.withAutoRetry(provider, (p) => p.fetch(value));
|
|
78
|
+
} catch (e) {
|
|
79
|
+
log.error(`provider ${provider.name} failed: ${e.message}`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const linkedRefs = await fetchLinkedRefs(provider, value, fetched.body_md || '', flags);
|
|
85
|
+
const routingQueryParts = [
|
|
86
|
+
fetched.title,
|
|
87
|
+
fetched.body_md,
|
|
88
|
+
...linkedRefs.items.map((item) => [item.title, item.body_md].join('\n')),
|
|
89
|
+
];
|
|
90
|
+
if (kind !== 'url') routingQueryParts.push(value);
|
|
91
|
+
const projectRouting = await resolvePreCreateProjectRouting(
|
|
92
|
+
root,
|
|
93
|
+
flags,
|
|
94
|
+
'ingest',
|
|
95
|
+
routingQueryParts.join('\n'),
|
|
96
|
+
{ selectProjectIndices: _selectProjectIndices }
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Derive slug
|
|
100
|
+
let slug;
|
|
101
|
+
if (kind === 'issue') {
|
|
102
|
+
slug = change.slugify(value + '-' + fetched.title);
|
|
103
|
+
} else {
|
|
104
|
+
slug = change.slugify(fetched.title || path.basename(value));
|
|
105
|
+
}
|
|
106
|
+
if (flags.slug) slug = change.slugify(flags.slug);
|
|
107
|
+
const branchRoot = projectRouting && projectRouting.slugBranchSource ? projectRouting.slugBranchSource : root;
|
|
108
|
+
slug = change.withCurrentBranchSlug(branchRoot, slug);
|
|
109
|
+
|
|
110
|
+
const level = flags.level || 'L2';
|
|
111
|
+
let info;
|
|
112
|
+
try {
|
|
113
|
+
info = await change.create(root, {
|
|
114
|
+
slug,
|
|
115
|
+
title: fetched.title,
|
|
116
|
+
level,
|
|
117
|
+
source: { type: kind === 'issue' ? 'issue' : 'url', ref: value },
|
|
118
|
+
});
|
|
119
|
+
await persistProjectRouting(root, slug, info.state, projectRouting, 'ingest');
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (/already exists/.test(e.message)) {
|
|
122
|
+
log.warn(`change already exists: ${slug} (re-using)`);
|
|
123
|
+
info = { slug, dir: change.resolveChangeDir(root, slug) };
|
|
124
|
+
if (projectRouting) {
|
|
125
|
+
try {
|
|
126
|
+
const st = await state.read(root, slug);
|
|
127
|
+
await persistProjectRouting(root, slug, st, projectRouting, 'ingest');
|
|
128
|
+
} catch (readErr) {
|
|
129
|
+
log.warn(`project routing not saved: ${readErr.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} else { throw e; }
|
|
133
|
+
}
|
|
134
|
+
printProjectRouting(projectRouting);
|
|
135
|
+
|
|
136
|
+
// refs/<source>-<id>.md
|
|
137
|
+
const refsDir = await change.ensureSubdir(root, slug, 'refs');
|
|
138
|
+
let bodyMd = fetched.body_md || '';
|
|
139
|
+
const savedAssets = await saveImageAttachments(refsDir, fetched.attachments || []);
|
|
140
|
+
if (savedAssets.length) {
|
|
141
|
+
bodyMd = rewriteImageRefs(bodyMd, savedAssets);
|
|
142
|
+
}
|
|
143
|
+
const refName = kind === 'issue' ? `issue-${value}.md` : `intake-${slugFromValue(value)}.md`;
|
|
144
|
+
const refFile = path.join(refsDir, refName);
|
|
145
|
+
await fs.writeFile(refFile, bodyMd, 'utf8');
|
|
146
|
+
log.ok(`refs/${refName}`);
|
|
147
|
+
if (savedAssets.length) log.ok(`refs/assets (${savedAssets.length} image${savedAssets.length === 1 ? '' : 's'})`);
|
|
148
|
+
await writeLinkedRefs(refsDir, linkedRefs);
|
|
149
|
+
|
|
150
|
+
// Draft proposal.md
|
|
151
|
+
const tpl = await templates.load('proposal.md');
|
|
152
|
+
const body = templates.render(tpl, {
|
|
153
|
+
slug,
|
|
154
|
+
title: fetched.title,
|
|
155
|
+
level,
|
|
156
|
+
source: value,
|
|
157
|
+
sourceType: kind,
|
|
158
|
+
sourceRef: value,
|
|
159
|
+
summary: extractSummary(bodyMd),
|
|
160
|
+
date: new Date().toISOString().slice(0, 10),
|
|
161
|
+
});
|
|
162
|
+
await change.writeArtifact(root, slug, 'proposal.md', body);
|
|
163
|
+
log.ok(`proposal: ${path.join(info.dir, 'proposal.md')}`);
|
|
164
|
+
await ensureProblemCardCheckpoint(root, slug, {
|
|
165
|
+
title: fetched.title,
|
|
166
|
+
level,
|
|
167
|
+
source: value,
|
|
168
|
+
refName,
|
|
169
|
+
projectRouting,
|
|
170
|
+
summary: extractSummary(bodyMd),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
log.raw('');
|
|
174
|
+
log.ok(`change ready: ${slug}`);
|
|
175
|
+
log.dim(`workspace: ${info.dir}`);
|
|
176
|
+
log.dim('next: resolve the problem_card checkpoint, then confirm this change workflow.');
|
|
177
|
+
const draftResult = await createEntryWorkflowDraft(root, slug);
|
|
178
|
+
if (draftResult) {
|
|
179
|
+
printWorkflowSelectionGuidance(slug);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fetchLinkedRefs(provider, sourceUrl, bodyMd, flags = {}) {
|
|
184
|
+
if (!isTruthy(flags.followLinks || flags['follow-links'])) {
|
|
185
|
+
return { enabled: false, items: [], skipped: [] };
|
|
186
|
+
}
|
|
187
|
+
const requestedDepth = parseInt(flags.linkDepth || flags['link-depth'] || '1', 10);
|
|
188
|
+
if (Number.isFinite(requestedDepth) && requestedDepth > 1) {
|
|
189
|
+
log.warn('ingest linked refs currently supports max depth=1; using 1.');
|
|
190
|
+
}
|
|
191
|
+
const maxLinks = clampInt(flags.maxLinks || flags['max-links'], 10, 0, 20);
|
|
192
|
+
const candidates = extractMarkdownLinks(bodyMd)
|
|
193
|
+
.map((link) => ({ ...link, resolved: resolveIngestLink(sourceUrl, link.href) }))
|
|
194
|
+
.filter((link) => link.resolved);
|
|
195
|
+
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
const items = [];
|
|
198
|
+
const skipped = [];
|
|
199
|
+
for (const link of candidates) {
|
|
200
|
+
const resolved = link.resolved;
|
|
201
|
+
if (seen.has(resolved)) continue;
|
|
202
|
+
seen.add(resolved);
|
|
203
|
+
if (!isAllowedLinkedRef(sourceUrl, resolved)) {
|
|
204
|
+
skipped.push({ label: link.label, url: resolved, reason: '非同域或非本地 markdown 链接' });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (provider && typeof provider.supports === 'function' && !provider.supports(resolved)) {
|
|
208
|
+
skipped.push({ label: link.label, url: resolved, reason: '当前 intake provider 不支持该链接' });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (items.length >= maxLinks) {
|
|
212
|
+
skipped.push({ label: link.label, url: resolved, reason: `超过本次链接抓取上限 ${maxLinks}` });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const fetched = await lifecycle.withAutoRetry(provider, (p) => p.fetch(resolved));
|
|
217
|
+
items.push({
|
|
218
|
+
label: link.label,
|
|
219
|
+
url: resolved,
|
|
220
|
+
title: fetched.title || link.label || resolved,
|
|
221
|
+
body_md: fetched.body_md || '',
|
|
222
|
+
source: fetched.source || { type: 'url', ref: resolved },
|
|
223
|
+
});
|
|
224
|
+
} catch (e) {
|
|
225
|
+
skipped.push({ label: link.label, url: resolved, reason: e.message });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (items.length) log.ok(`linked refs (${items.length})`);
|
|
230
|
+
if (skipped.length) log.warn(`linked refs skipped: ${skipped.length}`);
|
|
231
|
+
return { enabled: true, items, skipped };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function writeLinkedRefs(refsDir, linkedRefs) {
|
|
235
|
+
if (!linkedRefs || !linkedRefs.enabled) return;
|
|
236
|
+
const lines = [
|
|
237
|
+
'# 链接抓取索引',
|
|
238
|
+
'',
|
|
239
|
+
'本文件由 `devflow ingest --follow-links` 自动生成。当前只抓取入口页中的一层同域/本地链接。',
|
|
240
|
+
'',
|
|
241
|
+
'## 已抓取',
|
|
242
|
+
'',
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
for (const [i, item] of linkedRefs.items.entries()) {
|
|
246
|
+
const n = String(i + 1).padStart(2, '0');
|
|
247
|
+
const base = change.slugify(item.title || item.label || `linked-${n}`) || `linked-${n}`;
|
|
248
|
+
const filename = `linked-${n}-${base}.md`;
|
|
249
|
+
const content = [
|
|
250
|
+
`# ${item.title || item.label || item.url}`,
|
|
251
|
+
'',
|
|
252
|
+
`- 来源链接:${item.url}`,
|
|
253
|
+
item.label ? `- 原文标签:${item.label}` : null,
|
|
254
|
+
'',
|
|
255
|
+
'---',
|
|
256
|
+
'',
|
|
257
|
+
item.body_md || '',
|
|
258
|
+
'',
|
|
259
|
+
].filter((x) => x !== null).join('\n');
|
|
260
|
+
await fs.writeFile(path.join(refsDir, filename), content, 'utf8');
|
|
261
|
+
lines.push(`- [x] ${item.label || item.title || item.url} -> ${filename}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!linkedRefs.items.length) lines.push('- 无');
|
|
265
|
+
lines.push('', '## 跳过', '');
|
|
266
|
+
for (const item of linkedRefs.skipped || []) {
|
|
267
|
+
lines.push(`- [ ] ${item.label || item.url} (${item.url}):${item.reason}`);
|
|
268
|
+
}
|
|
269
|
+
if (!linkedRefs.skipped.length) lines.push('- 无');
|
|
270
|
+
lines.push('');
|
|
271
|
+
await fs.writeFile(path.join(refsDir, 'link-index.md'), lines.join('\n'), 'utf8');
|
|
272
|
+
log.ok('refs/link-index.md');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function resolvePreCreateProjectRouting(root, flags, source, query, opts = {}) {
|
|
276
|
+
const roots = parseRoots(flags.projectRoot || flags['project-root']);
|
|
277
|
+
if (roots.length) return routing.explicitProjectRouting(roots, { source });
|
|
278
|
+
if (flags['no-project-routing'] || flags.noProjectRouting) return null;
|
|
279
|
+
|
|
280
|
+
const searchRoots = parseRoots(flags.searchRoot || flags['search-root'] || flags.projectsRoot || root);
|
|
281
|
+
const result = await routing.routeProjects({
|
|
282
|
+
searchRoots,
|
|
283
|
+
query,
|
|
284
|
+
maxDepth: parseInt(flags.projectDepth || flags['project-depth'] || '3', 10),
|
|
285
|
+
topK: parseInt(flags.projectTopK || flags['project-top-k'] || '5', 10),
|
|
286
|
+
});
|
|
287
|
+
if (!result.recommended.length) return null;
|
|
288
|
+
const projectRouting = {
|
|
289
|
+
status: 'recommended',
|
|
290
|
+
source,
|
|
291
|
+
searchRoots: result.searchRoots,
|
|
292
|
+
queryTerms: routing.extractTerms(query).slice(0, 20),
|
|
293
|
+
candidates: result.candidates,
|
|
294
|
+
recommended: result.recommended,
|
|
295
|
+
slugBranchSource: result.recommended[0].root,
|
|
296
|
+
};
|
|
297
|
+
return maybeSelectProjectRouting(projectRouting, opts);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function persistProjectRouting(root, slug, st, projectRouting, source) {
|
|
301
|
+
if (!projectRouting || !st) return st;
|
|
302
|
+
st.projectRouting = projectRouting;
|
|
303
|
+
st.projects = projectRouting.recommended;
|
|
304
|
+
await routing.writeProjectRootsArtifact(root, slug, projectRouting, { source });
|
|
305
|
+
state.logEvent(st, 'project routing selected', {
|
|
306
|
+
source,
|
|
307
|
+
status: projectRouting.status,
|
|
308
|
+
projects: st.projects.length,
|
|
309
|
+
});
|
|
310
|
+
await state.write(root, slug, st);
|
|
311
|
+
return st;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function maybeSelectProjectRouting(projectRouting, opts = {}) {
|
|
315
|
+
if (!projectRouting || projectRouting.candidates.length <= 1) return projectRouting;
|
|
316
|
+
|
|
317
|
+
let picks = null;
|
|
318
|
+
if (typeof opts.selectProjectIndices === 'function') {
|
|
319
|
+
picks = await opts.selectProjectIndices(projectRouting.candidates);
|
|
320
|
+
} else if (isTty(opts.stdin || process.stdin, opts.stdout || process.stdout)) {
|
|
321
|
+
picks = await promptProjectSelection(projectRouting.candidates, opts);
|
|
322
|
+
}
|
|
323
|
+
if (!Array.isArray(picks)) return projectRouting;
|
|
324
|
+
if (!picks.length) return null;
|
|
325
|
+
return selectProjectRouting(projectRouting, picks);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function promptProjectSelection(candidates, opts = {}) {
|
|
329
|
+
log.raw('');
|
|
330
|
+
log.info('project routing candidates:');
|
|
331
|
+
for (const [i, p] of candidates.slice(0, 8).entries()) {
|
|
332
|
+
log.raw(` ${i + 1}. ${p.name} confidence=${p.confidence} ${p.root}`);
|
|
333
|
+
for (const e of (p.evidence || []).slice(0, 2)) log.dim(` - ${e}`);
|
|
334
|
+
}
|
|
335
|
+
const answer = await ask('select project(s), e.g. "1" or "1,2" [1]: ', opts);
|
|
336
|
+
return parseProjectSelection(answer, candidates.length);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function selectProjectRouting(projectRouting, picks) {
|
|
340
|
+
const candidates = projectRouting.candidates || [];
|
|
341
|
+
const recommended = picks
|
|
342
|
+
.map((idx) => candidates[idx])
|
|
343
|
+
.filter(Boolean)
|
|
344
|
+
.map((p, i) => ({
|
|
345
|
+
name: p.name,
|
|
346
|
+
root: p.root,
|
|
347
|
+
role: i === 0 ? 'primary' : 'dependency',
|
|
348
|
+
branch: p.branch || change.currentBranch(p.root) || null,
|
|
349
|
+
confidence: p.confidence,
|
|
350
|
+
evidence: p.evidence || [],
|
|
351
|
+
}));
|
|
352
|
+
if (!recommended.length) return projectRouting;
|
|
353
|
+
return {
|
|
354
|
+
...projectRouting,
|
|
355
|
+
status: 'selected',
|
|
356
|
+
recommended,
|
|
357
|
+
slugBranchSource: recommended[0].root,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseProjectSelection(input, count) {
|
|
362
|
+
const s = String(input || '').trim().toLowerCase();
|
|
363
|
+
if (!s) return [0];
|
|
364
|
+
if (s === 'none' || s === 'no' || s === 'n') return [];
|
|
365
|
+
if (s === 'all' || s === 'a' || s === '*') {
|
|
366
|
+
return Array.from({ length: count }, (_, i) => i);
|
|
367
|
+
}
|
|
368
|
+
const seen = new Set();
|
|
369
|
+
for (const tok of s.split(/[,\s]+/)) {
|
|
370
|
+
const n = parseInt(tok, 10);
|
|
371
|
+
if (Number.isFinite(n) && n >= 1 && n <= count) seen.add(n - 1);
|
|
372
|
+
}
|
|
373
|
+
return Array.from(seen).sort((a, b) => a - b);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function ask(question, opts = {}) {
|
|
377
|
+
const input = opts.stdin || process.stdin;
|
|
378
|
+
const output = opts.stdout || process.stdout;
|
|
379
|
+
const rl = readline.createInterface({ input, output });
|
|
380
|
+
return new Promise((resolve) => {
|
|
381
|
+
rl.question(question, (answer) => {
|
|
382
|
+
rl.close();
|
|
383
|
+
resolve(answer);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isTty(stdin, stdout) {
|
|
389
|
+
return Boolean(stdin && stdout && stdin.isTTY && stdout.isTTY);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function printProjectRouting(projectRouting) {
|
|
393
|
+
if (!projectRouting || !projectRouting.candidates.length) return;
|
|
394
|
+
log.raw('');
|
|
395
|
+
log.info('project routing recommendations:');
|
|
396
|
+
for (const [i, p] of projectRouting.candidates.slice(0, 5).entries()) {
|
|
397
|
+
const mark = projectRouting.recommended.some((r) => r.root === p.root) ? '*' : ' ';
|
|
398
|
+
log.raw(` ${mark} ${i + 1}. ${p.name} confidence=${p.confidence} ${p.root}`);
|
|
399
|
+
for (const e of (p.evidence || []).slice(0, 3)) log.dim(` - ${e}`);
|
|
400
|
+
}
|
|
401
|
+
log.dim('override if needed: devflow ingest <input> --project-root=<dir>[,<dir>]');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function parseRoots(value) {
|
|
405
|
+
return String(value || '')
|
|
406
|
+
.split(',')
|
|
407
|
+
.map((s) => s.trim())
|
|
408
|
+
.filter(Boolean);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function slugFromValue(v) {
|
|
412
|
+
return change.slugify(v.replace(/^https?:\/\//, '').replace(/[\/?#].*$/, '')).slice(0, 60);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function extractSummary(md) {
|
|
416
|
+
if (!md) return '';
|
|
417
|
+
// Skip first H1 and any frontmatter, take first 5 non-heading paragraphs.
|
|
418
|
+
let body = md.replace(/^---[\s\S]*?\n---\n/, '');
|
|
419
|
+
body = body.replace(/^#\s.+\n+/, '');
|
|
420
|
+
const paras = body.split(/\n\s*\n/).slice(0, 3);
|
|
421
|
+
return paras.join('\n\n').slice(0, 800);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function ensureProblemCardCheckpoint(root, slug, details) {
|
|
425
|
+
const st = await state.read(root, slug);
|
|
426
|
+
if (checkpoint.latestPendingByType(st, 'problem_card')) return null;
|
|
427
|
+
if (checkpoint.hasResolvedDecision(st, 'problem_card', 'accept')) return null;
|
|
428
|
+
|
|
429
|
+
const projectNames = details.projectRouting && details.projectRouting.recommended && details.projectRouting.recommended.length
|
|
430
|
+
? details.projectRouting.recommended.map((p) => `${p.name}:${p.role || 'candidate'}`).join(', ')
|
|
431
|
+
: '未确认';
|
|
432
|
+
const summary = `${details.title || slug};level=${details.level || st.level || 'L2'};projects=${projectNames}`;
|
|
433
|
+
const evidence = ['proposal.md'];
|
|
434
|
+
if (details.refName) evidence.push(`refs/${details.refName}`);
|
|
435
|
+
const cp = checkpoint.addCheckpoint(st, {
|
|
436
|
+
type: 'problem_card',
|
|
437
|
+
phase: 'intake',
|
|
438
|
+
summary,
|
|
439
|
+
question: '是否接受当前问题卡、复杂度分级和项目范围,并进入 requirement?',
|
|
440
|
+
options: [
|
|
441
|
+
{ id: 'accept', label: '接受并进入需求分析', command: 'devflow checkpoint resolve --id=<checkpoint-id> --decision=accept' },
|
|
442
|
+
{ id: 'revise', label: '继续修正问题卡', command: '编辑 proposal.md,必要时重跑 devflow ingest --project-root=...' },
|
|
443
|
+
],
|
|
444
|
+
nextAction: 'devflow checkpoint resolve --id=<checkpoint-id> --decision=accept',
|
|
445
|
+
evidence,
|
|
446
|
+
risks: ['未确认问题卡时直接 requirement,容易把错误范围带入后续设计和实现。'],
|
|
447
|
+
});
|
|
448
|
+
cp.options = cp.options.map((opt) => opt.id === 'accept'
|
|
449
|
+
? { ...opt, command: `devflow checkpoint resolve --id=${cp.id} --decision=accept` }
|
|
450
|
+
: opt);
|
|
451
|
+
cp.nextAction = `devflow checkpoint resolve --id=${cp.id} --decision=accept`;
|
|
452
|
+
state.logEvent(st, 'checkpoint.add', { id: cp.id, type: cp.type, phase: cp.phase });
|
|
453
|
+
await state.write(root, slug, st);
|
|
454
|
+
log.raw('');
|
|
455
|
+
log.raw(checkpoint.renderNextStepCard(cp));
|
|
456
|
+
return cp;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function createEntryWorkflowDraft(root, slug) {
|
|
460
|
+
try {
|
|
461
|
+
const cfg = (await config.read(root)) || {};
|
|
462
|
+
const st = await state.read(root, slug);
|
|
463
|
+
const result = workflowInit.draftRecommendedWorkflow(root, st, cfg, { auto: true });
|
|
464
|
+
await state.write(root, slug, st);
|
|
465
|
+
if (result.created && result.recipe) {
|
|
466
|
+
log.ok(`workflow draft: ${result.recipe.label} (${result.recipe.id})`);
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
} catch (e) {
|
|
470
|
+
log.warn(`workflow draft not created: ${e.message}`);
|
|
471
|
+
log.dim(`workflow: devflow flow recommend --slug=${slug}`);
|
|
472
|
+
log.dim(` devflow flow draft --slug=${slug}`);
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function extractMarkdownLinks(markdown) {
|
|
478
|
+
const out = [];
|
|
479
|
+
const re = /(!)?\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
480
|
+
let m;
|
|
481
|
+
while ((m = re.exec(markdown || '')) !== null) {
|
|
482
|
+
if (m[1]) continue;
|
|
483
|
+
const label = (m[2] || '').trim();
|
|
484
|
+
const href = (m[3] || '').trim();
|
|
485
|
+
if (!href || href.startsWith('#')) continue;
|
|
486
|
+
if (/^(mailto|javascript|tel):/i.test(href)) continue;
|
|
487
|
+
out.push({ label, href });
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function resolveIngestLink(sourceUrl, href) {
|
|
493
|
+
if (!href) return null;
|
|
494
|
+
if (/^https?:\/\//i.test(href) || href.startsWith('file://')) return href;
|
|
495
|
+
if (/^https?:\/\//i.test(sourceUrl)) {
|
|
496
|
+
try { return new URL(href, sourceUrl).toString(); } catch { return null; }
|
|
497
|
+
}
|
|
498
|
+
let base = sourceUrl || '';
|
|
499
|
+
if (base.startsWith('file://')) base = base.slice(7);
|
|
500
|
+
if (!base) return null;
|
|
501
|
+
return path.resolve(path.dirname(path.resolve(base)), href);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isAllowedLinkedRef(sourceUrl, linkedUrl) {
|
|
505
|
+
if (!linkedUrl) return false;
|
|
506
|
+
if (/^https?:\/\//i.test(sourceUrl) && /^https?:\/\//i.test(linkedUrl)) {
|
|
507
|
+
try {
|
|
508
|
+
const source = new URL(sourceUrl);
|
|
509
|
+
const linked = new URL(linkedUrl);
|
|
510
|
+
return source.origin === linked.origin;
|
|
511
|
+
} catch { return false; }
|
|
512
|
+
}
|
|
513
|
+
if (!/^https?:\/\//i.test(sourceUrl) && !/^https?:\/\//i.test(linkedUrl)) {
|
|
514
|
+
const p = linkedUrl.startsWith('file://') ? linkedUrl.slice(7) : linkedUrl;
|
|
515
|
+
return /\.(md|markdown|txt)$/i.test(p);
|
|
516
|
+
}
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function isTruthy(v) {
|
|
521
|
+
return v === true || v === 'true' || v === '1' || v === 'yes' || v === 'y';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function clampInt(v, def, min, max) {
|
|
525
|
+
const n = v === undefined || v === null || v === '' ? def : parseInt(String(v), 10);
|
|
526
|
+
if (!Number.isFinite(n)) return def;
|
|
527
|
+
return Math.max(min, Math.min(max, n));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function saveImageAttachments(refsDir, attachments) {
|
|
531
|
+
const images = (attachments || []).filter((a) => a && a.filename && Buffer.isBuffer(a.data));
|
|
532
|
+
if (!images.length) return [];
|
|
533
|
+
const assetsDir = path.join(refsDir, 'assets');
|
|
534
|
+
await fs.mkdir(assetsDir, { recursive: true });
|
|
535
|
+
const used = new Set();
|
|
536
|
+
const saved = [];
|
|
537
|
+
for (const image of images) {
|
|
538
|
+
const filename = uniqueFilename(safeFilename(image.filename), used);
|
|
539
|
+
const abs = path.join(assetsDir, filename);
|
|
540
|
+
await fs.writeFile(abs, image.data);
|
|
541
|
+
saved.push({
|
|
542
|
+
original: image.filename,
|
|
543
|
+
filename,
|
|
544
|
+
rel: `assets/${filename}`,
|
|
545
|
+
abs,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return saved;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function rewriteImageRefs(markdown, assets) {
|
|
552
|
+
let out = markdown || '';
|
|
553
|
+
for (const asset of assets) {
|
|
554
|
+
const candidates = new Set([
|
|
555
|
+
asset.original,
|
|
556
|
+
encodeURI(asset.original),
|
|
557
|
+
asset.filename,
|
|
558
|
+
encodeURI(asset.filename),
|
|
559
|
+
]);
|
|
560
|
+
out = out.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (full, alt, src) => {
|
|
561
|
+
const clean = decodeMaybe(src.trim()).split(/[?#]/)[0];
|
|
562
|
+
const base = path.basename(clean);
|
|
563
|
+
if (candidates.has(src.trim()) || candidates.has(clean) || candidates.has(base)) {
|
|
564
|
+
return ``;
|
|
565
|
+
}
|
|
566
|
+
return full;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function safeFilename(name) {
|
|
573
|
+
const ext = path.extname(name) || '.png';
|
|
574
|
+
const base = path.basename(name, ext)
|
|
575
|
+
.normalize('NFKC')
|
|
576
|
+
.replace(/[\\/:*?"<>|\n\r\t]+/g, '-')
|
|
577
|
+
.replace(/\s+/g, '-')
|
|
578
|
+
.replace(/-+/g, '-')
|
|
579
|
+
.replace(/^-|-$/g, '')
|
|
580
|
+
.slice(0, 80) || 'image';
|
|
581
|
+
return base + ext.toLowerCase();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function uniqueFilename(name, used) {
|
|
585
|
+
let candidate = name;
|
|
586
|
+
const ext = path.extname(name);
|
|
587
|
+
const base = path.basename(name, ext);
|
|
588
|
+
let i = 2;
|
|
589
|
+
while (used.has(candidate)) {
|
|
590
|
+
candidate = `${base}-${i}${ext}`;
|
|
591
|
+
i++;
|
|
592
|
+
}
|
|
593
|
+
used.add(candidate);
|
|
594
|
+
return candidate;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function decodeMaybe(s) {
|
|
598
|
+
try { return decodeURIComponent(s); }
|
|
599
|
+
catch { return s; }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
module.exports = { run, classify };
|