@arjunkhera/atlas 0.0.0 → 0.2.1
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/.claude-plugin/plugin.json +18 -0
- package/.mcp.json +18 -0
- package/README.md +32 -1
- package/agents/artifact-format/example.html +71 -0
- package/agents/artifact-format/system.css +273 -0
- package/agents/artifact-renderer.md +132 -0
- package/agents/capability-reader.md +100 -0
- package/agents/code-explorer.md +108 -0
- package/agents/reviewer-architect.md +78 -0
- package/agents/reviewer-pm.md +81 -0
- package/agents/reviewer-security.md +82 -0
- package/bin/atlas +5 -0
- package/door/cli.mjs +280 -0
- package/door/lib/helpers.mjs +139 -0
- package/door/lib/install.mjs +260 -0
- package/door/lib/kind-drift.mjs +76 -0
- package/door/lib/onboarding.mjs +130 -0
- package/door/lib/pull-request.mjs +119 -0
- package/door/lib/releases.mjs +42 -0
- package/door/lib/status.mjs +47 -0
- package/door/lib/tooling.mjs +119 -0
- package/package.json +24 -3
- package/shape/check.mjs +1797 -0
- package/shape/releases.json +15 -0
- package/skills/sdlc-task/SKILL.md +174 -0
- package/skills/sdlc-task/lifecycle.yaml +27 -0
- package/skills/sdlc-task/templates/design-doc.md +118 -0
- package/skills/sdlc-task/templates/resume-anchor.md +25 -0
- package/work/code-read.mjs +65 -0
- package/work/lib/action-store.mjs +84 -0
- package/work/lib/capability.mjs +487 -0
- package/work/lib/child-env.mjs +11 -0
- package/work/lib/client.mjs +214 -0
- package/work/lib/code-read.mjs +238 -0
- package/work/lib/delivery.mjs +624 -0
- package/work/lib/mcp-stdio.mjs +68 -0
- package/work/lib/questions.mjs +278 -0
- package/work/lib/sweep.mjs +189 -0
- package/work/lib/verb-fields.mjs +53 -0
- package/work/lib/verbs.mjs +511 -0
- package/work/manifest-0.5.0.json +609 -0
- package/work/mcp.mjs +216 -0
- package/work/protected-paths.json +38 -0
package/work/mcp.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Atlas work verbs as one MCP server on the owner's Mac (M1 slices L1 to L5,
|
|
3
|
+
// D2 as cut for M1). The server holds the Engram token from the environment
|
|
4
|
+
// and stamps writer.session itself. Callers never supply authority fields.
|
|
5
|
+
//
|
|
6
|
+
// Row F11 (amendment A1.9): the server ships in the Atlas plugin. Claude Code
|
|
7
|
+
// starts it from the plugin's .mcp.json, which sets every variable it reads.
|
|
8
|
+
// The Engram address and key arrive together, as one setting, in
|
|
9
|
+
// ATLAS_ENGRAM. The server reads them from nowhere else.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { resolve, isAbsolute, dirname, join, delimiter } from 'node:path';
|
|
13
|
+
import { existsSync, realpathSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createEngramClient } from './lib/client.mjs';
|
|
16
|
+
import { createActionStore } from './lib/action-store.mjs';
|
|
17
|
+
import { createVerbs, STAGES, TIERS, DRIVERS, LINK_KINDS, REPO_KINDS, MANIFEST_VERSION, COSTS } from './lib/verbs.mjs';
|
|
18
|
+
import { COST_MEANING, DECISION_KINDS } from './lib/questions.mjs';
|
|
19
|
+
import { FATAL_CONCLUSIONS } from './lib/delivery.mjs';
|
|
20
|
+
import { createMcpServer } from './lib/mcp-stdio.mjs';
|
|
21
|
+
import { childEnvironment } from './lib/child-env.mjs';
|
|
22
|
+
export { childEnvironment };
|
|
23
|
+
|
|
24
|
+
const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
25
|
+
const S = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
26
|
+
const ORIGIN = { type: 'object', description: "The owner's words, verbatim, with where and when they were said.", required: ['text', 'source', 'date'], properties: { text: S('Verbatim words of the owner.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), date: S('ISO date, in the form YYYY-MM-DD.') } };
|
|
27
|
+
const REQUEST_ID = S('Stable unique id for this request. A retry with the same id and the same arguments is a no-op that returns the first receipt. The same id with different arguments is refused.');
|
|
28
|
+
const RULES = 'Rules: every write needs request_id. Never pass writer, session, actor or role; the server stamps them. Unknown product or repo ids are refused.';
|
|
29
|
+
|
|
30
|
+
const CODE_FINDING = { type: 'object', required: ['statement', 'path', 'line'], properties: { name: S('Short label for the finding.'), statement: S('One sentence about what the code does, or what bounds it.'), path: S('Repo-relative path the claim is read from. A never-read path is refused.'), line: { type: 'integer', description: 'The line the claim is read from, 1 or higher.' }, quote: S('The minimal proving line. Never from a never-quote path; a quote from one is refused.') } };
|
|
31
|
+
const LIMIT_FINDING = { type: 'object', required: ['statement', 'path', 'line'], properties: { ...CODE_FINDING.properties, kind: { type: 'string', enum: ['cap', 'flag', 'skipped', 'untested', 'other'], description: 'What kind of limit this is.' } } };
|
|
32
|
+
const CODE_DIGEST = { type: 'object', description: 'One sub-agent digest for one repo. Every claim carries a path and a line, and every cited path must be in files_read.', required: ['repo', 'commit', 'files_read'], properties: { repo: S('Registered repo id the read ran in, for example Arjunkhera/Engram.'), commit: S('The commit the read ran against, 7 to 40 hex characters.'), capability: S('The capability the read was briefed with.'), read_by: S('Which sub-agent read it.'), files_read: { type: 'array', description: 'Every file the read opened. A never-read path is refused.', items: { type: 'string' } }, supports: { type: 'array', description: 'What the code supports today.', items: CODE_FINDING }, limits: { type: 'array', description: 'Caps, flags, skipped paths and untested paths.', items: LIMIT_FINDING }, unknowns: { type: 'array', description: 'What the read could not settle, and where it looked.', items: { type: 'string' } } } };
|
|
33
|
+
|
|
34
|
+
export const TOOLS = [
|
|
35
|
+
{ name: 'registry_list', description: `List the registered products and their repos with registry ids and entity ids. When: before any item verb, to find the ids to pass. Scope: read, no owner flag needed. ${RULES}`, inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
36
|
+
{ name: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its kind, container, decision, question, receipt, mark and the surviving edges). Idempotent. When: once per tenant, before the first product add, and again after a manifest change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id'], properties: { request_id: REQUEST_ID }, additionalProperties: false } },
|
|
37
|
+
{ name: 'product_add', description: `Register a product in the name registry. Refuses when the id exists. When: a product gets its first work item. Owner present only. registry_id is a lowercase slug and is immutable. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'name'], properties: { request_id: REQUEST_ID, registry_id: S('Lowercase slug, for example "engram". Immutable.'), name: S('Display name.'), project_id: S('Entity id of the existing Engram project entity this product links to.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint. The done verb reads the deployed commit there, and every live check must be on this origin.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths on that origin the functional check of item_done may read, for example ["/v1/housekeeping"]. Unset, the check may read only the health endpoint\'s own path.' }, notes: S('Free text.') }, additionalProperties: false } },
|
|
38
|
+
{ name: 'product_edit', description: `Change a product's name, notes, health endpoint, check paths or project link. Refuses an unknown id. The registry id never changes. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing product id.'), name: S('New display name.'), project_id: S('Entity id of an Engram project entity.'), health_url: S('Absolute https URL of the deployed app\'s health endpoint.'), check_paths: { type: 'array', items: { type: 'string' }, description: 'Absolute paths the functional check of item_done may read on that origin.' }, notes: S('Free text.') }, additionalProperties: false } },
|
|
39
|
+
{ name: 'repo_add', description: `Register a GitHub repo under a product. The server reads GitHub itself: the id must equal the repo's full name and the owner must have push rights, or the add is refused. The default branch comes from GitHub. The kind says which of the seven Atlas files the repo needs. Refuses an unknown product id and an existing repo id; repo ids are immutable. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id', 'product', 'kind'], properties: { request_id: REQUEST_ID, registry_id: S('GitHub "owner/repo", exact spelling.'), product: S('Existing product id.'), kind: S(`What the repo is. One of ${REPO_KINDS.join(', ')}. The repo-shape check reads it.`), notes: S('Free text.') }, additionalProperties: false } },
|
|
40
|
+
{ name: 'repo_edit', description: `Change a repo's kind or notes, or refresh its default branch from GitHub. registry_id and product never change. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'registry_id'], properties: { request_id: REQUEST_ID, registry_id: S('Existing repo id.'), kind: S(`What the repo is. One of ${REPO_KINDS.join(', ')}. The repo-shape check reads it.`), notes: S('Free text.'), refresh_default_branch: { type: 'boolean', description: 'Re-read the default branch and push rights from GitHub.' } }, additionalProperties: false } },
|
|
41
|
+
{ name: 'item_propose', description: `Create a proposed work item (lifecycle proposed, stage capture) for a product, with its repos and the owner's words as origin. When: the owner names a piece of work that is not yet locked. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'title', 'product', 'repos', 'origin'], properties: { request_id: REQUEST_ID, title: S('Short title in the owner\'s words.'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}. Default standard.`), driver: S(`One of ${DRIVERS.join(', ')}. Default owner-led.`), next_step: S('One line: what happens next.'), summary: S('Short summary.'), body: S('Longer body, optional.'), tags: { type: 'array', items: { type: 'string' } } }, additionalProperties: false } },
|
|
42
|
+
{ name: 'item_lock', description: `Lock a proposed or active item: product and repos by registry id, the scope (text, optional design doc URL and acceptance-criteria hash), and the owner's words as origin. Lifecycle becomes active; stage defaults to build. Refuses an unknown product or repo id, a missing scope or origin, and an item that is already locked. When: the owner says "lock". Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos', 'scope', 'origin'], properties: { request_id: REQUEST_ID, item_id: S('Entity id of the work item (container).'), product: S('Product id.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product.' }, scope: { type: 'object', required: ['text'], properties: { text: S('The accepted scope, in full.'), design_doc: S('URL of the design doc at its lock commit.'), acceptance_criteria_hash: S('64-hex sha256 of the frozen acceptance text, when the design doc carries one.') } }, origin: ORIGIN, tier: S(`One of ${TIERS.join(', ')}.`), stage: S(`One of ${STAGES.join(', ')}. Default build.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
|
|
43
|
+
{ name: 'item_split', description: `Split an item into pieces. Each child is a proposed item (stage capture) with a spun_out_of relation to the parent and the reason. Children inherit product, repos, tier, origin and driver unless given. Refuses an unknown parent, a missing reason, or a child repo outside the product. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'children'], properties: { request_id: REQUEST_ID, item_id: S('Parent item id.'), reason: S('Why the split, in the owner\'s words.'), children: { type: 'array', items: { type: 'object', required: ['title'], properties: { title: S('Child title.'), repos: { type: 'array', items: { type: 'string' } }, tier: S(`One of ${TIERS.join(', ')}.`), origin: ORIGIN, driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line.'), summary: S('Short summary.') } } } }, additionalProperties: false } },
|
|
44
|
+
{ name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog and appear only on request. Refuses a missing reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it is parked.') }, additionalProperties: false } },
|
|
45
|
+
{ name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), reason: S('Why it resumes.') }, additionalProperties: false } },
|
|
46
|
+
{ name: 'item_stage', description: `Move an active item to a stage (${STAGES.join(', ')}). When: work moves; the lead or the delivering agent calls it. No owner flag needed. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'stage'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), stage: S(`One of ${STAGES.join(', ')}.`), note: S('One line on what moved.') }, additionalProperties: false } },
|
|
47
|
+
{ name: 'item_link', description: `Attach a typed link (kind, url, role, note) to an item: a PR, an issue, an artifact, a design doc, a conversation. When: any in-session agent has a link the item should carry, for example a PR it opened. No owner flag needed. A duplicate kind and url is a no-op. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'kind', 'url'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), kind: S(`One of ${LINK_KINDS.join(', ')}.`), url: S('Absolute http or https URL.'), role: S('What the link is to the item, for example "delivers", "design", "evidence".'), note: S('One line.') }, additionalProperties: false } },
|
|
48
|
+
{ name: 'item_adopt', description: `Give an existing work item its product and repos. This is the slice L5 sweep: most items in the tenant were made before the registry existed and name no product, so no view can place them. Refuses an item that already names another registered product; an item is named once. The audit stamps adopted_at and adopted_from move only when the product actually changes. When: the owner says which product a piece of work belongs to. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'product', 'repos'], properties: { request_id: REQUEST_ID, item_id: S('Item id.'), product: S('Product id the item belongs to.'), repos: { type: 'array', items: { type: 'string' }, description: 'Repo ids under that product. Required. Pass an empty list for an item that has no repo, such as a container root; a repo of another product is dropped and named in dropped_repos.' }, stage: S(`One of ${STAGES.join(', ')}. Active items only.`), tier: S(`One of ${TIERS.join(', ')}.`), driver: S(`One of ${DRIVERS.join(', ')}.`), next_step: S('One line: what happens next.') }, additionalProperties: false } },
|
|
49
|
+
{ name: 'question_ask', description: `Ask the owner one question that blocks work on an item, and say what waiting costs. When: an agent is blocked and cannot decide by itself. Any in-session agent may call it; no owner flag. The question shows in the "Needs me" view until it is answered. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'item_id', 'product', 'cost'], properties: { request_id: REQUEST_ID, text: S('The question, in plain words, answerable without opening a source.'), item_id: S('Entity id of the work item the question blocks.'), product: S('Product id of that item.'), cost: S(`What waiting costs. One of: ${COSTS.map((cost) => `${cost} (${COST_MEANING[cost]})`).join('; ')}.`), context: { type: 'object', description: 'What the owner needs to answer without opening a source.', properties: { current_behavior: S('What happens today.'), change: S('What would change.'), affected: { type: 'array', items: { type: 'string' }, description: 'Parts that change.' }, options: { type: 'array', items: { type: 'string' }, description: 'The options, one per entry.' }, consequences: S('What follows from each option.'), sources: { type: 'array', items: { type: 'string' }, description: 'Absolute http or https URLs that back the context.' } } } }, additionalProperties: false } },
|
|
50
|
+
{ name: 'question_answer', description: `Record the owner's answer to one open question. One call writes the decision in the owner's words, keeps the rendered answer as a receipt, and closes the question, so the "Needs me" view loses the row. Refuses an unknown question and one that is already answered. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'question_id', 'text', 'date'], properties: { request_id: REQUEST_ID, question_id: S('Entity id of the open question.'), text: S("The owner's answer, verbatim."), date: S('ISO date the owner said it, in the form YYYY-MM-DD.'), source: S('Where the words are on record: a chat turn, a doc URL, an issue.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default answer.`), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches. Default: the question\'s item.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on. Default: the question and the answer.'), view: S('Which view the owner read, for example needs-me.') }, additionalProperties: false } },
|
|
51
|
+
{ name: 'decision_record', description: `Record what the owner decided outside a question, in their own words. Refuses missing text and a missing date. Pass rendered to keep the view the owner acted on as a receipt in the same call. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'text', 'date'], properties: { request_id: REQUEST_ID, text: S("The owner's words, verbatim."), date: S('ISO date the owner said it.'), source: S('Where the words are on record.'), kind: S(`One of ${DECISION_KINDS.join(', ')}. Default decision.`), product: S('Product id the decision is about.'), items: { type: 'array', items: { type: 'string' }, description: 'Item ids the decision touches.' }, priority_order: { type: 'array', items: { type: 'string' }, description: 'Item ids in the order the owner set.' }, replaces: S('Entity id of the decision this one replaces.'), rendered: S('The view text the owner acted on.'), view: S('Which view the owner read.') }, additionalProperties: false } },
|
|
52
|
+
{ name: 'receipt_keep', description: `Keep the rendered answer the owner acted on and attach it to a decision. Refuses when there is no decision to attach to. Secret patterns are redacted before the write. Owner present only. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'decision_id', 'rendered'], properties: { request_id: REQUEST_ID, decision_id: S('Entity id of the decision this receipt belongs to.'), rendered: S('The answer as the owner read it.'), view: S('Which view it came from, for example needs-me.') }, additionalProperties: false } },
|
|
53
|
+
{ name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service repo gets the three read-only live checks: the deployed commit carries the pull requests, the health endpoint answers, and a functional check scoped to the change passes. Every check is a GET. The merge state of each pull request and the gate runs come from GitHub; the caller supplies none of them, and a red gate (${FATAL_CONCLUSIONS.join(', ')}) refuses the mark. A library or schema repo has no deploy target, so its delivery is proven by the merge and the gate alone: pass no functional, and the product needs no health_url. An item with no registered repo, or a repo whose kind was never set, keeps the three checks. The delivering agent calls this; the lead calls it to catch up a delivery made outside a session. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'prs'], properties: { request_id: REQUEST_ID, item_id: S('Item id. The item must be active, and its product must carry a health_url.'), prs: { type: 'array', items: { type: 'string' }, description: 'The merged GitHub pull request URLs this delivery shipped. Required: the item\'s own pr links are not read, because a link can be a design or a lock, not a delivery.' }, functional: { type: 'object', required: ['url', 'contains'], description: 'The read-only functional check. Required for a service repo, and refused for a library or schema repo. The verb GETs this URL once. No method, body or headers are accepted.', properties: { url: S('Absolute URL on the product health endpoint\'s origin, under one of the product\'s check_paths, and not the health endpoint itself.'), contains: { type: 'array', items: { type: 'string' }, description: 'Required. Strings the answer must carry, chosen so they exist only because of this change. Only whether each was found is recorded; the answer itself never enters the graph.' }, status: { type: 'number', description: 'Expected HTTP status, 2xx only. Default 200.' } } }, note: S('One line on what was delivered.') }, additionalProperties: false } },
|
|
54
|
+
{ name: 'item_reopen', description: `Reopen a delivered item: the lifecycle goes back to active and a reopen mark is appended, citing the failed check by URL. The delivery mark is never erased; the reopen points at it with the corrects relation. The owner, or the lead in this session, calls it. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'item_id', 'reason', 'failed_check'], properties: { request_id: REQUEST_ID, item_id: S('Item id carrying at least one delivery mark.'), reason: S('Why it is reopened, in plain words.'), failed_check: S('Absolute URL of the check that failed: a CI run, a monitor, an issue.'), corrects: S('Mark id of the delivery this reopen corrects. Default: the item\'s latest delivery mark.') }, additionalProperties: false } },
|
|
55
|
+
{ name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by days since the last change with the latest on top, and the days shown on each row. Movement is read from the marks and from each registered repo\'s recent pull requests: the item\'s own pr links, plus any pull request whose title, body or branch names the item id. Merged pull requests with no delivery mark are listed as "merged, not marked". A legacy seal reads as a delivery mark with verdict "legacy". Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Adds what was delivered since then.') }, additionalProperties: false } },
|
|
56
|
+
{ name: 'sweep_check', description: 'Check the sweep against the "In flight" view: the newest decision record of kind sweep names the items the owner called in flight, and this reads the view and names every difference — what is listed and not in flight, and what is in flight and not listed. A product with no sweep on record is named too. It answers two things apart: `matches` says each product\'s view holds exactly what its sweep lists, and `complete` says that and that no item in the tenant sits outside every product. An item nobody adopted is missing from both sides, so `matches` alone can read "equal" over an unfinished sweep. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the check to one product id.') }, additionalProperties: false } },
|
|
57
|
+
{ name: 'needs_me', description: 'Answer "what needs me?": every open question on an active item, per product, ordered by what waiting costs. Each row is three lines, the question, the item and the cost, with the context folded under it. Computed at read time and never stored. Scope: read, no owner flag needed. Pass the row text back as rendered when the owner answers.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.') }, additionalProperties: false } },
|
|
58
|
+
{ name: 'code_read_brief', description: 'The brief for the sub-agents that read the code for "where are we with X", one per registered repo of the product. It carries the never-read and never-quote rules, the citation contract and the JSON shape of the digest. Give one brief to one sub-agent; sub-agents never receive this server or the Engram MCP. Pass each digest back in where_are_we.code. When: before answering "where are we with X". Scope: read, no owner flag needed; it opens no repo and writes nothing.', inputSchema: { type: 'object', required: ['product', 'capability'], properties: { product: S('Product registry id.'), capability: S('The capability to read for, the X of the question.'), repos: { type: 'array', description: 'Limit the briefs to these registered repo ids. Default: every repo of the product.', items: { type: 'string' } }, roots: { type: 'object', description: 'Repo id to the local checkout path the sub-agent reads.', additionalProperties: { type: 'string' } }, commits: { type: 'object', description: 'Repo id to the commit the read runs against.', additionalProperties: { type: 'string' } }, focus: { type: 'array', description: 'Extra things the read should look for.', items: { type: 'string' } } }, additionalProperties: false } },
|
|
59
|
+
{ name: 'where_are_we', description: 'Answer "where are we with X" in six parts: live support and known limits from the code read at question time, then in progress, delivered and proposed from the items, then the current priority decision. Every row carries a citation: a path and a line, an entity id, a pull request, or a decision date. The code half comes from the sub-agent digests, which are refused when they cite a never-read path or quote a never-quote path, and every string is scrubbed by pattern. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product', 'capability', 'code'], properties: { product: S('Product registry id.'), capability: S('The capability the question is about, the X.'), code: { type: 'array', description: 'One digest per repo, from the sub-agents briefed by code_read_brief. At least one is required: an answer with no code read says nothing about what the code does today.', items: CODE_DIGEST }, items: { type: 'array', description: 'Item ids the lead judges to answer this question.', items: { type: 'string' } }, match: { type: 'array', description: 'Terms to match against item titles, summaries and tags. Give items, match, or both.', items: { type: 'string' } }, movement_repos: { type: 'array', description: "Narrow the movement read to these registered repo ids. Default: every repo of the product. The digests' own repos are always read.", items: { type: 'string' } }, since: S('ISO date. Adds what changed since then.') }, additionalProperties: false } },
|
|
60
|
+
{ name: 'built_pending_possible', description: 'Answer "what are we building, what is built, what is pending and what can be done": delivered items by date, active items, paused items with their reason, proposed items with their age, and the decisions that set the order. Item records, delivery marks and decisions only; no code read and no GitHub read. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { product: S('Limit the view to one product id.'), since: S('ISO date. Limits built and the decisions to what came after it.') }, additionalProperties: false } },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
// The Engram connection: the address and the key, in one setting, separated by
|
|
64
|
+
// white space: "https://<host> <key>". Changing the address means entering the
|
|
65
|
+
// key again, so the key never follows an address someone else chose.
|
|
66
|
+
//
|
|
67
|
+
// It never throws. A missing, malformed or expired connection is a state, and
|
|
68
|
+
// the server still starts and lists its tools; each tool then answers with the
|
|
69
|
+
// sentence below (fix six of the F11 study).
|
|
70
|
+
const CONNECTION_HELP = 'Set it in the Atlas plugin settings, as "https://<host> <key>".';
|
|
71
|
+
export function readConnection(env, { allowLoopbackHttp = false, now = Date.now() } = {}) {
|
|
72
|
+
const raw = unset(env.ATLAS_ENGRAM) ? '' : String(env.ATLAS_ENGRAM).trim();
|
|
73
|
+
if (!raw) return { state: 'missing', message: `No Engram connection is set. ${CONNECTION_HELP}` };
|
|
74
|
+
const parts = raw.split(/\s+/);
|
|
75
|
+
if (parts.length !== 2) return { state: 'bad', message: `The Engram connection is not in the form "https://<host> <key>". ${CONNECTION_HELP}` };
|
|
76
|
+
const [address, token] = parts;
|
|
77
|
+
let url;
|
|
78
|
+
try { url = new URL(address); } catch { return { state: 'bad', message: `The Engram address is not a URL. ${CONNECTION_HELP}` }; }
|
|
79
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
80
|
+
if (url.protocol !== 'https:' && !(allowLoopbackHttp && loopback && url.protocol === 'http:')) return { state: 'bad', message: `The Engram address must use https. ${CONNECTION_HELP}` };
|
|
81
|
+
if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return { state: 'bad', message: `The Engram address must be the host only, with no path. ${CONNECTION_HELP}` };
|
|
82
|
+
let claims;
|
|
83
|
+
try { claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url')); } catch { return { state: 'bad', message: `The Engram key is not an Engram token. ${CONNECTION_HELP}` }; }
|
|
84
|
+
if (claims.principal_type !== 'user' || !claims.jti || !claims.exp) return { state: 'bad', message: `The Engram key must be a user token with a token id. ${CONNECTION_HELP}` };
|
|
85
|
+
if (claims.exp <= now / 1000) {
|
|
86
|
+
const day = new Date(claims.exp * 1000).toISOString().slice(0, 10);
|
|
87
|
+
return { state: 'expired', message: `The Engram key expired on ${day}. Enter a new one in the Atlas plugin settings.`, expired: day };
|
|
88
|
+
}
|
|
89
|
+
return { state: 'ok', url: url.origin, token, tokenId: claims.jti, expires: new Date(claims.exp * 1000).toISOString().slice(0, 10) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Node reads these from the environment it inherits, before any code of ours
|
|
93
|
+
// runs. Each can send the key somewhere else or turn certificate checks off,
|
|
94
|
+
// so the server refuses to start when one is set. The plugin's .mcp.json sets
|
|
95
|
+
// NODE_OPTIONS to empty.
|
|
96
|
+
export const HOSTILE_NODE_VARIABLES = Object.freeze(['NODE_TLS_REJECT_UNAUTHORIZED', 'NODE_EXTRA_CA_CERTS', 'NODE_USE_ENV_PROXY', 'NODE_PATH']);
|
|
97
|
+
export function hostileVariables(env) {
|
|
98
|
+
const found = HOSTILE_NODE_VARIABLES.filter((name) => env[name] !== undefined && env[name] !== '');
|
|
99
|
+
if (env.NODE_OPTIONS !== undefined && env.NODE_OPTIONS !== '') found.push('NODE_OPTIONS');
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findOnPath(program, env) {
|
|
104
|
+
for (const directory of String(env.PATH ?? '').split(delimiter)) if (directory && existsSync(join(directory, program))) return join(directory, program);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const unset = (value) => !value || value.startsWith('${');
|
|
109
|
+
export function journalDirectory(env) {
|
|
110
|
+
const directory = unset(env.ATLAS_WORK_JOURNAL) ? (env.HOME ? join(env.HOME, '.atlas', 'work-journal') : undefined) : env.ATLAS_WORK_JOURNAL;
|
|
111
|
+
if (!isAbsolute(directory ?? '')) throw new Error('ATLAS_WORK_JOURNAL must be an absolute host directory outside repositories.');
|
|
112
|
+
let existing = directory;
|
|
113
|
+
while (!existsSync(existing) && dirname(existing) !== existing) existing = dirname(existing);
|
|
114
|
+
for (let parent = realpathSync(existing); ; parent = dirname(parent)) {
|
|
115
|
+
if (existsSync(join(parent, '.git'))) throw new Error('Keep the work journal outside repository workspaces.');
|
|
116
|
+
if (parent === dirname(parent)) break;
|
|
117
|
+
}
|
|
118
|
+
return directory;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function gh(args) {
|
|
122
|
+
return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 30_000, stdio: ['pipe', 'pipe', 'pipe'], env: childEnvironment() });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Every GitHub read the verbs may make, GET only and one shape per line. The
|
|
126
|
+
// done verb needs the merge state, the gate runs and the commit comparison; the
|
|
127
|
+
// "In flight" view needs each repo's recent pull requests (lock block L3).
|
|
128
|
+
const REPO = '[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+';
|
|
129
|
+
const SHA = '[0-9a-fA-F]{7,40}';
|
|
130
|
+
export const GITHUB_READS = Object.freeze([
|
|
131
|
+
new RegExp(`^repos/${REPO}$`),
|
|
132
|
+
new RegExp(`^repos/${REPO}/pulls/\\d{1,9}$`),
|
|
133
|
+
new RegExp(`^repos/${REPO}/pulls\\?state=all&sort=updated&direction=desc&per_page=\\d{1,3}$`),
|
|
134
|
+
new RegExp(`^repos/${REPO}/commits/${SHA}/check-runs\\?per_page=\\d{1,3}$`),
|
|
135
|
+
new RegExp(`^repos/${REPO}/compare/${SHA}\\.\\.\\.${SHA}$`),
|
|
136
|
+
]);
|
|
137
|
+
export function githubReader() {
|
|
138
|
+
return async (path) => {
|
|
139
|
+
if (!GITHUB_READS.some((shape) => shape.test(path))) throw new Error(`this read is not on the allowlist: ${path}`);
|
|
140
|
+
// --method GET is explicit: the reader can never turn into a write.
|
|
141
|
+
return JSON.parse(gh(['api', '--method', 'GET', path]));
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// One GET against the deployed app, for the health endpoint and the functional
|
|
146
|
+
// check (lock block L3, finding R10). There is no other method, no body and no
|
|
147
|
+
// redirect: a redirect is not an answer, so it is reported as its own status.
|
|
148
|
+
// The Engram credential travels only to the Engram origin the server is already
|
|
149
|
+
// configured to write to, and never to any other host.
|
|
150
|
+
export const MAX_LIVE_BODY = 262_144;
|
|
151
|
+
export function liveReader({ engramUrl, token, timeout = 20_000 } = {}) {
|
|
152
|
+
const trusted = engramUrl ? new URL(engramUrl).origin : null;
|
|
153
|
+
return async (target) => {
|
|
154
|
+
const parsed = new URL(target);
|
|
155
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('only http and https are read');
|
|
156
|
+
const headers = { accept: 'application/json, text/plain;q=0.9, */*;q=0.8' };
|
|
157
|
+
if (token && parsed.origin === trusted) headers.authorization = `Bearer ${token}`;
|
|
158
|
+
const answer = await fetch(parsed, { method: 'GET', headers, redirect: 'manual', signal: AbortSignal.timeout(timeout) });
|
|
159
|
+
const body = (await answer.text()).slice(0, MAX_LIVE_BODY);
|
|
160
|
+
return { status: answer.status, body };
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// What `atlas doctor` reads: the last start of this server, from whichever
|
|
165
|
+
// surface started it. The desktop app starts servers with a shorter PATH than
|
|
166
|
+
// a terminal, so the gh line is measured here, not in the terminal.
|
|
167
|
+
export function healthPath(env) { return env.HOME ? join(env.HOME, '.atlas', 'health.json') : null; }
|
|
168
|
+
function writeHealth(env, health) {
|
|
169
|
+
const path = healthPath(env);
|
|
170
|
+
if (!path) return;
|
|
171
|
+
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(health, null, 2)}\n`, { mode: 0o600 }); } catch { /* health is advice, never a reason to stop */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function buildServer({ env = process.env, input, output, readGitHub, readLive, log, allowLoopbackHttp = false } = {}) {
|
|
175
|
+
const hostile = hostileVariables(env);
|
|
176
|
+
if (hostile.length) throw new Error(`refusing to start: ${hostile.join(', ')} ${hostile.length === 1 ? 'is' : 'are'} set, and could send the Engram key elsewhere or turn certificate checks off. Unset ${hostile.length === 1 ? 'it' : 'them'}.`);
|
|
177
|
+
const connection = readConnection(env, { allowLoopbackHttp });
|
|
178
|
+
const session = (unset(env.ATLAS_WORK_SESSION) ? '' : env.ATLAS_WORK_SESSION.trim()) || `atlas-work:${randomUUID()}`;
|
|
179
|
+
const ownerPresent = env.ATLAS_WORK_OWNER_PRESENT === '1';
|
|
180
|
+
let bound = null;
|
|
181
|
+
const store = createActionStore({ directory: journalDirectory(env) });
|
|
182
|
+
const agent = unset(env.ATLAS_WORK_AGENT) ? 'lead' : env.ATLAS_WORK_AGENT.trim() || 'lead';
|
|
183
|
+
const { url: engramUrl, token: key } = connection;
|
|
184
|
+
const client = connection.state === 'ok' ? createEngramClient({ baseUrl: engramUrl, token: key }) : null;
|
|
185
|
+
const live = readLive ?? (connection.state === 'ok' ? liveReader({ engramUrl, token: key }) : null);
|
|
186
|
+
const verbsFor = (boundClient) => createVerbs({ client, store, writer: { token_id: connection.tokenId }, session, agent, boundClient, ownerPresent, readGitHub: readGitHub ?? githubReader(), readLive: live });
|
|
187
|
+
let verbs = connection.state === 'ok' ? verbsFor(null) : null;
|
|
188
|
+
const health = { package_version: ATLAS_VERSION, started_at: new Date().toISOString(), pid: process.pid, node: process.version, connection: connection.state, connection_expires: connection.expires ?? connection.expired ?? null, owner_present: ownerPresent, gh: findOnPath('gh', env), engram: connection.state === 'ok' ? 'not checked yet' : connection.message };
|
|
189
|
+
writeHealth(env, health);
|
|
190
|
+
if (client) {
|
|
191
|
+
client.getPublisher().then(() => { health.engram = 'answered'; }, (error) => { health.engram = `did not answer: ${error.message}`; }).finally(() => writeHealth(env, health));
|
|
192
|
+
}
|
|
193
|
+
const instructions = connection.state === 'ok'
|
|
194
|
+
? `Atlas work verbs for the owner's session. Session ${session}; owner present: ${ownerPresent ? 'yes' : 'no: the owner-only tools refuse'}. Call registry_list first to learn the product and repo ids, needs_me to see the open questions, and in_flight to see the active work. Every write needs a request_id. The server stamps writer.session and the bound client; never pass writer, session, actor or role. Unknown product or repo ids are refused. Sub-agents must not receive this server.`
|
|
195
|
+
: `Atlas work verbs. They cannot reach Engram yet: ${connection.message}`;
|
|
196
|
+
const server = createMcpServer({ name: 'atlas-work', version: MANIFEST_VERSION, instructions, tools: TOOLS, input, output, log, call: (name, args, boundTo) => {
|
|
197
|
+
if (!verbs) throw new Error(connection.message);
|
|
198
|
+
// The writer names the one client this server is bound to (D6 R3): stamped by the server, never by the caller.
|
|
199
|
+
if (boundTo && boundTo.session !== bound) { bound = boundTo.session; verbs = verbsFor(bound); }
|
|
200
|
+
return verbs.run(name, args);
|
|
201
|
+
} });
|
|
202
|
+
return { server, session, connection, health, verbs: () => verbs };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Real paths on both sides: macOS reaches /var through /private/var, and a
|
|
206
|
+
// plugin cache can sit behind a link, so a plain compare would never start.
|
|
207
|
+
if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
208
|
+
try {
|
|
209
|
+
const { server, session } = buildServer({ log: (line) => process.stderr.write(`atlas-work: ${line}\n`) });
|
|
210
|
+
process.stderr.write(`atlas-work: MCP server ready; session ${session}\n`);
|
|
211
|
+
await server.start();
|
|
212
|
+
} catch (error) {
|
|
213
|
+
process.stderr.write(`atlas-work: ${error.message}\n`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"authority": "the Atlas M1 lock: design review finding R12, lock blocks L1 and L4",
|
|
3
|
+
"never_read": [
|
|
4
|
+
"deploy/",
|
|
5
|
+
".env*",
|
|
6
|
+
".testenv/",
|
|
7
|
+
".claude/",
|
|
8
|
+
".stack/",
|
|
9
|
+
".atlas/",
|
|
10
|
+
".mcp.json",
|
|
11
|
+
".sdlc/"
|
|
12
|
+
],
|
|
13
|
+
"never_quote": [
|
|
14
|
+
"fixtures",
|
|
15
|
+
"test/*.integration.test.ts",
|
|
16
|
+
"docs/references",
|
|
17
|
+
"docs/exec-plans"
|
|
18
|
+
],
|
|
19
|
+
"secret_patterns": [
|
|
20
|
+
"(?i)(api[_-]?key|secret|token|password|passwd|authorization)\\s*[:=]\\s*\\S{8,}",
|
|
21
|
+
"\\b(?:eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})\\b",
|
|
22
|
+
"\\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\\b",
|
|
23
|
+
"\\b(?:sk-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16})\\b",
|
|
24
|
+
"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
|
25
|
+
"\\b[a-z][a-z0-9+.-]*:\\/\\/[^\\s:@/]+:[^\\s:@/]+@[^\\s/]+"
|
|
26
|
+
],
|
|
27
|
+
"sub_agents_with_repo_read": [
|
|
28
|
+
"agents/code-explorer.md",
|
|
29
|
+
"agents/capability-reader.md",
|
|
30
|
+
"agents/reviewer-architect.md",
|
|
31
|
+
"agents/reviewer-pm.md",
|
|
32
|
+
"agents/reviewer-security.md",
|
|
33
|
+
"agents/artifact-renderer.md"
|
|
34
|
+
],
|
|
35
|
+
"sub_agents_that_write_files": [
|
|
36
|
+
"agents/artifact-renderer.md"
|
|
37
|
+
]
|
|
38
|
+
}
|