cabloy 5.1.200 → 5.1.202

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.
Files changed (22) hide show
  1. package/.cabloy-version +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +6 -0
  4. package/package.json +1 -1
  5. package/repo-agent-governance/managed-assets.json +21 -21
  6. package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +3 -2
  7. package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +1 -1
  8. package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +11 -10
  9. package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +1 -1
  10. package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +6 -2
  11. package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +16 -13
  12. package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +19 -12
  13. package/repo-agent-governance/tests/spec-audit.test.mjs +493 -0
  14. package/repo-agent-governance/tools/spec-audit/audit.mjs +189 -5
  15. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +26 -7
  16. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +82 -0
  17. package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +40 -1
  18. package/repo-docs/ai/ai-spec-driven-development.md +1 -0
  19. package/repo-docs/ai/playbook-spec-execution.md +3 -2
  20. package/repo-docs/ai/playbook-spec-generation.md +1 -1
  21. package/repo-docs/assets/img/cabloy-admin-ssr-cover-en.png +0 -0
  22. package/repo-docs/index.md +6 -0
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import {
6
6
  findIdentifiers,
7
+ isPlanningCompleteEligible,
8
+ isPrerequisiteSatisfied,
7
9
  markdownLines,
8
10
  parseDefinitions,
9
11
  parseProgress,
@@ -71,6 +73,46 @@ function addMapping(groups, edges, definitions) {
71
73
  }
72
74
  }
73
75
 
76
+ function traceabilityException(definition, report, annotatedLines) {
77
+ const fields = markdownLines(definition.body ?? '').flatMap(({ text, line }) =>
78
+ Array.from(text.matchAll(/\bTraceability\s+exception\b/gi), match => ({
79
+ text,
80
+ line,
81
+ start: match.index,
82
+ })),
83
+ );
84
+ if (!fields.length) return null;
85
+ for (const field of fields) {
86
+ const line = definition.line + field.line - (definition.kind === 'WBS' ? 0 : 1);
87
+ annotatedLines.add(`${definition.file}:${line}`);
88
+ }
89
+ const field = fields[0];
90
+ const value =
91
+ definition.kind === 'bullet' && field.line === 1
92
+ ? field.text.slice(field.start).trim()
93
+ : field.text.trim();
94
+ const match = value.match(/^Traceability exception:\s*(technical-only|authority-only)\s+—\s+/i);
95
+ const exception = match?.[1].toLowerCase();
96
+ const rationale = match && value.slice(match[0].length);
97
+ const validOwner =
98
+ (exception === 'technical-only' &&
99
+ definition.id.startsWith('SRS-') &&
100
+ definition.kind === 'bullet' &&
101
+ field.line === 1) ||
102
+ (exception === 'authority-only' && definition.id.startsWith('WBS-'));
103
+ if (fields.length !== 1 || !validOwner || !/[\p{L}\p{N}]/u.test(rationale ?? '')) {
104
+ report(
105
+ 'traceability-exception',
106
+ definition.file,
107
+ definition.line + field.line - (definition.kind === 'WBS' ? 0 : 1),
108
+ 'Invalid Traceability exception: use one declaration-local technical-only SRS or authority-only WBS field with a rationale.',
109
+ definition.id,
110
+ );
111
+ return null;
112
+ }
113
+ return exception;
114
+ }
115
+
74
116
  function collectMappings(records, definitions) {
75
117
  const edges = new Map(Array.from(definitions.keys(), id => [id, new Set()]));
76
118
  for (const definition of definitions.values()) {
@@ -120,6 +162,116 @@ function collectMappings(records, definitions) {
120
162
  return edges;
121
163
  }
122
164
 
165
+ function acceptedDecision(markdown) {
166
+ if (!markdown) return false;
167
+ const lines = markdownLines(markdown);
168
+ const statuses = [];
169
+ let section = null;
170
+ for (const [index, { text, heading }] of lines.entries()) {
171
+ if (heading?.level === 2) section = heading.text;
172
+ const field = text.match(/^\s*-\s*\*\*Status:\*\*/i);
173
+ if (field && (section === null || /^Status$/i.test(section)))
174
+ statuses.push(text.slice(field[0].length).trim());
175
+ if (!heading || !/^Status$/i.test(heading.text)) continue;
176
+ const value = lines.slice(index + 1).find(record => {
177
+ if (record.heading && record.heading.level <= heading.level) return true;
178
+ return record.text.trim();
179
+ });
180
+ if (value && !(value.heading && value.heading.level <= heading.level))
181
+ statuses.push(value.text.trim());
182
+ }
183
+ return statuses.length > 0 && statuses.every(status => /^Accepted\.?$/i.test(status));
184
+ }
185
+
186
+ function planningBaselineGates(records, wbs, edges, exceptions, report) {
187
+ const gates = new Set();
188
+ const field = /^\s*(?:-\s*)?(?:\*\*)?Traceability mode(?:\*\*)?\s*:/i;
189
+ const declarations = new Map();
190
+ for (const [file, markdown] of records) {
191
+ for (const { text, line } of markdownLines(markdown)) {
192
+ if (!field.test(text)) continue;
193
+ const task =
194
+ file === 'pdp-wbs.md'
195
+ ? wbs?.tasks.find(candidate => candidate.line < line && candidate.endLine >= line)
196
+ : null;
197
+ if (!task) {
198
+ report(
199
+ 'traceability-mode',
200
+ file,
201
+ line,
202
+ 'Traceability mode must be declared inside a WBS task.',
203
+ );
204
+ continue;
205
+ }
206
+ if (!declarations.has(task.id)) declarations.set(task.id, []);
207
+ declarations.get(task.id).push({ text, line });
208
+ }
209
+ }
210
+ for (const task of wbs?.tasks ?? []) {
211
+ const declared = declarations.get(task.id);
212
+ if (!declared) continue;
213
+ const [{ text, line }] = declared;
214
+ const mode = text.replace(field, '').trim().replace(/`/g, '');
215
+ const body = markdownLines(task.body).map(record => record.text);
216
+ const deliveryAction =
217
+ /(?:^|[,;]\s*|\b(?:and|then|to)\s+)(?:implement|deploy|migrate|release|regenerate|develop|ship|code|build\b(?!\s+risks?\b)|create|run\s+(?:an?\s+)?(?:migrations?|tests?|ATP-|scenarios?)|execute\s+(?:an?\s+)?(?:ATP-|tests?|scenarios?))\b/i;
218
+ const mixedScope =
219
+ deliveryAction.test(task.title) ||
220
+ body.some(value => {
221
+ const bullet = value.match(/^\s*[-*]\s+(.*)/);
222
+ return bullet && deliveryAction.test(bullet[1].replace(/^\*\*[^*]+\*\*\s*/, ''));
223
+ });
224
+ const hasItems = label =>
225
+ body.some((value, index) => {
226
+ if (value.trim().toLowerCase() !== `${label}:`) return false;
227
+ const next = body.slice(index + 1).find(line => line.trim());
228
+ return Boolean(next?.trim().match(/^[-*] \S/));
229
+ });
230
+ const authorities = body.flatMap(value => {
231
+ const match = value.match(/^\s*(?:-\s*)?(?:\*\*)?Review authority(?:\*\*)?\s*:/i);
232
+ return match ? [value.replace(match[0], '').trim()] : [];
233
+ });
234
+ const authority = authorities[0]?.match(
235
+ /^\[([^\]]+)\]\((\.\/decisions\/[^/()]+\.md)\)(?:\s*\([^\n]*\))?\s*\.?$/,
236
+ );
237
+ const adr = authority?.[2].slice(2);
238
+ const decision = adr && records.get(adr);
239
+ const mappedIncoming = [...edges].some(
240
+ ([id, targets]) => id.startsWith('SRS-') && targets.has(task.id),
241
+ );
242
+ const valid =
243
+ declared.length === 1 &&
244
+ mode === 'planning-baseline-review' &&
245
+ task.phase.number === '10' &&
246
+ /\b(?:review|freeze)\b/i.test(task.title) &&
247
+ /\bbaseline\b/i.test(task.title) &&
248
+ hasItems('tasks') &&
249
+ hasItems('acceptance checks') &&
250
+ !mixedScope &&
251
+ !findIdentifiers(task.title, 'SRS-').length &&
252
+ !findIdentifiers(task.title, 'ATP-').length &&
253
+ !findIdentifiers(task.body, 'SRS-').length &&
254
+ !findIdentifiers(task.body, 'ATP-').length &&
255
+ !mappedIncoming &&
256
+ !edges.get(task.id)?.size &&
257
+ !exceptions.has(task.id) &&
258
+ authorities.length === 1 &&
259
+ acceptedDecision(decision);
260
+ if (!valid) {
261
+ report(
262
+ 'traceability-mode',
263
+ 'pdp-wbs.md',
264
+ line,
265
+ 'Planning-baseline review requires one valid mode, Phase 10 baseline review, substantive review-only Tasks and Acceptance checks, no individual SRS/ATP mapping or other traceability exception, and one accepted local ADR review authority.',
266
+ task.id,
267
+ );
268
+ continue;
269
+ }
270
+ gates.add(task.id);
271
+ }
272
+ return gates;
273
+ }
274
+
123
275
  function headingIds(markdown) {
124
276
  const slugs = new Set();
125
277
  const counts = new Map();
@@ -271,8 +423,22 @@ export async function auditPlanning(
271
423
  report('declaration', file, 1, error.message);
272
424
  }
273
425
  }
426
+ const exceptions = new Map();
427
+ const annotatedLines = new Set();
428
+ for (const definition of definitions.values()) {
429
+ const exception = traceabilityException(definition, report, annotatedLines);
430
+ if (exception) exceptions.set(definition.id, exception);
431
+ }
274
432
  for (const [file, markdown] of records) {
275
433
  for (const { text, line } of markdownLines(markdown)) {
434
+ if (/\bTraceability\s+exception\b/i.test(text) && !annotatedLines.has(`${file}:${line}`)) {
435
+ report(
436
+ 'traceability-exception',
437
+ file,
438
+ line,
439
+ 'Traceability exception must be in its own formal declaration.',
440
+ );
441
+ }
276
442
  for (const prefix of OWNERS.keys()) {
277
443
  for (const id of findIdentifiers(referenceText(text), prefix)) {
278
444
  if (!definitions.has(id)) {
@@ -308,10 +474,23 @@ export async function auditPlanning(
308
474
  task.id,
309
475
  );
310
476
  }
311
- if (['implementation-complete', 'verified'].includes(rows.get(task.id))) {
477
+ if (rows.get(task.id) === 'planning-complete' && !isPlanningCompleteEligible(task)) {
478
+ report(
479
+ 'completion-mode',
480
+ 'progress.md',
481
+ locations?.get(task.id)?.line ?? 1,
482
+ 'Planning-complete requires a declaration-local Completion mode: planning-only field.',
483
+ task.id,
484
+ );
485
+ }
486
+ if (
487
+ ['planning-complete', 'implementation-complete', 'verified'].includes(rows.get(task.id))
488
+ ) {
312
489
  for (const dependency of task.dependencyIds) {
490
+ const predecessor = wbs.tasks.find(item => item.id === dependency);
313
491
  if (
314
- ['not-started', 'in-progress', 'blocked', 'deferred'].includes(rows.get(dependency))
492
+ rows.has(dependency) &&
493
+ !isPrerequisiteSatisfied(predecessor, rows.get(dependency))
315
494
  ) {
316
495
  report(
317
496
  'dependency-state',
@@ -344,10 +523,11 @@ export async function auditPlanning(
344
523
  }
345
524
  if (!lightweight) {
346
525
  const edges = collectMappings(records, definitions);
526
+ const gates = planningBaselineGates(records, wbs, edges, exceptions, report);
347
527
  const incoming = new Set([...edges.values()].flatMap(values => [...values]));
348
528
  for (const [id, definition] of definitions) {
349
- if (definition.status === 'deferred' || definition.deferred) continue;
350
- if (!id.startsWith('PRD-') && !incoming.has(id)) {
529
+ if (definition.status === 'deferred' || definition.deferred || gates.has(id)) continue;
530
+ if (!id.startsWith('PRD-') && !incoming.has(id) && exceptions.get(id) !== 'technical-only') {
351
531
  report(
352
532
  'traceability-incoming',
353
533
  definition.file,
@@ -356,7 +536,11 @@ export async function auditPlanning(
356
536
  id,
357
537
  );
358
538
  }
359
- if (!id.startsWith('ATP-') && !edges.get(id).size) {
539
+ if (
540
+ !id.startsWith('ATP-') &&
541
+ !edges.get(id).size &&
542
+ exceptions.get(id) !== 'authority-only'
543
+ ) {
360
544
  report(
361
545
  'traceability-outgoing',
362
546
  definition.file,
@@ -2,12 +2,20 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises';
3
3
  import { basename, resolve } from 'node:path';
4
4
 
5
- import { markdownLines, parseAtpIds, parseProgress, parseWbs } from './spec-parser.mjs';
5
+ import {
6
+ isPlanningCompleteEligible,
7
+ isPrerequisiteSatisfied,
8
+ markdownLines,
9
+ parseAtpIds,
10
+ parseProgress,
11
+ parseWbs,
12
+ } from './spec-parser.mjs';
6
13
  export { parseAtpIds, parseProgress, parseWbs } from './spec-parser.mjs';
7
14
 
8
15
  const STATUS_COLORS = {
9
16
  'not-started': 'var(--blue)',
10
17
  'in-progress': 'var(--orange)',
18
+ 'planning-complete': 'var(--teal)',
11
19
  'implementation-complete': 'var(--violet)',
12
20
  'verified': 'var(--aqua)',
13
21
  'blocked': 'var(--red)',
@@ -47,6 +55,7 @@ const COPY = {
47
55
  statuses: {
48
56
  'not-started': 'Not started',
49
57
  'in-progress': 'In progress',
58
+ 'planning-complete': 'Planning complete',
50
59
  'implementation-complete': 'Implementation complete',
51
60
  'verified': 'Verified',
52
61
  'blocked': 'Blocked',
@@ -104,6 +113,7 @@ const COPY = {
104
113
  statuses: {
105
114
  'not-started': '未开始',
106
115
  'in-progress': '进行中',
116
+ 'planning-complete': '规划完成',
107
117
  'implementation-complete': '实施完成',
108
118
  'verified': '已核验',
109
119
  'blocked': '受阻',
@@ -248,6 +258,11 @@ export function createChartModel({ readme, wbs, progress, testPlan }) {
248
258
  }
249
259
  task.recordedStatus = task.status;
250
260
  task.status = progressStatus;
261
+ if (task.status === 'planning-complete' && !isPlanningCompleteEligible(task)) {
262
+ throw new Error(
263
+ `${task.id} has planning-complete status without Completion mode: planning-only.`,
264
+ );
265
+ }
251
266
  if (task.deferred && task.status !== 'deferred') {
252
267
  throw new Error(
253
268
  `${task.id} is marked deferred in pdp-wbs.md but has progress status ${task.status}.`,
@@ -274,10 +289,10 @@ export function createChartModel({ readme, wbs, progress, testPlan }) {
274
289
  };
275
290
  }
276
291
 
277
- function style() {
292
+ function style(planningComplete) {
278
293
  return `<style>
279
- :root { --surface:#fcfcfb;--page:#f9f9f7;--ink:#0b0b0b;--secondary:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--blue:#2a78d6;--orange:#eb6834;--aqua:#1baf7a;--violet:#4a3aa7;--red:#e34948;--yellow:#eda100;--blue-wash:#eaf3fd;--aqua-wash:#e7f7f0; }
280
- @media (prefers-color-scheme:dark) { :root { --surface:#1a1a19;--page:#0d0d0d;--ink:#fff;--secondary:#c3c2b7;--muted:#a09f99;--grid:#2c2c2a;--axis:#383835;--blue:#3987e5;--orange:#d95926;--aqua:#199e70;--violet:#9085e9;--red:#e66767;--yellow:#c98500;--blue-wash:#172435;--aqua-wash:#122b23; } }
294
+ :root { --surface:#fcfcfb;--page:#f9f9f7;--ink:#0b0b0b;--secondary:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--blue:#2a78d6;--orange:#eb6834;${planningComplete ? '--teal:#087f83;' : ''}--aqua:#1baf7a;--violet:#4a3aa7;--red:#e34948;--yellow:#eda100;--blue-wash:#eaf3fd;--aqua-wash:#e7f7f0; }
295
+ @media (prefers-color-scheme:dark) { :root { --surface:#1a1a19;--page:#0d0d0d;--ink:#fff;--secondary:#c3c2b7;--muted:#a09f99;--grid:#2c2c2a;--axis:#383835;--blue:#3987e5;--orange:#d95926;${planningComplete ? '--teal:#29aeb0;' : ''}--aqua:#199e70;--violet:#9085e9;--red:#e66767;--yellow:#c98500;--blue-wash:#172435;--aqua-wash:#122b23; } }
281
296
  .canvas{fill:var(--page)}.card{fill:var(--surface);stroke:var(--grid);stroke-width:1}.title{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:27px;font-weight:700}.subtitle{fill:var(--secondary);font-family:Arial,Helvetica,sans-serif;font-size:14px}.heading{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700}.phase{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:700}.task{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:10px}.small{fill:var(--muted);font-family:Arial,Helvetica,sans-serif;font-size:9px}.caption{fill:var(--secondary);font-family:Arial,Helvetica,sans-serif;font-size:11px}.num{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}.grid{stroke:var(--grid);stroke-width:1;shape-rendering:crispEdges}.axis{stroke:var(--axis);stroke-width:1;shape-rendering:crispEdges}.row:focus{outline:2px solid var(--ink);outline-offset:2px}
282
297
  </style>`;
283
298
  }
@@ -302,13 +317,13 @@ function renderChartTextLines(className, x, y, lines, lineHeight) {
302
317
  return `<text class="${className}" x="${x}" y="${y}">${lines.map((line, lineIndex) => `<tspan x="${x}" dy="${lineIndex ? lineHeight : 0}">${escapeXml(line)}</tspan>`).join('')}</text>`;
303
318
  }
304
319
 
305
- function svgDocument(title, desc, body, height, metadata) {
320
+ function svgDocument(title, desc, body, height, metadata, planningComplete = false) {
306
321
  return `<?xml version="1.0" encoding="UTF-8"?>
307
322
  <svg xmlns="http://www.w3.org/2000/svg" width="1600" height="${height}" viewBox="0 0 1600 ${height}" role="img" aria-labelledby="chart-title chart-desc">
308
323
  <title id="chart-title">${escapeXml(title)}</title>
309
324
  <desc id="chart-desc">${escapeXml(desc)}</desc>
310
325
  <metadata>${escapeXml(metadata)}</metadata>
311
- ${style()}
326
+ ${style(planningComplete)}
312
327
  ${body}
313
328
  </svg>
314
329
  `;
@@ -392,9 +407,12 @@ export function renderGantt(model, suiteName) {
392
407
  },
393
408
  )
394
409
  .join('\n');
395
- const completed = new Set(tasks.filter(task => task.status === 'verified').map(task => task.id));
410
+ const completed = new Set(
411
+ tasks.filter(task => isPrerequisiteSatisfied(task, task.status)).map(task => task.id),
412
+ );
396
413
  const nextTask = tasks.find(
397
414
  task =>
415
+ task.status !== 'planning-complete' &&
398
416
  task.status !== 'verified' &&
399
417
  task.status !== 'deferred' &&
400
418
  task.status !== 'waived' &&
@@ -418,6 +436,7 @@ export function renderGantt(model, suiteName) {
418
436
  body,
419
437
  height,
420
438
  t.metadata,
439
+ tasks.some(task => task.status === 'planning-complete'),
421
440
  );
422
441
  }
423
442
 
@@ -152,6 +152,88 @@ test('creates a mixed-status model with deferred scope separated', () => {
152
152
  assert.equal(model.activeTasks.length, 2);
153
153
  assert.equal(model.deferredTasks.length, 1);
154
154
  assert.equal(model.verified, 0);
155
+ assert.doesNotMatch(renderGantt(model, 'Demo'), /--teal:/);
156
+ });
157
+
158
+ test('planning completion requires an explicit task mode and preserves verified-only metrics', () => {
159
+ const wbs = files['pdp-wbs.md'].replace(
160
+ '#### WBS-10-01: Freeze the baseline',
161
+ '#### WBS-10-01: Freeze the baseline\n\nCompletion mode: planning-only.',
162
+ );
163
+ const progress = files['progress.md'].replace('`not-started`', '`planning-complete`');
164
+ assert.throws(
165
+ () =>
166
+ createChartModel({
167
+ readme: files['README.md'],
168
+ wbs: files['pdp-wbs.md'],
169
+ progress,
170
+ testPlan: files['test-plan.md'],
171
+ }),
172
+ /planning-complete status without Completion mode/,
173
+ );
174
+ const model = createChartModel({
175
+ readme: files['README.md'],
176
+ wbs,
177
+ progress,
178
+ testPlan: files['test-plan.md'],
179
+ });
180
+ assert.equal(model.tasks[0].completionMode, 'planning-only');
181
+ assert.equal(model.verified, 0);
182
+ const gantt = renderGantt(model, 'Demo');
183
+ assert.match(gantt, /Planning complete/);
184
+ assert.match(gantt, /--teal:#087f83/);
185
+ assert.doesNotMatch(renderBurndown(model, 'Demo'), /--teal:/);
186
+ assert.match(gantt, /class="caption" x="96" y="195">WBS-20-01: Deliver/);
187
+ assert.doesNotMatch(gantt, /class="caption" x="96" y="195">WBS-10-01/);
188
+ assert.match(renderBurndown(model, 'Demo'), />2 remaining · 0 verified</);
189
+ const blocked = createChartModel({
190
+ readme: files['README.md'],
191
+ wbs,
192
+ progress: progress.replace('`in-progress`', '`blocked`'),
193
+ testPlan: files['test-plan.md'],
194
+ });
195
+ assert.match(renderGantt(blocked, 'Demo'), /No dependency-ready WBS candidate is recorded/);
196
+ assert.equal(blocked.verified, 0);
197
+ const chinese = createChartModel({
198
+ readme: '# 中文规划\n\n完整中文记录。',
199
+ wbs,
200
+ progress,
201
+ testPlan: files['test-plan.md'],
202
+ });
203
+ assert.match(renderGantt(chinese, '示例'), /规划完成/);
204
+ assert.match(renderBurndown(chinese, '示例'), /2 剩余 · 0 已核验/);
205
+ });
206
+
207
+ test('completion mode must be unique, supported and task-local', () => {
208
+ const wbs = files['pdp-wbs.md'];
209
+ for (const value of ['unknown.', '', 'planning-only.\nCompletion mode: planning-only.']) {
210
+ assert.throws(
211
+ () => parseWbs(wbs.replace('Tasks:', `Completion mode: ${value}\n\nTasks:`)),
212
+ /Completion mode/,
213
+ );
214
+ }
215
+ assert.throws(
216
+ () =>
217
+ parseWbs(
218
+ wbs.replace('Dependencies: none.', 'Dependencies: none.\nCompletion mode: planning-only.'),
219
+ ),
220
+ /Completion mode must belong to a formal WBS task/,
221
+ );
222
+ assert.throws(
223
+ () => parseWbs(`Completion mode: planning-only.\n${wbs}`),
224
+ /Completion mode must belong to a formal WBS task/,
225
+ );
226
+ const progress = files['progress.md'].replace('`not-started`', '`planning-complete`');
227
+ assert.throws(
228
+ () =>
229
+ createChartModel({
230
+ readme: files['README.md'],
231
+ wbs: wbs.replace('Tasks:', 'Status: in-progress.\nCompletion mode: planning-only.\nTasks:'),
232
+ progress,
233
+ testPlan: files['test-plan.md'],
234
+ }),
235
+ /explicit.*Status in-progress.*planning-complete/,
236
+ );
155
237
  });
156
238
 
157
239
  test('selects the earliest dependency-ready WBS item', async t => {
@@ -2,6 +2,7 @@
2
2
  export const STATUS_ORDER = [
3
3
  'not-started',
4
4
  'in-progress',
5
+ 'planning-complete',
5
6
  'implementation-complete',
6
7
  'verified',
7
8
  'blocked',
@@ -306,6 +307,16 @@ function normalizeStatus(value) {
306
307
  return normalized;
307
308
  }
308
309
 
310
+ export function isPlanningCompleteEligible(task) {
311
+ return task.completionMode === 'planning-only';
312
+ }
313
+
314
+ export function isPrerequisiteSatisfied(task, status) {
315
+ return (
316
+ status === 'verified' || (status === 'planning-complete' && isPlanningCompleteEligible(task))
317
+ );
318
+ }
319
+
309
320
  function reviewDateFromLine(line) {
310
321
  return (
311
322
  line.match(/(?:last reviewed|最后审查日期)\s*(?:[::]\s*)?(\d{4}-\d{2}-\d{2})/i)?.[1] ?? null
@@ -360,7 +371,7 @@ export function parseProgress(markdown, { file } = {}) {
360
371
  }
361
372
 
362
373
  function labeledValue(record, labels) {
363
- const match = record.text.trim().match(/^([A-Z]+)\s*:(.*)$/i);
374
+ const match = record.text.trim().match(/^([A-Z]+(?:\s+[A-Z]+)*)\s*:(.*)$/i);
364
375
  return match && labels.includes(match[1].toLowerCase()) ? match[2].trim() : null;
365
376
  }
366
377
 
@@ -502,6 +513,11 @@ export function parseWbs(markdown, { file } = {}) {
502
513
  task = null;
503
514
  inPhase = false;
504
515
  }
516
+ if (!task && labeledValue(record, ['completion mode']) !== null) {
517
+ throw new Error(
518
+ `Completion mode must belong to a formal WBS task at ${atLocation(record, file)}.`,
519
+ );
520
+ }
505
521
  if (!inPhase) continue;
506
522
  if (task) task.body.push(record);
507
523
  else if (!phase.tasks.length) phase.preamble.push(record);
@@ -519,6 +535,29 @@ export function parseWbs(markdown, { file } = {}) {
519
535
  ? { line: dependency.line, ...(file ? { file } : {}) }
520
536
  : undefined;
521
537
  task.phase = { number: phase.number, title: phase.title };
538
+ const modeRecords = task.body.filter(
539
+ record => labeledValue(record, ['completion mode']) !== null,
540
+ );
541
+ if (modeRecords.length > 1) {
542
+ throw new Error(
543
+ `Multiple ${task.id} Completion mode fields at ${atLocation(modeRecords[1], file)}.`,
544
+ );
545
+ }
546
+ const mode = modeRecords.length
547
+ ? labeledValue(modeRecords[0], ['completion mode'])
548
+ .replace(/`/g, '')
549
+ .replace(/\.$/, '')
550
+ .toLowerCase()
551
+ : undefined;
552
+ if (mode && mode !== 'planning-only') {
553
+ throw new Error(
554
+ `Unsupported ${task.id} Completion mode at ${atLocation(modeRecords[0], file)}.`,
555
+ );
556
+ }
557
+ if (modeRecords.length && !mode) {
558
+ throw new Error(`Empty ${task.id} Completion mode at ${atLocation(modeRecords[0], file)}.`);
559
+ }
560
+ task.completionMode = mode;
522
561
  const statusRecords = task.body.filter(record => labeledValue(record, ['status']) !== null);
523
562
  if (statusRecords.length > 1) {
524
563
  throw new Error(
@@ -53,6 +53,7 @@ For the detailed authority table, identifiers, and chart boundary, see [Generate
53
53
 
54
54
  **Evidence-gated delivery** means delivery status stays precise about what is known and what has been proven.
55
55
 
56
+ - `planning-complete` means an explicitly `planning-only` WBS task's documentary checks have a named-reviewer, revision-scoped closure disposition and retained planning proof. It satisfies that task's prerequisite edge, not implementation, ATP, successor approval, or release.
56
57
  - `implementation-complete` means the source work is complete, while required ATP or release proof remains.
57
58
  - `verified` means all applicable WBS checks and ATPs have durable, linked, redacted observed evidence.
58
59
 
@@ -109,11 +109,12 @@ Use the narrowest approved check first, then follow the linked ATP procedures an
109
109
 
110
110
  Use status precisely:
111
111
 
112
- - `in-progress` — approved implementation or verification has actually begun
112
+ - `in-progress` — approved work or verification has actually begun
113
+ - `planning-complete` — only an opted-in documentary/design WBS task with a named-reviewer, revision-scoped closure disposition and linked planning proof; no source or ATP closure
113
114
  - `implementation-complete` — source work is complete, but required ATP or release proof remains
114
115
  - `verified` — all applicable WBS checks and ATPs have durable, linked, redacted observed evidence
115
116
 
116
- A successful build, generation command, hook, manual walkthrough, screenshot, or unrelated test run is not by itself `verified` unless the authoritative test plan defines it as sufficient retained proof. Evidence is revision- and authority-scoped; mark old proof superseded or requiring rerun when relevant source or authority changes.
117
+ `planning-complete` satisfies a predecessor dependency edge but does not unblock a task still recorded `blocked` or grant an execution dossier. Charts count only `verified` toward verified/remaining metrics. A successful build, generation command, hook, manual walkthrough, screenshot, or unrelated test run is not by itself `verified` unless the authoritative test plan defines it as sufficient retained proof. Evidence is revision- and authority-scoped; mark old proof superseded or requiring rerun when relevant source or authority changes.
117
118
 
118
119
  ## Refresh derived charts last
119
120
 
@@ -115,7 +115,7 @@ PRD → SRS → WBS → ATP → Evidence
115
115
 
116
116
  A product or technical change belongs in its PRD, SRS, or accepted ADR before its WBS, acceptance, progress, evidence, and chart implications are updated. A progress entry or chart cannot introduce a requirement, resolve a contract conflict, or accept an ADR.
117
117
 
118
- Planning records and derived charts do not establish `implementation-complete` or `verified`. `verified` requires the applicable acceptance procedure and retained, redacted observed evidence. A generated plan, planned command, scaffold, screenshot, or unrelated check is not automatically sufficient proof.
118
+ A WBS task may declare `Completion mode: planning-only.` only for documentary/design-only checks. It can reach `planning-complete` after an explicit named-reviewer, revision-scoped closure disposition and linked planning proof; this satisfies its dependency edge without executing source, passing future ATPs, unblocking a recorded `blocked` successor, or counting as `verified` in charts. Planning records and derived charts do not establish `implementation-complete` or `verified`. `verified` requires the applicable acceptance procedure and retained, redacted observed evidence. A generated plan, planned command, scaffold, screenshot, or unrelated check is not automatically sufficient proof.
119
119
 
120
120
  ## Check planning without claiming implementation
121
121
 
@@ -30,6 +30,12 @@ features:
30
30
  details: Organize capabilities for SSR, SPA, Web, and Admin applications with shared conventions.
31
31
  ---
32
32
 
33
+ ## Demonstrations(Videos)
34
+
35
+ ### 1. Can an Admin Site Use SSR? CabloyJS in Three Practical Demos (Duration: 1:16)
36
+
37
+ [![CabloyJS Admin SSR video](./assets/img/cabloy-admin-ssr-cover-en.png)](https://youtu.be/786IQhRdr1I)
38
+
33
39
  ## Choose a reading path
34
40
 
35
41
  ### Start a project