@starci/skills 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/INDEX.md +3 -1
  2. package/INDEX.vi.md +3 -1
  3. package/README.md +6 -1
  4. package/README.vi.md +6 -1
  5. package/knowledge/ui/presentation/INDEX.md +5 -0
  6. package/knowledge/ui/presentation/INDEX.vi.md +1 -0
  7. package/knowledge/ui/presentation/radius.md +183 -0
  8. package/knowledge/ui/presentation/radius.vi.md +182 -0
  9. package/operators/INDEX.md +5 -5
  10. package/operators/INDEX.vi.md +5 -5
  11. package/operators/architecture-decide/operator.md +27 -4
  12. package/operators/architecture-decide/operator.vi.md +23 -4
  13. package/operators/architecture-decide/self-test.mjs +18 -1
  14. package/operators/architecture-decide/validate.mjs +29 -1
  15. package/operators/backend-source-apply/operator.md +197 -182
  16. package/operators/backend-source-apply/operator.vi.md +190 -178
  17. package/operators/backend-source-apply/self-test.mjs +257 -257
  18. package/operators/backend-source-apply/validate.mjs +240 -240
  19. package/operators/business-decide/self-test.mjs +2 -1
  20. package/operators/business-decide/validate.mjs +3 -0
  21. package/operators/content-generate/self-test.mjs +1 -1
  22. package/operators/frontend-direction-decide/self-test.mjs +1 -1
  23. package/operators/frontend-presentation-resolve/operator.md +6 -0
  24. package/operators/frontend-presentation-resolve/operator.vi.md +6 -0
  25. package/operators/frontend-presentation-resolve/self-test.mjs +6 -5
  26. package/operators/frontend-presentation-resolve/validate.mjs +30 -6
  27. package/operators/frontend-source-apply/operator.md +3 -1
  28. package/operators/frontend-source-apply/operator.vi.md +3 -1
  29. package/operators/frontend-source-apply/self-test.mjs +1 -1
  30. package/operators/frontend-surface-audit/self-test.mjs +1 -1
  31. package/operators/git-publish/self-test.mjs +1 -1
  32. package/operators/platform-operate/self-test.mjs +1 -1
  33. package/operators/quality-verify/operator.md +5 -1
  34. package/operators/quality-verify/operator.vi.md +5 -1
  35. package/operators/quality-verify/self-test.mjs +15 -2
  36. package/operators/quality-verify/validate.mjs +21 -1
  37. package/operators/release-deploy/self-test.mjs +235 -235
  38. package/operators/uat-verify/operator.md +20 -6
  39. package/operators/uat-verify/operator.vi.md +21 -7
  40. package/operators/uat-verify/self-test.mjs +6 -5
  41. package/operators/uat-verify/validate.mjs +10 -4
  42. package/operators/workspace-bind/operator.md +18 -4
  43. package/operators/workspace-bind/operator.vi.md +16 -4
  44. package/operators/workspace-bind/self-test.mjs +3 -1
  45. package/operators/workspace-bind/validate.mjs +1 -0
  46. package/package.json +1 -1
  47. package/readiness/initialization/workspaces/local-route.schema.json +1 -1
  48. package/resources/orchestrator.json +14 -8
  49. package/routing.json +2 -1
  50. package/scripts/validate-request.mjs +12 -1
  51. package/scripts/validate-response.mjs +14 -3
  52. package/scripts/validate-step.mjs +2 -1
  53. package/scripts/validate-workflows.mjs +8 -1
  54. package/templates/kinds/architecture-decision.contract.json +1 -0
  55. package/templates/kinds/architecture-decision.skeleton.md +6 -0
  56. package/templates/kinds/backend-source-application.contract.json +1 -1
  57. package/templates/kinds/backend-source-application.skeleton.md +1 -1
  58. package/templates/kinds/frontend-presentation-resolution.contract.json +1 -1
  59. package/templates/kinds/frontend-presentation-resolution.skeleton.md +1 -0
  60. package/templates/kinds/mutations.schema.json +65 -65
  61. package/templates/kinds/route.schema.json +4 -2
  62. package/templates/kinds/stack-model.schema.json +23 -1
  63. package/templates/kinds/workspace-route-binding.contract.json +2 -2
  64. package/templates/step/response.schema.json +10 -0
  65. package/templates/step/state.schema.json +205 -0
  66. package/workflows/README.md +3 -0
  67. package/workflows/README.vi.md +3 -0
  68. package/workflows/backend-feature.json +26 -5
  69. package/workflows/content-unit.json +4 -1
  70. package/workflows/frontend-new-surface.json +15 -5
  71. package/workflows/frontend-reconstruct.json +10 -4
  72. package/workflows/frontend-refine.json +10 -4
  73. package/workflows/frontend-with-uat.json +15 -3
  74. package/workflows/full-feature.json +31 -8
  75. package/workflows/release.json +7 -1
@@ -1,7 +1,7 @@
1
1
  // Proves validate.mjs on a synthetic session branch: one conforming resolution of a three-node tree
2
2
  // (one application-owned gap, one Grammar-owned padding, one recorded gap), one branch with the
3
- // contract emission off, one blocked on RULE_MISSING before anything was written, and one mutation
4
- // per law, each of which must fail with a line that names the defect.
3
+ // contract emission off, one blocked on RULE_MISSING before anything was written, one whose gap rule
4
+ // claims no attribute because its node publishes no path for one, and one mutation per law, each of which must fail with a line that names the defect.
5
5
  import assert from 'node:assert/strict';
6
6
  import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
@@ -107,7 +107,7 @@ function writeBranch(files) {
107
107
  for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
108
108
  mkdirSync(path.join(session, 'step-1', 'parallel-1', 'response'), { recursive: true });
109
109
  writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'response.md'), '# frontend-direction-decision — plan-picker\n');
110
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1'], ['2/1']], steps: { '1/1': 'frontend.direction.decide', '2/1': 'frontend.presentation.resolve' }, current: '2/1', status: 'running' }));
110
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1'], ['2/1']], steps: { '1/1': 'frontend.direction.decide', '2/1': 'frontend.presentation.resolve' }, current: '2/1', status: 'running' }));
111
111
  for (const [name, content] of Object.entries(files)) {
112
112
  if (content === null) continue;
113
113
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -140,13 +140,14 @@ const mutate = (change) => { const m = inventory(); change(m); return { ...basel
140
140
  await expectValid(baseline(), 'one application-owned gap, one Grammar-owned padding, one recorded gap');
141
141
  await expectValid({ ...baseline(), 'request/request.json': requestJson({ extra: { contractEmission: 'off' } }), [TREE]: resolvedTree({ emission: 'off' }) }, 'contract emission off leaves the tree bare');
142
142
  await expectValid({ 'request/request.json': requestJson(), 'response/response.json': responseJson({ status: 'blocked', stop: 'RULE_MISSING', next: [], fields: {} }) }, 'blocked on RULE_MISSING before anything was written');
143
+ await expectValid({ ...baseline(), [TREE]: resolvedTree().replace(' data-contract="GAP-1"', '') }, 'a rule whose only node is recorded under Gaps claims no attribute');
143
144
 
144
145
  await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { mystery: 1 } }) }, 'requirements.mystery is not a field', 'undeclared requirement');
145
146
  await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { maxRounds: 0 } }) }, 'maxRounds must be a positive whole number', 'no rounds allowed');
146
147
  await expectError(mutate((m) => { m.ruleIds.push('GAP-99'); }), 'is outside the published presentation inventory', 'unpublished rule');
147
148
  await expectError(mutate((m) => { m.classNames.push('gap-11'); }), 'is in the inventory and not in the resolved tree', 'class that never reached the tree');
148
149
  await expectError(mutate((m) => { m.treeFingerprint = 'nope'; }), 'treeFingerprint', 'inventory schema');
149
- await expectError({ ...baseline(), [TREE]: resolvedTree().replace(' data-contract="GAP-1"', '') }, 'no node claims it under data-contract', 'applied rule with no claim');
150
+ await expectError({ ...baseline(), [TREE]: resolvedTree().replace(' data-contract="GAP-5"', '') }, 'no node claims it under data-contract', 'applied rule with no claim');
150
151
  await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { contractEmission: 'off' } }) }, 'contract emission is off and the resolved tree carries a data-contract', 'claims written under emission off');
151
152
  await expectError({ ...baseline(), 'response/response.md': responseMd().replace('| `body>main` | `GAP-5` | `gap-6` |', '| `body>main` | `GAP-5` | `gap-5` |'), 'response/data/inventory.json': (() => { const m = inventory(); m.classNames = ['gap-5', 'gap-1']; return m; })(), [TREE]: resolvedTree().replace('gap-6', 'gap-5') }, 'renders GAP-5 as gap-5, expected step 6', 'ordinal written as the step');
152
153
  await expectError({ ...baseline(), 'response/response.md': responseMd().replace(`| \`${SECTION}\` | padding | grammar | \`PADDING-4\` |`, `| \`${SECTION}\` | padding | app | \`PADDING-4\` |`) }, 'owns PADDING-4 and chooses no class for it', 'application claims an owned property and writes nothing');
@@ -160,4 +161,4 @@ await expectError({ ...baseline(), 'response/data/inventory.json': null, 'respon
160
161
  await expectError({ ...baseline(), 'response/response.json': responseJson({ status: 'blocked', stop: 'MADE_UP_CODE', next: [] }) }, 'not a registered code', 'unknown stop code');
161
162
  await expectError({ ...baseline(), 'response/response.json': responseJson({ fallbacks: ['RULE_MISSING'] }) }, 'has disposition terminate under these requirements', 'a terminate code taken as a fallback');
162
163
 
163
- process.stdout.write('frontend.presentation.resolve self-test: 3 valid branches, 18 rejected mutations\n');
164
+ process.stdout.write('frontend.presentation.resolve self-test: 4 valid branches, 18 rejected mutations\n');
@@ -3,7 +3,8 @@
3
3
  // carries appears in the resolved tree; a Grammar-owned property emits no application class while an
4
4
  // application-owned one emits a class whose step matches the ordinal of its rule; the gaps the
5
5
  // receipt lists are exactly the gaps the inventory records; and with emission on every claimed rule
6
- // reaches the tree as a data-contract token.
6
+ // reaches the tree as a data-contract token, except a rule whose every node the receipt records under
7
+ // `## Gaps`, which has no attribute to reach.
7
8
  import { existsSync } from 'node:fs';
8
9
  import { readFile, readdir } from 'node:fs/promises';
9
10
  import path from 'node:path';
@@ -67,6 +68,21 @@ export async function validateResolutionStep(branchDir, root = ROOT) {
67
68
  if (!published.has(ruleId)) errors.push(`${at}: rule ${ruleId} is outside the published presentation inventory (UNKNOWN_RULE)`);
68
69
  }
69
70
 
71
+ // The receipt is read before the tree, because a rule recorded under `## Gaps` is a rule whose node
72
+ // publishes no path for the attribute: the emission check below must know that before it accuses the
73
+ // tree of hiding a claim that could never have been written.
74
+ const receiptText = present.has('frontend-presentation-resolution') && has('response/response.md') ? await read('response/response.md') : null;
75
+ const gapRows = receiptText === null ? [] : (tableUnder(receiptText, '## Gaps') ?? []);
76
+ const gapNodes = new Set(gapRows.map(([node]) => node));
77
+ const chosenRows = receiptText === null ? [] : (tableUnder(receiptText, '## Rules chosen') ?? []);
78
+ // A rule is exempt only when every node that chose it is a gap node; one ordinary node still owes
79
+ // its attribute.
80
+ const gapOnlyRules = new Set();
81
+ for (const [, rule] of chosenRows) {
82
+ const nodes = chosenRows.filter(([, r]) => r === rule).map(([n]) => n);
83
+ if (nodes.length && nodes.every((n) => gapNodes.has(n))) gapOnlyRules.add(String(rule).replace(/`/g, ''));
84
+ }
85
+
70
86
  // The resolved tree is the only place the classes may land, so every class the inventory claims
71
87
  // must actually be in it.
72
88
  const treeRefs = list(response.fields?.['resolved-tree']);
@@ -79,19 +95,23 @@ export async function validateResolutionStep(branchDir, root = ROOT) {
79
95
  }
80
96
  if (emission === 'on') {
81
97
  for (const ruleId of inventory.ruleIds) {
98
+ // A property the application owns on a Grammar component's className carries no attribute:
99
+ // the component forwards className and nothing else, so the claim is recorded under `## Gaps`
100
+ // instead of being demanded of a tree that cannot carry it.
101
+ if (gapOnlyRules.has(ruleId)) continue;
82
102
  const claimed = [...tree.matchAll(/data-contract="([^"]*)"/g)].some((m) => m[1].split(/\s+/).includes(ruleId));
83
103
  if (!claimed) errors.push(`${at}: rule ${ruleId} is applied and no node claims it under data-contract`);
84
104
  }
85
105
  } else if (/data-contract=/.test(tree)) errors.push('response/artifacts: contract emission is off and the resolved tree carries a data-contract attribute');
86
106
  }
87
107
 
88
- if (present.has('frontend-presentation-resolution') && has('response/response.md')) {
89
- const text = await read('response/response.md');
108
+ if (receiptText !== null) {
109
+ const text = receiptText;
90
110
  const rel = 'response/response.md';
91
111
  const owners = tableUnder(text, '## Owner map') ?? [];
92
- const chosen = tableUnder(text, '## Rules chosen') ?? [];
112
+ const chosen = chosenRows;
93
113
  const removed = tableUnder(text, '## Removed') ?? [];
94
- const gaps = tableUnder(text, '## Gaps') ?? [];
114
+ const gaps = gapRows;
95
115
 
96
116
  const appRules = new Set();
97
117
  const grammarRules = new Set();
@@ -129,7 +149,11 @@ export async function validateResolutionStep(branchDir, root = ROOT) {
129
149
  const receiptGaps = gaps.map(([node, property, missing]) => `${node}|${property}|${missing}`).sort();
130
150
  const dataGaps = inventory.gaps.map((g) => `${g.nodePath}|${g.property}|${g.missingPath}`).sort();
131
151
  if (receiptGaps.join('\n') !== dataGaps.join('\n')) {
132
- errors.push(`${rel}: the Gaps table and inventory.gaps differ (${receiptGaps.length} rows against ${dataGaps.length})`);
152
+ // Two counts say nothing about which row is wrong, so the first row that differs is named.
153
+ const width = Math.max(receiptGaps.length, dataGaps.length);
154
+ let first = 0;
155
+ while (first < width && receiptGaps[first] === dataGaps[first]) first += 1;
156
+ errors.push(`${rel}: the Gaps table and inventory.gaps differ (${receiptGaps.length} rows against ${dataGaps.length}); the first differing row is the receipt's ${receiptGaps[first] ?? '(absent)'} against the inventory's ${dataGaps[first] ?? '(absent)'}`);
133
157
  }
134
158
  }
135
159
  return { errors };
@@ -95,7 +95,9 @@ knows what it wrote, never how it renders.
95
95
  | 7 | Emit | — | everything above | `response/response.md`, `response/changes.md`, `response/response.json` | — |
96
96
 
97
97
  Under `mode = dry` the branch stops after step 4 with the plan alone: `writes.json` carries a null
98
- commit, `response.json` carries no commit, and the checkout is untouched. Under `apply`, step 5 writes
98
+ commit, `response.json` carries no commit, and the checkout is untouched. A dry run is granted neither
99
+ `@tools/sourcewrite` nor `@tools/git`, because a mode that writes nothing needs no tool that can
100
+ write; the grant and this paragraph say the same thing so neither can drift. Under `apply`, step 5 writes
99
101
  and commits exactly once and step 6 proves that the committed tree is the resolved tree. `changes.md`
100
102
  is the record the next steps read: which paths moved, which claims they carry, which gates the
101
103
  checkout pins for them, and which surfaces must now be observed.
@@ -94,7 +94,9 @@ không bao giờ biết nó render ra sao.
94
94
  | 7 | Phát | — | mọi thứ ở trên | `response/response.md`, `response/changes.md`, `response/response.json` | — |
95
95
 
96
96
  Dưới `mode = dry`, nhánh dừng sau bước 4 với riêng bản kế hoạch: `writes.json` mang commit rỗng,
97
- `response.json` không mang commit nào, và checkout y nguyên. Dưới `apply`, bước 5 ghi rồi commit đúng
97
+ `response.json` không mang commit nào, và checkout y nguyên. Một lượt dry không được cấp
98
+ `@tools/sourcewrite` lẫn `@tools/git`, vì một chế độ không ghi gì thì không cần công cụ nào ghi được;
99
+ phần cấp quyền và đoạn này nói cùng một điều nên chúng không thể trôi khỏi nhau. Dưới `apply`, bước 5 ghi rồi commit đúng
98
100
  một lần và bước 6 chứng minh rằng cây đã commit đúng là cây đã resolve. `changes.md` là bản ghi mà
99
101
  các bước sau đọc: path nào đã dịch chuyển, chúng mang lời khai nào, checkout ghim cổng nào cho chúng,
100
102
  và bề mặt nào bây giờ phải được quan sát.
@@ -145,7 +145,7 @@ function writeBranch(files, { inventoryDoc = inventory(), withInventory = true }
145
145
  writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'response.md'), '# frontend-direction-decision — plan-picker\n');
146
146
  writeFileSync(path.join(session, 'step-2', 'parallel-1', 'response', 'response.md'), '# frontend-presentation-resolution — plan-picker\n');
147
147
  if (withInventory) writeFileSync(path.join(upstream, 'inventory.json'), JSON.stringify(inventoryDoc, null, 2));
148
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: SESSION, chain: [['2/1'], ['3/1']], steps: { '2/1': 'frontend.presentation.resolve', '3/1': 'frontend.source.apply' }, current: '3/1', status: 'running' }));
148
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: SESSION, project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['2/1'], ['3/1']], steps: { '2/1': 'frontend.presentation.resolve', '3/1': 'frontend.source.apply' }, current: '3/1', status: 'running' }));
149
149
  for (const [name, content] of Object.entries(files)) {
150
150
  if (content === null) continue;
151
151
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -123,7 +123,7 @@ function writeBranch(files) {
123
123
  mkdirSync(dir, { recursive: true });
124
124
  writeFileSync(path.join(dir, 'response.md'), `# ${name} — plan-picker\n`);
125
125
  }
126
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['3/1'], ['4/1']], steps: { '3/1': 'frontend.source.apply', '4/1': 'frontend.surface.audit' }, current: '4/1', status: 'running' }));
126
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['3/1'], ['4/1']], steps: { '3/1': 'frontend.source.apply', '4/1': 'frontend.surface.audit' }, current: '4/1', status: 'running' }));
127
127
  for (const [name, content] of Object.entries(files)) {
128
128
  if (content === null) continue;
129
129
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -102,7 +102,7 @@ function writeBranch(files) {
102
102
  const session = mkdtempSync(path.join(tmpdir(), 'publish-session-'));
103
103
  const branch = path.join(session, 'step-1', 'parallel-1');
104
104
  for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
105
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'git.publish' }, current: '1/1', status: 'running' }));
105
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'git.publish' }, current: '1/1', status: 'running' }));
106
106
  for (const name of ['response.md', 'changes.md', 'quality.md']) writeFileSync(path.join(branch, 'response', name), '# placeholder\n');
107
107
  for (const [name, content] of Object.entries(files)) {
108
108
  if (content === null) continue;
@@ -122,7 +122,7 @@ function writeBranch(files) {
122
122
  const session = mkdtempSync(path.join(tmpdir(), 'platform-session-'));
123
123
  const branch = path.join(session, 'step-1', 'parallel-1');
124
124
  for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
125
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'platform.operate' }, current: '1/1', status: 'running' }));
125
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'platform.operate' }, current: '1/1', status: 'running' }));
126
126
  for (const [name, content] of Object.entries(files)) {
127
127
  if (content === null) continue;
128
128
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -15,7 +15,11 @@ predecessor receipt must report the same source head, and that head must be the
15
15
  `request/request.json` froze; two predecessors on different heads describe two different deliveries,
16
16
  and gating the union of them measures something nobody built. That is `PREDECESSOR_MIXED`, refused
17
17
  before a single command runs rather than discovered later as a confusing gate failure. A predecessor
18
- whose fingerprint no longer matches the frozen source is `PREDECESSOR_STALE`. What a predecessor
18
+ whose fingerprint no longer matches the frozen source is `PREDECESSOR_STALE`. So is a predecessor
19
+ produced under `mode: dry`: it carries no commit and describes a plan rather than a delivery, its
20
+ change record says `nothing written` and its receipt's `Commit` reads `—`, and it is refused at step 2
21
+ before any command runs, because a plan has no head to stand on and gating the base head would
22
+ publish a green verdict about code nobody wrote. What a predecessor
19
23
  decided is consumed unchanged: this operator never re-plans the delivery, re-opens its boundary, or
20
24
  forms an opinion about whether the change was a good one.
21
25
 
@@ -15,7 +15,11 @@ tiền nhiệm phải báo cùng một source head, và head đó phải là hea
15
15
  băng; hai tiền nhiệm trên hai head khác nhau mô tả hai delivery khác nhau, và gác hợp của chúng là đo
16
16
  một thứ không ai xây. Đó là `PREDECESSOR_MIXED`, bị từ chối trước khi có lệnh nào chạy thay vì lộ ra
17
17
  về sau dưới dạng một lỗi cổng khó hiểu. Một tiền nhiệm có fingerprint không còn khớp source đã đóng
18
- băng là `PREDECESSOR_STALE`. Điều tiền nhiệm đã quyết được tiêu thụ nguyên vẹn: operator này không
18
+ băng là `PREDECESSOR_STALE`. Một tiền nhiệm làm ra dưới `mode: dry` cũng vậy: không mang commit nào
19
+ và mô tả một bản kế hoạch chứ không phải một delivery, bản ghi thay đổi của nó ghi `nothing written`
20
+ và ô `Commit` của biên nhận ghi `—`, nên nó bị từ chối ở bước 2 trước khi có lệnh nào chạy, bởi một
21
+ bản kế hoạch không có head nào để đứng lên và gác head gốc là công bố một phán quyết xanh về đoạn code
22
+ chưa ai viết. Điều tiền nhiệm đã quyết được tiêu thụ nguyên vẹn: operator này không
19
23
  bao giờ lập lại kế hoạch delivery, mở lại ranh giới của nó, hay có ý kiến về việc thay đổi ấy hay dở.
20
24
 
21
25
  ## Head được xác nhận ngay trong cổng vào
@@ -109,10 +109,12 @@ function writeBranch(files) {
109
109
  const session = mkdtempSync(path.join(tmpdir(), 'quality-session-'));
110
110
  const branch = path.join(session, 'step-1', 'parallel-1');
111
111
  for (const d of ['request', 'response/data/gates', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
112
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'quality.verify' }, current: '1/1', status: 'running' }));
112
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['1/1']], steps: { '1/1': 'quality.verify' }, current: '1/1', status: 'running' }));
113
113
  mkdirSync(path.join(session, 'step-1', 'parallel-1', 'response'), { recursive: true });
114
114
  writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'changes.md'), '# changes\n');
115
+ if (files.__predecessorChanges !== undefined) writeFileSync(path.join(session, 'step-1', 'parallel-1', 'response', 'changes.md'), files.__predecessorChanges);
115
116
  for (const [name, content] of Object.entries(files)) {
117
+ if (name === '__predecessorChanges') continue;
116
118
  if (content === null) continue;
117
119
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
118
120
  }
@@ -206,5 +208,16 @@ await expectError(baseline({ 'response/response.md': responseMd().replace('## Ve
206
208
  await expectError(baseline({ 'response/data/gates/lint.json': { ...gateResult('lint'), sourceHead: 'nope' } }), 'sourceHead', 'gate-result schema');
207
209
  await expectError(baseline({ 'response/response.json': responseJson({ gates: ['response/data/gates/format.json', 'response/data/gates/lint.json'] }) }), 'gate-result does not list', 'a gate file the response never lists');
208
210
  await expectError(baseline({ 'request/request.json': requestJson({ inputs: {} }) }), 'needs at least one of', 'a verification with no producer receipt');
211
+ await expectError(baseline({ __predecessorChanges: `# changes — backend.source.apply step-1/parallel-1
209
212
 
210
- process.stdout.write('quality.verify self-test: 3 valid branches, 25 rejected mutations\n');
213
+ ## Binding
214
+
215
+ | Field | Value |
216
+ | --- | --- |
217
+ | Operator | \`backend.source.apply\` |
218
+ | Step | \`step-1/parallel-1\` |
219
+ | Checkout | \`@workspaces/be\` at \`${HEAD}\` on \`session/s-test\`, nothing written |
220
+ | Predecessor | \`step-1/parallel-1/response/response.md\` |
221
+ ` }), 'PREDECESSOR_STALE', 'a predecessor produced under mode dry');
222
+
223
+ process.stdout.write('quality.verify self-test: 3 valid branches, 26 rejected mutations\n');
@@ -1,6 +1,7 @@
1
1
  // quality.verify's own law over one branch, on top of the shared step check: the gate plan the
2
2
  // person declared is the plan that ran, one file per gate; every gate file stands on the same head,
3
- // which is the head the request pinned and the commit the predecessor recorded; e2e runs only under
3
+ // which is the head the request pinned and the commit the predecessor recorded, and no predecessor was
4
+ // produced under mode dry; e2e runs only under
4
5
  // an explicit request and sonar carries the scope it was measured at; a result's exit code, evidence
5
6
  // and classification match its status; coverage exists exactly where the unit gate produced it and no
6
7
  // metric sits below its own threshold beside a green unit result; a debt is live, in-boundary and
@@ -11,6 +12,7 @@ import path from 'node:path';
11
12
  import process from 'node:process';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { validateStep } from '../../scripts/validate-step.mjs';
15
+ import { sessionRootOf } from '../../scripts/validate-request.mjs';
14
16
  import { tableUnder } from '../../scripts/validate-response.mjs';
15
17
 
16
18
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
@@ -74,6 +76,24 @@ export async function validateQualityStep(branchDir, root = ROOT) {
74
76
  for (const d of debts) if (!plannedNames.includes(d.gate)) errors.push(`request.json: debt ${d.debtId} covers ${d.gate}, which is not a planned gate`);
75
77
 
76
78
  // At least one producer receipt: a verification with no predecessor has no delivery to measure.
79
+ // A dry predecessor is a plan, not a delivery: it has no commit, so there is no head for a gate to
80
+ // stand on. It is refused here, at step 2, rather than later as a confusing gate failure.
81
+ const sessionRoot = sessionRootOf(branchDir);
82
+ if (sessionRoot) {
83
+ for (const kind of ['changes', 'backend-source-application', 'frontend-source-application']) {
84
+ const ref = request?.inputs?.[kind];
85
+ if (!ref) continue;
86
+ const full = path.join(sessionRoot, ref);
87
+ if (!existsSync(full)) continue;
88
+ let text; try { text = await readFile(full, 'utf8'); } catch { continue; }
89
+ const binding = Object.fromEntries((tableUnder(text, '## Binding') ?? []).map(([k, v]) => [k, v]));
90
+ const wroteNothing = kind === 'changes'
91
+ ? /nothing written/.test(String(binding.Checkout ?? ''))
92
+ : empty(binding.Commit);
93
+ if (wroteNothing) errors.push(`request.json: PREDECESSOR_STALE — the ${kind} input ${ref} was produced under mode dry and carries no commit; a plan has no head for a gate to stand on`);
94
+ }
95
+ }
96
+
77
97
  const inputs = Object.keys(request?.inputs ?? {});
78
98
  if (response.status !== 'blocked' && !inputs.some((k) => ['backend-source-application', 'frontend-source-application', 'changes'].includes(k))) {
79
99
  errors.push('request.json: quality.verify needs at least one of backend-source-application, frontend-source-application, changes');