deuk-agent-flow 4.0.19
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.ko.md +223 -0
- package/CHANGELOG.md +227 -0
- package/LICENSE +184 -0
- package/README.ko.md +282 -0
- package/README.md +270 -0
- package/bin/deuk-agent-flow.js +50 -0
- package/bin/deuk-agent-rule.js +2 -0
- package/core-rules/AGENTS.md +153 -0
- package/core-rules/GEMINI.md +7 -0
- package/docs/architecture.ko.md +34 -0
- package/docs/architecture.md +33 -0
- package/docs/assets/architecture-v3.png +0 -0
- package/docs/how-it-works.ko.md +52 -0
- package/docs/how-it-works.md +71 -0
- package/docs/principles.ko.md +68 -0
- package/docs/principles.md +68 -0
- package/docs/usage-guide.ko.md +212 -0
- package/package.json +96 -0
- package/scripts/cli-args.mjs +200 -0
- package/scripts/cli-init-commands.mjs +1799 -0
- package/scripts/cli-init-logic.mjs +64 -0
- package/scripts/cli-prompts.mjs +104 -0
- package/scripts/cli-rule-compiler.mjs +112 -0
- package/scripts/cli-skill-commands.mjs +201 -0
- package/scripts/cli-telemetry-commands.mjs +599 -0
- package/scripts/cli-ticket-commands.mjs +2393 -0
- package/scripts/cli-ticket-index.mjs +298 -0
- package/scripts/cli-ticket-migration.mjs +320 -0
- package/scripts/cli-ticket-parser.mjs +209 -0
- package/scripts/cli-usage-commands.mjs +326 -0
- package/scripts/cli-utils.mjs +587 -0
- package/scripts/cli.mjs +246 -0
- package/scripts/lint-md.mjs +267 -0
- package/scripts/lint-rules.mjs +186 -0
- package/scripts/merge-logic.mjs +44 -0
- package/scripts/plan-parser.mjs +53 -0
- package/scripts/publish-dual-npm.mjs +141 -0
- package/scripts/smoke-npm-docker.mjs +102 -0
- package/scripts/smoke-npm-local.mjs +109 -0
- package/scripts/update-download-badge.mjs +107 -0
- package/templates/MODULE_RULE_TEMPLATE.md +11 -0
- package/templates/PROJECT_RULE.md +47 -0
- package/templates/TICKET_TEMPLATE.ko.md +44 -0
- package/templates/TICKET_TEMPLATE.md +44 -0
- package/templates/project-pilot/CONFORMANCE_GATE_TEMPLATE.md +23 -0
- package/templates/project-pilot/DRIFT_CHECKLIST.md +19 -0
- package/templates/project-pilot/FLOW_CONTRACT_TEMPLATE.md +26 -0
- package/templates/project-pilot/IMPLEMENTATION_MATRIX_TEMPLATE.md +30 -0
- package/templates/project-pilot/INTEGRATION_CONTRACT_TEMPLATE.md +26 -0
- package/templates/project-pilot/OWNER_MAP_TEMPLATE.md +15 -0
- package/templates/project-pilot/PROJECT_PILOT_RULE_TEMPLATE.md +34 -0
- package/templates/project-pilot/REFACTOR_CONTRACT_TEMPLATE.md +32 -0
- package/templates/project-pilot/REMEDIATION_PLAN_TEMPLATE.md +33 -0
- package/templates/rules.d/deukcontext-mcp.md +31 -0
- package/templates/rules.d/platform-coexistence.md +29 -0
- package/templates/skills/context-recall/SKILL.md +25 -0
- package/templates/skills/generated-file-guard/SKILL.md +25 -0
- package/templates/skills/project-pilot/SKILL.md +63 -0
- package/templates/skills/safe-refactor/SKILL.md +25 -0
package/scripts/cli.mjs
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, rmSync } from "fs";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { parseArgs, parseTicketArgs, parseSkillArgs, parseTelemetryArgs, parseUsageArgs } from "./cli-args.mjs";
|
|
6
|
+
import { runInit, runMerge } from "./cli-init-commands.mjs";
|
|
7
|
+
import { runTicketCreate, runTicketList, runTicketUse, runTicketClose, runTicketArchive, runTicketDiscard, runTicketReports, runTicketMeta, runTicketConnect, runTicketRebuild, runTicketReportAttach, runTicketMove, runTicketNext, runTicketHotfix, runTicketStatus, runTicketGuard, runTicketHandoff, runTicketEvidenceCheck, runTicketEvidenceReport } from "./cli-ticket-commands.mjs";
|
|
8
|
+
import { runTelemetry } from "./cli-telemetry-commands.mjs";
|
|
9
|
+
import { runUsage } from "./cli-usage-commands.mjs";
|
|
10
|
+
import { performUpgradeMigration } from "./cli-ticket-migration.mjs";
|
|
11
|
+
import { loadInitConfig, writeInitConfig, checkUpdateNotifier, normalizeWorkflowMode, WORKFLOW_MODE_EXECUTE, AGENT_ROOT_DIR, resolveWorkflowMode, LEGACY_TEMPLATE_DIR, LEGACY_CONFIG_FILE } from "./cli-utils.mjs";
|
|
12
|
+
import { runInteractive } from "./cli-prompts.mjs";
|
|
13
|
+
|
|
14
|
+
const updatePromise = checkUpdateNotifier();
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const pkgRoot = join(__dirname, "..");
|
|
18
|
+
async function main() {
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const sub = argv[0];
|
|
21
|
+
if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
|
|
22
|
+
printHelp();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rest = argv.slice(1);
|
|
27
|
+
|
|
28
|
+
if (sub === "ticket") {
|
|
29
|
+
const action = rest[0];
|
|
30
|
+
const opts = parseTicketArgs(rest.slice(1));
|
|
31
|
+
if (action === "create") await runTicketCreate(opts);
|
|
32
|
+
else if (action === "list") await runTicketList(opts);
|
|
33
|
+
else if (action === "use") await runTicketUse(opts);
|
|
34
|
+
else if (action === "next") await runTicketNext(opts);
|
|
35
|
+
else if (action === "evidence") await runTicketEvidenceCheck(opts);
|
|
36
|
+
else if (action === "close") await runTicketClose(opts);
|
|
37
|
+
else if (action === "archive") await runTicketArchive(opts);
|
|
38
|
+
else if (action === "discard" || action === "delete") await runTicketDiscard(opts);
|
|
39
|
+
else if (action === "reports") await runTicketReports(opts);
|
|
40
|
+
else if (action === "meta") await runTicketMeta(opts);
|
|
41
|
+
else if (action === "connect") await runTicketConnect(opts);
|
|
42
|
+
else if (action === "rebuild") await runTicketRebuild(opts);
|
|
43
|
+
else if (action === "move" || action === "step") await runTicketMove(opts);
|
|
44
|
+
else if (action === "hotfix") await runTicketHotfix(opts);
|
|
45
|
+
else if (action === "status") await runTicketStatus(opts);
|
|
46
|
+
else if (action === "guard" || action === "context") await runTicketGuard(opts);
|
|
47
|
+
else if (action === "handoff" || action === "continue") await runTicketHandoff(opts);
|
|
48
|
+
else if (action === "report") {
|
|
49
|
+
const subAction = rest[1];
|
|
50
|
+
if (subAction === "attach") {
|
|
51
|
+
const attachOpts = parseTicketArgs(rest.slice(2));
|
|
52
|
+
await runTicketReportAttach(attachOpts);
|
|
53
|
+
} else if (opts.claim) {
|
|
54
|
+
await runTicketEvidenceReport(opts);
|
|
55
|
+
} else {
|
|
56
|
+
await runTicketReports(opts);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (action === "upgrade" || action === "migrate") {
|
|
60
|
+
const count = performUpgradeMigration(opts.cwd, opts);
|
|
61
|
+
console.log(`Migration complete: ${count} tickets upgraded.`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.error("Unknown ticket action: " + action);
|
|
65
|
+
printHelp();
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (sub === "telemetry") {
|
|
71
|
+
const opts = parseTelemetryArgs(rest);
|
|
72
|
+
await runTelemetry(opts);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (sub === "usage") {
|
|
77
|
+
const action = rest[0];
|
|
78
|
+
const opts = parseUsageArgs(rest.slice(1));
|
|
79
|
+
await runUsage(action, opts);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (sub === "skill") {
|
|
84
|
+
const action = rest[0];
|
|
85
|
+
const opts = parseSkillArgs(rest.slice(1));
|
|
86
|
+
const { runSkill } = await import("./cli-skill-commands.mjs");
|
|
87
|
+
await runSkill(action, opts);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (sub === "lint:md" || sub === "lint-md") {
|
|
92
|
+
const { runMarkdownLint } = await import("./lint-md.mjs");
|
|
93
|
+
runMarkdownLint(rest);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (sub === "rules") {
|
|
98
|
+
const action = rest[0];
|
|
99
|
+
const opts = parseArgs(rest.slice(1));
|
|
100
|
+
if (action === "audit") {
|
|
101
|
+
const { runRulesAudit } = await import("./lint-rules.mjs");
|
|
102
|
+
runRulesAudit(opts);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.error("Unknown rules action: " + action);
|
|
106
|
+
printHelp();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (sub === "init" || sub === "merge") {
|
|
111
|
+
const opts = parseArgs(rest);
|
|
112
|
+
if (opts.help) {
|
|
113
|
+
printHelp();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const saved = loadInitConfig(opts.cwd);
|
|
118
|
+
if (saved && !opts.interactive) {
|
|
119
|
+
// CLI flags (opts) take precedence over saved config
|
|
120
|
+
for (const key in saved) {
|
|
121
|
+
if (opts[key] === undefined) opts[key] = saved[key];
|
|
122
|
+
}
|
|
123
|
+
console.log(`Using saved config from ${AGENT_ROOT_DIR}/config.json (CLI overrides applied)`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (sub === "init") {
|
|
127
|
+
await handleInit(opts, saved);
|
|
128
|
+
} else {
|
|
129
|
+
runMerge(opts, pkgRoot);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.error("Unknown command: " + sub);
|
|
135
|
+
printHelp();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Removed legacy migration runTicketMigrate
|
|
139
|
+
|
|
140
|
+
async function handleInit(opts, saved) {
|
|
141
|
+
if (opts.clean && !opts.dryRun) {
|
|
142
|
+
console.log(`[CLEAN] Removing legacy templates and config...`);
|
|
143
|
+
const templatesDir = join(opts.cwd, LEGACY_TEMPLATE_DIR);
|
|
144
|
+
const configFile = join(opts.cwd, LEGACY_CONFIG_FILE);
|
|
145
|
+
if (existsSync(templatesDir)) rmSync(templatesDir, { recursive: true, force: true });
|
|
146
|
+
if (existsSync(configFile)) rmSync(configFile, { force: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!opts.interactive && !opts.nonInteractive && !saved) {
|
|
150
|
+
// If no config and not interactive, prompt unless non-interactive
|
|
151
|
+
await runInteractive(opts);
|
|
152
|
+
} else if (opts.interactive) {
|
|
153
|
+
await runInteractive(opts);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!opts.dryRun) writeInitConfig(opts.cwd, opts);
|
|
157
|
+
|
|
158
|
+
const workflowMode = resolveWorkflowMode(opts, saved);
|
|
159
|
+
if (!opts.dryRun && workflowMode !== WORKFLOW_MODE_EXECUTE) {
|
|
160
|
+
console.log(`[WORKFLOW] Plan mode active. Re-run with --workflow execute or --approval approved to apply file mutations.`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
await runInit(opts, pkgRoot);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function printHelp() {
|
|
168
|
+
console.log(`DeukAgentFlow CLI - Generalization Rules & Ticket Management
|
|
169
|
+
|
|
170
|
+
Usage:
|
|
171
|
+
npx deuk-agent-flow init [options]
|
|
172
|
+
npx deuk-agent-flow merge [options]
|
|
173
|
+
npx deuk-agent-flow lint:md [--cwd <path>] [files...]
|
|
174
|
+
npx deuk-agent-flow rules audit [--compact|--json]
|
|
175
|
+
npx deuk-agent-flow skill <list|add|expose|lint> [options]
|
|
176
|
+
npx deuk-agent-flow ticket <create|evidence|list|status|guard|context|handoff|continue|use|close|archive|discard|delete|reports|migrate|upgrade|meta|connect|move> [options]
|
|
177
|
+
npx deuk-agent-flow telemetry <log|sync|summary|migrate> [options]
|
|
178
|
+
npx deuk-agent-flow usage <set|status|advise> [options]
|
|
179
|
+
|
|
180
|
+
Options:
|
|
181
|
+
--cwd <path> Target repo root
|
|
182
|
+
--dry-run Print actions without writing
|
|
183
|
+
--non-interactive CI/scripts mode: no prompts
|
|
184
|
+
--tag <id> Custom marker ID (default: deuk-agent-rule)
|
|
185
|
+
--agents <mode> inject | skip | overwrite
|
|
186
|
+
--cursorrules <mode> inject | skip | overwrite
|
|
187
|
+
--workflow <mode> plan | execute
|
|
188
|
+
--approval <state> pending | approved (alias for workflow)
|
|
189
|
+
--docs-language <lang> auto | ko | en
|
|
190
|
+
--json Output result in JSON format
|
|
191
|
+
--remote <url> Temporary pipeline URL
|
|
192
|
+
--sync Force enable remote sync
|
|
193
|
+
--no-sync Force disable remote sync
|
|
194
|
+
|
|
195
|
+
Skill Options:
|
|
196
|
+
--skill, --id <name> Skill ID (safe-refactor|generated-file-guard|context-recall|project-pilot)
|
|
197
|
+
--platform <name> Exposure target (claude|cursor)
|
|
198
|
+
|
|
199
|
+
Ticket Options:
|
|
200
|
+
--topic, --id <name> Ticket topic slug or ID
|
|
201
|
+
--group <name> Ticket group (sub|main|discussion)
|
|
202
|
+
--project <name> Project filter (DeukUI|DeukAgentFlow)
|
|
203
|
+
--submodule <name> Submodule filter (DeukPack|DeukUI)
|
|
204
|
+
--docs-language <lang> auto | ko | en
|
|
205
|
+
--evidence <text> Provide Phase 0 RAG evidence summary
|
|
206
|
+
--claim <text> Validate or report only from ticket evidence matching this claim
|
|
207
|
+
--skip-phase0 Bypass Phase 0 RAG validation
|
|
208
|
+
--plan-body <text> Create ticket with filled Phase 1 markdown body
|
|
209
|
+
--plan-body-file <p> Read filled Phase 1 markdown from a file, or '-' for stdin
|
|
210
|
+
--content-file <path> Read extra ticket context from a file, or '-' for stdin
|
|
211
|
+
--require-filled Enforce non-placeholder APC and compact plan content before create succeeds
|
|
212
|
+
--allow-placeholder Opt out of strict create guard (legacy behavior)
|
|
213
|
+
--ticket-started Confirm the clickable ticket-start line was shared before guard/context
|
|
214
|
+
--ticket-reviewed Confirm the durable ticket body was reopened and reviewed before guard/context
|
|
215
|
+
--compact Prefer one-line ticket outputs in automation flows
|
|
216
|
+
--status-detail Include detailed reasons in ticket status output
|
|
217
|
+
--handoff Emit compact handoff output for session continuation
|
|
218
|
+
--phase <number> Explicitly set the phase number (e.g., --phase 2)
|
|
219
|
+
--next Move to the next phase
|
|
220
|
+
--latest, -l Use most recent ticket (default if no topic)
|
|
221
|
+
--path-only Print only the file path
|
|
222
|
+
--json Output result in JSON format
|
|
223
|
+
|
|
224
|
+
Usage Options:
|
|
225
|
+
--platform <name> Platform name (Codex/Copilot supported)
|
|
226
|
+
--client <name> Client label for usage state/output
|
|
227
|
+
--agent-id <id> Agent identifier for per-agent tracking
|
|
228
|
+
--weekly-remaining <pct> Remaining weekly percentage (0-100)
|
|
229
|
+
--five-hour-remaining <pct> Remaining 5-hour percentage (0-100)
|
|
230
|
+
--weekly-reset <text> Weekly reset time label
|
|
231
|
+
--five-hour-reset <text> 5-hour reset time label
|
|
232
|
+
--task-grade <grade> Task grade (S|A|B|C)
|
|
233
|
+
--task <label> Optional task label for advice output
|
|
234
|
+
--turn-count <n> Conversation turn count for split advice
|
|
235
|
+
--linked-ticket-count <n> Linked ticket count for split advice
|
|
236
|
+
--cross-workspace Mark the conversation as cross-workspace
|
|
237
|
+
`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
main().then(async () => {
|
|
241
|
+
await updatePromise;
|
|
242
|
+
}).catch(async err => {
|
|
243
|
+
await updatePromise;
|
|
244
|
+
console.error(err.message || err);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import { join, relative, dirname, resolve } from "path";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { AGENT_ROOT_DIR, TICKET_DIR_NAME } from "./cli-utils.mjs";
|
|
7
|
+
|
|
8
|
+
const ignoredDirs = new Set([".git", "node_modules"]);
|
|
9
|
+
|
|
10
|
+
export function collectChangedFiles(repoRoot) {
|
|
11
|
+
const changed = new Set();
|
|
12
|
+
|
|
13
|
+
const gitArgs = ["-C", repoRoot, "diff", "--name-only", "--diff-filter=ACMRTUXB"];
|
|
14
|
+
const gitDiff = spawnSync("git", gitArgs, { encoding: "utf8" });
|
|
15
|
+
if (gitDiff.status === 0) {
|
|
16
|
+
gitDiff.stdout
|
|
17
|
+
.split("\n")
|
|
18
|
+
.map(s => s.trim())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.forEach(f => changed.add(f));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const gitUntracked = spawnSync("git", ["-C", repoRoot, "ls-files", "--others", "--exclude-standard"], { encoding: "utf8" });
|
|
24
|
+
if (gitUntracked.status === 0) {
|
|
25
|
+
gitUntracked.stdout
|
|
26
|
+
.split("\n")
|
|
27
|
+
.map(s => s.trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.forEach(f => changed.add(f));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Array.from(changed).sort();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function collectChangedMarkdownFiles(repoRoot) {
|
|
36
|
+
return collectChangedFiles(repoRoot).filter(isMarkdownFile);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isMarkdownFile(filePath) {
|
|
40
|
+
const lower = filePath.toLowerCase();
|
|
41
|
+
return lower.endsWith(".md") || lower.endsWith(".mdx") || lower.endsWith(".markdown");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPlanReport(relPath) {
|
|
45
|
+
return relPath.includes(`${AGENT_ROOT_DIR}/docs/plan/`) && relPath.endsWith("-report.md");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const LEGACY_ARCHIVED_TEMPLATE_NAMES = new Set([
|
|
49
|
+
"module-rule-template.md",
|
|
50
|
+
"ticket-list-template.md",
|
|
51
|
+
"ticket-template-ko.md",
|
|
52
|
+
"ticket-template.md"
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function isLegacyArchivedTemplateArtifact(relPath, content) {
|
|
56
|
+
const normalized = relPath.replace(/\\/g, "/");
|
|
57
|
+
if (!normalized.includes(`${TICKET_DIR_NAME}/archive/`)) return false;
|
|
58
|
+
if (!LEGACY_ARCHIVED_TEMPLATE_NAMES.has(normalized.split("/").pop())) return false;
|
|
59
|
+
|
|
60
|
+
const src = String(content || "");
|
|
61
|
+
return src.includes("<%=") || src.includes("<%-") || /^id:\s*(module-rule-template|ticket-list-template|ticket-template-ko|ticket-template)\s*$/m.test(src);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function looksLikeYamlFrontmatter(content) {
|
|
65
|
+
if (!(content.startsWith("---\n") || content.startsWith("---\r\n"))) return false;
|
|
66
|
+
const afterOpening = content.replace(/^---\r?\n/, "");
|
|
67
|
+
const lines = afterOpening.split(/\r?\n/);
|
|
68
|
+
const frontmatterLines = [];
|
|
69
|
+
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (line.trim() === "---") break;
|
|
72
|
+
frontmatterLines.push(line);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (frontmatterLines.length === 0) return false;
|
|
76
|
+
|
|
77
|
+
const firstNonEmpty = frontmatterLines.find(line => line.trim());
|
|
78
|
+
if (!firstNonEmpty) return false;
|
|
79
|
+
if (/^(#|>|- |\* |\d+\.)/.test(firstNonEmpty.trim())) return false;
|
|
80
|
+
|
|
81
|
+
return frontmatterLines.some(line => /^[A-Za-z0-9_-]+\s*:/.test(line.trim()));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lintWalkthroughReportStructure(relPath, content) {
|
|
85
|
+
const errors = [];
|
|
86
|
+
const hasSummary = /##\s+(Summary|요약)(?:\s|$)/i.test(content);
|
|
87
|
+
const hasVerification = /##\s+(Verification|검증)(?:\s|$)/i.test(content);
|
|
88
|
+
const hasOutcome = /##\s+(Verification Outcome|Verification Results|검증 결과)(?:\s|$)/i.test(content);
|
|
89
|
+
|
|
90
|
+
if (!hasSummary) {
|
|
91
|
+
errors.push(`${relPath}: report missing Summary/요약 section`);
|
|
92
|
+
}
|
|
93
|
+
if (!hasVerification) {
|
|
94
|
+
errors.push(`${relPath}: report missing Verification/검증 section`);
|
|
95
|
+
}
|
|
96
|
+
if (!hasOutcome) {
|
|
97
|
+
errors.push(`${relPath}: report missing Verification Outcome/Verification Results/검증 결과 section`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walkMarkdownFiles(rootDir, out = []) {
|
|
104
|
+
for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
if (ignoredDirs.has(entry.name)) continue;
|
|
107
|
+
walkMarkdownFiles(join(rootDir, entry.name), out);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const filePath = join(rootDir, entry.name);
|
|
111
|
+
if (isMarkdownFile(filePath)) out.push(filePath);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lintFile(absPath, repoRoot) {
|
|
117
|
+
const rel = relative(repoRoot, absPath);
|
|
118
|
+
const content = readFileSync(absPath, "utf8");
|
|
119
|
+
const errors = [];
|
|
120
|
+
|
|
121
|
+
if (isLegacyArchivedTemplateArtifact(rel, content)) {
|
|
122
|
+
return errors;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lines = content.split(/\r?\n/);
|
|
126
|
+
lines.forEach((line, idx) => {
|
|
127
|
+
if (/\s+$/.test(line)) {
|
|
128
|
+
errors.push(`${rel}:${idx + 1}: trailing whitespace`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const fenceCount = lines.filter(line => /^```/.test(line.trim())).length;
|
|
133
|
+
if (fenceCount % 2 !== 0) {
|
|
134
|
+
errors.push(`${rel}: unmatched fenced code block`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (looksLikeYamlFrontmatter(content)) {
|
|
138
|
+
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---\r?\n?/);
|
|
139
|
+
if (!match) {
|
|
140
|
+
const afterOpeningRule = content.replace(/^---\r?\n/, "");
|
|
141
|
+
if (!/^\s*\r?\n#{1,6}\s/.test(afterOpeningRule)) {
|
|
142
|
+
errors.push(`${rel}: invalid or unterminated frontmatter`);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = YAML.parse(match[1]);
|
|
147
|
+
const isDeukAgentDoc = rel.includes(`${AGENT_ROOT_DIR}/docs/`) || rel.includes(`${AGENT_ROOT_DIR}/tickets/`);
|
|
148
|
+
const isArchive = rel.includes("archive/");
|
|
149
|
+
const isTemplate = rel.includes("templates/");
|
|
150
|
+
if (isDeukAgentDoc && !isArchive && !isTemplate) {
|
|
151
|
+
const requiredKeys = ["summary", "status", "priority", "tags"];
|
|
152
|
+
for (const key of requiredKeys) {
|
|
153
|
+
if (!parsed || parsed[key] === undefined || parsed[key] === null || parsed[key] === "") {
|
|
154
|
+
errors.push(`${rel}: missing required frontmatter key: ${key}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
errors.push(`${rel}: invalid frontmatter YAML (${err.message})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const isDeukAgentDoc = rel.includes(`${AGENT_ROOT_DIR}/docs/`) || rel.includes(`${AGENT_ROOT_DIR}/tickets/`);
|
|
164
|
+
const isArchive = rel.includes("archive/");
|
|
165
|
+
const isTemplate = rel.includes("templates/");
|
|
166
|
+
if (isDeukAgentDoc && !isArchive && !isTemplate) {
|
|
167
|
+
errors.push(`${rel}: missing required frontmatter`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const linkPattern = /!?\[[^\]]+\]\(([^)]+)\)/g;
|
|
172
|
+
let match;
|
|
173
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
174
|
+
const target = String(match[1] || "").trim();
|
|
175
|
+
if (!target || target.startsWith("#") || /^[a-z]+:\/\//i.test(target) || target.startsWith("mailto:")) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const pathOnly = target.split("#")[0].split("?")[0];
|
|
179
|
+
if (!pathOnly) continue;
|
|
180
|
+
const resolved = join(dirname(absPath), pathOnly);
|
|
181
|
+
if (!statExists(resolved)) {
|
|
182
|
+
errors.push(`${rel}: broken relative link -> ${target}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isPlanReport(rel)) {
|
|
187
|
+
errors.push(...lintWalkthroughReportStructure(rel, content));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function statExists(absPath) {
|
|
194
|
+
try {
|
|
195
|
+
return statSync(absPath).isFile() || statSync(absPath).isDirectory();
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveMarkdownLintTargets(repoRoot, explicitPaths = []) {
|
|
202
|
+
const files = explicitPaths.length > 0
|
|
203
|
+
? explicitPaths.map(p => resolve(repoRoot, p)).filter(statExists).filter(isMarkdownFile)
|
|
204
|
+
: collectChangedMarkdownFiles(repoRoot).map(p => join(repoRoot, p));
|
|
205
|
+
|
|
206
|
+
const targets = files.length > 0 ? files : walkMarkdownFiles(repoRoot);
|
|
207
|
+
return Array.from(new Set(targets));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseLintArgs(argv) {
|
|
211
|
+
const out = { cwd: process.cwd(), paths: [] };
|
|
212
|
+
for (let i = 0; i < argv.length; i++) {
|
|
213
|
+
const arg = argv[i];
|
|
214
|
+
if (arg === "--cwd") {
|
|
215
|
+
out.cwd = argv[++i] || out.cwd;
|
|
216
|
+
} else if (arg.startsWith("--cwd=")) {
|
|
217
|
+
out.cwd = arg.slice("--cwd=".length);
|
|
218
|
+
} else if (!arg.startsWith("-")) {
|
|
219
|
+
out.paths.push(arg);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function lintMarkdownPaths(paths, cwd = process.cwd()) {
|
|
226
|
+
const repoRoot = resolve(cwd);
|
|
227
|
+
const targets = Array.from(new Set(
|
|
228
|
+
(Array.isArray(paths) ? paths : [])
|
|
229
|
+
.map(p => resolve(repoRoot, p))
|
|
230
|
+
.filter(statExists)
|
|
231
|
+
.filter(isMarkdownFile)
|
|
232
|
+
));
|
|
233
|
+
const errors = [];
|
|
234
|
+
for (const filePath of targets) {
|
|
235
|
+
errors.push(...lintFile(filePath, repoRoot));
|
|
236
|
+
}
|
|
237
|
+
return { repoRoot, targets, errors };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function runMarkdownLint(argv = process.argv.slice(2)) {
|
|
241
|
+
const opts = parseLintArgs(argv);
|
|
242
|
+
const repoRoot = resolve(opts.cwd);
|
|
243
|
+
const targets = opts.paths.length > 0
|
|
244
|
+
? lintMarkdownPaths(opts.paths, opts.cwd).targets
|
|
245
|
+
: resolveMarkdownLintTargets(repoRoot);
|
|
246
|
+
const errors = [];
|
|
247
|
+
for (const filePath of targets) {
|
|
248
|
+
errors.push(...lintFile(filePath, repoRoot));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (targets.length === 0) {
|
|
252
|
+
console.log("lint:md: no markdown files found");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (errors.length > 0) {
|
|
257
|
+
console.error("lint:md failed");
|
|
258
|
+
for (const err of errors) console.error(`- ${err}`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(`lint:md passed (${targets.length} file${targets.length === 1 ? "" : "s"})`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
266
|
+
runMarkdownLint();
|
|
267
|
+
}
|