@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
@@ -13,8 +13,21 @@ cổng. Không có gì ở operator này là thường lệ: nó đăng nhập n
13
13
  vào một runtime dùng chung và để lại một hồ sơ lượt chạy vĩnh viễn, nên thứ khởi động nó là một con
14
14
  người, không bao giờ là lịch hẹn, mặc định của chain hay sự tiện tay của một agent khác. `runId` và
15
15
  `lease` cũng không phải câu hỏi dành cho người: orchestrator sinh mã lượt chạy và cấp lease độc quyền
16
- trên thư mục luồng trước khi nhánh bắt đầu, và một lần gọi đến mà thiếu chúng là `INVALID_INPUT` chứ
17
- không phải một lời hỏi.
16
+ trên thư mục luồng trước khi nhánh bắt đầu, và một lần gọi đến mà thiếu chúng là `INVALID_INPUT` ở bước 1 chứ
17
+ không phải một lời hỏi. Vì thế Mặc định của chúng là `—`: một ô Mặc định ghi "mã lượt chạy của
18
+ orchestrator" là lời văn chứ không phải một giá trị cổng dùng được, và một cổng chấp nhận lời văn là
19
+ một cổng chấp nhận ô rỗng. `LEASE_INVALID` là một thất bại khác và giữ chỗ riêng của nó: đó là cái
20
+ lease có tồn tại nhưng đã hết hạn, thuộc về nơi khác, hoặc đang ràng vào một lượt chạy khác, và nó bị
21
+ phát hiện ở bước 6 trên chính thư mục luồng mà lượt chạy này đang giữ.
22
+
23
+ ## Endpoint là cái đã ràng, không phải cái suy lại
24
+
25
+ Luồng được lái theo endpoint mà đầu vào `route` mang, chính cái mà nhánh `workspace.bind` của chuỗi
26
+ này đã quan sát và đóng lại. Operator này không suy lại sự sẵn sàng từ sổ đăng ký runtime: một sổ
27
+ đăng ký báo `ready` trong khi không ai lắng nghe đúng là nguồn đẩy trình duyệt vào một cổng chết, và
28
+ bước ràng đã từ chối một cổng chỉ-lắng-nghe thay cho chuỗi này rồi. Khi endpoint đã ràng không trả
29
+ lời, mã dừng là `RUNTIME_UNAVAILABLE` trên một endpoint có tên, chứ không phải một phỏng đoán xem
30
+ origin nào mới là origin được nhắc tới.
18
31
 
19
32
  ## Mật khẩu là một cái tên, không bao giờ là một giá trị
20
33
 
@@ -80,8 +93,8 @@ lượt chạy, và không xoá bất cứ thứ gì ngoài namespace fixture c
80
93
  | Alias | Bind | Bắt buộc |
81
94
  | --- | --- | --- |
82
95
  | `@worktrees/uat/<flow>/<case>` | thư mục luồng: `flow.md`, `account.json`, `seed/`, lịch sử chỉ-thêm `runs/<runId>/` và con trỏ `latest`, bind theo fingerprint từng file và chỉ ghi khi giữ lease độc quyền | có |
83
- | `@worktrees/_templates` | khuôn luồng UAT dùng để tạo thư mục luồng mới; tiêu thụ, không sửa | có |
84
- | `@worktrees/sessions/central-runtime` | chủ runtime đã sẵn sàng, generation các origin chính xác của nó; sự sẵn sàng phải được chứng minh, không được giả định | có |
96
+ | `@worktrees/_templates` | khuôn luồng UAT dùng để tạo thư mục luồng mới, đúng ba thứ mà bước 4 đọc: `uat/flow.md` với các case và những khẳng định có tên của chúng, `uat/account.json` với username, vai, tên thông tin đăng nhập và đường dẫn file niêm phong, không trường nào có thể chứa bí mật, và `uat/seed/` với các bản ghi mà một lượt chạy đặt namespace lên; tiêu thụ, không sửa | có |
97
+ | `@worktrees/sessions/central-runtime` | generation của chủ runtime đứng sau endpoint đã ràng; sự sẵn sàng do đầu vào `route` chứng minh, không bao giờ suy lại từ sổ đăng ký này | có |
85
98
  | `@workspaces/device-state` | sổ thông tin đăng nhập niêm phong; mật khẩu UAT dùng chung được giải theo tên ở đây lúc đăng nhập và không đọc ở đâu khác | có |
86
99
  | `@workspaces/be` | checkout backend được route tại commit đã ghim, nơi luồng kiểm hành vi và nơi store giữ các bản ghi có namespace | có |
87
100
 
@@ -91,6 +104,7 @@ lượt chạy, và không xoá bất cứ thứ gì ngoài namespace fixture c
91
104
  | --- | --- | --- |
92
105
  | `frontend-surface-audit` | lượt soi bề mặt kết luận frontend sạch, lấy tại commit đã ghim | có |
93
106
  | `quality-verification` | cổng chất lượng đã xanh, lấy tại đúng commit đã ghim ấy | có |
107
+ | `route` | `workspace.bind` ở vai fe; route đã ràng mà lượt chạy này lái theo endpoint của nó | có |
94
108
 
95
109
  ## Yêu cầu
96
110
 
@@ -100,8 +114,8 @@ lượt chạy, và không xoá bất cứ thứ gì ngoài namespace fixture c
100
114
  | `feature` | id | — | Khoá feature dùng để địa chỉ hoá thư mục luồng |
101
115
  | `flow` | id | — | Luồng sản phẩm duy nhất mà lần gọi này kiểm chứng |
102
116
  | `cases` | list `caseId` | every case of the flow | Chạy những case đã đóng băng nào; mặc định là mọi case `flow.md` khai, theo đúng thứ tự của nó |
103
- | `runId` | id | the orchestrator's run id | Không hỏi người: orchestrator sinh ra và nó namespace mọi bản ghi lượt chạy này ghi ra |
104
- | `lease` | token | the orchestrator's lease | Không hỏi người: orchestrator cấp lease độc quyền trên thư mục luồng trước khi nhánh bắt đầu |
117
+ | `runId` | id | | Không hỏi người: orchestrator điền nó, và nó namespace mọi bản ghi lượt chạy này ghi ra |
118
+ | `lease` | token | | Không hỏi người: orchestrator điền nó, cấp lease độc quyền trên thư mục luồng trước khi nhánh bắt đầu |
105
119
  | `resume` | token | null | Token của nhánh bị chặn khi vào lại sau một mã dừng |
106
120
 
107
121
  ## Các bước
@@ -113,7 +127,7 @@ lượt chạy, và không xoá bất cứ thứ gì ngoài namespace fixture c
113
127
  | 3 | Preflight runtime: thông tin đăng nhập niêm phong giải được theo tên, tài khoản tồn tại, store trả lời | — | @workspaces/device-state để lấy thông tin đăng nhập mà `account.json` nêu tên, @worktrees/sessions/central-runtime để lấy generation và các origin, @tools/secrets, @tools/http | — | `PROVISIONING_UNAVAILABLE` |
114
128
  | 4 | Đóng băng snapshot từ `flow.md`, `account.json` và `seed/` | `feature`, `flow`, `cases` | @worktrees/uat/<flow>/<case>, @worktrees/_templates để lấy khuôn luồng | @worktrees/uat/<flow>/<case> (snapshot), `response/data/snapshot.json`, @tools/sourcewrite | `CANONICAL_WRITE_DENIED` |
115
129
  | 5 | Gieo các bản ghi đã đóng băng vào namespace lượt chạy | `runId` | `response/data/snapshot.json`, @workspaces/be | @tools/database | `FIXTURE_VIOLATION` |
116
- | 6 | Chạy các case đã đóng băng theo thứ tự trên worktree phiên tại commit đã ghim | — | `response/data/snapshot.json`, @worktrees/sessions/central-runtime để lấy origin generation, @workspaces/device-state để lấy thông tin đăng nhập chỉ lúc đăng nhập, @tools/browsercontrol, @tools/websearch | — | `LEASE_INVALID`, `RUNTIME_UNAVAILABLE` |
130
+ | 6 | Chạy các case đã đóng băng theo thứ tự trên endpoint route đã ràng mang, tại commit đã ghim | — | `response/data/snapshot.json`, đầu vào `route` để lấy endpoint lượt chạy này lái theo, @worktrees/sessions/central-runtime để lấy generation đứng sau endpoint đó, @workspaces/device-state để lấy thông tin đăng nhập chỉ lúc đăng nhập, @tools/browsercontrol, @tools/websearch | — | `LEASE_INVALID`, `RUNTIME_UNAVAILABLE` |
117
131
  | 7 | Capture tại từng khẳng định có tên với ô mật khẩu đã che, rồi ghép tấm sheet | — | `response/data/snapshot.json`, @worktrees/sessions/central-runtime để lấy bằng chứng runtime trực tiếp nhất | `response/data/captures/<case>.json`, `response/artifacts/<case>.png`, `response/artifacts/sheet.png`, @tools/visualize | `EVIDENCE_UNAVAILABLE` |
118
132
  | 8 | Xét ba làn tách rời nhau | — | `response/data/captures/<case>.json` | `response/data/verdicts.json` | — |
119
133
  | 9 | Kiểm chỉ-đọc, rồi xoá namespace lượt chạy và không gì khác | `runId` | @workspaces/be để lấy các bản ghi mang `is_uat=true` và namespace này, `response/data/verdicts.json` | @tools/database | — |
@@ -25,6 +25,7 @@ const CASES = ['pay-and-enrol', 'abandon-checkout'];
25
25
  const ASSERTIONS = { 'pay-and-enrol': ['entry', 'commitment', 'terminal'], 'abandon-checkout': ['entry', 'recovery'] };
26
26
  const AUDIT_IN = 'step-1/parallel-1/response/response.md';
27
27
  const QUALITY_IN = 'step-2/parallel-1/response/response.md';
28
+ const ROUTE_IN = 'step-1/parallel-2/response/data/route.json';
28
29
  // The placeholder this operator's law forbids anywhere it writes.
29
30
  const LEAK = 'UAT-SHARED-PASSWORD';
30
31
 
@@ -118,7 +119,7 @@ const requestJson = ({ extra = {}, inputs, cases = [...CASES] } = {}) => ({
118
119
  schemaVersion: 9, operatorId: 'uat.verify', step: 3, parallel: 1, sessionId: 's-test',
119
120
  contexts: [{ alias: '@workspaces/be', head: COMMIT }, { alias: '@worktrees/uat/enrollment/paid-enrollment', head: null }],
120
121
  requirements: { requestedBy: 'the product owner', feature: FEATURE, flow: FLOW, cases, runId: RUN, lease: `uat-lease://s-test/${FLOW}`, resume: null, ...extra },
121
- inputs: inputs ?? { 'frontend-surface-audit': AUDIT_IN, 'quality-verification': QUALITY_IN },
122
+ inputs: inputs ?? { 'frontend-surface-audit': AUDIT_IN, 'quality-verification': QUALITY_IN, route: ROUTE_IN },
122
123
  resume: null,
123
124
  });
124
125
 
@@ -142,11 +143,11 @@ function writeBranch(files, history = 'match') {
142
143
  const session = mkdtempSync(path.join(tmpdir(), 'uat-session-'));
143
144
  const branch = path.join(session, 'step-3', 'parallel-1');
144
145
  for (const d of ['request', 'response/data/captures', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
145
- for (const input of [AUDIT_IN, QUALITY_IN]) {
146
+ for (const input of [AUDIT_IN, QUALITY_IN, ROUTE_IN]) {
146
147
  mkdirSync(path.dirname(path.join(session, input)), { recursive: true });
147
- writeFileSync(path.join(session, input), '# admitting receipt\n');
148
+ writeFileSync(path.join(session, input), input.endsWith('.json') ? '{}\n' : '# admitting receipt\n');
148
149
  }
149
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['3/1']], steps: { '3/1': 'uat.verify' }, current: '3/1', status: 'running' }));
150
+ writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', project: 'starci-academy', startedAt: '2026-09-03T00:00:00Z', requestHashes: {}, chain: [['3/1']], steps: { '3/1': 'uat.verify' }, current: '3/1', status: 'running' }));
150
151
  for (const c of CASES) writeFileSync(path.join(branch, 'response', 'artifacts', `${c}.png`), 'png');
151
152
  writeFileSync(path.join(branch, 'response', 'artifacts', 'sheet.png'), 'png');
152
153
 
@@ -222,7 +223,7 @@ await expectError({ ...baseline(), 'response/response.json': { ...responseJson()
222
223
  await expectValid({ ...blocked(), 'response/response.json': { schemaVersion: 9, operatorId: 'uat.verify', step: 3, parallel: 1, status: 'blocked', stop: 'RUNTIME_UNAVAILABLE', fallbacks: [], fields: {}, commits: [], next: [] } }, 'blocked on the shared RUNTIME_UNAVAILABLE code');
223
224
  await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { password: 'x' } }) }, 'requirements.password is not a field', 'a credential has nowhere to go in a request');
224
225
  await expectError({ ...baseline(), 'request/request.json': requestJson({ extra: { requestedBy: '' } }) }, 'required field requestedBy has no value', 'a run nobody asked for');
225
- await expectError({ ...baseline(), 'request/request.json': requestJson({ inputs: { 'frontend-surface-audit': AUDIT_IN } }) }, 'required input quality-verification is absent', 'a run admitted by one receipt only');
226
+ await expectError({ ...baseline(), 'request/request.json': requestJson({ inputs: { 'frontend-surface-audit': AUDIT_IN, route: ROUTE_IN } }) }, 'required input quality-verification is absent', 'a run admitted by one receipt only');
226
227
 
227
228
  // Admission at the pinned commit.
228
229
  await expectError({ ...baseline(), 'response/data/snapshot.json': snapshot({ admission: [{ kind: 'frontend-surface-audit', ref: AUDIT_IN, commit: COMMIT }, { kind: 'quality-verification', ref: QUALITY_IN, commit: OTHER_COMMIT }] }) }, 'ADMISSION_MISSING — quality-verification was taken at', 'an admission from another commit');
@@ -33,12 +33,19 @@ export async function validateUatStep(branchDir, root = ROOT) {
33
33
 
34
34
  // UAT is never routine: a run exists because a person asked for it, and the run identifier and the
35
35
  // exclusive lease arrive from the orchestrator rather than from a person.
36
- if (empty(requirements.requestedBy)) errors.push('request.json: UAT runs only when a person asked; requestedBy has no value');
36
+ if (decided && empty(requirements.requestedBy)) errors.push('request.json: UAT runs only when a person asked; requestedBy has no value');
37
37
  if (decided && empty(requirements.runId)) errors.push('request.json: the orchestrator supplies runId; a decided run cannot namespace its records without one');
38
38
  if (decided && empty(requirements.lease)) errors.push('request.json: the orchestrator supplies the exclusive lease; a decided run cannot write the flow directory without one');
39
39
 
40
40
  const pinned = (request?.contexts ?? []).find((c) => c.alias === '@workspaces/be')?.head ?? null;
41
41
 
42
+ // A branch that never froze a snapshot is judged on its admissions too: the code that names the
43
+ // missing receipt is the one whose resume instruction is right for a person, and it must be
44
+ // reachable on the branch that blocked before any snapshot existed.
45
+ for (const kind of ADMISSIONS) {
46
+ if (request?.inputs?.[kind] === undefined) errors.push(`request.json: ADMISSION_MISSING — input ${kind} is absent`);
47
+ }
48
+
42
49
  let snapshot = null;
43
50
  if (present.has('uat-snapshot') && has('response/data/snapshot.json')) {
44
51
  try { snapshot = JSON.parse(await read('response/data/snapshot.json')); } catch { snapshot = null; }
@@ -65,8 +72,7 @@ export async function validateUatStep(branchDir, root = ROOT) {
65
72
  if (!entry) { errors.push(`response/data/snapshot.json: ADMISSION_MISSING — ${kind} is absent from the admission record`); continue; }
66
73
  if (entry.commit !== snapshot.commit) errors.push(`response/data/snapshot.json: ADMISSION_MISSING — ${kind} was taken at ${entry.commit}, not at the pinned commit ${snapshot.commit}`);
67
74
  const given = request?.inputs?.[kind];
68
- if (given === undefined) errors.push(`request.json: ADMISSION_MISSING input ${kind} is absent`);
69
- else if (entry.ref !== given) errors.push(`response/data/snapshot.json: ${kind} admits ${entry.ref}, but the request handed in ${given}`);
75
+ if (given !== undefined && entry.ref !== given) errors.push(`response/data/snapshot.json: ${kind} admits ${entry.ref}, but the request handed in ${given}`);
70
76
  }
71
77
 
72
78
  // The frozen order is declared in advance, not discovered while running.
@@ -164,7 +170,7 @@ export async function validateUatStep(branchDir, root = ROOT) {
164
170
  } else if (decided) errors.push('response/response.md: a done branch needs the verification receipt');
165
171
 
166
172
  // Custody is proved on the written bytes, not asserted in prose.
167
- const scanned = ['response/response.md', 'response/data/snapshot.json', 'response/data/verdicts.json', ...asList(response.fields?.['uat-capture'])];
173
+ const scanned = ['response/response.md', 'response/response.json', 'response/data/snapshot.json', 'response/data/verdicts.json', ...asList(response.fields?.['uat-capture'])];
168
174
  for (const f of scanned) {
169
175
  if (!has(f)) continue;
170
176
  if (PASSWORD_LEAK.test(await read(f))) errors.push(`${f}: the shared UAT password appears in a file this operator writes; the credential is resolved by name at login and is never recorded`);
@@ -51,7 +51,13 @@ anything: this operator does not stash, clean, or reset a working tree to make a
51
51
  The businesses authority root is derived as `<git root>/.worktrees/businesses` when that worktree
52
52
  exists on a source checkout and is absent otherwise; it is never accepted from the person, because a
53
53
  typed authority root is how a second business tree is born. Provenance and freshness are not a step
54
- of their own: they are written inside the emit, next to the binding they describe.
54
+ of their own: they are written inside the emit, next to the binding they describe. The head a hydrated
55
+ route recorded is a record of the hydration and never route authority: the observed head wins, and a
56
+ hydration head two commits behind the checkout is not a stop.
57
+
58
+ A blocked branch emits no receipt and no route: `response.json` is the whole record, and `reason`
59
+ carries the observation that justified the stop, including the registry generation, the endpoints
60
+ probed and what each answered.
55
61
 
56
62
  ## Boundary
57
63
 
@@ -87,7 +93,7 @@ workspace, or provisions an account. It makes no product decision and carries no
87
93
  | --- | --- | --- | --- |
88
94
  | `project` | id | — | The project to bind |
89
95
  | `role` | choice | — | `fe` or `be`: the role of that project to bind |
90
- | `gitPolicy` | list of `{worktreeBranches, mutationBranch}` | the policy the route declaration carries | The branch law this binding is verified against; `forbidden` keeps every write on the mutation branch |
96
+ | `gitPolicy` | list of `{worktreeBranches, mutationBranch}` | the policy the route declaration carries; a declaration that carries none is `INVALID_INPUT` at step 1, never a guessed policy | The branch law this binding is verified against; `forbidden` keeps every write on the mutation branch |
91
97
  | `declaredWriteRoots` | list | empty | The only paths later work may write; anything dirty outside them is `CHECKOUT_DIRTY` |
92
98
  | `runtimeNeed` | choice | none | `none` binds no runtime and skips step 5; `consume` binds the owner's endpoints as a consumer |
93
99
  | `resume` | token | null | The blocked branch's token when re-entering after a stop |
@@ -106,8 +112,15 @@ workspace, or provisions an account. It makes no product decision and carries no
106
112
  Step 5 recomputes nothing: the endpoint fingerprint either matches the closed projection or the
107
113
  branch stops. Under `worktreeBranches` set to forbidden, a route binds only on the mutation branch and
108
114
  records `WORKTREE_BRANCH_FORBIDDEN`; under `session-only` it binds on the mutation branch or on a
109
- `session/<sessionId>` worktree branch, the only shape a source-writing operator may commit to; a redacted conversation head records `PROVENANCE_HEAD_BOUND`, and
110
- a cached receipt matching the same identity tuple and fingerprints records `CACHED_ROUTE_REUSED`. A
115
+ `session/<sessionId>` worktree branch, the only shape a source-writing operator may commit to, and
116
+ records `WORKTREE_BRANCH_SESSION_ONLY`, because a policy that opens a write path is exactly the
117
+ finding a later reader looks for; a redacted conversation head records `PROVENANCE_HEAD_BOUND`, and
118
+ a cached receipt matching the same identity tuple and fingerprints records `CACHED_ROUTE_REUSED`. `mutationReadiness` is
119
+ `ready` when the observed branch is one the routed policy permits a write on — the mutation branch, or
120
+ a `session/<sessionId>` branch under `session-only` — and the working tree is clean outside the
121
+ declared write roots; it is `read-only` in every other case, including a route bound with no declared
122
+ write roots. It is derived here and never accepted from the request, because a readiness a caller can
123
+ assert is a readiness nobody measured. A
111
124
  resume begins again at step 1, reuses only unchanged fingerprinted observations, and consumes the
112
125
  exact delta; a republished route arrives as a new route fingerprint, because the same fingerprint
113
126
  cannot yield a different binding.
@@ -146,3 +159,4 @@ cannot yield a different binding.
146
159
  | the runtime owner is missing or not ready and one coordination request must be raised | `platform.operate` |
147
160
  | the route is bound and a frontend surface must be decided inside it | `frontend.direction.decide` |
148
161
  | the route is bound and a published head must be verified before it ships | `quality.verify` |
162
+ | the route is bound and a served surface must be observed | `frontend.surface.audit` |
@@ -50,7 +50,13 @@ operator này không stash, không clean, không reset cây làm việc để m
50
50
  thẩm quyền businesses được suy ra là `<gốc git>/.worktrees/businesses` khi worktree ấy tồn tại trên
51
51
  một checkout source và vắng mặt nếu không; nó không bao giờ được nhận từ người, vì một gốc thẩm quyền
52
52
  do người gõ vào chính là cách cây business thứ hai ra đời. Provenance và độ tươi không phải một bước
53
- riêng: chúng được ghi ngay trong bước phát, cạnh binding mà chúng mô tả.
53
+ riêng: chúng được ghi ngay trong bước phát, cạnh binding mà chúng mô tả. Head mà một route đã hydrate
54
+ ghi lại chỉ là hồ sơ của lần hydrate ấy chứ không bao giờ là thẩm quyền của route: head quan sát được
55
+ mới thắng, và một head hydrate chậm hơn checkout hai commit không phải một mã dừng.
56
+
57
+ Một nhánh bị chặn không phát biên nhận và không phát route: `response.json` là toàn bộ hồ sơ, còn
58
+ `reason` mang quan sát đã biện minh cho lần dừng, gồm generation của sổ đăng ký, các endpoint đã dò và
59
+ mỗi endpoint đã trả lời ra sao.
54
60
 
55
61
  ## Ranh giới
56
62
 
@@ -87,7 +93,7 @@ và không mang phán quyết nào.
87
93
  | --- | --- | --- | --- |
88
94
  | `project` | id | — | Project cần ràng |
89
95
  | `role` | choice | — | `fe` hay `be`: vai của project ấy cần ràng |
90
- | `gitPolicy` | list `{worktreeBranches, mutationBranch}` | the policy the route declaration carries | Luật nhánh mà binding này được kiểm theo; `forbidden` giữ mọi lần ghi trên nhánh mutation |
96
+ | `gitPolicy` | list `{worktreeBranches, mutationBranch}` | the policy the route declaration carries; a declaration that carries none is `INVALID_INPUT` at step 1, never a guessed policy | Luật nhánh mà binding này được kiểm theo; `forbidden` giữ mọi lần ghi trên nhánh mutation |
91
97
  | `declaredWriteRoots` | list | empty | Những đường dẫn duy nhất mà việc sau được ghi; bẩn ngoài chúng là `CHECKOUT_DIRTY` |
92
98
  | `runtimeNeed` | choice | none | `none` không ràng runtime và bỏ qua bước 5; `consume` ràng endpoint của chủ với vai tiêu thụ |
93
99
  | `resume` | token | null | Token của nhánh bị chặn khi vào lại sau một mã dừng |
@@ -105,9 +111,14 @@ và không mang phán quyết nào.
105
111
 
106
112
  Bước 5 không tính lại gì cả: fingerprint endpoint hoặc khớp phép chiếu đóng, hoặc nhánh dừng. Dưới
107
113
  `worktreeBranches` đặt là forbidden, một route chỉ ràng trên nhánh mutation và ghi
108
- `WORKTREE_BRANCH_FORBIDDEN`; một head hội thoại đã che ghi `PROVENANCE_HEAD_BOUND`, và một biên nhận Đặt là `session-only` thì route ràng trên nhánh mutation hoặc trên nhánh worktree
114
+ `WORKTREE_BRANCH_FORBIDDEN`, và khi đặt là `session-only` thì ghi `WORKTREE_BRANCH_SESSION_ONLY`, bởi
115
+ một chính sách mở ra đường ghi đúng là phát hiện mà người đọc sau đi tìm; một head hội thoại đã che ghi `PROVENANCE_HEAD_BOUND`, và một biên nhận Đặt là `session-only` thì route ràng trên nhánh mutation hoặc trên nhánh worktree
109
116
  `session/<sessionId>`, hình dạng duy nhất mà operator ghi source được commit lên;
110
- cache khớp cùng bộ định danh và fingerprint ghi `CACHED_ROUTE_REUSED`. Chạy lại thì bắt đầu lại từ
117
+ cache khớp cùng bộ định danh và fingerprint ghi `CACHED_ROUTE_REUSED`. `mutationReadiness` `ready` khi nhánh quan sát được là nhánh mà chính sách được route cho phép ghi
118
+ lên — nhánh mutation, hoặc một nhánh `session/<sessionId>` dưới `session-only` — và cây làm việc sạch
119
+ ngoài các gốc ghi đã khai; mọi trường hợp khác là `read-only`, kể cả một route ràng mà không khai gốc
120
+ ghi nào. Nó được suy ra ở đây và không bao giờ nhận từ request, vì một mức sẵn sàng mà người gọi khẳng
121
+ định được là một mức sẵn sàng không ai đo. Chạy lại thì bắt đầu lại từ
111
122
  bước 1, chỉ dùng lại quan sát có fingerprint không đổi, và tiêu thụ đúng phần delta; một route
112
123
  publish lại tới dưới dạng một fingerprint route mới, vì cùng một fingerprint không thể cho một ràng
113
124
  buộc khác.
@@ -146,3 +157,4 @@ buộc khác.
146
157
  | chủ runtime thiếu hoặc chưa sẵn sàng và cần nêu một yêu cầu phối hợp | `platform.operate` |
147
158
  | route đã ràng và một bề mặt frontend phải được quyết bên trong nó | `frontend.direction.decide` |
148
159
  | route đã ràng và một head đã publish phải được kiểm trước khi lên | `quality.verify` |
160
+ | route đã ràng và một bề mặt đang phục vụ phải được quan sát | `frontend.surface.audit` |
@@ -50,6 +50,7 @@ function responseMd({ binding = routeBinding(), findings = null, runtimeRows = n
50
50
  ['ROUTE_HYDRATED_FROM_PORTABLE', binding.hydratedRouteRef, 'the portable declaration resolved to this local route'],
51
51
  ['IDENTITY_ROSTER_SEALED', 'the credential roster reference', 'the roster was bound by name and never read'],
52
52
  ...(binding.gitPolicy.worktreeBranches === 'forbidden' ? [['WORKTREE_BRANCH_FORBIDDEN', binding.gitPolicy.mutationBranch, 'the routed policy forbids task and worktree branches']] : []),
53
+ ...(binding.gitPolicy.worktreeBranches === 'session-only' ? [['WORKTREE_BRANCH_SESSION_ONLY', binding.gitPolicy.mutationBranch, 'the routed policy permits a session worktree branch']] : []),
53
54
  ...(binding.provenanceHeadRef ? [['PROVENANCE_HEAD_BOUND', binding.provenanceHeadRef, 'a redacted conversation head was attached']] : []),
54
55
  ...(binding.runtime ? [['RUNTIME_CONSUMED_NOT_OWNED', binding.runtime.ownerTaskId, 'the caller consumes the owner endpoints and owns no lifecycle']] : []),
55
56
  ];
@@ -131,7 +132,7 @@ function writeBranch(files) {
131
132
  const session = mkdtempSync(path.join(tmpdir(), 'workspace-session-'));
132
133
  const branch = path.join(session, 'step-1', 'parallel-1');
133
134
  for (const d of ['request', 'response/data', 'response/artifacts']) mkdirSync(path.join(branch, d), { recursive: true });
134
- writeFileSync(path.join(session, 'state.json'), JSON.stringify({ id: 's-test', chain: [['1/1']], steps: { '1/1': 'workspace.bind' }, current: '1/1', status: 'running' }));
135
+ 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': 'workspace.bind' }, current: '1/1', status: 'running' }));
135
136
  for (const [name, content] of Object.entries(files)) {
136
137
  if (content === null) continue;
137
138
  writeFileSync(path.join(branch, name), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
@@ -196,6 +197,7 @@ await expectError({ ...baseline(), 'response/response.md': responseMd({ findings
196
197
  await expectError({ ...baseline(), 'response/response.md': responseMd({ findings: [['ROUTE_HYDRATED_FROM_PORTABLE', HYDRATED, 'resolved'], ['IDENTITY_ROSTER_SEALED', 'roster', 'sealed'], ['WORKTREE_BRANCH_FORBIDDEN', 'mtp', 'forbidden'], ['HINT_REJECTED', 'D:/Repositories/starci-academy', 'a similar directory name']] }) }, 'a hint is INVALID_INPUT at the gate', 'a receipt that weighs a hint');
197
198
  await expectError({ ...baseline(), 'response/response.md': responseMd({ findings: [['ROUTE_HYDRATED_FROM_PORTABLE', HYDRATED, 'resolved'], ['WORKTREE_BRANCH_FORBIDDEN', 'mtp', 'forbidden']] }) }, 'the credential roster was sealed and never read', 'no sealed roster finding');
198
199
  await expectError({ ...baseline(), 'response/response.md': responseMd({ findings: [['ROUTE_HYDRATED_FROM_PORTABLE', HYDRATED, 'resolved'], ['IDENTITY_ROSTER_SEALED', 'roster', 'sealed']] }) }, 'a forbidden worktree policy must be recorded', 'a forbidden policy that was never recorded');
200
+ await expectError({ ...baseline(), 'response/data/route.json': routeBinding({ gitPolicy: { worktreeBranches: 'session-only', mutationBranch: 'mtp' } }), 'request/request.json': requestJson({ extra: { gitPolicy: { worktreeBranches: 'session-only', mutationBranch: 'mtp' } } }) }, 'a session-only worktree policy must be recorded', 'a session-only policy that was never recorded');
199
201
  await expectError({ ...baseline(), 'response/response.md': responseMd({ findings: [['ROUTE_HYDRATED_FROM_PORTABLE', HYDRATED, 'resolved'], ['ROUTE_HYDRATED_FROM_PORTABLE', HYDRATED, 'resolved again'], ['IDENTITY_ROSTER_SEALED', 'roster', 'sealed'], ['WORKTREE_BRANCH_FORBIDDEN', 'mtp', 'forbidden']] }) }, 'repeats subject', 'a repeated finding subject');
200
202
  await expectError({ ...baseline(), 'response/response.md': responseMd().replace('## Policy', '## Git policy') }, 'missing section ^## Policy$', 'response section renamed');
201
203
  await expectError({ ...baseline(), 'response/response.md': responseMd().replace('| Branch | mtp |', '| Branch | feature/x |') }, 'differs from the route binding', 'the receipt and the binding disagree on the branch');
@@ -122,6 +122,7 @@ export async function validateWorkspaceStep(branchDir, root = ROOT) {
122
122
  // rather than assumed.
123
123
  if (!findingKeys.has(`ROUTE_HYDRATED_FROM_PORTABLE|${route.hydratedRouteRef}`)) errors.push('response/response.md: a bound route must record the hydrated route it resolved from');
124
124
  if (route.gitPolicy.worktreeBranches === 'forbidden' && !findingKeys.has(`WORKTREE_BRANCH_FORBIDDEN|${route.gitPolicy.mutationBranch}`)) errors.push('response/response.md: a forbidden worktree policy must be recorded on the bound route');
125
+ if (route.gitPolicy.worktreeBranches === 'session-only' && !findingKeys.has(`WORKTREE_BRANCH_SESSION_ONLY|${route.gitPolicy.mutationBranch}`)) errors.push('response/response.md: a session-only worktree policy must be recorded on the bound route');
125
126
  if (route.provenanceHeadRef !== null && !findingKeys.has(`PROVENANCE_HEAD_BOUND|${route.provenanceHeadRef}`)) errors.push('response/response.md: a bound provenance head must be recorded');
126
127
  if (route.runtime !== null) {
127
128
  if (!findingKeys.has(`RUNTIME_CONSUMED_NOT_OWNED|${route.runtime.ownerTaskId}`)) errors.push('response/response.md: a consumed runtime must record that the caller does not own it');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/skills",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "StarCi Skills: one entry, fourteen operators, a closed routing map and a tool registry, installed as the .claude runtime of a repository for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,7 +44,7 @@
44
44
  "pattern": "^https://github\\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\\.git)?$"
45
45
  },
46
46
  "branch": { "type": "string", "minLength": 1, "maxLength": 255 },
47
- "head": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
47
+ "head": { "type": "string", "pattern": "^[0-9a-f]{40}$", "description": "The head recorded when this route was hydrated. It is never route authority: a binding reports the head it observes on the checkout, and an observed head ahead of this one is the current one, not a stop." },
48
48
  "gitPolicy": { "$ref": "#/$defs/gitPolicy" }
49
49
  }
50
50
  },
@@ -6,12 +6,18 @@
6
6
  "dispatch": "routing.json",
7
7
  "agent": {
8
8
  "profile": "operator.json → resources.profile",
9
- "grants": "operator.json → resources.tools: each @tools/<id> with one mode from resources/tools.json, and nothing the profile would otherwise permit",
9
+ "grants": "operator.json → resources.tools: each @tools/<id> with one mode from resources/tools.json, and nothing the profile would otherwise permit. A tool whose only use the operator's mode forbids is not granted for that run: backend.source.apply and frontend.source.apply under mode dry receive neither @tools/sourcewrite nor @tools/git",
10
10
  "refs": "operator.md → Context table, resolved through alias/alias.json; an unlisted location is unreadable",
11
- "requirements": "operator.md → Requirements table; the orchestrator fills defaults into request.json and asks a person only for fields whose Default is — or whose value a stop code names",
11
+ "requirements": "operator.md → Requirements table; the orchestrator fills defaults into request.json and asks a person only for fields whose Default is — or whose value a stop code names. Fields under agent.fills (project from the mission scope, runId and lease from the session) the orchestrator supplies itself: a workflow neither presets them nor asks a person for them",
12
12
  "prompt": "rendered at dispatch from operator.md plus request.json; never stored",
13
13
  "isolation": "fresh, no inherited turns",
14
- "writes": "only response/ of its own branch (step-N/parallel-M/response/); a nested-exchange agent writes only <exchange>/response/ of that branch; a source-writing operator also writes the session branch of its checkout under an exclusive lease (see sourceWrites)"
14
+ "writes": "only response/ of its own branch (step-N/parallel-M/response/); a nested-exchange agent writes only <exchange>/response/ of that branch; a source-writing operator also writes the session branch of its checkout under an exclusive lease (see sourceWrites)",
15
+ "profileRecord": "the orchestrator fills response.json boundProfile (operator.json → resources.profile) and ranProfile at dispatch whenever the two differ; validate-response refuses one without the other or a boundProfile that is not the operator's",
16
+ "fills": [
17
+ "project",
18
+ "runId",
19
+ "lease"
20
+ ]
15
21
  },
16
22
  "concurrency": {
17
23
  "rule": "at most maxConcurrentAgents agents at once; branches of one step run concurrently only when their Writes touch no common alias",
@@ -20,14 +26,14 @@
20
26
  },
21
27
  "handoff": {
22
28
  "carrier": "response.json fields: kind → path relative to the branch; the next branch's request.json inputs point at step-N/parallel-M/<path> from the session root, written explicitly by the orchestrator (nearest earlier producer guides the orchestrator, never the agent)",
23
- "stop": "response.json status blocked with stop = code; errors/ says whether the code terminates or falls back, and routing.json says where a terminated step hands to",
29
+ "stop": "response.json status blocked with stop = code; errors/ says whether the code terminates or falls back, and routing.json says where a terminated step hands to. A code the merged registry does not list is read by the orchestrator as UNKNOWN_STOP and routed on domain caller; the branch keeps the code the agent wrote as evidence and the orchestrator records the substitution in state.json.substitutions",
24
30
  "waiting": "response.json status waiting with awaiting { exchange, kind }: the orchestrator validates the fields written so far, creates <exchange>/request/request.json in the same branch, runs a fresh agent for it, and resumes the paused agent when <exchange>/response/response.json is done; other branches of the step keep running",
25
31
  "resume": "a blocked branch re-enters the same operator as a new agent in step-(N+1)/parallel-1, with request.json.resume naming the blocked step and parallel; the blocked branch stays on disk as evidence"
26
32
  },
27
33
  "session": {
28
34
  "root": "<Source>/.worktrees/sessions/<sessionId>/",
29
35
  "id": "<yyyymmdd-HHMMss>-<project>-<first operator>, e.g. 20260903-142200-starci-academy-frontend.direction.decide",
30
- "manifest": "state.json: id, project, startedAt, status (running | blocked | done), chain [[\"1/1\"], [\"2/1\", \"2/2\"], ...], steps { \"N/M\": operatorId }, current, leases { \"N/M\": { agent, holds } }, requestHashes { \"N/M\": sha256 of request/request.json }",
36
+ "manifest": "state.json, validated against templates/step/state.schema.json by validate-request: id, project, workflow, startedAt, status (running | blocked | done | stopped), chain [[\"1/1\"], [\"2/1\", \"2/2\"], ...], steps { \"N/M\": operatorId }, current, leases { \"N/M\": { agent, holds } }, requestHashes { \"N/M\": sha256 of request/request.json }, resumes { \"N/M\": { resumes: \"K/L\", stop } } for every re-entry, stoppedAt { branch, operator, stop | null, domain, route, why } when the session ended, substitutions { \"N/M\": code } for codes read as UNKNOWN_STOP, transitions [] as the audit trail",
31
37
  "branch": "step-<N>/parallel-<M>/ — N is the position in the chain, M the parallel branch; parallel-1 always exists",
32
38
  "branchLayout": [
33
39
  "request/request.json",
@@ -42,9 +48,9 @@
42
48
  "lifecycle": [
43
49
  "create: the orchestrator writes state.json and takes the leases before the first agent starts",
44
50
  "branch: each agent gets a fresh step-N/parallel-M/ with request/request.json already written and hashed into state.json, and writes only response/",
45
- "advance: response.json status done and both validators green; the orchestrator writes the next branches' request.json from the Next table and routing.json",
51
+ "advance: response.json status done and both validators green; the orchestrator writes the next branches' request.json from the Next table and routing.json. A done response that fails a validator does not route: the session ends with stoppedAt { stop: null, why: the validator output } exactly as a terminating stop ends it",
46
52
  "wait: status waiting runs the nested exchange, then resumes the same agent; the branch stays running in state.json",
47
- "block: status blocked keeps the session on disk; the orchestrator asks the person for the field the stop names, then re-enters the operator in step-(N+1)/parallel-1 with resume set",
53
+ "block: status blocked keeps the session on disk. When routing.json answers the stop's domain with kind resume, the orchestrator asks the person for the field the stop names and re-enters the operator in step-(N+1)/parallel-1 with resume set and state.json.resumes recorded. When it answers operator, the named operator runs next and the blocked branch is re-entered the same way once that operator is done. When it answers user or external, the branch stays blocked and the chain continues only after the person or the outside party has changed something the stop names; that continuation is also a step-(N+1)/parallel-1 re-entry with resume set, and request.json must carry the changed requirement or input, because a re-entry with no delta is NO_PROGRESS",
48
54
  "done: when routing reaches user or external, or git.publish emits done, the orchestrator releases the leases and deletes the session folder; what survives is what the steps published (commits, business heads, UAT pairs, remote runs) and any audit record the owner copies out first"
49
55
  ]
50
56
  },
@@ -53,7 +59,7 @@
53
59
  "track": "changes.md names every path with before/after hashes and the commit; the next requests pin @workspaces/<role> at that sha (contexts[].head) so quality.verify and frontend.surface.audit verify exactly what was written",
54
60
  "publish": "git.publish merges the session branch into the target branch: fast-forward when the target has not moved; a merge commit only when there is no conflict and the gates are re-run on the merge result; a conflict is NON_FAST_FORWARD and a person resolves it; never rebase, never force",
55
61
  "cleanup": "after a successful publish the worktree and the session branch are removed together with the session folder; a blocked session keeps both",
56
- "policy": "the routed checkout must declare gitPolicy.worktreeBranches = session-only (readiness/initialization/workspaces); a route declaring forbidden binds read-only and no source-writing operator may run against it"
62
+ "policy": "the routed checkout must declare gitPolicy.worktreeBranches = session-only (readiness/initialization/workspaces); a route declaring forbidden binds read-only and no source-writing operator may run against it; mode dry is exempt, because it writes nothing: a dry backend.source.apply or frontend.source.apply may run against a read-only binding, and its plan records a null commit"
57
63
  },
58
64
  "profileEquivalents": {
59
65
  "rule": "an operator binds one profile; when the processor runs on a runtime that lacks that profile, it runs the equivalent profile of its own runtime with exactly the grants the operator requires, and response.json records both (boundProfile, ranProfile) so an audit can tell a stand-in from the binding",
package/routing.json CHANGED
@@ -259,7 +259,8 @@
259
259
  "kind": "resume"
260
260
  },
261
261
  "runtime": {
262
- "kind": "external"
262
+ "kind": "operator",
263
+ "target": "platform.operate"
263
264
  },
264
265
  "identity": {
265
266
  "kind": "external"
@@ -12,7 +12,8 @@ import { fileURLToPath } from 'node:url';
12
12
  import { validateAgainst } from './json-schema.mjs';
13
13
  import { loadOperatorPackages, kindOf, isYes, exchangeOf } from './operator-md.mjs';
14
14
 
15
- const unquote = (s) => String(s ?? '').trim().replace(/^`|`$/g, '');
15
+ // Only a fully quoted cell is unquoted: a sentence that opens with a code span keeps its backticks.
16
+ const unquote = (s) => { const t = String(s ?? '').trim(); return /^`[^`]*`$/.test(t) ? t.slice(1, -1) : t; };
16
17
  export const isRequiredField = (row) => row.default.trim().startsWith('—');
17
18
  export const isEmpty = (v) => v === undefined || v === null || v === '' || v === '—';
18
19
 
@@ -59,6 +60,16 @@ export async function validateRequest(root, dir, packages) {
59
60
  if (sessionRoot && existsSync(path.join(sessionRoot, 'state.json'))) {
60
61
  try {
61
62
  const state = JSON.parse(await readFile(path.join(sessionRoot, 'state.json'), 'utf8'));
63
+ errors.push(...validateAgainst(JSON.parse(await readFile(path.join(root, 'templates', 'step', 'state.schema.json'), 'utf8')), state, 'state.json'));
64
+ // A resume re-enters the same operator and names a branch state.json knows; a re-entry state.json does not record is unrecorded evidence.
65
+ if (request.resume) {
66
+ const target = `${request.resume.step}/${request.resume.parallel}`;
67
+ if (state.steps?.[target] === undefined) errors.push(`request.json: resume names ${target}, which state.json does not record`);
68
+ else if (state.steps[target] !== request.operatorId) errors.push(`request.json: resume names ${target}, a ${state.steps[target]} branch, but this request runs ${request.operatorId}`);
69
+ const mine = `${request.step}/${request.parallel}`;
70
+ if (state.resumes && !state.resumes[mine]) errors.push(`request.json: state.json records no resumes[${mine}] for this re-entry`);
71
+ else if (state.resumes?.[mine] && state.resumes[mine].resumes !== target) errors.push(`request.json: state.json resumes[${mine}] names ${state.resumes[mine].resumes}, the request names ${target}`);
72
+ }
62
73
  const key = `${request.step}/${request.parallel}${request.exchange ? `/${request.exchange}` : ''}`;
63
74
  const expected = state.requestHashes?.[key];
64
75
  if (expected) {
@@ -17,7 +17,8 @@ import { loadOperatorPackages, kindOf, isYes, exchangeOf } from './operator-md.m
17
17
  import { loadErrorsRegistry } from './errors-registry.mjs';
18
18
  import { sessionRootOf } from './validate-request.mjs';
19
19
 
20
- const unquote = (s) => String(s ?? '').trim().replace(/^`|`$/g, '');
20
+ // Only a fully quoted cell is unquoted: a sentence that opens with a code span keeps its backticks.
21
+ const unquote = (s) => { const t = String(s ?? '').trim(); return /^`[^`]*`$/.test(t) ? t.slice(1, -1) : t; };
21
22
 
22
23
  // Rows of the first table under `## <heading>`, cells unquoted.
23
24
  export function tableUnder(text, heading) {
@@ -98,7 +99,8 @@ export async function validateResponse(root, dir, { requirements = {}, exchange
98
99
  const dispositionOf = (code) => { const e = registry.codes[code]; return e && registry.allowed(code, op.id) ? effectiveDisposition(e, requirements) : null; };
99
100
  if (response.status === 'blocked') {
100
101
  const d = dispositionOf(response.stop);
101
- if (!stopsTable.has(response.stop)) errors.push(`${rel('response/response.json')}: stop ${response.stop} is not in the Stops table of ${op.id}`);
102
+ // UNKNOWN_STOP is the one code no operator declares: the orchestrator writes it when it meets a code the merged registry does not list.
103
+ if (response.stop !== 'UNKNOWN_STOP' && !stopsTable.has(response.stop)) errors.push(`${rel('response/response.json')}: stop ${response.stop} is not in the Stops table of ${op.id}`);
102
104
  if (d === null) errors.push(`${rel('response/response.json')}: stop ${response.stop} is not a registered code ${op.id} may emit`);
103
105
  else if (d !== 'terminate') errors.push(`${rel('response/response.json')}: ${response.stop} has disposition fallback under these requirements; the step should have continued`);
104
106
  }
@@ -121,7 +123,16 @@ export async function validateResponse(root, dir, { requirements = {}, exchange
121
123
  for (const c of taken) if (!declaredTaken.has(c)) errors.push(`${rel('response/response.md')}: Fallbacks taken lists ${c}, which response.json does not`);
122
124
  for (const c of declaredTaken) if (!taken.includes(c)) errors.push(`${rel('response/response.json')}: fallback ${c} is not recorded under ## Fallbacks taken in response.md`);
123
125
  }
124
- for (const nextId of response.next ?? []) if (nextId !== 'user' && nextId !== 'external' && !packages.some((p) => p.manifest.id === nextId)) errors.push(`${rel('response/response.json')}: next names unknown operator ${nextId}`);
126
+ // next names only what the operator's own Next table offers (or user / external); a workflow cannot add a hand-off the operator does not declare.
127
+ const nextTable = new Set((op.tables.next?.rows ?? []).map((r) => unquote(r.operator)));
128
+ for (const nextId of response.next ?? []) {
129
+ if (nextId === 'user' || nextId === 'external') continue;
130
+ if (!packages.some((p) => p.manifest.id === nextId)) errors.push(`${rel('response/response.json')}: next names unknown operator ${nextId}`);
131
+ else if (!nextTable.has(nextId)) errors.push(`${rel('response/response.json')}: next names ${nextId}, which the Next table of ${op.id} does not offer`);
132
+ }
133
+ // A stand-in is recorded as a pair: the profile operator.json binds and the profile that actually ran.
134
+ if ((response.boundProfile === undefined) !== (response.ranProfile === undefined)) errors.push(`${rel('response/response.json')}: boundProfile and ranProfile are recorded together or not at all`);
135
+ if (response.boundProfile !== undefined && response.boundProfile !== pkg.manifest.resources?.profile) errors.push(`${rel('response/response.json')}: boundProfile ${response.boundProfile} is not the profile ${op.id} binds (${pkg.manifest.resources?.profile})`);
125
136
  if (exchange && (response.next ?? []).length) errors.push(`${rel('response/response.json')}: a nested exchange does not route; next must be empty`);
126
137
  return { errors, response, present, pkg };
127
138
  }
@@ -12,7 +12,8 @@ import { loadOperatorPackages, exchangeOf } from './operator-md.mjs';
12
12
  import { loadKindTemplates } from './validate-templates.mjs';
13
13
  import { loadErrorsRegistry } from './errors-registry.mjs';
14
14
 
15
- const unquote = (s) => String(s ?? '').trim().replace(/^`|`$/g, '');
15
+ // Only a fully quoted cell is unquoted: a sentence that opens with a code span keeps its backticks.
16
+ const unquote = (s) => { const t = String(s ?? '').trim(); return /^`[^`]*`$/.test(t) ? t.slice(1, -1) : t; };
16
17
 
17
18
  export async function validateStep(root, branchDir) {
18
19
  const packages = await loadOperatorPackages(root);
@@ -11,12 +11,15 @@ import { fileURLToPath } from 'node:url';
11
11
  import { loadOperatorPackages, cellAliases, kindOf, isYes } from './operator-md.mjs';
12
12
  import { loadAliasRegistry, baseOf } from './alias-registry.mjs';
13
13
 
14
- const unquote = (s) => String(s ?? '').trim().replace(/^`|`$/g, '');
14
+ // Only a fully quoted cell is unquoted: a sentence that opens with a code span keeps its backticks.
15
+ const unquote = (s) => { const t = String(s ?? '').trim(); return /^`[^`]*`$/.test(t) ? t.slice(1, -1) : t; };
15
16
 
16
17
  export async function validateWorkflows(root) {
17
18
  const errors = [];
18
19
  const packages = (await loadOperatorPackages(root)).filter((p) => p.shape === 'v9');
19
20
  const aliases = (await loadAliasRegistry(root)).aliases;
21
+ // Fields the orchestrator fills from the session or the mission scope: neither preset nor asked (resources/orchestrator.json#agent.fills).
22
+ const fills = new Set(JSON.parse(await readFile(path.join(root, 'resources', 'orchestrator.json'), 'utf8')).agent?.fills ?? []);
20
23
  const ops = new Map(packages.map((p) => {
21
24
  const op = p.en;
22
25
  const writes = new Set();
@@ -26,6 +29,8 @@ export async function validateWorkflows(root) {
26
29
  for (const r of op.tables.context?.rows ?? []) { const a = cellAliases(r.alias)[0]; const m = a && /^@workspaces\/(fe|be)\b/.exec(a); if (m && isYes(r.required)) roles.add(m[1]); }
27
30
  return [p.manifest.id, {
28
31
  fields: new Set((op.tables.requirements?.rows ?? []).map((r) => unquote(r.field))),
32
+ // Fields with no Default: the workflow presets them or declares under asks who supplies them before the branch starts.
33
+ mustSupply: (op.tables.requirements?.rows ?? []).filter((r) => r.default.trim().startsWith('—')).map((r) => unquote(r.field)),
29
34
  required: (op.tables.inputs?.rows ?? []).filter((r) => isYes(r.required)).map((r) => kindOf(r.kind)),
30
35
  outputs: new Set((op.tables.outputs?.rows ?? []).map((r) => kindOf(r.kind))),
31
36
  next: new Set((op.tables.next?.rows ?? []).map((r) => unquote(r.operator))),
@@ -67,6 +72,8 @@ export async function validateWorkflows(root) {
67
72
  if (!op) { errors.push(`${at}: unknown operator ${b.operator}`); return; }
68
73
  if (!positions.has(b.operator)) positions.set(b.operator, n);
69
74
  for (const key of Object.keys(b.requirements ?? {})) if (!op.fields.has(key)) errors.push(`${at}: requirement ${key} is not a field of ${b.operator}`);
75
+ for (const key of b.asks ?? []) { if (!op.fields.has(key)) errors.push(`${at}: asks ${key}, which is not a field of ${b.operator}`); else if (key in (b.requirements ?? {})) errors.push(`${at}: asks ${key} and presets it`); else if (fills.has(key)) errors.push(`${at}: asks ${key}, which the orchestrator fills`); }
76
+ for (const key of op.mustSupply) if (!fills.has(key) && !(key in (b.requirements ?? {})) && !(b.asks ?? []).includes(key)) errors.push(`${at}: ${b.operator} requires ${key} (no Default); preset it or list it under asks`);
70
77
  for (const kind of op.required) if (!produced.has(kind)) errors.push(`${at}: ${b.operator} requires input ${kind}, which no earlier step produces`);
71
78
  // A required @workspaces/<role> context needs a workspace.bind of that role in an earlier step.
72
79
  if (b.operator !== 'workspace.bind') for (const role of op.roles) if (!boundRoles.has(role)) errors.push(`${at}: ${b.operator} requires @workspaces/${role}, which no earlier workspace.bind (role ${role}) bound`);
@@ -8,6 +8,7 @@
8
8
  { "heading": "^## Boundaries$", "table": "| Boundary | Responsibility | Owner | Interfaces | Owns data |", "minRows": 1, "cell": { "Owns data": "^(yes|no)$" } },
9
9
  { "heading": "^## Data ownership$", "table": "| Store | Owning boundary | Writers | Readers | Migrators | Transaction scope | Backup | Restore |" },
10
10
  { "heading": "^## Stack delta$", "table": "| Component | Status | Justification | Evidence | Compatibility |", "minRows": 1, "cell": { "Status": "^(existing|added|replaced|removed|replaced-candidate)$", "Justification": "^(measured-constraint|observed-evidence|requirement-fit|—)$" } },
11
+ { "heading": "^## Operations$", "table": "| Operation | Transport | Writer | Stores | Transaction | Idempotency | Dimensions |", "minRows": 1, "cell": { "Transport": "^(graphql-mutation|graphql-query|rest|worker|cron|event-consumer)$", "Transaction": "^(single-transaction|per-item|read-only|none)$", "Idempotency": "^(none|natural-key|request-token|event-id)$", "Dimensions": "^[a-z0-9][a-z0-9-]*(?:, [a-z0-9][a-z0-9-]*)*$" } },
11
12
  { "heading": "^## Handoff$", "table": "| Item | Kind | Detail |", "minRows": 1, "cell": { "Kind": "^(invariant|risk|contract|migration|rollback|proof|unknown)$" } },
12
13
  { "heading": "^## Fallbacks taken$", "table": "| Code | Action |", "cell": { "Code": "^`[A-Z][A-Z0-9_]+`$" } }
13
14
  ]
@@ -39,6 +39,12 @@
39
39
  | --- | --- | --- | --- | --- |
40
40
  | `component-id` | existing | measured-constraint | `path@head` | 5/5 verified, or the axes still unknown |
41
41
 
42
+ ## Operations
43
+
44
+ | Operation | Transport | Writer | Stores | Transaction | Idempotency | Dimensions |
45
+ | --- | --- | --- | --- | --- | --- | --- |
46
+ | `operation-id` | graphql-mutation | `src/features/api/core/graphql/mutations/handler.ts` | `store-id` | single-transaction | request-token | effective-access |
47
+
42
48
  ## Handoff
43
49
 
44
50
  | Item | Kind | Detail |
@@ -3,7 +3,7 @@
3
3
  "title": "^# backend-source-application — [a-z0-9][a-z0-9-]*$",
4
4
  "sections": [
5
5
  { "heading": "^## Binding$", "table": "| Field | Value |", "rows": ["Outcome", "Feature", "Contract fingerprint", "Base", "Branch", "Commit"], "cell": { "Value": "\\S" } },
6
- { "heading": "^## Operations$", "table": "| Operation | Transport | Writer | Transaction | Idempotency | Decisions |", "minRows": 1, "cell": { "Transport": "^(graphql-mutation|graphql-query|rest|worker|cron|event-consumer)$", "Transaction": "^(single-transaction|per-item|read-only|none)$", "Idempotency": "^(none|natural-key|request-token|event-id)$", "Decisions": "^BA-[0-9]+(?:, BA-[0-9]+)*$" } },
6
+ { "heading": "^## Operations$", "table": "| Operation | Transport | Writer | Transaction | Idempotency | Decisions |", "minRows": 1, "cell": { "Transport": "^(graphql-mutation|graphql-query|rest|worker|cron|event-consumer)$", "Transaction": "^(single-transaction|per-item|read-only|none)$", "Idempotency": "^(none|natural-key|request-token|event-id)$", "Decisions": "^[a-z0-9][a-z0-9-]*(?:, [a-z0-9][a-z0-9-]*)*$" } },
7
7
  { "heading": "^## Changes$", "table": "| Path | Change | Operation | Before | After |", "minRows": 1, "cell": { "Change": "^(added|modified|deleted)$", "Before": "^(sha256:[0-9a-f]{64}|—)$", "After": "^(sha256:[0-9a-f]{64}|—)$" } },
8
8
  { "heading": "^## Findings$", "table": "| Code | Operation | File | Statement |", "cell": { "Code": "^`(PATTERN_BOUND|NEW_CONVENTION_REFUSED|SNAPSHOT_REVALIDATED|MIGRATION_INCLUDED|EXCEPTION_IDENTITY_REUSED|BUSINESS_QUESTION_RAISED)`$", "Statement": "\\S" } }
9
9
  ]
@@ -19,7 +19,7 @@ the conformance and proof records that measure it live beside it in `response/da
19
19
 
20
20
  | Operation | Transport | Writer | Transaction | Idempotency | Decisions |
21
21
  | --- | --- | --- | --- | --- | --- |
22
- | `operation-id` | graphql-mutation | `src/features/api/core/graphql/mutations/handler.ts` | single-transaction | request-token | BA-1 |
22
+ | `operation-id` | graphql-mutation | `src/features/api/core/graphql/mutations/handler.ts` | single-transaction | request-token | effective-access |
23
23
 
24
24
  ## Changes
25
25
 
@@ -4,7 +4,7 @@
4
4
  "sections": [
5
5
  { "heading": "^## Owner map$", "table": "| Node | Property | Owner | Rule |", "minRows": 1, "cell": { "Node": "^`[^`]+`$", "Owner": "^(app|grammar)$", "Rule": "^`[A-Z][A-Z0-9-]*-[0-9]+`$" } },
6
6
  { "heading": "^## Rules chosen$", "table": "| Node | Rule | Class | Condition |", "minRows": 1, "cell": { "Node": "^`[^`]+`$", "Rule": "^`[A-Z][A-Z0-9-]*-[0-9]+`$", "Class": "^`[^`]+`$", "Condition": "\\S" } },
7
- { "heading": "^## Removed$", "table": "| Node | Class | Because |", "cell": { "Node": "^`[^`]+`$", "Because": "^(reimplements an owned relationship|overrides Grammar anatomy|off the closed scale)$" } },
7
+ { "heading": "^## Removed$", "table": "| Node | Class | Because |", "cell": { "Node": "^`[^`]+`$", "Because": "^(reimplements an owned relationship|overrides Grammar anatomy|off the closed scale|refused by [A-Z][A-Z0-9-]*-[0-9]+ Case [0-9]+)$" } },
8
8
  { "heading": "^## Gaps$", "table": "| Node | Property | Missing path |", "cell": { "Node": "^`[^`]+`$", "Missing path": "\\S" } },
9
9
  { "heading": "^## Fallbacks taken$", "table": "| Code | Action |", "cell": { "Code": "^`[A-Z][A-Z0-9_]+`$" } }
10
10
  ]
@@ -18,6 +18,7 @@
18
18
  | Node | Class | Because |
19
19
  | --- | --- | --- |
20
20
  | `body>main>div[0]>section` | `p-4` | reimplements an owned relationship |
21
+ | `body>main>div[0]>section>span` | `text-accent-soft-foreground` | refused by SURFACE-4 Case 2 |
21
22
 
22
23
  ## Gaps
23
24