@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.
- package/INDEX.md +3 -1
- package/INDEX.vi.md +3 -1
- package/README.md +6 -1
- package/README.vi.md +6 -1
- package/knowledge/ui/presentation/INDEX.md +5 -0
- package/knowledge/ui/presentation/INDEX.vi.md +1 -0
- package/knowledge/ui/presentation/radius.md +183 -0
- package/knowledge/ui/presentation/radius.vi.md +182 -0
- package/operators/INDEX.md +5 -5
- package/operators/INDEX.vi.md +5 -5
- package/operators/architecture-decide/operator.md +27 -4
- package/operators/architecture-decide/operator.vi.md +23 -4
- package/operators/architecture-decide/self-test.mjs +18 -1
- package/operators/architecture-decide/validate.mjs +29 -1
- package/operators/backend-source-apply/operator.md +197 -182
- package/operators/backend-source-apply/operator.vi.md +190 -178
- package/operators/backend-source-apply/self-test.mjs +257 -257
- package/operators/backend-source-apply/validate.mjs +240 -240
- package/operators/business-decide/self-test.mjs +2 -1
- package/operators/business-decide/validate.mjs +3 -0
- package/operators/content-generate/self-test.mjs +1 -1
- package/operators/frontend-direction-decide/self-test.mjs +1 -1
- package/operators/frontend-presentation-resolve/operator.md +6 -0
- package/operators/frontend-presentation-resolve/operator.vi.md +6 -0
- package/operators/frontend-presentation-resolve/self-test.mjs +6 -5
- package/operators/frontend-presentation-resolve/validate.mjs +30 -6
- package/operators/frontend-source-apply/operator.md +3 -1
- package/operators/frontend-source-apply/operator.vi.md +3 -1
- package/operators/frontend-source-apply/self-test.mjs +1 -1
- package/operators/frontend-surface-audit/self-test.mjs +1 -1
- package/operators/git-publish/self-test.mjs +1 -1
- package/operators/platform-operate/self-test.mjs +1 -1
- package/operators/quality-verify/operator.md +5 -1
- package/operators/quality-verify/operator.vi.md +5 -1
- package/operators/quality-verify/self-test.mjs +15 -2
- package/operators/quality-verify/validate.mjs +21 -1
- package/operators/release-deploy/self-test.mjs +235 -235
- package/operators/uat-verify/operator.md +20 -6
- package/operators/uat-verify/operator.vi.md +21 -7
- package/operators/uat-verify/self-test.mjs +6 -5
- package/operators/uat-verify/validate.mjs +10 -4
- package/operators/workspace-bind/operator.md +18 -4
- package/operators/workspace-bind/operator.vi.md +16 -4
- package/operators/workspace-bind/self-test.mjs +3 -1
- package/operators/workspace-bind/validate.mjs +1 -0
- package/package.json +1 -1
- package/readiness/initialization/workspaces/local-route.schema.json +1 -1
- package/resources/orchestrator.json +14 -8
- package/routing.json +2 -1
- package/scripts/validate-request.mjs +12 -1
- package/scripts/validate-response.mjs +14 -3
- package/scripts/validate-step.mjs +2 -1
- package/scripts/validate-workflows.mjs +8 -1
- package/templates/kinds/architecture-decision.contract.json +1 -0
- package/templates/kinds/architecture-decision.skeleton.md +6 -0
- package/templates/kinds/backend-source-application.contract.json +1 -1
- package/templates/kinds/backend-source-application.skeleton.md +1 -1
- package/templates/kinds/frontend-presentation-resolution.contract.json +1 -1
- package/templates/kinds/frontend-presentation-resolution.skeleton.md +1 -0
- package/templates/kinds/mutations.schema.json +65 -65
- package/templates/kinds/route.schema.json +4 -2
- package/templates/kinds/stack-model.schema.json +23 -1
- package/templates/kinds/workspace-route-binding.contract.json +2 -2
- package/templates/step/response.schema.json +10 -0
- package/templates/step/state.schema.json +205 -0
- package/workflows/README.md +3 -0
- package/workflows/README.vi.md +3 -0
- package/workflows/backend-feature.json +26 -5
- package/workflows/content-unit.json +4 -1
- package/workflows/frontend-new-surface.json +15 -5
- package/workflows/frontend-reconstruct.json +10 -4
- package/workflows/frontend-refine.json +10 -4
- package/workflows/frontend-with-uat.json +15 -3
- package/workflows/full-feature.json +31 -8
- 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
|
|
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 |
|
|
104
|
-
| `lease` | token |
|
|
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
|
|
130
|
+
| 6 | Chạy các case đã đóng băng theo thứ tự trên endpoint mà 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
|
|
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
|
|
110
|
-
|
|
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
|
|
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`.
|
|
117
|
+
cache khớp cùng bộ định danh và fingerprint ghi `CACHED_ROUTE_REUSED`. `mutationReadiness` là `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.
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "^
|
|
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 |
|
|
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
|
]
|