@osovv/vv-opencode 1.3.8 → 1.4.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.
- package/CHANGELOG.md +19 -0
- package/README.md +66 -14
- package/dist/commands/completion.js +10 -2
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/patch-provider.d.ts +148 -1
- package/dist/commands/patch-provider.js +151 -5
- package/dist/commands/patch-provider.js.map +1 -1
- package/dist/lib/orchestration.d.ts +2 -2
- package/dist/lib/orchestration.js +31 -2
- package/dist/lib/orchestration.js.map +1 -1
- package/dist/lib/spec-lint.d.ts +71 -1
- package/dist/lib/spec-lint.js +566 -8
- package/dist/lib/spec-lint.js.map +1 -1
- package/dist/lib/vvoc-config.d.ts +3 -3
- package/dist/lib/vvoc-preset-registry.d.ts +42 -30
- package/dist/lib/vvoc-preset-registry.js +41 -31
- package/dist/lib/vvoc-preset-registry.js.map +1 -1
- package/dist/plugins/hashline-edit/index.js.map +1 -1
- package/dist/plugins/workflow/checkpoint-io.d.ts +33 -0
- package/dist/plugins/workflow/checkpoint-io.js +224 -0
- package/dist/plugins/workflow/checkpoint-io.js.map +1 -0
- package/dist/plugins/workflow/checkpoints.d.ts +182 -0
- package/dist/plugins/workflow/checkpoints.js +1067 -0
- package/dist/plugins/workflow/checkpoints.js.map +1 -0
- package/dist/plugins/workflow/delegated.d.ts +182 -0
- package/dist/plugins/workflow/delegated.js +751 -0
- package/dist/plugins/workflow/delegated.js.map +1 -0
- package/dist/plugins/workflow/index.js +685 -23
- package/dist/plugins/workflow/index.js.map +1 -1
- package/dist/plugins/workflow/persistence.d.ts +32 -3
- package/dist/plugins/workflow/persistence.js +516 -70
- package/dist/plugins/workflow/persistence.js.map +1 -1
- package/dist/plugins/workflow/repair.js +3 -1
- package/dist/plugins/workflow/repair.js.map +1 -1
- package/dist/plugins/workflow/snapshots.d.ts +59 -0
- package/dist/plugins/workflow/snapshots.js +261 -0
- package/dist/plugins/workflow/snapshots.js.map +1 -0
- package/dist/plugins/workflow/state.d.ts +20 -2
- package/dist/plugins/workflow/state.js +127 -12
- package/dist/plugins/workflow/state.js.map +1 -1
- package/dist/plugins/workflow/tooling.d.ts +45 -0
- package/dist/plugins/workflow/tooling.js +344 -12
- package/dist/plugins/workflow/tooling.js.map +1 -1
- package/dist/plugins/workflow/transitions.js +2 -2
- package/dist/plugins/workflow/transitions.js.map +1 -1
- package/dist/tui/context/analyze.js +3 -1
- package/dist/tui/context/analyze.js.map +1 -1
- package/package.json +1 -1
- package/schemas/vvoc/v3.json +5 -3
- package/templates/agents/vv-code-reviewer.md +3 -1
- package/templates/agents/vv-implementer.md +5 -2
- package/templates/agents/vv-spec-reviewer.md +3 -0
- package/templates/skills/vv-execute/SKILL.md +62 -15
- package/templates/skills/vv-plan/SKILL.md +13 -3
- package/templates/skills/vv-plan/references/plan-template.xml +45 -0
- package/templates/skills/vv-review/SKILL.md +1 -0
package/dist/lib/spec-lint.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// FILE: src/lib/spec-lint.ts
|
|
2
|
-
// VERSION: 1.
|
|
2
|
+
// VERSION: 1.1.0
|
|
3
3
|
// START_MODULE_CONTRACT
|
|
4
|
-
// PURPOSE: Pure strict linter for .vvoc spec-package XML artifacts (spec.xml, plan.xml, design-context.xml) enforcing the element-name identity format.
|
|
5
|
-
// SCOPE: Strict well-formedness validation over the htmlparser2 xmlMode event stream, template-contract checks per artifact kind, component/task/wave identity rules, reference integrity, lifecycle-aware severity, plan-subset-of-spec cross-file checking, and package layout checks. No filesystem access — callers pass artifact contents.
|
|
4
|
+
// PURPOSE: Pure strict linter for .vvoc spec-package XML artifacts (spec.xml, plan.xml, design-context.xml) enforcing the element-name identity format and delegated execution/checkpoint contracts.
|
|
5
|
+
// SCOPE: Strict well-formedness validation over the htmlparser2 xmlMode event stream, template-contract checks per artifact kind, component/task/wave/checkpoint identity rules, reference integrity, lifecycle-aware severity, plan-subset-of-spec cross-file checking, delegated execution and review-checkpoint validation with typed extraction, and package layout checks. No filesystem access — callers pass artifact contents.
|
|
6
6
|
// DEPENDS: [htmlparser2]
|
|
7
|
-
// LINKS: [M-SPEC-LINT, M-PLUGIN-SPEC-GUARD, M-CLI-COMMANDS]
|
|
7
|
+
// LINKS: [M-SPEC-LINT, M-PLUGIN-SPEC-GUARD, M-CLI-COMMANDS, M-WORKFLOW-CHECKPOINTS]
|
|
8
8
|
// ROLE: RUNTIME
|
|
9
9
|
// MAP_MODE: EXPORTS
|
|
10
10
|
// END_MODULE_CONTRACT
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
// SpecLintVerdict - Per-artifact lint result with kind, ok flag, and findings.
|
|
18
18
|
// SpecLintArtifactInput - One artifact to lint identified by a file label and raw content.
|
|
19
19
|
// SpecLintOptions - Options for lint runs (skipCrossFile for single-file contexts).
|
|
20
|
+
// DelegatedReviewer - Canonical delegated checkpoint reviewer roles (spec, code).
|
|
21
|
+
// DelegatedTaskDefinition - Typed delegated obligation for one declared plan task.
|
|
22
|
+
// DelegatedCheckpointDefinition - Typed delegated obligation for one declared review checkpoint.
|
|
23
|
+
// DelegatedPlanDefinition - Typed obligations extracted from a delegated execution section.
|
|
24
|
+
// DelegatedPlanExtractionResult - Success payload or error list returned by delegated extraction.
|
|
25
|
+
// DeclaredScopePathResult - Normalized workspace-relative path or a rejection reason.
|
|
26
|
+
// normalizeDeclaredScopePath - Text-level canonical normalization for declared scope file paths.
|
|
27
|
+
// extractDelegatedPlanDefinition - Strictly extract and validate typed delegated obligations from plan content.
|
|
20
28
|
// parseSpecXml - Strict xmlMode parse producing a positioned element tree or well-formedness findings.
|
|
21
29
|
// lintSpecArtifacts - Lint a set of artifacts together, applying cross-file rules between plans and specs.
|
|
22
30
|
// detectSpecArtifactKind - Map a root element name onto an artifact kind.
|
|
@@ -24,19 +32,23 @@
|
|
|
24
32
|
// END_MODULE_MAP
|
|
25
33
|
//
|
|
26
34
|
// START_CHANGE_SUMMARY
|
|
27
|
-
// LAST_CHANGE: [C-
|
|
35
|
+
// LAST_CHANGE: [C-DELEGATED-WORKFLOW-ASTRA-PRESETS - Added the delegated execution section, review-checkpoint vocabulary, scope-path normalization, typed extractDelegatedPlanDefinition, and bumped LINT_VERSION to 2.]
|
|
28
36
|
// END_CHANGE_SUMMARY
|
|
29
37
|
import { Tokenizer } from "htmlparser2";
|
|
30
38
|
// START_BLOCK_PUBLIC_TYPES
|
|
31
|
-
export const LINT_VERSION =
|
|
39
|
+
export const LINT_VERSION = 2;
|
|
32
40
|
const IDENTITY_PATTERNS = {
|
|
33
41
|
component: /^COMPONENT-[A-Z0-9]+(-[A-Z0-9]+)*$/,
|
|
34
42
|
task: /^TASK-T-\d{3,}$/,
|
|
35
43
|
wave: /^WAVE-\d+$/,
|
|
44
|
+
checkpoint: /^CHECKPOINT-R-\d{3,}$/,
|
|
36
45
|
};
|
|
37
46
|
const TASK_ID_REF = /^T-\d{3,}$/;
|
|
38
47
|
const DOC_STATUSES = new Set(["draft", "approved", "applied"]);
|
|
39
48
|
const TASK_STATUSES = new Set(["pending", "in_progress", "done", "skipped"]);
|
|
49
|
+
const EXECUTION_MODES = new Set(["inline", "classic", "delegated"]);
|
|
50
|
+
const CHECKPOINT_KINDS = new Set(["milestone", "final"]);
|
|
51
|
+
const DELEGATED_REVIEWERS = new Set(["spec", "code"]);
|
|
40
52
|
const RESERVED_PACKAGE_SLUGS = new Set(["draft", "archive", "template", "plan", "spec", "vvoc"]);
|
|
41
53
|
const PACKAGE_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z0-9]([a-z0-9_-]*[a-z0-9_])?$/;
|
|
42
54
|
// END_BLOCK_PARSER_TYPES
|
|
@@ -271,10 +283,23 @@ const SPEC_CONTRACT = {
|
|
|
271
283
|
};
|
|
272
284
|
const COMPONENT_CHILDREN = ["name", "responsibility", "depends_on"];
|
|
273
285
|
const PLAN_CONTRACT = {
|
|
274
|
-
plan: {
|
|
286
|
+
plan: {
|
|
287
|
+
names: [
|
|
288
|
+
"spec",
|
|
289
|
+
"design_context",
|
|
290
|
+
"created",
|
|
291
|
+
"status",
|
|
292
|
+
"meta",
|
|
293
|
+
"architecture",
|
|
294
|
+
"tasks",
|
|
295
|
+
"execution",
|
|
296
|
+
],
|
|
297
|
+
},
|
|
275
298
|
meta: { names: ["summary", "waves", "affected_modules", "complexity"] },
|
|
276
299
|
architecture: { identity: "component" },
|
|
277
300
|
tasks: { identity: "wave" },
|
|
301
|
+
execution: { names: ["mode", "review_checkpoints"] },
|
|
302
|
+
review_checkpoints: { identity: "checkpoint" },
|
|
278
303
|
};
|
|
279
304
|
const TASK_CHILDREN = [
|
|
280
305
|
"title",
|
|
@@ -285,12 +310,26 @@ const TASK_CHILDREN = [
|
|
|
285
310
|
"snippet",
|
|
286
311
|
"acceptance",
|
|
287
312
|
"verification",
|
|
313
|
+
"write_scope",
|
|
288
314
|
];
|
|
289
315
|
const TASK_DEPENDS_CHILDREN = ["task_id"];
|
|
290
316
|
const ACCEPTANCE_CHILDREN = ["criterion"];
|
|
291
317
|
const VERIFICATION_CHILDREN = ["command"];
|
|
292
318
|
const WAVE_CHILDREN = ["goal"];
|
|
293
319
|
const PLAN_FILE_CHILDREN = ["path", "role"];
|
|
320
|
+
const TASK_WRITE_SCOPE_CHILDREN = ["file"];
|
|
321
|
+
const CHECKPOINT_CHILDREN = [
|
|
322
|
+
"kind",
|
|
323
|
+
"after_wave",
|
|
324
|
+
"covers",
|
|
325
|
+
"scope",
|
|
326
|
+
"reviewers",
|
|
327
|
+
"acceptance",
|
|
328
|
+
"verification",
|
|
329
|
+
];
|
|
330
|
+
const CHECKPOINT_COVERS_CHILDREN = ["task_id"];
|
|
331
|
+
const CHECKPOINT_SCOPE_CHILDREN = ["file"];
|
|
332
|
+
const CHECKPOINT_REVIEWERS_CHILDREN = ["reviewer"];
|
|
294
333
|
const DESIGN_CONTEXT_CONTRACT = {
|
|
295
334
|
"design-context": {
|
|
296
335
|
names: ["decisions", "assumptions", "deferred", "scenarios", "external_constraints"],
|
|
@@ -333,7 +372,521 @@ function textOf(node) {
|
|
|
333
372
|
function nonEmpty(node) {
|
|
334
373
|
return textOf(node) !== "";
|
|
335
374
|
}
|
|
336
|
-
//
|
|
375
|
+
// END_BLOCK_DELEGATED_TYPES
|
|
376
|
+
// START_BLOCK_SCOPE_PATH_NORMALIZATION
|
|
377
|
+
/**
|
|
378
|
+
* Text-level canonical normalization for declared scope paths. The linter and
|
|
379
|
+
* the runtime snapshot fingerprint share this representation so a plan that
|
|
380
|
+
* lints clean cannot declare paths the runtime normalizer rejects on sight.
|
|
381
|
+
* Filesystem-specific checks (existence, symlinks, regular files) stay in the
|
|
382
|
+
* runtime normalizer; this function is deliberately pure.
|
|
383
|
+
*/
|
|
384
|
+
export function normalizeDeclaredScopePath(raw) {
|
|
385
|
+
const trimmed = raw.trim();
|
|
386
|
+
if (trimmed === "")
|
|
387
|
+
return { ok: false, reason: "empty path" };
|
|
388
|
+
if (trimmed.includes("\\"))
|
|
389
|
+
return { ok: false, reason: "backslash separator" };
|
|
390
|
+
if (trimmed.startsWith("/"))
|
|
391
|
+
return { ok: false, reason: "absolute path" };
|
|
392
|
+
if (/^[A-Za-z]:/.test(trimmed))
|
|
393
|
+
return { ok: false, reason: "drive-absolute path" };
|
|
394
|
+
if (trimmed.startsWith("~"))
|
|
395
|
+
return { ok: false, reason: "home-relative path" };
|
|
396
|
+
if (/[*?[\]]/.test(trimmed))
|
|
397
|
+
return { ok: false, reason: "wildcard characters" };
|
|
398
|
+
// eslint-disable-next-line no-control-regex
|
|
399
|
+
if (/[\u0000-\u001f\u007f]/.test(trimmed))
|
|
400
|
+
return { ok: false, reason: "control characters" };
|
|
401
|
+
const segments = trimmed.split("/");
|
|
402
|
+
for (const segment of segments) {
|
|
403
|
+
if (segment === "")
|
|
404
|
+
return { ok: false, reason: "empty path segment" };
|
|
405
|
+
if (segment === "." || segment === "..")
|
|
406
|
+
return { ok: false, reason: "traversal segment" };
|
|
407
|
+
}
|
|
408
|
+
return { ok: true, path: segments.join("/") };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Collect wave order and per-task delegated facts with a standalone walk so
|
|
412
|
+
* lintPlan's main task validation and extractDelegatedPlanDefinition share one
|
|
413
|
+
* interpretation of the declared task/write-scope vocabulary.
|
|
414
|
+
*/
|
|
415
|
+
function collectDelegatedTaskFacts(root) {
|
|
416
|
+
const waveOrder = [];
|
|
417
|
+
const tasks = [];
|
|
418
|
+
const duplicateTaskElements = [];
|
|
419
|
+
const seenTasks = new Set();
|
|
420
|
+
const tasksNode = child(root, "tasks");
|
|
421
|
+
if (!tasksNode)
|
|
422
|
+
return { waveOrder, tasks, duplicateTaskElements };
|
|
423
|
+
for (const wave of tasksNode.children) {
|
|
424
|
+
if (!IDENTITY_PATTERNS.wave.test(wave.name))
|
|
425
|
+
continue;
|
|
426
|
+
if (!waveOrder.includes(wave.name))
|
|
427
|
+
waveOrder.push(wave.name);
|
|
428
|
+
for (const task of wave.children) {
|
|
429
|
+
if (!IDENTITY_PATTERNS.task.test(task.name))
|
|
430
|
+
continue;
|
|
431
|
+
if (seenTasks.has(task.name)) {
|
|
432
|
+
duplicateTaskElements.push(task.name);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
seenTasks.add(task.name);
|
|
436
|
+
const writeScopeNode = child(task, "write_scope");
|
|
437
|
+
const writeScope = [];
|
|
438
|
+
const writeScopeDuplicates = [];
|
|
439
|
+
if (writeScopeNode) {
|
|
440
|
+
for (const fileNode of writeScopeNode.children) {
|
|
441
|
+
if (fileNode.name !== "file")
|
|
442
|
+
continue;
|
|
443
|
+
const normalized = normalizeDeclaredScopePath(textOf(fileNode));
|
|
444
|
+
if (!normalized.ok)
|
|
445
|
+
continue;
|
|
446
|
+
if (writeScope.includes(normalized.path)) {
|
|
447
|
+
// Duplicate write-scope entries are rejected by the lint rules;
|
|
448
|
+
// here the duplicate is recorded so the rule can point at it.
|
|
449
|
+
if (!writeScopeDuplicates.includes(normalized.path)) {
|
|
450
|
+
writeScopeDuplicates.push(normalized.path);
|
|
451
|
+
}
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
writeScope.push(normalized.path);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
tasks.push({
|
|
458
|
+
taskElement: task.name,
|
|
459
|
+
taskId: task.name.slice("TASK-".length),
|
|
460
|
+
wave: wave.name,
|
|
461
|
+
line: task.line,
|
|
462
|
+
writeScope,
|
|
463
|
+
writeScopeDeclared: writeScopeNode !== undefined,
|
|
464
|
+
writeScopeDuplicates,
|
|
465
|
+
primaryFile: (() => {
|
|
466
|
+
const normalized = normalizeDeclaredScopePath(textOf(child(task, "file")));
|
|
467
|
+
return normalized.ok ? normalized.path : "";
|
|
468
|
+
})(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return { waveOrder, tasks, duplicateTaskElements };
|
|
473
|
+
}
|
|
474
|
+
// END_BLOCK_DELEGATED_FACTS
|
|
475
|
+
// START_BLOCK_DELEGATED_VALIDATION
|
|
476
|
+
/** Emit write-scope obligations for every declared delegated task. */
|
|
477
|
+
function lintDelegatedWriteScopes(facts, completeness, findings, file) {
|
|
478
|
+
for (const task of facts.tasks) {
|
|
479
|
+
if (task.writeScopeDuplicates.length > 0) {
|
|
480
|
+
findings.push({
|
|
481
|
+
severity: "error",
|
|
482
|
+
rule: "execution.scope_path",
|
|
483
|
+
message: `task ${task.taskId} declares write_scope path ${JSON.stringify(task.writeScopeDuplicates[0])} more than once; the runtime normalizer rejects duplicate scope entries`,
|
|
484
|
+
file,
|
|
485
|
+
line: task.line,
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (!task.writeScopeDeclared || task.writeScope.length === 0) {
|
|
490
|
+
findings.push({
|
|
491
|
+
severity: completeness ? "error" : "warning",
|
|
492
|
+
rule: "lifecycle.required",
|
|
493
|
+
message: `task ${task.taskId} declares no write_scope; delegated tasks must list their workspace-relative write scope`,
|
|
494
|
+
file,
|
|
495
|
+
line: task.line,
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (task.primaryFile && !task.writeScope.includes(task.primaryFile)) {
|
|
500
|
+
findings.push({
|
|
501
|
+
severity: "error",
|
|
502
|
+
rule: "execution.write_scope",
|
|
503
|
+
message: `task ${task.taskId} write_scope does not include its primary file ${task.primaryFile}`,
|
|
504
|
+
file,
|
|
505
|
+
line: task.line,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Validate the optional <execution> section. Structural violations of declared
|
|
512
|
+
* content (unknown modes/children, duplicate identities, dangling references,
|
|
513
|
+
* future-wave coverage, multiple or misplaced finals, incomplete declared
|
|
514
|
+
* coverage) are always errors; presence obligations for approved/applied plans
|
|
515
|
+
* escalate from draft warnings.
|
|
516
|
+
*/
|
|
517
|
+
function lintExecutionSection(root, file, facts, completeness, findings) {
|
|
518
|
+
const executionNode = child(root, "execution");
|
|
519
|
+
if (!executionNode)
|
|
520
|
+
return;
|
|
521
|
+
checkChildren(executionNode, PLAN_CONTRACT.execution, file, findings);
|
|
522
|
+
const mode = textOf(child(executionNode, "mode"));
|
|
523
|
+
if (!EXECUTION_MODES.has(mode)) {
|
|
524
|
+
findings.push({
|
|
525
|
+
severity: "error",
|
|
526
|
+
rule: "execution.mode",
|
|
527
|
+
message: `execution mode "${mode}" is not one of inline, classic, delegated`,
|
|
528
|
+
file,
|
|
529
|
+
line: child(executionNode, "mode")?.line ?? executionNode.line,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const reviewCheckpointsNode = child(executionNode, "review_checkpoints");
|
|
533
|
+
if (reviewCheckpointsNode && mode !== "delegated") {
|
|
534
|
+
findings.push({
|
|
535
|
+
severity: "error",
|
|
536
|
+
rule: "execution.checkpoints_mode",
|
|
537
|
+
message: `review_checkpoints are only valid with execution mode delegated, not "${mode}"`,
|
|
538
|
+
file,
|
|
539
|
+
line: reviewCheckpointsNode.line,
|
|
540
|
+
});
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (mode !== "delegated")
|
|
544
|
+
return;
|
|
545
|
+
if (!reviewCheckpointsNode) {
|
|
546
|
+
findings.push({
|
|
547
|
+
severity: completeness ? "error" : "warning",
|
|
548
|
+
rule: "lifecycle.required",
|
|
549
|
+
message: `delegated execution declares no review_checkpoints; the plan is ${completeness ? "approved or applied" : "draft"} and must declare its checkpoint obligations`,
|
|
550
|
+
file,
|
|
551
|
+
line: executionNode.line,
|
|
552
|
+
});
|
|
553
|
+
// Task write-scope obligations still apply even without declared checkpoints.
|
|
554
|
+
lintDelegatedWriteScopes(facts, completeness, findings, file);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
checkChildren(reviewCheckpointsNode, PLAN_CONTRACT.review_checkpoints, file, findings);
|
|
558
|
+
const obligation = (message, line, rule = "lifecycle.required") => {
|
|
559
|
+
findings.push({
|
|
560
|
+
severity: completeness ? "error" : "warning",
|
|
561
|
+
rule,
|
|
562
|
+
message,
|
|
563
|
+
file,
|
|
564
|
+
line,
|
|
565
|
+
});
|
|
566
|
+
};
|
|
567
|
+
const waveIndex = new Map(facts.waveOrder.map((wave, index) => [wave, index]));
|
|
568
|
+
const taskById = new Map(facts.tasks.map((task) => [task.taskId, task]));
|
|
569
|
+
const seenCheckpoints = new Set();
|
|
570
|
+
const validCheckpoints = [];
|
|
571
|
+
for (const checkpoint of reviewCheckpointsNode.children) {
|
|
572
|
+
if (!IDENTITY_PATTERNS.checkpoint.test(checkpoint.name)) {
|
|
573
|
+
findings.push({
|
|
574
|
+
severity: "error",
|
|
575
|
+
rule: "identity.pattern",
|
|
576
|
+
message: `checkpoint element <${checkpoint.name}> does not match the CHECKPOINT-R-NNN pattern`,
|
|
577
|
+
file,
|
|
578
|
+
line: checkpoint.line,
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (seenCheckpoints.has(checkpoint.name)) {
|
|
583
|
+
findings.push({
|
|
584
|
+
severity: "error",
|
|
585
|
+
rule: "identity.duplicate",
|
|
586
|
+
message: `checkpoint ${checkpoint.name} is declared more than once`,
|
|
587
|
+
file,
|
|
588
|
+
line: checkpoint.line,
|
|
589
|
+
});
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
seenCheckpoints.add(checkpoint.name);
|
|
593
|
+
checkChildren(checkpoint, { names: CHECKPOINT_CHILDREN }, file, findings);
|
|
594
|
+
const kind = textOf(child(checkpoint, "kind"));
|
|
595
|
+
if (!CHECKPOINT_KINDS.has(kind)) {
|
|
596
|
+
findings.push({
|
|
597
|
+
severity: "error",
|
|
598
|
+
rule: "execution.checkpoint_kind",
|
|
599
|
+
message: `checkpoint ${checkpoint.name} kind "${kind}" is not one of milestone, final`,
|
|
600
|
+
file,
|
|
601
|
+
line: child(checkpoint, "kind")?.line ?? checkpoint.line,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
const afterWaveNode = child(checkpoint, "after_wave");
|
|
605
|
+
const afterWave = textOf(afterWaveNode);
|
|
606
|
+
let afterWaveKnown = false;
|
|
607
|
+
if (!IDENTITY_PATTERNS.wave.test(afterWave) || !waveIndex.has(afterWave)) {
|
|
608
|
+
findings.push({
|
|
609
|
+
severity: "error",
|
|
610
|
+
rule: "ref.dangling",
|
|
611
|
+
message: `checkpoint ${checkpoint.name} after_wave "${afterWave}" is not a declared wave in this plan`,
|
|
612
|
+
file,
|
|
613
|
+
line: afterWaveNode?.line ?? checkpoint.line,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
afterWaveKnown = true;
|
|
618
|
+
}
|
|
619
|
+
const coversNode = child(checkpoint, "covers");
|
|
620
|
+
const covers = [];
|
|
621
|
+
if (coversNode) {
|
|
622
|
+
checkChildren(coversNode, { names: CHECKPOINT_COVERS_CHILDREN }, file, findings);
|
|
623
|
+
for (const taskRef of coversNode.children) {
|
|
624
|
+
if (taskRef.name !== "task_id")
|
|
625
|
+
continue;
|
|
626
|
+
const value = textOf(taskRef);
|
|
627
|
+
if (!TASK_ID_REF.test(value) || !taskById.has(value)) {
|
|
628
|
+
findings.push({
|
|
629
|
+
severity: "error",
|
|
630
|
+
rule: "ref.dangling",
|
|
631
|
+
message: `checkpoint ${checkpoint.name} covers task_id "${value}" which is not a declared task in this plan`,
|
|
632
|
+
file,
|
|
633
|
+
line: taskRef.line,
|
|
634
|
+
});
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
covers.push(value);
|
|
638
|
+
const coveredTask = taskById.get(value);
|
|
639
|
+
if (kind === "milestone" &&
|
|
640
|
+
afterWaveKnown &&
|
|
641
|
+
coveredTask &&
|
|
642
|
+
(waveIndex.get(coveredTask.wave) ?? -1) > (waveIndex.get(afterWave) ?? -1)) {
|
|
643
|
+
findings.push({
|
|
644
|
+
severity: "error",
|
|
645
|
+
rule: "execution.future_coverage",
|
|
646
|
+
message: `checkpoint ${checkpoint.name} (milestone after ${afterWave}) covers ${value} from a later wave ${coveredTask.wave}`,
|
|
647
|
+
file,
|
|
648
|
+
line: taskRef.line,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const scopeNode = child(checkpoint, "scope");
|
|
654
|
+
const scope = [];
|
|
655
|
+
if (scopeNode) {
|
|
656
|
+
checkChildren(scopeNode, { names: CHECKPOINT_SCOPE_CHILDREN }, file, findings);
|
|
657
|
+
for (const fileNode of scopeNode.children) {
|
|
658
|
+
if (fileNode.name !== "file")
|
|
659
|
+
continue;
|
|
660
|
+
const normalized = normalizeDeclaredScopePath(textOf(fileNode));
|
|
661
|
+
if (!normalized.ok) {
|
|
662
|
+
findings.push({
|
|
663
|
+
severity: "error",
|
|
664
|
+
rule: "execution.scope_path",
|
|
665
|
+
message: `checkpoint ${checkpoint.name} declares a malformed scope path (${normalized.reason}): ${JSON.stringify(textOf(fileNode))}`,
|
|
666
|
+
file,
|
|
667
|
+
line: fileNode.line,
|
|
668
|
+
});
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (scope.includes(normalized.path)) {
|
|
672
|
+
findings.push({
|
|
673
|
+
severity: "error",
|
|
674
|
+
rule: "execution.scope_path",
|
|
675
|
+
message: `checkpoint ${checkpoint.name} declares scope path ${JSON.stringify(normalized.path)} more than once; the runtime normalizer rejects duplicate scope entries`,
|
|
676
|
+
file,
|
|
677
|
+
line: fileNode.line,
|
|
678
|
+
});
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
scope.push(normalized.path);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const reviewersNode = child(checkpoint, "reviewers");
|
|
685
|
+
const reviewers = [];
|
|
686
|
+
if (reviewersNode) {
|
|
687
|
+
checkChildren(reviewersNode, { names: CHECKPOINT_REVIEWERS_CHILDREN }, file, findings);
|
|
688
|
+
for (const reviewerNode of reviewersNode.children) {
|
|
689
|
+
if (reviewerNode.name !== "reviewer")
|
|
690
|
+
continue;
|
|
691
|
+
const value = textOf(reviewerNode);
|
|
692
|
+
if (!DELEGATED_REVIEWERS.has(value)) {
|
|
693
|
+
findings.push({
|
|
694
|
+
severity: "error",
|
|
695
|
+
rule: "execution.reviewer",
|
|
696
|
+
message: `checkpoint ${checkpoint.name} reviewer "${value}" is not one of spec, code`,
|
|
697
|
+
file,
|
|
698
|
+
line: reviewerNode.line,
|
|
699
|
+
});
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
if (reviewers.includes(value)) {
|
|
703
|
+
findings.push({
|
|
704
|
+
severity: "error",
|
|
705
|
+
rule: "execution.reviewer_duplicate",
|
|
706
|
+
message: `checkpoint ${checkpoint.name} declares reviewer "${value}" more than once`,
|
|
707
|
+
file,
|
|
708
|
+
line: reviewerNode.line,
|
|
709
|
+
});
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
reviewers.push(value);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const acceptanceNode = child(checkpoint, "acceptance");
|
|
716
|
+
if (acceptanceNode)
|
|
717
|
+
checkChildren(acceptanceNode, { names: ACCEPTANCE_CHILDREN }, file, findings);
|
|
718
|
+
const verificationNode = child(checkpoint, "verification");
|
|
719
|
+
if (verificationNode)
|
|
720
|
+
checkChildren(verificationNode, { names: VERIFICATION_CHILDREN }, file, findings);
|
|
721
|
+
if (covers.length === 0) {
|
|
722
|
+
obligation(`checkpoint ${checkpoint.name} covers no tasks; delegated checkpoints must list covered task_ids`, coversNode?.line ?? checkpoint.line);
|
|
723
|
+
}
|
|
724
|
+
if (scope.length === 0) {
|
|
725
|
+
obligation(`checkpoint ${checkpoint.name} declares an empty scope; delegated checkpoints must list reviewed files`, scopeNode?.line ?? checkpoint.line);
|
|
726
|
+
}
|
|
727
|
+
if (reviewers.length === 0) {
|
|
728
|
+
obligation(`checkpoint ${checkpoint.name} declares no reviewers; delegated checkpoints require a non-empty spec/code reviewer set`, reviewersNode?.line ?? checkpoint.line);
|
|
729
|
+
}
|
|
730
|
+
if (!acceptanceNode || children(acceptanceNode, "criterion").length === 0) {
|
|
731
|
+
obligation(`checkpoint ${checkpoint.name} has no acceptance criteria`, acceptanceNode?.line ?? checkpoint.line);
|
|
732
|
+
}
|
|
733
|
+
if (!verificationNode || children(verificationNode, "command").length === 0) {
|
|
734
|
+
obligation(`checkpoint ${checkpoint.name} has no verification commands`, verificationNode?.line ?? checkpoint.line);
|
|
735
|
+
}
|
|
736
|
+
validCheckpoints.push({
|
|
737
|
+
node: checkpoint,
|
|
738
|
+
id: checkpoint.name,
|
|
739
|
+
kind,
|
|
740
|
+
afterWave,
|
|
741
|
+
covers,
|
|
742
|
+
scope,
|
|
743
|
+
reviewers,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
// Delegated task write-scope obligations.
|
|
747
|
+
lintDelegatedWriteScopes(facts, completeness, findings, file);
|
|
748
|
+
const finals = validCheckpoints.filter((c) => c.kind === "final");
|
|
749
|
+
if (finals.length > 1) {
|
|
750
|
+
for (const final of finals.slice(1)) {
|
|
751
|
+
findings.push({
|
|
752
|
+
severity: "error",
|
|
753
|
+
rule: "execution.final_count",
|
|
754
|
+
message: `checkpoint ${final.id} is an additional final checkpoint; exactly one final checkpoint is allowed`,
|
|
755
|
+
file,
|
|
756
|
+
line: final.node.line,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const declaredCheckpointCount = reviewCheckpointsNode.children.filter((c) => IDENTITY_PATTERNS.checkpoint.test(c.name)).length;
|
|
761
|
+
if (finals.length === 0 && declaredCheckpointCount > 0) {
|
|
762
|
+
obligation("delegated execution declares no final checkpoint; exactly one final checkpoint is required", reviewCheckpointsNode.line, "execution.final_count");
|
|
763
|
+
}
|
|
764
|
+
if (finals.length === 1) {
|
|
765
|
+
const finalCheckpoint = finals[0];
|
|
766
|
+
const lastWave = facts.waveOrder[facts.waveOrder.length - 1];
|
|
767
|
+
if (lastWave &&
|
|
768
|
+
finalCheckpoint.afterWave !== lastWave &&
|
|
769
|
+
waveIndex.has(finalCheckpoint.afterWave)) {
|
|
770
|
+
findings.push({
|
|
771
|
+
severity: "error",
|
|
772
|
+
rule: "execution.final_wave",
|
|
773
|
+
message: `final checkpoint ${finalCheckpoint.id} must sit after the last declared wave ${lastWave}, not ${finalCheckpoint.afterWave}`,
|
|
774
|
+
file,
|
|
775
|
+
line: finalCheckpoint.node.line,
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
for (const task of facts.tasks) {
|
|
779
|
+
if (!finalCheckpoint.covers.includes(task.taskId)) {
|
|
780
|
+
findings.push({
|
|
781
|
+
severity: "error",
|
|
782
|
+
rule: "execution.final_coverage",
|
|
783
|
+
message: `final checkpoint ${finalCheckpoint.id} does not cover task ${task.taskId}; the final checkpoint must cover every declared task`,
|
|
784
|
+
file,
|
|
785
|
+
line: finalCheckpoint.node.line,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const declaredWriteScopes = new Set(facts.tasks.flatMap((task) => task.writeScope));
|
|
790
|
+
for (const scopeFile of declaredWriteScopes) {
|
|
791
|
+
if (!finalCheckpoint.scope.includes(scopeFile)) {
|
|
792
|
+
findings.push({
|
|
793
|
+
severity: "error",
|
|
794
|
+
rule: "execution.final_scope",
|
|
795
|
+
message: `final checkpoint ${finalCheckpoint.id} scope does not cover declared task write scope file ${scopeFile}`,
|
|
796
|
+
file,
|
|
797
|
+
line: finalCheckpoint.node.line,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// END_BLOCK_DELEGATED_VALIDATION
|
|
804
|
+
// START_BLOCK_DELEGATED_EXTRACTION
|
|
805
|
+
/**
|
|
806
|
+
* Strictly extract typed delegated obligations from plan content. Returns
|
|
807
|
+
* ok:false with every validation message when the plan does not declare a
|
|
808
|
+
* structurally complete and valid delegated execution section, so runtime
|
|
809
|
+
* registration and lint share one contract instead of two interpretations.
|
|
810
|
+
*/
|
|
811
|
+
export function extractDelegatedPlanDefinition(planContent, file = "plan.xml") {
|
|
812
|
+
const parsed = parseSpecXml(planContent, file);
|
|
813
|
+
const parseErrors = parsed.findings
|
|
814
|
+
.filter((f) => f.severity === "error")
|
|
815
|
+
.map((f) => `${file}: ${f.message}`);
|
|
816
|
+
if (!parsed.root || parseErrors.length > 0) {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
errors: parseErrors.length > 0 ? parseErrors : [`${file}: document has no root element`],
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
if (parsed.root.name !== "plan") {
|
|
823
|
+
return { ok: false, errors: [`${file}: root element <${parsed.root.name}> is not a plan`] };
|
|
824
|
+
}
|
|
825
|
+
const facts = collectDelegatedTaskFacts(parsed.root);
|
|
826
|
+
if (facts.duplicateTaskElements.length > 0) {
|
|
827
|
+
return {
|
|
828
|
+
ok: false,
|
|
829
|
+
errors: facts.duplicateTaskElements.map((name) => `${file}: task ${name} is declared more than once`),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const findings = [];
|
|
833
|
+
lintExecutionSection(parsed.root, file, facts, true, findings);
|
|
834
|
+
const errors = findings.filter((f) => f.severity === "error").map((f) => `${file}: ${f.message}`);
|
|
835
|
+
if (errors.length > 0) {
|
|
836
|
+
return { ok: false, errors };
|
|
837
|
+
}
|
|
838
|
+
const executionNode = child(parsed.root, "execution");
|
|
839
|
+
if (!executionNode || textOf(child(executionNode, "mode")) !== "delegated") {
|
|
840
|
+
return { ok: false, errors: [`${file}: plan does not declare execution mode delegated`] };
|
|
841
|
+
}
|
|
842
|
+
const reviewCheckpointsNode = child(executionNode, "review_checkpoints");
|
|
843
|
+
if (!reviewCheckpointsNode) {
|
|
844
|
+
return { ok: false, errors: [`${file}: delegated execution declares no review_checkpoints`] };
|
|
845
|
+
}
|
|
846
|
+
const checkpoints = [];
|
|
847
|
+
for (const checkpoint of reviewCheckpointsNode.children) {
|
|
848
|
+
if (!IDENTITY_PATTERNS.checkpoint.test(checkpoint.name))
|
|
849
|
+
continue;
|
|
850
|
+
const fileTexts = (container) => {
|
|
851
|
+
const node = child(checkpoint, container);
|
|
852
|
+
return node ? children(node, "file").map((entry) => textOf(entry)) : [];
|
|
853
|
+
};
|
|
854
|
+
const childTexts = (container, entry) => {
|
|
855
|
+
const node = child(checkpoint, container);
|
|
856
|
+
return node ? children(node, entry).map((item) => textOf(item)) : [];
|
|
857
|
+
};
|
|
858
|
+
checkpoints.push({
|
|
859
|
+
checkpointId: checkpoint.name,
|
|
860
|
+
kind: textOf(child(checkpoint, "kind")) === "final" ? "final" : "milestone",
|
|
861
|
+
afterWave: textOf(child(checkpoint, "after_wave")),
|
|
862
|
+
covers: childTexts("covers", "task_id"),
|
|
863
|
+
scope: fileTexts("scope").map((raw) => {
|
|
864
|
+
const normalized = normalizeDeclaredScopePath(raw);
|
|
865
|
+
return normalized.ok ? normalized.path : raw;
|
|
866
|
+
}),
|
|
867
|
+
reviewers: childTexts("reviewers", "reviewer").map((value) => {
|
|
868
|
+
return value === "spec" ? "spec" : "code";
|
|
869
|
+
}),
|
|
870
|
+
acceptance: childTexts("acceptance", "criterion"),
|
|
871
|
+
verification: childTexts("verification", "command"),
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
ok: true,
|
|
876
|
+
definition: {
|
|
877
|
+
mode: "delegated",
|
|
878
|
+
waves: facts.waveOrder,
|
|
879
|
+
tasks: facts.tasks.map((task) => ({
|
|
880
|
+
taskId: task.taskId,
|
|
881
|
+
taskElement: task.taskElement,
|
|
882
|
+
wave: task.wave,
|
|
883
|
+
writeScope: [...task.writeScope],
|
|
884
|
+
})),
|
|
885
|
+
checkpoints,
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
// END_BLOCK_DELEGATED_EXTRACTION
|
|
337
890
|
// START_BLOCK_CONTRACT_CHECKS
|
|
338
891
|
/** Report children that are neither allowed fixed names nor valid identity elements. */
|
|
339
892
|
function checkChildren(node, rule, file, findings, extraAllowed = []) {
|
|
@@ -678,6 +1231,9 @@ function lintPlan(root, file, parseFindings, specByLabel, options) {
|
|
|
678
1231
|
const verificationNode = child(task, "verification");
|
|
679
1232
|
if (verificationNode)
|
|
680
1233
|
checkChildren(verificationNode, { names: VERIFICATION_CHILDREN }, file, findings);
|
|
1234
|
+
const writeScopeNode = child(task, "write_scope");
|
|
1235
|
+
if (writeScopeNode)
|
|
1236
|
+
checkChildren(writeScopeNode, { names: TASK_WRITE_SCOPE_CHILDREN }, file, findings);
|
|
681
1237
|
const taskStatusNode = child(task, "status");
|
|
682
1238
|
const taskStatus = textOf(taskStatusNode);
|
|
683
1239
|
if (!TASK_STATUSES.has(taskStatus)) {
|
|
@@ -803,6 +1359,8 @@ function lintPlan(root, file, parseFindings, specByLabel, options) {
|
|
|
803
1359
|
});
|
|
804
1360
|
}
|
|
805
1361
|
}
|
|
1362
|
+
// Optional execution section: delegated/classic/inline intent and checkpoints.
|
|
1363
|
+
lintExecutionSection(root, file, collectDelegatedTaskFacts(root), completeness, findings);
|
|
806
1364
|
// Cross-file: plan components are a subset of spec components.
|
|
807
1365
|
if (!options.skipCrossFile) {
|
|
808
1366
|
const specPath = textOf(specPathNodeEarly);
|