@ctrl-spc/cs 0.7.2 → 0.7.3

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/dist/login.js CHANGED
@@ -137,7 +137,15 @@ function renderPage(state) {
137
137
  input { padding:8px 10px; font-size:14px; border:1px solid #ccc; border-radius:6px; }
138
138
  button[type=submit] { margin-top:6px; padding:9px; border:none; border-radius:6px; background:#111; color:#fff; cursor:pointer; }
139
139
  .error { color:#c0392b; font-size:13px; min-height:16px; }
140
- @media (prefers-color-scheme: dark){ body{background:#111;color:#eee;} input{background:#1c1c1c;border-color:#333;color:#eee;} button[type=submit]{background:#eee;color:#111;} }
140
+ @media (prefers-color-scheme: dark){
141
+ body{background:#111;color:#eee;} p.sub{color:#999;}
142
+ input{background:#1c1c1c;border-color:#333;color:#eee;}
143
+ button[type=submit]{background:#eee;color:#111;}
144
+ .tabs{border-bottom-color:#333;}
145
+ .tabs button{color:#999;}
146
+ .tabs button.active{color:#fff;border-bottom-color:#fff;}
147
+ .error{color:#ff6b5e;}
148
+ }
141
149
  </style></head><body>
142
150
  <div id="app">
143
151
  <h1>CTRL+SPC</h1><p class="sub">Sign in to link this computer.</p>
package/dist/mcp.js CHANGED
@@ -4756,7 +4756,13 @@ export async function listCredentialsHandler(client) {
4756
4756
  * returns `{secret, username}`; that maps to screen 8's shapes —
4757
4757
  * api_key → `{kind, secret}`, login → `{kind, username, password}`.
4758
4758
  */
4759
- export async function getCredentialHandler(client, args,
4759
+ export async function getCredentialHandler(client,
4760
+ // `id` is the web's copy-for-agent handle, and it is the ONLY thing that can
4761
+ // resolve the ambiguity the name path can only report: names are unique per
4762
+ // (org, creator), so two credentials the caller can read may share one, and
4763
+ // by name this tool could previously do nothing but tell the user to go
4764
+ // rename something. A pasted id names the row the user actually clicked.
4765
+ args,
4760
4766
  // 18d Slice 3. OPTIONAL and trailing, so the existing two-argument call sites
4761
4767
  // (and their tests) are untouched. Absent means "not a run" — see
4762
4768
  // `rememberSecret`.
@@ -4765,26 +4771,38 @@ runTodoId = null) {
4765
4771
  // whatever casing the agent happened to type: the match below is
4766
4772
  // case-insensitive, so "talenttrack read token" must still redact as
4767
4773
  // "[redacted: TalentTrack read token]".
4774
+ const wantedId = typeof args.id === 'string' ? args.id.trim() : '';
4775
+ if (!wantedId && !args.name) {
4776
+ return errorResult('get_credential requires name — as listed by list_credentials.');
4777
+ }
4768
4778
  let match;
4769
4779
  try {
4770
4780
  const rows = (await must(client
4771
4781
  .from('credentials')
4772
4782
  .select('id, name, kind, org_id, created_by')
4773
4783
  .order('created_at', { ascending: true }))) ?? [];
4774
- const wanted = args.name.toLowerCase();
4775
- // Uniqueness is per (org, creator): the caller can see two credentials
4776
- // with the same lowercased name across two of their orgs, or within one
4777
- // org (their own plus one shared to them by another creator). Never guess
4778
- // which secret was meant. org_id/created_by are read only to word this
4779
- // error; they never appear in tool output.
4780
- const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
4781
- if (matches.length > 1) {
4782
- const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
4783
- return errorResult(sameOrg
4784
- ? `credential name "${args.name}" is ambiguous — you can access more than one credential with this name in the same organization (e.g. your own and one shared with you); delete or re-create yours under a different name, or ask an org owner to revoke a shared copy`
4785
- : `credential name "${args.name}" is ambiguous you have access to credentials with this name in more than one of your organizations; delete or re-create one under a different name`);
4786
- }
4787
- match = matches[0];
4784
+ if (wantedId) {
4785
+ // An id names one row, so there is no ambiguity branch to reach. RLS
4786
+ // already filtered the rows, so a credential the caller cannot read is
4787
+ // simply absent and falls through to the uniform not-found below.
4788
+ match = rows.find((row) => row.id === wantedId);
4789
+ }
4790
+ else {
4791
+ const wanted = args.name.toLowerCase();
4792
+ // Uniqueness is per (org, creator): the caller can see two credentials
4793
+ // with the same lowercased name across two of their orgs, or within one
4794
+ // org (their own plus one shared to them by another creator). Never guess
4795
+ // which secret was meant. org_id/created_by are read only to word this
4796
+ // error; they never appear in tool output.
4797
+ const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
4798
+ if (matches.length > 1) {
4799
+ const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
4800
+ return errorResult(sameOrg
4801
+ ? `credential name "${args.name}" is ambiguous — you can access more than one credential with this name in the same organization (e.g. your own and one shared with you); delete or re-create yours under a different name, or ask an org owner to revoke a shared copy`
4802
+ : `credential name "${args.name}" is ambiguous — you have access to credentials with this name in more than one of your organizations; delete or re-create one under a different name`);
4803
+ }
4804
+ match = matches[0];
4805
+ }
4788
4806
  }
4789
4807
  catch (err) {
4790
4808
  return errorResult(`get_credential failed: ${err.message}`);
@@ -4978,9 +4996,51 @@ export async function listSkillsHandler(client, args) {
4978
4996
  * survives a compaction — an agent that has lost `get_skill`'s output still has
4979
4997
  * the skill's name in the conversation and can still reach its files.
4980
4998
  */
4981
- async function resolveSkill(client, rawName, rawOrgId, tool) {
4999
+ /**
5000
+ * Resolve a skill by its row id — the handle the web's copy-for-agent button
5001
+ * pastes. An id names ONE row, so neither of the name path's two ambiguity
5002
+ * errors can arise and `org_id` has nothing left to disambiguate: the id
5003
+ * already decided which org's skill this is, and the bundle it belongs to says
5004
+ * which org that was. RLS does the access check, so a row the caller cannot see
5005
+ * is simply not found.
5006
+ */
5007
+ async function resolveSkillById(client, id, tool) {
5008
+ const rows = (await must(client
5009
+ .from('skills')
5010
+ .select('id, bundle_id, name, description, relative_path, status, status_reason')
5011
+ .eq('id', id)
5012
+ .is('deleted_at', null))) ?? [];
5013
+ const row = rows[0];
5014
+ if (!row) {
5015
+ return {
5016
+ ok: false,
5017
+ error: errorResult(`${tool}: no skill with id ${id}. It may have been deleted, or belong to an organization you ` +
5018
+ 'cannot see. Call list_skills to see the skills that exist.'),
5019
+ };
5020
+ }
5021
+ // The bundle is what carries the org, and only a LIVE bundle counts: a skill
5022
+ // row can outlive the archiving of the bundle that brought it in.
5023
+ const bundles = await liveSkillBundles(client, null);
5024
+ const orgId = bundles.find((bundle) => bundle.id === row.bundle_id)?.org_id;
5025
+ if (!orgId) {
5026
+ return {
5027
+ ok: false,
5028
+ error: errorResult(`${tool}: skill "${row.name}" belongs to a skill pack that is no longer active. ` +
5029
+ 'Re-import the pack in the web app.'),
5030
+ };
5031
+ }
5032
+ return { ok: true, value: { row, orgId } };
5033
+ }
5034
+ async function resolveSkill(client, rawName, rawOrgId, tool,
5035
+ // The web's copy-for-agent handle. A name is still the addressing an agent
5036
+ // reaches for unaided (it survives a compaction; see this section's header),
5037
+ // so `id` is the ALTERNATIVE, not the replacement: it exists because the
5038
+ // Skills page copies `/ctrl-spc skill <id>` and a pasted row must resolve to
5039
+ // the row the user clicked, never to a same-named skill in another org.
5040
+ rawId = undefined) {
5041
+ const wantedId = typeof rawId === 'string' ? rawId.trim() : '';
4982
5042
  const wanted = typeof rawName === 'string' ? rawName.trim() : '';
4983
- if (!wanted) {
5043
+ if (!wantedId && !wanted) {
4984
5044
  return {
4985
5045
  ok: false,
4986
5046
  error: errorResult(`${tool} requires name — the name of the skill to read, as listed by list_skills.`),
@@ -4989,6 +5049,8 @@ async function resolveSkill(client, rawName, rawOrgId, tool) {
4989
5049
  const filter = skillOrgFilter(rawOrgId, tool);
4990
5050
  if (!filter.ok)
4991
5051
  return filter;
5052
+ if (wantedId)
5053
+ return resolveSkillById(client, wantedId, tool);
4992
5054
  const bundles = await liveSkillBundles(client, filter.value);
4993
5055
  const orgIdByBundle = new Map(bundles.map((bundle) => [bundle.id, bundle.org_id]));
4994
5056
  const rows = orgIdByBundle.size
@@ -5167,7 +5229,7 @@ async function listSkillBundleFiles(client, orgId, bundleId, relativePath) {
5167
5229
  }
5168
5230
  export async function getSkillHandler(client, args) {
5169
5231
  try {
5170
- const resolved = await resolveSkill(client, args?.name, args?.org_id, 'get_skill');
5232
+ const resolved = await resolveSkill(client, args?.name, args?.org_id, 'get_skill', args?.id);
5171
5233
  if (!resolved.ok)
5172
5234
  return resolved.error;
5173
5235
  const { row: match, orgId } = resolved.value;
@@ -5276,7 +5338,7 @@ export async function readSkillFileHandler(client, args) {
5276
5338
  if (!wantedPath) {
5277
5339
  return errorResult('read_skill_file requires path — one of the paths get_skill listed in bundle_files.');
5278
5340
  }
5279
- const resolved = await resolveSkill(client, args?.name, args?.org_id, 'read_skill_file');
5341
+ const resolved = await resolveSkill(client, args?.name, args?.org_id, 'read_skill_file', args?.id);
5280
5342
  if (!resolved.ok)
5281
5343
  return resolved.error;
5282
5344
  const { row: match, orgId } = resolved.value;
@@ -5883,6 +5945,58 @@ async function unnarrowedEmptyNote(client, projectId) {
5883
5945
  }
5884
5946
  return NO_PROJECT_DOCUMENTS_NOTE;
5885
5947
  }
5948
+ export async function getDocumentHandler(client, args) {
5949
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
5950
+ if (!id) {
5951
+ return errorResult('get_document requires id — the id of the document to read, as the web app’s “Copy for agent” ' +
5952
+ 'button pastes it.');
5953
+ }
5954
+ // Shape-checked once, here at the boundary, exactly as resolveContextProject
5955
+ // does with task_id: without it a mistyped id comes back as a Postgres
5956
+ // "invalid input syntax for type uuid", which reads like a bug in the tool.
5957
+ if (!UUID_RE.test(id)) {
5958
+ return errorResult(`get_document: not a valid document id: "${id}".`);
5959
+ }
5960
+ try {
5961
+ // Context documents first, then instructions. RLS scopes both reads, so a
5962
+ // row the caller may not see is simply absent and falls through to the
5963
+ // not-found below.
5964
+ const documents = await must(client
5965
+ .from('project_documents')
5966
+ .select('title, content, codebase_id, type')
5967
+ .eq('id', id));
5968
+ const document = (documents ?? [])[0];
5969
+ if (document)
5970
+ return textResult(shapeDocument(document));
5971
+ const instructions = await must(client.from('agent_instructions').select('title, content, codebase_id').eq('id', id));
5972
+ const instruction = (instructions ?? [])[0];
5973
+ if (instruction) {
5974
+ return textResult({
5975
+ ...shapeDocument(instruction),
5976
+ // Said plainly, so an agent that fetched one does not conclude these are
5977
+ // reference material it may choose to consult: it already has them.
5978
+ note: 'This is an agent instruction. Every instruction on this project is already delivered in ' +
5979
+ 'your prompt — reading one here does not make it optional.',
5980
+ });
5981
+ }
5982
+ return errorResult(`get_document: no document with id ${id}. It may have been deleted, or belong to a project you ` +
5983
+ 'cannot see. Call get_project_context to read the documents on the project you are working.');
5984
+ }
5985
+ catch (err) {
5986
+ return errorResult(`get_document failed: ${errorMessage(err)}`);
5987
+ }
5988
+ }
5989
+ /** The one shape both tables report in, so a caller never has to branch on
5990
+ * which table answered. `scope` is what `codebase_id` MEANS — null is the
5991
+ * project's own document, a value is that codebase's. */
5992
+ function shapeDocument(row) {
5993
+ return {
5994
+ title: row.title,
5995
+ ...(row.type ? { type: row.type } : {}),
5996
+ scope: row.codebase_id === null ? 'project' : 'codebase',
5997
+ content: row.content,
5998
+ };
5999
+ }
5886
6000
  // ---------------------------------------------------------------------------
5887
6001
  // Project context (feature 13a, Phase 4) — `propose_project_context`.
5888
6002
  //
@@ -9802,6 +9916,7 @@ export const TOOL_NAMES = [
9802
9916
  'get_skill',
9803
9917
  'read_skill_file',
9804
9918
  'get_project_context',
9919
+ 'get_document',
9805
9920
  'propose_project_context',
9806
9921
  'list_product_ideas',
9807
9922
  'create_product_idea',
@@ -11216,7 +11331,16 @@ runTodoIdSource = null) {
11216
11331
  'comments, artifacts, documents and questions, and name the credential instead. Never refuse the ' +
11217
11332
  'work to avoid touching the value.',
11218
11333
  inputSchema: {
11219
- name: z.string().min(1).describe('Credential name, as listed by list_credentials'),
11334
+ name: z
11335
+ .string()
11336
+ .min(1)
11337
+ .optional()
11338
+ .describe('Credential name, as listed by list_credentials'),
11339
+ id: z
11340
+ .string()
11341
+ .optional()
11342
+ .describe('Credential id, as pasted by the web app\'s "Copy for agent" button. Use it instead of ' +
11343
+ 'name; it names exactly one credential, where a name can be ambiguous.'),
11220
11344
  },
11221
11345
  }, async (args) => {
11222
11346
  touchSession(connectionId);
@@ -11250,7 +11374,12 @@ runTodoIdSource = null) {
11250
11374
  'about to do. If two organizations hold a skill of the same name this refuses rather than ' +
11251
11375
  'guessing: pass org_id to say which you mean. Read-only.',
11252
11376
  inputSchema: {
11253
- name: z.string().min(1).describe('Skill name, as listed by list_skills'),
11377
+ name: z.string().min(1).optional().describe('Skill name, as listed by list_skills'),
11378
+ id: z
11379
+ .string()
11380
+ .optional()
11381
+ .describe('Skill id, as pasted by the web app\'s "Copy for agent" button. Use it instead of name; ' +
11382
+ 'it names exactly one skill, so org_id is never needed with it.'),
11254
11383
  org_id: z
11255
11384
  .string()
11256
11385
  .optional()
@@ -11266,7 +11395,11 @@ runTodoIdSource = null) {
11266
11395
  'those paths here. WHEN A SKILL POINTS AT A FILE BESIDE IT, FETCH IT AND FOLLOW IT — never ask ' +
11267
11396
  'the user to supply a file the organization already stored. Text only, up to 512 KB. Read-only.',
11268
11397
  inputSchema: {
11269
- name: z.string().min(1).describe('Skill name, as listed by list_skills'),
11398
+ name: z.string().min(1).optional().describe('Skill name, as listed by list_skills'),
11399
+ id: z
11400
+ .string()
11401
+ .optional()
11402
+ .describe('Skill id. Use it instead of name; it names exactly one skill.'),
11270
11403
  path: z
11271
11404
  .string()
11272
11405
  .min(1)
@@ -11313,6 +11446,22 @@ runTodoIdSource = null) {
11313
11446
  touchSession(connectionId);
11314
11447
  return getProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
11315
11448
  });
11449
+ server.registerTool('get_document', {
11450
+ description: 'Read ONE context document or agent instruction by its id — the whole document, not an excerpt. ' +
11451
+ 'Use it when someone hands you an id (the web app’s “Copy for agent” button pastes one), or when ' +
11452
+ 'get_project_context named a document you need in full. It resolves either kind, so you do not ' +
11453
+ 'need to know which one the id belongs to. To read a project’s context as a whole instead, call ' +
11454
+ 'get_project_context. Read-only.',
11455
+ inputSchema: {
11456
+ id: z
11457
+ .string()
11458
+ .min(1)
11459
+ .describe('Document id, as the web app’s “Copy for agent” button pastes it'),
11460
+ },
11461
+ }, async (args) => {
11462
+ touchSession(connectionId);
11463
+ return getDocumentHandler(client, args);
11464
+ });
11316
11465
  server.registerTool('propose_project_context', {
11317
11466
  // The description TEACHES: when to reach for this (after reading a repo),
11318
11467
  // what a good proposal is (content it actually read, one line of why),
package/dist/skills.js CHANGED
@@ -15,7 +15,7 @@ import { dirname, join } from 'node:path';
15
15
  */
16
16
  // Verbatim from the web/skill contract the copied `/ctrl-spc work <id>`
17
17
  // invocation depends on.
18
- const SKILL_DESCRIPTION = 'Work a CTRL+SPC work item or artifact by ID — delegates read-only exploration and writes analysis/plans/specs/diagrams/mocks/wireframes back via the ctrl-spc MCP tools. Use when the user pastes /ctrl-spc work <id> or /ctrl-spc artifact <id>.';
18
+ const SKILL_DESCRIPTION = 'Work a CTRL+SPC work item or artifact by ID — delegates read-only exploration and writes analysis/plans/specs/diagrams/mocks/wireframes back via the ctrl-spc MCP tools. Use when the user pastes /ctrl-spc work <id> or /ctrl-spc artifact <id>, and to fetch one object when they paste /ctrl-spc workflow <id>, /ctrl-spc skill <id>, /ctrl-spc credential <id> or /ctrl-spc document <id>.';
19
19
  /** Home the skill files are written under. `CTRL_SPC_HOME` is the existing
20
20
  * documented name for exactly this (docs/cli-surface-catalog.md), reused
21
21
  * rather than renamed so one machine has one sandbox-home concept. */
@@ -77,6 +77,15 @@ export function installSkills(agents) {
77
77
  */
78
78
  const PROTOCOL_BODY = `Resolve a CTRL+SPC work item or artifact pasted as \`/ctrl-spc work <id>\` or \`/ctrl-spc artifact <id>\`, then follow the ctrl-spc working protocol:
79
79
 
80
+ FETCH VERBS — \`workflow\`, \`skill\`, \`credential\` and \`document\` are NOT the working protocol. They name ONE object the user copied so they can use it in this conversation, and nothing below applies to them: no \`begin_work\`, no subagents, no context artifact, no question, no \`end_work\`. Fetch the object, use it for what the user asked, and answer.
81
+
82
+ - \`/ctrl-spc workflow <id>\` — call \`read_workflow_library\` and use the workflow whose \`id\` matches, with the full body of each stage it holds. To RUN it on a work item, call \`start_workflow\` with that workflow id, then follow the working protocol below for the item. Report and stop when the user only asked to see it.
83
+ - \`/ctrl-spc skill <id>\` — call \`get_skill\` with \`id\`. THE RESULT IS A DOCUMENT TO FOLLOW for the rest of this work, not reference material to summarise. Its supporting files come from \`read_skill_file\` with the same \`id\`.
84
+ - \`/ctrl-spc credential <id>\` — call \`get_credential\` with \`id\`, then USE the value to do the work: a header, an environment variable, a command, local config. Never publish it: keep it out of your answer, comments, artifacts and questions, and name the credential instead.
85
+ - \`/ctrl-spc document <id>\` — call \`get_document\` with \`id\`. One context document or one agent instruction, whole. It resolves either kind, so you do not need to know which the id names. To read a project's context as a whole instead, call \`get_project_context\`.
86
+
87
+ Pass the pasted id as \`id\`. It names exactly one object, so \`org_id\` is never needed with it and a duplicate name cannot send you to the wrong one. Everything that follows is the WORK ITEM protocol:
88
+
80
89
  WORKFLOW AUTHORITY — when \`get_task.workflow.enabled\` is true, follow only its current stage, stored instructions, capabilities, requirements, and gate. Never infer or skip a stage. Persist each required artifact, then call \`hand_off_stage\` with the work item id and this request id, and stop: the next stage is worked by a fresh agent reading the record. It refuses a stage with unfinished steps and the last stage of the process, where you answer and stop instead. Review gates advance only after explicit user approval in the web app or conversation; requested changes stay in the same stage. Only final approval sets Done.
81
90
 
82
91
  HARD STOP — unclear novel feature (No workflow only): when \`workflow.enabled\` is false, after \`record_context_exploration\` succeeds, if the task does not explicitly request a build or name a deliverable, the next tool call MUST be \`ask_question\` with category \`intent\`, the exact question “What should I produce for this feature?”, \`answer_mode = multi_select\`, and options \`build\`, \`plan\`, \`spec\`, \`diagram\`, \`mock\`, and \`wireframe\`. Then call \`end_work\` with reason \`pending_user_answer\` and outcome \`blocked\`. In that run, never call \`get_task\` again, \`update_task\`, \`create_artifact\`, \`reserve_work_paths\`, or any other tool between the context artifact and \`ask_question\`. The context artifact is the only allowed artifact. Do not copy findings into the task description before the user answers. A read-only execution sandbox is not a missing checkout and must not change this intent question. The user may select one or more: build, plan, spec, diagram, mock, wireframe.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"