@devflow-core/dsh-devflow 0.5.0 → 0.6.1

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.
@@ -12,7 +12,7 @@ Use Fast for pure Q&A, fact lookup, verification, or an already approved trivial
12
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.
13
13
  If the boundary is unclear after Brainstorm, have Core select the smallest compatible route instead of guessing.
14
14
  Run Sense first by reading or citing relevant project facts.
15
- 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
+ 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
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.
17
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.
18
18
  For bug fixes, include Root-Cause Check: searched callers/references; shared vs narrow fix; reason.
@@ -66,7 +66,7 @@
66
66
  - id: persona
67
67
  name: '@deepseek-ai/dsh-persona'
68
68
  config:
69
- text: |-
69
+ prefix: |-
70
70
  You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
71
71
 
72
72
  You run DevFlow. Before any development work — creating features, building components, adding functionality, modifying behavior, or defining an unapproved problem-directed change — load the `devflow-core` skill with the skill tool and read its `core-methods.md` reference, then load only the selected lifecycle owner skill. The route table and hard boundaries live in that skill and in AGENTS.md; do not invent lifecycle details. Pure Q&A, lookup, verification, and investigation-only reports need no lifecycle.
@@ -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"));
@@ -122,6 +122,8 @@ Confirmed request:
122
122
 
123
123
  The summary records the agreed request and the exploration findings only. It must not contain an implementation plan, solution-space design, lifecycle route, or handoff instruction. It is the factual basis that downstream skills — starting with `devflow-spec` — build on, so record findings faithfully rather than trimming them away.
124
124
 
125
+ On `Status: clarified`, create or update the requirement row in `docs/requirements.md` with status `open`; depth is recorded when the lifecycle path is chosen. Pure Q&A, lookup, and read-only verification create no row.
126
+
125
127
  ## A/B/C Gate
126
128
 
127
129
  After the fixed summary, present these choices and wait for one explicit user selection:
@@ -19,7 +19,7 @@ When no plan file exists, the approved design and Cut Decision form the Build Co
19
19
 
20
20
  Load `skills/devflow-build/references/build-methods.md` after this section and before implementation slices. It owns the detailed minimal-change and slice discipline.
21
21
 
22
- There is no broad pre-edit plan review. The executor reads only the current task's execution spec — `Files`, `Change mechanics`, `Steps`, `Verify` and must reread the current task's named anchors. A current task anchor plus a directly changed neighbor may be reread only when that neighbor is already listed in the task `Files` and its contract could invalidate the edit. An actual edit, anchor mismatch, or verification failure must stop and return `BUILD_BLOCKED` with facts to `devflow-core`: observed mismatch, affected anchor, and smallest replan decision. Do not broadly rediscover, guess, silently repair the plan, or expand scope.
22
+ There is no broad pre-edit plan review. The executor reads the current task's `Files`, `Change`, `Acceptance`, `Verify`, and `Not doing`, and may read the task's named anchors plus one directly changed neighbor to choose the smallest implementation. Build owns how inside the task boundary; it must not broadly rediscover the repository, redesign outside the task boundary, silently repair the plan, or expand scope. An anchor mismatch or verification failure must stop and return `BUILD_BLOCKED` with facts to `devflow-core`: observed mismatch, affected anchor, and smallest replan decision.
23
23
 
24
24
  Every skill declared in `External Skills` (Cut Decision or plan header) must actually be loaded through the platform's skill mechanism, or the reason it does not apply recorded; loading alone is not completion — Build requires the specialist's returned result, not-applicable, or failure facts. A specialist result implying structure outside the approved scope returns scope-drift facts to `devflow-core`, not silent adoption. Skill loading is not a pre-edit view and remains mandatory.
25
25
 
@@ -44,9 +44,11 @@ If work touches more than one file or one logical step, create Implementation Sl
44
44
 
45
45
  When saving a plan file, use `docs/plans/YYYY-MM-DD-<short-kebab-name>.md`, resolved from the current target project's root, unless that project already documents another plan/spec path. Do not save implementation plans under `docs/features/`; that directory is for feature ledgers.
46
46
 
47
- For multi-step work, tasks must cite the approved source, be small and verifiable, and follow the required task contract (Task: / Task type: / Files: / Interfaces: / Current behavior: / Target behavior: / Change mechanics: / Call impact: / Steps: / Acceptance: / Verify: / Comments: / Not doing:) defined in `skills/devflow-plan/SKILL.md`.
47
+ For multi-step work, tasks must cite the approved source, be small and verifiable, and follow the six-field contract (`Task` / `Files` / `Change` / `Acceptance` / `Verify` / `Not doing`) in `skills/devflow-plan/SKILL.md`. Legacy plans with `Change mechanics` and `Prewalk` stay executable under their own contract.
48
48
 
49
- No unresolved markers. For `Code change`, the dispatched execution spec follows the recorded file symbol/anchor and `Change mechanics`; do not re-decide the implementation mechanism in Build. `Current behavior`, `Target behavior`, and `Call impact` are the plan author's records; bounded current-anchor/neighbor reread is allowed only for drift detection. The verification step must retain its trigger/input, expected result, and command or manual scenario. `Documentation-only` applies only to tasks with no runtime code files and explicit `documentation-only` interfaces. No "add tests" without naming the behavior. No "handle edge cases" without naming the edge case. No "similar to Task N" shortcuts; repeat enough detail for each task to stand alone.
49
+ No unresolved markers. `Change` states the executable intent and boundary; add exact mechanics only when the change crosses a module contract, is irreversible, or touches security or data boundaries. Otherwise Build chooses the smallest implementation and records it as Progress evidence. The verification step keeps its trigger/input, expected result, and command or manual scenario. A task whose files are all documentation paths is documentation-only. No "add tests" without naming the behavior, no "handle edge cases" without naming the edge case, no "similar to Task N" shortcuts.
50
+
51
+ Close each task by writing back its `## Progress` row: `doing` when starting, `done` with the command and key result when its `Verify` passes. A `done` row without evidence fails the checker. On a legacy plan without a Progress table, report the same evidence in the completion message. When every task is `done`, advance the requirement row in `docs/requirements.md` to `built`.
50
52
 
51
53
  Before Build, run `node scripts/devflow-plan.js <plan-file>` when a plan is saved to a file. If not found at `scripts/devflow-plan.js` (project-level), try `~/.codex/scripts/devflow-plan.js` or `~/.claude/scripts/devflow-plan.js` (user-level). Do NOT look under `skills/scripts/`. See `core-methods.md` Script Path Resolution.
52
54
 
@@ -191,7 +193,7 @@ If any file has no goal link, remove that change.
191
193
  | "We'll verify everything at the end." | Verify slices when focused checks exist. |
192
194
  | "Docs changes do not need proof." | Docs/rules/skills need validation just like code. |
193
195
  | "The issue only mentions one caller." | Check sibling callers before choosing the fix location. |
194
- | "The plan is approved, so I just execute." | Right: the approved execution spec is edited directly; an actual edit or verification failure returns `BUILD_BLOCKED` to Core. |
196
+ | "The plan is approved, so I just execute." | The plan fixes the boundary and proof; Build still chooses the smallest implementation inside that boundary. A real edit or verification failure returns `BUILD_BLOCKED` to Core. |
195
197
  | "I'll infer the missing step." | Guessing past a gap is forbidden; unclear instructions return `BUILD_BLOCKED` facts. |
196
198
  | "The code is self-explanatory." | That does not waive a comment required by the approved contract, project convention, or a non-obvious boundary. |
197
199
  | "Comments will get stale." | Keep a required comment accurate or remove a stale one; a stale explanation is not a reason to skip a needed decision record. |