@devflow-core/dsh-devflow 0.4.0 → 0.6.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.
@@ -4,6 +4,8 @@ Run DevFlow Prove.
4
4
 
5
5
  Identify the narrowest sufficient command, test, build, lint, validation script, or manual scenario.
6
6
  Run it now if tools are available.
7
+ Load `skills/devflow-prove/references/proof-recovery-methods.md` always. Load `skills/devflow-prove/references/flow-self-test.md` only for DevFlow runtime, skill, command, host, hook, validator, scenario, installer, or harness changes. For code, load `skills/devflow-prove/references/code-review-checklist.md`, always apply General Engineering Review, and apply only language sections matching changed extensions.
8
+ Read the actual diff and the current task's named anchors; reread at most one already-listed neighbor needed to detect drift; do not restart broad repository discovery.
7
9
  For development work, run adversarial review before completion: state the strongest plausible reason the result is still wrong, incomplete, unreachable, over-broad, or under-verified, then check it against the acceptance criteria, touched files, likely regressions, activation path, and proof coverage.
8
10
  If adversarial review finds a real gap, report FAIL or continue the appropriate DevFlow route before any completion claim.
9
11
  Read the real output and exit status.
@@ -2,17 +2,18 @@ description = "Route a task through DevFlow Core."
2
2
  prompt = '''
3
3
  Use DevFlow Core for this request.
4
4
 
5
- Route the work as Fast, Design-lite, Design, Build, or Recovery.
6
- Before creative work creating features, building components, adding functionality, modifying behavior, or defining an unapproved problem-directed change run Brainstorm to confirm the request: read facts, follow `skills/devflow-brainstorm/references/interview-discipline.md`, send a Semantic Echo-Back, apply the Understanding Revision Rule when a correction changes the request, ask one question at a time only for goal/scope/exclusions/constraints/acceptance/open-question gaps, then output `Confirmed request` with `Status: clarified`. Brainstorm then presents A/B/C and waits for the user's explicit choice: A directly starts Spec, B directly starts Cut then Plan, and C directly starts Cut then Build. Brainstorm never infers the depth; a missing or changed selection returns facts to Core.
5
+ Route the work as Fast, Design-lite, Design, Build, or Recovery. Apply this precedence: explicit independent review -> investigation/pure inquiry -> approved scope -> risk gate -> lifecycle owner; keywords alone never choose a route.
6
+ For creative work, run Brainstorm only when the request is ambiguous or materially risky. A clear existing-feature change may go directly to Cut only when all conditions hold: clear goal, existing local behavior, one plausible path, local impact, reversible change, no security/data-loss/permission/contract risk, and quick proof. This bypass compresses clarification only; Cut and Prove remain mandatory. When Brainstorm is selected, read `skills/devflow-brainstorm/references/interview-discipline.md`, send a Semantic Echo-Back, apply the Understanding Revision Rule when a correction changes the request, ask one question at a time only for decision-impact gaps, then output `Confirmed request` with `Status: clarified`. Brainstorm then presents A/B/C and waits for the user's explicit choice: A directly starts Spec, B directly starts Cut then Plan, and C directly starts Cut then Build. Brainstorm never infers the depth; a missing or changed selection returns facts to Core.
7
+ An unapproved problem-directed change uses the same risk gate: ambiguity or material risk selects Brainstorm; otherwise the low-risk direct Cut criteria must all pass.
7
8
  An investigation-only problem report without a requested change uses Problem: Sense -> Prove facts. Pure Q&A, fact lookup, verification, and already approved changes are exceptions to the Brainstorm entry gate.
8
9
  If the user explicitly asks for upgraded adversarial review, deep adversarial review, red-team review, 对抗审查, or 升级版对抗审查, load `skills/devflow-adversarial/SKILL.md` directly. If the user explicitly asks to find faults, identify the biggest omission, surface a blind spot, name the least certain point, 找茬, 最大遗漏, 没有意识到什么, or 最没有把握, load `skills/devflow-find-fault/SKILL.md` directly. These are independent manual reviews: they may run at any stage and must not read, require, modify, or hand off to devflow-prove, PUA, Build, Learn, or any completion state.
9
10
  If the user challenges the result, says changed wrong, says your code is wrong/you wrote it wrong/has a problem/not right/missing/incomplete/still missing/有问题/不对/写错了/少了/少个/缺少/缺漏/遗漏/漏了, reports a quality complaint, or repeated edits miss, use Recovery through devflow-pua before more edits. For repeated challenge, explicit wrong-code signals, or repeated missing-piece complaints, return recovery facts to devflow-core; Core may restart devflow-brainstorm only for request re-confirmation, then selects the lifecycle work. Quarantine the old wrong context, diagnose the user-view miss, ask what is wrong and what result is wanted, then switch to a different/opposite method when the prior method failed.
10
- Use Fast for pure Q&A, fact lookup, verification, or an already approved trivial code change (one line, no logic change, no risk).
11
- Use Design-lite only after Brainstorm confirms a small unapproved change to an existing feature with clear behavior, one plausible path, low risk, local impact, and quick proof. Not for new requirements.
11
+ Use Fast for pure Q&A, fact lookup, verification, or an already approved trivial code change (one line, no logic change, no risk). Unapproved edits are not Fast and must not skip Cut.
12
+ Use Design-lite when the risk gate skips Brainstorm for a small unapproved change to an existing feature with all low-risk conditions holding. Design-lite states goal, acceptance, and exclusions, records Depth C, and selects Cut then Build. It does not wait for Brainstorm. Not for new requirements.
12
13
  If the boundary is unclear after Brainstorm, have Core select the smallest compatible route instead of guessing.
13
14
  Run Sense first by reading or citing relevant project facts.
14
- At Sense, probe `.copilot/LEARNING_INDEX.md` and `docs/project-knowledge/` when present. Read only matched learning cards or navigation-selected knowledge documents; missing locations are non-blocking and do not create storage.
15
- The direct success map is A: Brainstorm -> Spec -> Cut -> Plan -> Build -> Prove, B: Brainstorm -> Cut -> Plan -> Build -> Prove, C: Brainstorm -> Cut -> Build -> Prove. An approved A Spec directly starts Cut; A/B `CUT_PASS` directly starts Plan; C `CUT_PASS` directly starts Build; an approved A/B Plan directly starts Build; a completed Build directly starts Prove.
15
+ At Sense, match the task keywords against `docs/features/INDEX.md` and `docs/plans/INDEX.md` when present and open only the matched row's entry file or ledger section (an index over 40 rows is filtered with `node scripts/devflow-plan.js --index --query 关键词`); do not bulk-read capability documents. Then probe `.copilot/LEARNING_INDEX.md` (global preference cards first) and `docs/project-knowledge/`, reading only matched learning cards or navigation-selected knowledge documents and applying known developer preferences; missing locations are non-blocking and do not create storage.
16
+ The direct success map is Skip-Brainstorm Design-lite: Cut -> Build -> Prove, A: Brainstorm -> Spec -> Cut -> Plan -> Build -> Prove, B: Brainstorm -> Cut -> Plan -> Build -> Prove, C: Brainstorm -> Cut -> Build -> Prove. An approved A Spec directly starts Cut; A/B `CUT_PASS` directly starts Plan; C `CUT_PASS` and a skip-path `CUT_PASS` directly start Build; an approved A/B Plan directly starts Build; a completed Build directly starts Prove.
16
17
  Before implementation, run Cut with Required Gates: Reuse, Native, Overbuild, Diff, and Scope checks. Core keeps non-unique selection: `CUT_REDUCE`/`CUT_REUSE` STOP for user confirmation, then return facts to Core; `CUT_BLOCKED`, Plan scope drift, `BUILD_BLOCKED`, Proof `FAIL`/`BLOCKED`, changed intent, and PUA recovery return facts so Core chooses the next owner.
17
18
  For bug fixes, include Root-Cause Check: searched callers/references; shared vs narrow fix; reason.
18
19
  For deliberate simplifications, add or report `devflow: <ceiling>, revisit when <trigger>`.
@@ -1,4 +1,5 @@
1
1
  const fs = require("node:fs");
2
+ const path = require("node:path");
2
3
 
3
4
  const requiredGlobalFields = ["Goal", "Architecture", "Tech Stack", "Source", "Spec coverage", "External Skills"];
4
5
  const requiredTaskFields = ["Task", "Task type", "Files", "Interfaces", "Steps", "Acceptance", "Verify", "Comments", "Not doing"];
@@ -38,10 +39,32 @@ const worklistItemPattern = /^\s*-\s*\[ \]\s+(.+)$/gim;
38
39
  const worklistDetailNames = ["Anchors", "Verify", "Done when"];
39
40
  const maximumWorklistItems = 12;
40
41
 
42
+ // v2 契约(2026-09-10 可用性重构):计划只保留排序与验收字段;实现改法归 Build。
43
+ // 判定:含 `## Progress` 且不含 `Prewalk` 视为 v2;否则走 legacy 分支,旧计划无需迁移。
44
+ const v2GlobalFields = ["Goal", "Not doing", "Cut"];
45
+ const v2TaskFields = ["Task", "Files", "Change", "Acceptance", "Verify", "Not doing"];
46
+ const v2AllFields = [...v2GlobalFields, ...v2TaskFields];
47
+ const v2FieldPatterns = Object.fromEntries(
48
+ v2AllFields.map((field) => [field, new RegExp(`^(?:\\*\\*)?${field}(?:\\*\\*)?\\s*:`, "im")])
49
+ );
50
+ const progressHeadingPattern = /^##\s+Progress\s*$/im;
51
+ const progressRowPattern = /^\s*\|\s*(\d+)\s*\|\s*([^|]*)\|\s*(todo|doing|done)\s*\|\s*([^|]*)\|/gim;
52
+ const indexStatuses = ["active", "planned", "legacy", "retired"];
53
+ const indexPaths = { plans: "docs/plans/INDEX.md", features: "docs/features/INDEX.md" };
54
+ // 需求台账:一条需求从确认到落地的唯一记录;opt-out 表示用户显式跳过文档(仍必须有验证证据与原因)。
55
+ const requirementPath = "docs/requirements.md";
56
+ const requirementStatuses = ["open", "designed", "planned", "built", "landed", "opt-out", "dropped"];
57
+ const terminalRequirementStatuses = ["landed", "opt-out", "dropped"];
58
+ const promotionConfidence = 0.7;
59
+
41
60
  /** Print the checker command contract and default plan landing. */
42
61
  function usage() {
43
- console.log("Usage: node scripts/devflow-plan.js [plan-file] [--self-test] [--json]");
62
+ console.log("Usage: node scripts/devflow-plan.js [plan-file] [--index] [--self-test] [--json]");
44
63
  console.log("Checks whether a DevFlow Plan Pack has an executable header, task contracts, and plan landing.");
64
+ console.log("v2 plans carry a slim header, per-task Files/Change/Acceptance/Verify/Not doing, and a ## Progress table; legacy plans keep the old contract.");
65
+ console.log("--index checks docs/plans/INDEX.md and docs/features/INDEX.md against the filesystem.");
66
+ console.log("--index --query <keyword> prints only matching index rows for progressive loading; an empty result still exits 0.");
67
+ console.log("--loop prints a read-only loop report: requirement status counts, plan landing rate, feature rows, and promotion candidates.");
45
68
  console.log("Default plan landing is docs/plans/YYYY-MM-DD-<short-kebab-name>.md unless the project documents another plan path.");
46
69
  console.log("--json prints a single-line machine-readable summary; optional Status header values: " + validStatuses.join(" | "));
47
70
  }
@@ -311,8 +334,369 @@ function checkTask(task, fileStructure) {
311
334
  };
312
335
  }
313
336
 
314
- /** Validate global plan headers and all independently scoped task contracts. */
337
+ /** v2 计划判定:新格式带 Progress 表且不含 Prewalk;旧格式继续走 legacy 校验。 */
338
+ function detectV2(body) {
339
+ return progressHeadingPattern.test(body) && !/^\s*Prewalk\s*:\s*$/im.test(body);
340
+ }
341
+
342
+ /** Extract one v2 field block; ends at the next v1 or v2 field line. */
343
+ function v2FieldBlock(body, field) {
344
+ const lines = body.split(/\r?\n/);
345
+ const start = lines.findIndex((line) => v2FieldPatterns[field].test(line));
346
+ if (start < 0) return "";
347
+
348
+ const value = lines[start].replace(v2FieldPatterns[field], "").trim();
349
+ const end = lines.findIndex(
350
+ (line, index) =>
351
+ index > start && (v2AllFields.some((name) => v2FieldPatterns[name].test(line)) || /^#{1,6}\s+\S/.test(line))
352
+ );
353
+ return [value, ...lines.slice(start + 1, end < 0 ? lines.length : end)].join("\n").trim();
354
+ }
355
+
356
+ /** Split a v2 plan at Task fields; the Progress table is not part of any task body. */
357
+ function splitTasksV2(body) {
358
+ const lines = body.split(/\r?\n/);
359
+ const progressIndex = lines.findIndex((line) => progressHeadingPattern.test(line));
360
+ const limit = progressIndex < 0 ? lines.length : progressIndex;
361
+ const taskStarts = [];
362
+ lines.forEach((line, index) => {
363
+ if (index < limit && v2FieldPatterns.Task.test(line)) taskStarts.push(index);
364
+ });
365
+ return taskStarts.map((start, index) => {
366
+ const end = taskStarts[index + 1] ?? limit;
367
+ return { number: index + 1, body: lines.slice(start, end).join("\n") };
368
+ });
369
+ }
370
+
371
+ /** Validate one v2 task: files, change intent, acceptance, proof, and exclusion. */
372
+ function checkTaskV2(task) {
373
+ const missing = v2TaskFields.filter((field) => !v2FieldPatterns[field].test(task.body));
374
+ const files = v2FieldBlock(task.body, "Files");
375
+ const change = v2FieldBlock(task.body, "Change");
376
+ const verify = v2FieldBlock(task.body, "Verify");
377
+ const notDoing = v2FieldBlock(task.body, "Not doing");
378
+ const fileEntries = parseFileEntries(files);
379
+ const invalidFiles = fileEntries.filter(({ match }) => !match).map(({ line }) => line);
380
+ const unlocatedCodeFiles = findUnlocatedCodeFiles(fileEntries);
381
+ const unresolved = findMatches(task.body, unresolvedPatterns);
382
+ const vague = findMatches([change, v2FieldBlock(task.body, "Acceptance"), verify].join("\n"), vaguePatterns);
383
+ // v2 不强制精确改法;只要求 Change 说出可执行意图,具体实现归 Build。
384
+ const missingChange = !implementationVerbPattern.test(change) || genericMechanicsPattern.test(change);
385
+ const incompleteVerification = !hasVerificationExpectation(verify);
386
+
387
+ return {
388
+ number: task.number,
389
+ missing,
390
+ unresolved,
391
+ vague,
392
+ invalidFiles,
393
+ unlocatedCodeFiles,
394
+ missingChange,
395
+ incompleteVerification,
396
+ missingNotDoing: !notDoing,
397
+ ok:
398
+ missing.length === 0 &&
399
+ unresolved.length === 0 &&
400
+ vague.length === 0 &&
401
+ invalidFiles.length === 0 &&
402
+ unlocatedCodeFiles.length === 0 &&
403
+ !missingChange &&
404
+ !incompleteVerification &&
405
+ Boolean(notDoing)
406
+ };
407
+ }
408
+
409
+ /** Validate the v2 plan contract: slim header, task rows, Progress table, and Cut subtraction. */
410
+ function checkPlanV2(body) {
411
+ const tasks = splitTasksV2(body);
412
+ const taskResults = tasks.map(checkTaskV2);
413
+ const missingGlobal = v2GlobalFields.filter((field) => !v2FieldPatterns[field].test(body));
414
+ const cutBlock = v2FieldBlock(body, "Cut");
415
+ const missingRejected = !/Rejected\s*:\s*\S/im.test(cutBlock);
416
+ const statusMatch = body.match(statusPattern);
417
+ const status = statusMatch ? statusMatch[1].trim() : "legacy";
418
+ const invalidStatus = statusMatch ? !validStatuses.includes(status) : false;
419
+ const progress = [...body.matchAll(progressRowPattern)].map((match) => ({
420
+ number: Number(match[1]),
421
+ state: match[3],
422
+ evidence: match[4].trim()
423
+ }));
424
+ const missingEvidence = progress.filter((row) => row.state === "done" && (!row.evidence || row.evidence === "-"));
425
+ const progressMismatch = progress.length !== tasks.length;
426
+ const doneStatusNeedsAllDone = status === "done" && progress.some((row) => row.state !== "done");
427
+
428
+ return {
429
+ v2: true,
430
+ status,
431
+ invalidStatus,
432
+ missingGlobal,
433
+ cut: { missingRejected },
434
+ tasks: taskResults,
435
+ progress: { count: progress.length, mismatch: progressMismatch, missingEvidence },
436
+ doneStatusNeedsAllDone,
437
+ ok:
438
+ missingGlobal.length === 0 &&
439
+ !missingRejected &&
440
+ !invalidStatus &&
441
+ tasks.length > 0 &&
442
+ taskResults.every((task) => task.ok) &&
443
+ !progressMismatch &&
444
+ missingEvidence.length === 0 &&
445
+ !doneStatusNeedsAllDone
446
+ };
447
+ }
448
+
449
+ /** Read every markdown table in a file as header cells plus data rows, skipping separator rows. */
450
+ function readMarkdownTables(filePath) {
451
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
452
+ const tables = [];
453
+ let current = [];
454
+ for (const line of lines) {
455
+ if (/^\s*\|/.test(line)) {
456
+ current.push(line.split("|").slice(1, -1).map((cell) => cell.trim()));
457
+ continue;
458
+ }
459
+ if (current.length) {
460
+ tables.push(current);
461
+ current = [];
462
+ }
463
+ }
464
+ if (current.length) tables.push(current);
465
+ return tables
466
+ .map((cells) => cells.filter((row) => !row.every((cell) => /^:?-{2,}:?$/.test(cell))))
467
+ .filter((cells) => cells.length > 1)
468
+ .map((cells) => ({ header: cells[0], rows: cells.slice(1) }));
469
+ }
470
+
471
+ /** Read the first markdown table into its header cells and data rows. */
472
+ function readMarkdownTable(filePath) {
473
+ return readMarkdownTables(filePath)[0] || { header: [], rows: [] };
474
+ }
475
+
476
+ /** Resolve named column positions from the table header so a new column cannot shift the checks. */
477
+ function columnIndexes(header, names) {
478
+ return Object.fromEntries(names.map((name) => [name, header.findIndex((cell) => cell.includes(name))]));
479
+ }
480
+
481
+ /** Read one index file's data rows, returning an empty list when the index does not exist yet. */
482
+ function readIndexRows(root, kind) {
483
+ const filePath = path.join(root, ...indexPaths[kind].split("/"));
484
+ return fs.existsSync(filePath) ? readMarkdownTable(filePath).rows : [];
485
+ }
486
+
487
+ /** Return index rows whose text contains the query, for progressive index loading. */
488
+ function matchIndexRows(rows, query) {
489
+ const needle = String(query || "").trim().toLowerCase();
490
+ if (!needle) return [];
491
+ return rows.filter((cells) => cells.join(" ").toLowerCase().includes(needle));
492
+ }
493
+
494
+ /** Read the value after a flag, or an empty string when it is absent or looks like another flag. */
495
+ function argValue(args, flag) {
496
+ const index = args.indexOf(flag);
497
+ return index >= 0 && args[index + 1] && !args[index + 1].startsWith("-") ? args[index + 1] : "";
498
+ }
499
+
500
+ /** Check docs/plans/INDEX.md and docs/features/INDEX.md against the filesystem. */
501
+ function checkIndexes(root) {
502
+ const problems = [];
503
+ const plansDir = path.join(root, "docs", "plans");
504
+ const featuresDir = path.join(root, "docs", "features");
505
+ const plansIndex = path.join(root, ...indexPaths.plans.split("/"));
506
+ const featuresIndex = path.join(root, ...indexPaths.features.split("/"));
507
+ const planFiles = fs.existsSync(plansDir)
508
+ ? fs.readdirSync(plansDir).filter((name) => name.endsWith(".md") && name !== "INDEX.md")
509
+ : [];
510
+ const planRows = readIndexRows(root, "plans");
511
+ const featureRows = readIndexRows(root, "features");
512
+ const plansTable = fs.existsSync(plansIndex) ? readMarkdownTable(plansIndex) : { header: [] };
513
+ const featuresTable = fs.existsSync(featuresIndex) ? readMarkdownTable(featuresIndex) : { header: [] };
514
+ const planCols = columnIndexes(plansTable.header, ["计划", "Status", "落地证据", "功能条目"]);
515
+ const featureCols = columnIndexes(featuresTable.header, ["功能", "Status", "关键文件", "来源计划"]);
516
+
517
+ if (planFiles.length > 0 && !fs.existsSync(plansIndex)) {
518
+ problems.push("docs/plans/INDEX.md missing while plan files exist");
519
+ }
520
+ for (const file of planFiles) {
521
+ const rows = planRows.filter((cells) => cells.some((cell) => cell.includes(file)));
522
+ if (rows.length === 0) problems.push(`plan not listed in docs/plans/INDEX.md: ${file}`);
523
+ if (rows.length > 1) problems.push(`plan listed more than once in docs/plans/INDEX.md: ${file}`);
524
+ }
525
+ for (const cells of planRows) {
526
+ const link = cells.find((cell) => cell.includes(".md"));
527
+ const file = link ? (link.match(/([^()/]+\.md)/) || [])[1] : null;
528
+ if (!file) continue;
529
+ if (!planFiles.includes(file)) problems.push(`docs/plans/INDEX.md lists unknown plan: ${file}`);
530
+ const filePath = path.join(plansDir, file);
531
+ if (!fs.existsSync(filePath)) continue;
532
+ const body = fs.readFileSync(filePath, "utf8");
533
+ const statusMatch = body.match(statusPattern);
534
+ const expected = statusMatch ? statusMatch[1].trim() : "legacy";
535
+ const rowStatus = (cells[planCols.Status] || "").trim();
536
+ if (rowStatus && rowStatus !== expected) {
537
+ problems.push(`status mismatch for ${file}: index ${rowStatus} vs file ${expected}`);
538
+ }
539
+ const evidence = (cells[planCols["落地证据"]] || "").trim();
540
+ if (rowStatus === "done" && (!evidence || evidence === "-")) {
541
+ problems.push(`done plan needs landing evidence in docs/plans/INDEX.md: ${file}`);
542
+ }
543
+ const featureCell = (cells[planCols["功能条目"]] || "").trim();
544
+ if (featureCell && featureCell !== "-" && !featureRows.some((row) => (row[featureCols["功能"]] || "").includes(featureCell))) {
545
+ problems.push(`feature entry not found in docs/features/INDEX.md: ${featureCell}`);
546
+ }
547
+ }
548
+ for (const cells of featureRows) {
549
+ const status = (cells[featureCols.Status] || "").trim();
550
+ if (status && !indexStatuses.includes(status)) {
551
+ problems.push(`invalid feature status in docs/features/INDEX.md: ${status}`);
552
+ }
553
+ const files = (cells[featureCols["关键文件"]] || "").split(",").map((value) => value.replaceAll("`", "").trim()).filter((value) => value && value !== "-");
554
+ for (const rel of files) {
555
+ if (!fs.existsSync(path.join(root, rel))) problems.push(`feature file missing: ${rel}`);
556
+ }
557
+ const plan = (cells[featureCols["来源计划"]] || "").replaceAll("`", "").trim();
558
+ if (plan && plan !== "-" && !fs.existsSync(path.join(root, "docs", "plans", plan))) {
559
+ problems.push(`feature source plan missing: ${plan}`);
560
+ }
561
+ }
562
+ const requirementTable = readRequirementTable(root);
563
+ problems.push(
564
+ ...checkRequirementTable(requirementTable.header, requirementTable.rows, (rel) => fs.existsSync(path.join(root, rel)))
565
+ );
566
+ return problems;
567
+ }
568
+
569
+ /** Run the plan and feature index consistency check, or answer a single query. */
570
+ function runIndexCheck(json, query) {
571
+ const root = path.resolve(__dirname, "..");
572
+ if (query) {
573
+ const featureMatches = matchIndexRows(readIndexRows(root, "features"), query);
574
+ const planMatches = matchIndexRows(readIndexRows(root, "plans"), query);
575
+ if (json) {
576
+ console.log(JSON.stringify({ checker: "plan-index-query", query, features: featureMatches, plans: planMatches, judgment: "PASS" }));
577
+ } else {
578
+ console.log(`DevFlow index query: ${query}`);
579
+ for (const cells of featureMatches) console.log(`功能行: ${cells.join(" | ")}`);
580
+ for (const cells of planMatches) console.log(`计划行: ${cells.join(" | ")}`);
581
+ console.log(`Matches: ${featureMatches.length + planMatches.length}`);
582
+ }
583
+ return 0;
584
+ }
585
+ const problems = checkIndexes(root);
586
+ if (json) {
587
+ console.log(JSON.stringify({ checker: "plan-index", problems, judgment: problems.length === 0 ? "PASS" : "FAIL" }));
588
+ } else {
589
+ console.log("DevFlow plan and feature index report");
590
+ console.log(`Index files: ${indexPaths.plans} | ${indexPaths.features}`);
591
+ if (problems.length === 0) console.log("Problems: none");
592
+ for (const problem of problems) console.log(`Problem: ${problem}`);
593
+ console.log(`Judgment: ${problems.length === 0 ? "PASS" : "FAIL"}`);
594
+ }
595
+ return problems.length === 0 ? 0 : 1;
596
+ }
597
+
598
+ /** Validate the requirement ledger table: status whitelist, terminal evidence, opt-out reason, artifact paths. */
599
+ function checkRequirementTable(header, rows, exists) {
600
+ const problems = [];
601
+ const cols = columnIndexes(header, ["需求", "日期", "落地物", "状态", "证据或跳过"]);
602
+ if (cols["状态"] < 0 || cols["证据或跳过"] < 0) {
603
+ problems.push("docs/requirements.md header must contain 状态 and 证据或跳过 columns");
604
+ return problems;
605
+ }
606
+ for (const cells of rows) {
607
+ const requirement = (cells[cols["需求"]] || "").trim();
608
+ const date = cols["日期"] >= 0 ? (cells[cols["日期"]] || "").trim() : "";
609
+ const status = (cells[cols["状态"]] || "").trim();
610
+ const evidence = (cells[cols["证据或跳过"]] || "").trim();
611
+ if (!requirement) problems.push("requirement row missing 需求 text");
612
+ if (!date) problems.push(`requirement row missing 日期: ${requirement}`);
613
+ if (!requirementStatuses.includes(status)) problems.push(`invalid requirement status: ${status}`);
614
+ if (terminalRequirementStatuses.includes(status) && (!evidence || evidence === "-")) {
615
+ problems.push(`terminal requirement needs evidence: ${requirement}`);
616
+ }
617
+ if (status === "opt-out" && !/原因|reason|skip/i.test(evidence)) {
618
+ problems.push(`opt-out needs a recorded reason: ${requirement}`);
619
+ }
620
+ const artifacts = (cells[cols["落地物"]] || "")
621
+ .split(",")
622
+ .map((value) => value.replaceAll("`", "").trim())
623
+ .filter((value) => value && value !== "-");
624
+ for (const rel of artifacts) {
625
+ if (!exists(rel)) problems.push(`requirement artifact missing: ${rel}`);
626
+ }
627
+ }
628
+ return problems;
629
+ }
630
+
631
+ /** Read the requirement ledger table, selecting the table that carries the ledger columns. */
632
+ function readRequirementTable(root) {
633
+ const filePath = path.join(root, ...requirementPath.split("/"));
634
+ if (!fs.existsSync(filePath)) return { header: [], rows: [] };
635
+ const tables = readMarkdownTables(filePath);
636
+ const ledger = tables.find(
637
+ (table) => table.header.some((cell) => cell.includes("状态")) && table.header.some((cell) => cell.includes("证据或跳过"))
638
+ );
639
+ return ledger || tables[0] || { header: [], rows: [] };
640
+ }
641
+
642
+ /** Count requirement statuses and promotion candidates for the read-only loop report. */
643
+ function summarizeLoop(root) {
644
+ const { header, rows } = readRequirementTable(root);
645
+ const cols = columnIndexes(header, ["状态", "需求"]);
646
+ const counts = Object.fromEntries(requirementStatuses.map((status) => [status, 0]));
647
+ for (const cells of rows) {
648
+ const status = cols["状态"] >= 0 ? (cells[cols["状态"]] || "").trim() : "";
649
+ if (Object.prototype.hasOwnProperty.call(counts, status)) counts[status] += 1;
650
+ }
651
+ const planRows = readIndexRows(root, "plans");
652
+ const plansWithStatus = planRows.filter((cells) => validStatuses.includes((cells[2] || "").trim()));
653
+ const plansDone = plansWithStatus.filter((cells) => (cells[2] || "").trim() === "done");
654
+ const learningIndex = path.join(root, ".copilot", "LEARNING_INDEX.md");
655
+ const learningTable = fs.existsSync(learningIndex) ? readMarkdownTable(learningIndex) : { header: [], rows: [] };
656
+ const cardCols = columnIndexes(learningTable.header, ["Card", "Confidence"]);
657
+ const candidates = learningTable.rows
658
+ .map((cells) => ({
659
+ name: ((cells[cardCols.Card] || "").match(/\[([^\]]+)\]/) || [])[1] || "",
660
+ confidence: Number((cells[cardCols.Confidence] || "").trim())
661
+ }))
662
+ .filter((card) => card.name && Number.isFinite(card.confidence) && card.confidence >= promotionConfidence);
663
+
664
+ return {
665
+ requirements: { total: rows.length, counts },
666
+ plans: { withStatus: plansWithStatus.length, done: plansDone.length },
667
+ features: readIndexRows(root, "features").length,
668
+ candidates
669
+ };
670
+ }
671
+
672
+ /** Run the read-only loop report: requirement counts, plan landing rate, feature rows, promotion candidates. */
673
+ function runLoopReport(json) {
674
+ const summary = summarizeLoop(path.resolve(__dirname, ".."));
675
+ const counts = summary.requirements.counts;
676
+ const landingRate = summary.plans.withStatus
677
+ ? Math.round((summary.plans.done / summary.plans.withStatus) * 100)
678
+ : 0;
679
+ if (json) {
680
+ console.log(JSON.stringify({ checker: "devflow-loop", ...summary, landingRate, judgment: "PASS" }));
681
+ return 0;
682
+ }
683
+ console.log("DevFlow loop report");
684
+ console.log(`Requirements: total ${summary.requirements.total} | ${requirementStatuses.map((status) => `${status} ${counts[status]}`).join(" | ")}`);
685
+ console.log(`Plan landing: ${summary.plans.done}/${summary.plans.withStatus} with status (${landingRate}%)`);
686
+ console.log(`Feature rows: ${summary.features}`);
687
+ console.log(`Promotion candidates (confidence >= ${promotionConfidence}): ${summary.candidates.length}`);
688
+ for (const card of summary.candidates) console.log(` - ${card.name} (${card.confidence})`);
689
+ console.log("Judgment: PASS");
690
+ return 0;
691
+ }
692
+
693
+ /** Dispatch the v2 and legacy plan contracts so old plans keep validating. */
315
694
  function checkPlan(body) {
695
+ return detectV2(body) ? checkPlanV2(body) : checkPlanLegacy(body);
696
+ }
697
+
698
+ /** Validate global plan headers and all independently scoped task contracts. */
699
+ function checkPlanLegacy(body) {
316
700
  const fileStructure = checkFileStructure(body);
317
701
  const tasks = splitTasks(body);
318
702
  const requiresFileStructure = tasks.some((task) => fieldBlock(task.body, "Task type") === "Code change");
@@ -366,12 +750,21 @@ function report(body, filePath, json) {
366
750
  console.log(
367
751
  JSON.stringify({
368
752
  checker: "plan",
753
+ format: result.v2 ? "v2" : "legacy",
369
754
  landing: landing.message,
370
755
  status: result.status,
371
756
  invalidStatus: result.invalidStatus,
372
757
  missingGlobal: result.missingGlobal,
373
- globalUnresolved: result.globalUnresolved,
374
- fileStructure: result.fileStructure.ok ? "ok" : "missing or invalid",
758
+ globalUnresolved: result.globalUnresolved || [],
759
+ fileStructure: result.v2 ? "v2" : result.fileStructure.ok ? "ok" : "missing or invalid",
760
+ cut: result.v2 ? { missingRejected: result.cut.missingRejected } : null,
761
+ progress: result.v2
762
+ ? {
763
+ count: result.progress.count,
764
+ mismatch: result.progress.mismatch,
765
+ missingEvidence: result.progress.missingEvidence.length
766
+ }
767
+ : null,
375
768
  tasks: result.tasks.map((task) => ({ number: task.number, ok: task.ok })),
376
769
  judgment
377
770
  })
@@ -381,22 +774,43 @@ function report(body, filePath, json) {
381
774
 
382
775
  console.log("DevFlow plan pack report");
383
776
  console.log(landing.message);
777
+ console.log(`Format: ${result.v2 ? "v2" : "legacy"}`);
384
778
  console.log(`Status: ${result.invalidStatus ? "invalid" : result.status}`);
385
- for (const field of requiredGlobalFields) console.log(`${field}: ${result.missingGlobal.includes(field) ? "missing" : "ok"}`);
386
- console.log(`Global unresolved markers: ${result.globalUnresolved.join(", ") || "none"}`);
387
- console.log(
388
- `File Structure: ${
389
- !result.requiresFileStructure ? "documentation-only exception" : result.fileStructure.ok ? "ok" : "missing or invalid"
390
- }`
391
- );
392
- if (result.requiresFileStructure && !result.fileStructure.ok) {
393
- console.log(`File Structure invalid rows: ${result.fileStructure.invalidRows.join("; ") || "none"}`);
779
+ for (const field of result.v2 ? v2GlobalFields : requiredGlobalFields) {
780
+ console.log(`${field}: ${result.missingGlobal.includes(field) ? "missing" : "ok"}`);
781
+ }
782
+ if (result.v2) {
783
+ console.log(`Cut Rejected: ${result.cut.missingRejected ? "missing" : "ok"}`);
784
+ console.log(`Progress rows: ${result.progress.count} (tasks ${result.tasks.length})${result.progress.mismatch ? " mismatch" : ""}`);
785
+ for (const row of result.progress.missingEvidence) console.log(`Progress row ${row.number} is done without evidence`);
786
+ if (result.doneStatusNeedsAllDone) console.log("Status done requires every Progress row done");
787
+ } else {
788
+ console.log(`Global unresolved markers: ${result.globalUnresolved.join(", ") || "none"}`);
789
+ console.log(
790
+ `File Structure: ${
791
+ !result.requiresFileStructure ? "documentation-only exception" : result.fileStructure.ok ? "ok" : "missing or invalid"
792
+ }`
793
+ );
794
+ if (result.requiresFileStructure && !result.fileStructure.ok) {
795
+ console.log(`File Structure invalid rows: ${result.fileStructure.invalidRows.join("; ") || "none"}`);
796
+ }
394
797
  }
395
798
  console.log(`Tasks: ${result.tasks.length}`);
396
799
  if (result.tasks.length === 0) console.log("Missing: at least one Task field");
397
800
 
398
801
  for (const task of result.tasks) {
399
- const issues = [
802
+ const issues = result.v2
803
+ ? [
804
+ ...task.missing.map((field) => `missing ${field}`),
805
+ ...task.unresolved.map((match) => `unresolved ${match}`),
806
+ ...task.vague.map((match) => `vague ${match}`),
807
+ ...task.invalidFiles.map((line) => `unclassified file ${line}`),
808
+ ...task.unlocatedCodeFiles.map((line) => `missing file symbol/anchor ${line}`),
809
+ ...(task.missingChange ? ["Change needs an executable intent verb"] : []),
810
+ ...(task.missingNotDoing ? ["missing Not doing exclusion"] : []),
811
+ ...(task.incompleteVerification ? ["Verify needs command/scenario and expected result"] : [])
812
+ ]
813
+ : [
400
814
  ...task.missing.map((field) => `missing ${field}`),
401
815
  ...task.unresolved.map((match) => `unresolved ${match}`),
402
816
  ...task.vague.map((match) => `vague ${match}`),
@@ -621,8 +1035,61 @@ function selfTest() {
621
1035
  if (!checkPlanLanding("docs/plans/2026-07-14-add-plan-scanner.md").ok) throw new Error("Self-test expected docs/plans landing to pass");
622
1036
  if (checkPlanLanding("docs/features/add-plan-scanner.md").ok) throw new Error("Self-test expected docs/features plan landing to fail");
623
1037
 
1038
+ const validV2Plan = [
1039
+ "Status: approved",
1040
+ "Goal: validate the slim v2 plan contract",
1041
+ "Not doing: architecture review",
1042
+ "Cut: 做 checker | 不做 迁移旧计划 | 复用 既有 self-test | 验证 node scripts/devflow-plan.js | Rejected: 新增独立脚本 — 复用既有 checker",
1043
+ "## Tasks",
1044
+ "",
1045
+ "Task: Add v2 validation",
1046
+ "Files:",
1047
+ "- Modify: scripts/devflow-plan.js | symbol: `checkPlanV2` | validate the slim contract",
1048
+ "Change: add v2 field validation and dispatch",
1049
+ "Acceptance: v2 plans pass and legacy plans keep passing",
1050
+ "Verify: run `node scripts/devflow-plan.js --self-test` expect exit 0",
1051
+ "Not doing: changing legacy validation",
1052
+ "",
1053
+ "## Progress",
1054
+ "",
1055
+ "| # | Task | Status | Evidence |",
1056
+ "|---|---|---|---|",
1057
+ "| 1 | Add v2 validation | todo | - |"
1058
+ ].join("\n");
1059
+ if (!detectV2(validV2Plan)) throw new Error("Self-test expected v2 detection to pass");
1060
+ if (!checkPlan(validV2Plan).ok) throw new Error("Self-test expected valid v2 plan to pass");
1061
+ if (checkPlan(validV2Plan.replace(/Rejected:[^\n]*/, "Rejected:")).ok) throw new Error("Self-test expected missing Rejected to fail");
1062
+ if (checkPlan(validV2Plan.replace("| 1 | Add v2 validation | todo | - |", "| 1 | Add v2 validation | done | - |")).ok) {
1063
+ throw new Error("Self-test expected a done Progress row without evidence to fail");
1064
+ }
1065
+ if (checkPlan(validV2Plan.replace("| 1 | Add v2 validation | todo | - |", "")).ok) {
1066
+ throw new Error("Self-test expected a Progress/task count mismatch to fail");
1067
+ }
1068
+ if (typeof checkIndexes !== "function") throw new Error("Self-test expected the index checker to exist");
1069
+ if (matchIndexRows([["功能A", "计划相关", "x"]], "计划").length !== 1) throw new Error("Self-test expected index query to match a row");
1070
+ if (matchIndexRows([["功能A", "计划相关", "x"]], "不存在").length !== 0) throw new Error("Self-test expected index query to miss");
1071
+ if (matchIndexRows([["功能A"]], "").length !== 0) throw new Error("Self-test expected an empty index query to match nothing");
1072
+ if (argValue(["--index", "--query", "计划"], "--query") !== "计划") throw new Error("Self-test expected --query to read its value");
1073
+ if (argValue(["--index", "--query", "--json"], "--query") !== "") throw new Error("Self-test expected --query to reject a flag as its value");
1074
+
1075
+ const requirementHeader = ["日期", "需求", "来源", "深度", "落地物", "状态", "证据或跳过", "更新日"];
1076
+ const requirementRows = [
1077
+ ["2026-09-10", "已落地需求", "用户请求", "A", "docs/specs/x.md", "landed", "npm test 通过", "2026-09-10"],
1078
+ ["2026-09-10", "跳过文档需求", "用户请求", "C", "-", "opt-out", "原因:用户显式跳过", "2026-09-10"]
1079
+ ];
1080
+ const requirementProblems = checkRequirementTable(requirementHeader, requirementRows, () => true);
1081
+ if (requirementProblems.length !== 0) throw new Error(`Self-test expected a valid requirement table to pass: ${requirementProblems.join("; ")}`);
1082
+ const badStatus = checkRequirementTable(requirementHeader, [["2026-09-10", "状态非法", "用户请求", "A", "-", "shipped", "证据", "2026-09-10"]], () => true);
1083
+ if (badStatus.length === 0) throw new Error("Self-test expected an invalid requirement status to fail");
1084
+ const missingEvidence = checkRequirementTable(requirementHeader, [["2026-09-10", "终态无证据", "用户请求", "A", "-", "landed", "-", "2026-09-10"]], () => true);
1085
+ if (missingEvidence.length === 0) throw new Error("Self-test expected a terminal requirement without evidence to fail");
1086
+ const missingReason = checkRequirementTable(requirementHeader, [["2026-09-10", "跳过无原因", "用户请求", "C", "-", "opt-out", "验证通过", "2026-09-10"]], () => true);
1087
+ if (missingReason.length === 0) throw new Error("Self-test expected an opt-out without a reason to fail");
1088
+ const missingArtifact = checkRequirementTable(requirementHeader, [["2026-09-10", "落地物不存在", "用户请求", "A", "docs/specs/nope.md", "planned", "-", "2026-09-10"]], () => false);
1089
+ if (missingArtifact.length === 0) throw new Error("Self-test expected a missing requirement artifact to fail");
1090
+
624
1091
  console.log("DevFlow plan self-test passed");
625
- console.log("Checked code-level fields, precise file locations, mechanics evidence, verification expectations, Read-basis/Live anchors handoff facts, documentation-only exception, external-skill declaration, and plan landing guidance");
1092
+ console.log("Checked v2 and legacy plan contracts, Progress evidence, Cut Rejected, code-level fields, precise file locations, verification expectations, documentation-only exception, external-skill declaration, and plan landing guidance");
626
1093
  }
627
1094
 
628
1095
  const args = process.argv.slice(2);
@@ -634,5 +1101,11 @@ if (args.includes("--self-test")) {
634
1101
  selfTest();
635
1102
  process.exit(0);
636
1103
  }
1104
+ if (args.includes("--loop")) {
1105
+ process.exit(runLoopReport(args.includes("--json")));
1106
+ }
1107
+ if (args.includes("--index")) {
1108
+ process.exit(runIndexCheck(args.includes("--json"), argValue(args, "--query")));
1109
+ }
637
1110
  const targetArg = args.find((arg) => !arg.startsWith("-"));
638
1111
  process.exitCode = report(readInput(args), targetArg, args.includes("--json"));