Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/30805-skills-namespace-prefix-every-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/skills': patch
---

docs(skills): the authoring skills teach one naming rule, every metadata name a package authors starts with its namespace

Clause-②: no
Metadata-protocol: none

- **Before.** `objectstack-platform` stated the prefix rule for objects only, and claimed `os lint` warns on bare app, page, dashboard, flow, action, report and dataset names whenever a namespace is set. The examples across the skills named flows, actions, hooks, pages, dashboards, reports, datasets, queues, positions, permission sets, assignment rules, agents and skills bare (`lead_conversion`, `sales_rep`, `support_intake`).
- **Now.** `objectstack-platform` states the rule once, beside the object rule: every metadata name a package authors starts with its namespace (`crm_send_email`, `crm_sales_rep`). The exceptions are fields, object extensions, container views and translations (`<ns>_<locale>`). It also says what is enforced today: `defineStack` refuses a bare object name, `os build` refuses a bare doc name, and `os lint`'s `naming/namespace-prefix` warns only on a name one package declares twice. Every example and eval that showed or generated a bare non-object name now carries `crm_` (or its package's namespace where the snippet has one), cross-references included.
- Connector ids, the inbound api flow whose name is part of its webhook URL, and the datasource whose name keys its password variable keep their names. No schema, key or behaviour changes.
7 changes: 6 additions & 1 deletion skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,12 @@ carries `SKILL.md` alone — `gen:skill-refs` only visits skills listed in its
## Conventions enforced across skills

- **Zod first.** Never invent types — read `node_modules/@objectstack/spec/src/**/*.zod.ts`.
- **Short object names** (`account`, `task`); no `namespace`, no `tableName`.
- **Every non-object name carries the namespace** (`crm_send_email`, `crm_sales_rep`);
a snippet with no manifest uses `crm`. Exempt: fields, object extensions,
container views, translations (`<ns>_<locale>`). The one rule lives in
`objectstack-platform/SKILL.md`.
- **Short object names in snippets** (`account`, `task`); no object-level
`namespace`, no `tableName`.
- **CEL for all expressions** — predicates, conditions, schedules. Use the
`F\`\``, `P\`\``, `cel\`\``, `cron\`\``, `tmpl\`\`` tagged templates from
`@objectstack/spec`. Legacy `OLD` / `NEW` are not CEL — use
Expand Down
6 changes: 3 additions & 3 deletions skills/objectstack-ai/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ copilot claiming abilities it did not have.
import { defineSkill } from '@objectstack/spec';

export default defineSkill({
name: 'case_management',
name: 'crm_case_management',
label: 'Case Management',
description: 'Triage, query, and escalate support cases.',
instructions: `
Expand All @@ -104,8 +104,8 @@ export default defineSkill({
- Escalated cases must summarise the actions already taken.
`,
// Every name resolves: two platform tools (rung 2) plus one action tool
// materialised from this stack's own `escalate_case` Action (rung 3).
tools: ['query_records', 'get_record', 'action_escalate_case'],
// materialised from this stack's own `crm_escalate_case` Action (rung 3).
tools: ['query_records', 'get_record', 'action_crm_escalate_case'],
triggerConditions: [
{ field: 'objectName', operator: 'eq', value: 'support_case' },
],
Expand Down
12 changes: 6 additions & 6 deletions skills/objectstack-ai/evals/skills-tools-knowledge.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"evals": [
{
"id": 1,
"prompt": "Add `src/skills/case-triage.skill.ts` for our support app: the assistant should be able to query and read support cases and run our existing `escalate_case` Action on a case. Only activate it when the user is working on the `support_case` object.",
"expected_output": "Emits a `defineSkill` with a snake_case `name`, `label`, `instructions`, and a `tools` list where every name resolves: platform tools `query_records` / `get_record` plus the action tool `action_escalate_case` materialised from the stack's own Action. Routing is a `triggerConditions` entry `{ field: 'objectName', operator: 'eq', value: 'support_case' }` (a string value for `eq`). No `permissions` key on the skill (removed in 17) and no `*.agent.ts` file.",
"prompt": "Add `src/skills/case-triage.skill.ts` for our support app: the assistant should be able to query and read support cases and run our existing `crm_escalate_case` Action on a case. Only activate it when the user is working on the `support_case` object.",
"expected_output": "Emits a `defineSkill` with a namespace-prefixed snake_case `name`, `label`, `instructions`, and a `tools` list where every name resolves: platform tools `query_records` / `get_record` plus the action tool `action_crm_escalate_case` materialised from the stack's own Action. Routing is a `triggerConditions` entry `{ field: 'objectName', operator: 'eq', value: 'support_case' }` (a string value for `eq`). No `permissions` key on the skill (removed in 17) and no `*.agent.ts` file.",
"files": [],
"assertions": {
"must_contain": ["defineSkill", "tools", "action_escalate_case", "triggerConditions"],
"must_contain": ["defineSkill", "tools", "action_crm_escalate_case", "triggerConditions"],
"must_not_contain": ["permissions:", "defineAgent("]
}
},
Expand All @@ -33,8 +33,8 @@
},
{
"id": 4,
"prompt": "Write a `*.tool.ts` so the assistant can call our `close_case` action on a support case.",
"expected_output": "Does not author a tool record: the default path is to opt the Action in with `ai: { exposed: true, description: '<40+ chars>' }` on the object's action, which materialises `action_close_case`, and to name that tool in a skill's `tools`. `defineTool` is only for presentation refinement and cannot make anything executable (no `handler` / `implementation`; `ToolSchema` is strict).",
"prompt": "Write a `*.tool.ts` so the assistant can call our `crm_close_case` action on a support case.",
"expected_output": "Does not author a tool record: the default path is to opt the Action in with `ai: { exposed: true, description: '<40+ chars>' }` on the object's action, which materialises `action_crm_close_case`, and to name that tool in a skill's `tools`. `defineTool` is only for presentation refinement and cannot make anything executable (no `handler` / `implementation`; `ToolSchema` is strict).",
"files": [],
"assertions": {
"must_contain": ["exposed", "description", "action_", "tools"],
Expand All @@ -43,7 +43,7 @@
},
{
"id": 5,
"prompt": "Create `src/ai/support-bot.agent.ts` with defineAgent({ name: 'support_bot', tools: ['query_records', 'action_close_case'], knowledge: ['support_kb'] }) and register it under agents.",
"prompt": "Create `src/ai/support-bot.agent.ts` with defineAgent({ name: 'crm_support_bot', tools: ['query_records', 'action_crm_close_case'], knowledge: ['support_kb'] }) and register it under agents.",
"expected_output": "Refuses the agent: the `agent` type is closed to third parties (a stack-authored agent is filtered out, refused by `loadAgent()` and 404s on chat), and `agent.tools` / `agent.knowledge` are protocol-17 tombstones. Re-declares the two tools in a `defineSkill` (registered under `skills`) with a `surface`, and restricts retrieval at the knowledge-source level (`aiExposed`, `where`) rather than on an agent. The runtime's `ask` / `build` agents pick the skill up by surface.",
"files": [],
"assertions": {
Expand Down
10 changes: 5 additions & 5 deletions skills/objectstack-automation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ write them right the first time:

## Assignment rules — route a new record to a queue or a person

The third automation family: `assignmentRules` on the stack routes each inserted
`assignmentRules` on the stack routes each inserted
row of an `ownership: 'user'` object whose `owner_id` and `queue` are both still
empty — once, after every `beforeInsert` hook, never on update, never refusing the
insert (ADR-0142 D5). Entries are ordered; the first whose `criteria` (CEL over
Expand All @@ -303,20 +303,20 @@ insert (ADR-0142 D5). Entries are ordered; the first whose `criteria` (CEL over
import { defineAssignmentRule } from '@objectstack/spec/automation';

export const caseIntake = defineAssignmentRule({
name: 'case_intake',
name: 'crm_case_intake',
object: 'crm_case',
entries: [
{ name: 'urgent', criteria: 'record.priority == "urgent"',
assignTo: { type: 'queue', value: 'escalations' } }, // no strategy: parked for a claim
{ name: 'rest', assignTo: { type: 'queue', value: 'support_intake' },
assignTo: { type: 'queue', value: 'crm_escalations' } }, // no strategy: parked for a claim
{ name: 'rest', assignTo: { type: 'queue', value: 'crm_support_intake' },
strategy: 'least_loaded', load: 'record.status != "closed"' }, // or 'round_robin'; `load` only with least_loaded
],
});
```

`strategy` is for a queue target only; `assignTo: { type: 'user', value: '<id>' }`
names one owner. Queues are declared in `queues` (objectstack-data); an approval
for the same people is `approvers: [{ type: 'queue', value: 'support_intake' }]`
for the same people is `approvers: [{ type: 'queue', value: 'crm_support_intake' }]`
([Approver Types](./references/state-machines-and-approvals.md#approver-types)).
⛔ Never a `beforeInsert` hook that picks an owner, nor a round-robin counter field.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This file states only what a grader scores, so the rule has one home.

## Scenario

> Build an autolaunched flow `budget_approval` on the `project` object: when
> Build an autolaunched flow `crm_budget_approval` on the `project` object: when
> `budget` increases past 100000, route to a manager for approval. The manager
> can **approve**, **reject**, or **send the record back to the submitter for
> revision**; after the submitter reworks and resubmits, it returns to the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
},
{
"id": 3,
"prompt": "Build an autolaunched flow `budget_approval` on `project`: when `budget` increases past 100000, route to a manager who can approve, reject, or send it back to the submitter for revision; a resubmission returns to the manager. Cap it at two send-backs.",
"expected_output": "The ADR-0044 shape: an `approval` node (`type: 'approval'`, `approvers: [{ type: 'position', value: 'manager' }]`, `lockRecord: true`, `maxRevisions: 2`) with `approve` / `reject` out-edges plus a `revise` out-edge to an `approval_revise` node, and a resubmit edge back into the approval node typed `type: 'back'` — what legalises the cycle for `registerFlow`. The flow lives in `flows` (no `approvals` collection).",
"prompt": "Build an autolaunched flow `crm_budget_approval` on `project`: when `budget` increases past 100000, route to a manager who can approve, reject, or send it back to the submitter for revision; a resubmission returns to the manager. Cap it at two send-backs.",
"expected_output": "The ADR-0044 shape: an `approval` node (`type: 'approval'`, `approvers: [{ type: 'position', value: 'crm_manager' }]`, `lockRecord: true`, `maxRevisions: 2`) with `approve` / `reject` out-edges plus a `revise` out-edge to an `approval_revise` node, and a resubmit edge back into the approval node typed `type: 'back'` — what legalises the cycle for `registerFlow`. The flow lives in `flows` (no `approvals` collection).",
"files": [],
"assertions": {
"must_contain": [
Expand Down Expand Up @@ -120,8 +120,8 @@
},
{
"id": 7,
"prompt": "An expense approval only holders of `expense.approve_request` may start; route to the `finance_manager` position; the submitter must never approve their own expense.",
"expected_output": "Flow-level `requiredPermissions: ['expense.approve_request']`, checked at every door that starts the flow (never a run-user positions check inside it); the `approval` node names `approvers: [{ type: 'position', value: 'finance_manager' }]` with `excludeSubmitter: true`, and `onEmptyApprovers` is anything but `auto_approve`, refused beside it.",
"prompt": "An expense approval only holders of `expense.approve_request` may start; route to the `crm_finance_manager` position; the submitter must never approve their own expense.",
"expected_output": "Flow-level `requiredPermissions: ['expense.approve_request']`, checked at every door that starts the flow (never a run-user positions check inside it); the `approval` node names `approvers: [{ type: 'position', value: 'crm_finance_manager' }]` with `excludeSubmitter: true`, and `onEmptyApprovers` is anything but `auto_approve`, refused beside it.",
"files": [],
"assertions": {
"must_contain": [
Expand Down Expand Up @@ -155,7 +155,7 @@
},
{
"id": 9,
"prompt": "Build a screen flow `convert_lead` that asks for a company name and a contact name, creates a `crm_account` and a linked `crm_contact`, and ends on a screen linking to both new records.",
"prompt": "Build a screen flow `crm_convert_lead` that asks for a company name and a contact name, creates a `crm_account` and a linked `crm_contact`, and ends on a screen linking to both new records.",
"expected_output": "A `type: 'screen'` flow: a `screen` collecting `company_name` and `contact_name`; two `create_record` nodes with `outputVariable` (`account`, `contact`), the contact linked by `account: { dialect: 'cel', source: 'account.id' }`; a last `screen` of two `type: 'record_link'` fields with `reference` (`crm_account`, `crm_contact`) and `defaultValue: { dialect: 'cel', source: 'account.id' }` / `'contact.id'`, no `required`, `placeholder` or `options`. The `end` carries no `message` (refused when completed), the `description` no markdown link — the links are the `record_link` fields, never the object field type.",
"files": [],
"assertions": {
Expand Down Expand Up @@ -190,7 +190,7 @@
},
{
"id": 11,
"prompt": "When a `contractor` is marked `onboarded`, grant its linked user (`contractor.user_id`) the `contractor_portal` permission set; run the flow as system so the grant always lands.",
"prompt": "When a `contractor` is marked `onboarded`, grant its linked user (`contractor.user_id`) the `crm_contractor_portal` permission set; run the flow as system so the grant always lands.",
"expected_output": "Not as system: a system-context insert into `sys_user_permission_set` (as into `sys_user_position`, `sys_invitation` or `sys_member`) is refused with `PERMISSION_DENIED` — elevation does not lift a governed identity write, a system subflow included. The flow runs `runAs: 'user'`: its `create_record` on `sys_user_permission_set` is judged by the delegated-administration gate as the user whose write fired it, recorded as `granted_by`; a user the gate would not let grant the set is refused — the rule, not a defect.",
"files": [],
"assertions": {
Expand All @@ -210,7 +210,7 @@
{
"id": 12,
"prompt": "Spread new `crm_case` records evenly across the support team; a `refund_requested` case needs sign-off from the support-leads queue.",
"expected_output": "An assignment rule, not a flow or hook: `defineAssignmentRule({ name: 'case_intake', object: 'crm_case', entries: [{ name: 'all', assignTo: { type: 'queue', value: 'support_team' }, strategy: 'round_robin' }] })` in `assignmentRules` — evaluated once per insert on a still-ownerless row, never on update; no counter field. The sign-off is an `approval` node (`type: 'approval'`) with `approvers: [{ type: 'queue', value: 'support_leads' }]`: the queue's members join the slate at node entry; an empty queue adds nobody.",
"expected_output": "An assignment rule, not a flow or hook: `defineAssignmentRule({ name: 'crm_case_intake', object: 'crm_case', entries: [{ name: 'all', assignTo: { type: 'queue', value: 'crm_support_team' }, strategy: 'round_robin' }] })` in `assignmentRules` — evaluated once per insert on a still-ownerless row, never on update; no counter field. The sign-off is an `approval` node (`type: 'approval'`) with `approvers: [{ type: 'queue', value: 'crm_support_leads' }]`: the queue's members join the slate at node entry; an empty queue adds nobody.",
"files": [],
"assertions": {
"must_contain": [
Expand Down
4 changes: 2 additions & 2 deletions skills/objectstack-automation/references/examples-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
import { defineFlow } from '@objectstack/spec';

export const EscalateOverdueCasesFlow = defineFlow({
name: 'escalate_overdue_cases',
name: 'crm_escalate_overdue_cases',
label: 'Escalate Overdue Cases',
type: 'schedule',
status: 'active',
Expand Down Expand Up @@ -82,7 +82,7 @@ day, a threshold is never missed.

```typescript
{
name: 'renewal_alert',
name: 'crm_renewal_alert',
label: 'Renewal Alert',
type: 'schedule',
runAs: 'system', // a sweep has no trigger user — elevate explicitly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const StripeConnector = defineConnector({
});
// crm_account gains `stripe_available` (currency) and `stripe_synced_at` (datetime).
export const StripeBalanceSweep = defineFlow({
name: 'stripe_balance_sweep', label: 'Stripe balance → house account',
name: 'crm_stripe_balance_sweep', label: 'Stripe balance → house account',
type: 'schedule', runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'Nightly 03:00',
Expand Down
Loading
Loading