@arjunkhera/atlas 0.4.19 → 0.4.21

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/work/mcp.mjs CHANGED
@@ -48,7 +48,7 @@ export const TOOLS = [
48
48
  { 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 kinds say what the repo is; item_done reads them to choose how a delivery is proved. Give kinds, a list; kind, one value, is the older form. 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'], properties: { request_id: REQUEST_ID, registry_id: S('GitHub "owner/repo", exact spelling.'), product: S('Existing product id.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.') }, additionalProperties: false } },
49
49
  { name: 'repo_edit', description: `Change a repo's kinds 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.'), kinds: KINDS, kind: S(`The older form: one kind. One of ${REPO_KINDS.join(', ')}. Give kinds instead.`), notes: S('Free text.'), refresh_default_branch: { type: 'boolean', description: 'Re-read the default branch and push rights from GitHub.' } }, additionalProperties: false } },
50
50
  { 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 } },
51
- { name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. 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; a change after approval is a decision_record on the same item. When: the owner approves the design. 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 definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('Old records only: a 64-hex sha256 a design doc once carried. Omit it.') } }, 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 } },
51
+ { name: 'item_lock', description: `Record the owner's approval of a short design: product and repos by registry id, the scope (the definition of done as text, and the design file), and the owner's words of approval as origin. That approval is the lock (target design, flow 4). The verb keeps a fingerprint of the text for the tools; nobody reads or writes a hash. 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; a change after approval is a decision_record on the same item. When: the owner approves the design. 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 definition of done, in full.'), design_doc: S('URL of the design file the owner approved.'), acceptance_criteria_hash: S('Refused: old records only. A new lock takes no hash; put the definition of done in text.') } }, 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 } },
52
52
  { 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 } },
53
53
  { name: 'item_park', description: `Park an item: lifecycle paused with the reason. Paused items are backlog: the in_flight rows leave them out, and needs_me still shows their open questions. 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 } },
54
54
  { name: 'item_resume', description: `Resume a parked item to its earlier lifecycle with a reason. It also undoes a drop: a dropped item goes back to the lifecycle it left, and the drop's words stay on it. 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 } },
@@ -62,15 +62,12 @@ export const TOOLS = [
62
62
  { name: 'step_in_name', description: `Name what one message of the person was, or link the lesson it led to (roadmap R15). The step-in hook counts each message the person sends, and the server writes each one as a step-in mark; this verb never adds, skips or deletes one. It writes a new mark that corrects the step-in, and the newest name stands. A message with no class is a step-in. The classes: ${STEP_IN_CLASSES.join(', ')}. The first eight are the classes of the owner-only list. "answer" needs question_id, a question that was open when the message came. When: the lead knows what the message was, or a lesson came out of it. No owner flag. ${RULES}`, inputSchema: { type: 'object', required: ['request_id', 'step_in'], properties: { request_id: REQUEST_ID, step_in: S('The step-in mark id, from step_ins.'), class: { type: 'string', enum: [...STEP_IN_CLASSES], description: 'What the message was. Leave it out to keep it a step-in and only link a lesson.' }, question_id: S('For class answer: the question the message answered.'), item_id: S('The item the message was about. An answer takes the item of its question.'), lesson: S('The lesson the step-in led to: a URL, or the id of a tracker record.') }, additionalProperties: false } },
63
63
  { name: 'step_ins', description: 'List the step-in marks, newest first, with the name that stands on each: the time, the session, the repos, and the class and lesson the lead named. step_ins counts the messages with no class; not_counted_sessions counts the sessions whose hooks were off. When: before step_in_name, to find the id. Scope: read, no owner flag needed.', inputSchema: { type: 'object', properties: { session: S('Only this session.'), since: S('ISO date or time: only step-ins from then on.'), limit: { type: 'integer', minimum: 1, maximum: 100, description: 'How many rows. Default 20.' } }, additionalProperties: false } },
64
64
  { 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 } },
65
- { 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 } },
66
65
  { 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 } },
67
66
  { 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 } },
68
67
  { 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 } },
69
- { 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 } },
70
68
  { name: 'item_done', description: `Mark an item delivered. What proves it follows the repo kind (amendment A1.7). A service or schema 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. One exception, a last resort for an extraordinary case with no other option: the owner can accept a known red test on one merged pull request. Name it in accepted_red with the decision that records the owner's words; those words must name the pull request and quote the exact test, and one decision accepts one red check. accepted_red needs the owner present. The gate names a check, not a test, so the verb proves the check level, and every other red check still refuses the mark. A library 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. A schema repo keeps the three checks, because its migration runs after the merge. When atlas.yaml on the base of a merge lists required_checks, each one needs a successful newest GitHub Actions run on the pull request head or on the merge commit; a skipped check and a check that never ran are named apart. When atlas.yaml says live: release, a published release must hold each merge commit, and the mark stores that version; a red run on the merge commit is then a warning on the mark, because the release was built from a later green main. 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. Unless every repo it names is a library, 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 or schema repo, and refused for a library 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.'), accepted_red: { type: 'array', description: 'Red checks the owner accepted, one entry for each pull request and check. Owner present only. The decision must be a decision or an answer on this item, written by decision_record or question_answer and not replaced. Its words must name the pull request and quote the test. One decision accepts one check on one pull request. A listed check that is not red is refused.', items: { type: 'object', required: ['pr', 'check', 'test', 'decision_id'], additionalProperties: false, properties: { pr: S('One of the prs of this delivery.'), check: S('The name of the red check run in the merge gate, for example "test".'), test: S('The exact name of the red test, as the owner\'s words quote it.'), decision_id: S('The decision that records the owner\'s acceptance.') } } } }, additionalProperties: false } },
71
69
  { 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 } },
72
70
  { name: 'in_flight', description: 'Answer "what are we working on, and what changed since a date?". Active items per product, ordered by the owner\'s current priority decision first, when it carries an order, then by days since the last change with the latest on top, and the days shown on each row. Each row says what the item waits on. 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 } },
73
- { 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 } },
74
71
  { name: 'needs_me', description: 'Answer "what needs me?": every open question on a proposed, active or paused item, per product, ordered by what waiting costs. A row on an item that is not active names its lifecycle. 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 } },
75
72
  { name: 'roundup', description: 'Answer "where does this product stand, and where is it going?" in five parts: 1 needs you (the needs_me read); 2 where we stand (items tagged stage-<n> in ladder order, a count of every item by lifecycle, and the active items); 3 what moved in the window (delivery marks, merged pull requests, new items); 4 stuck or drifting (active items with no change for 14 days, merged but not marked, open pull requests that name no item, questions waiting 14 days); 5 the trend per week for four weeks. Every row cites an item, a question or a pull request. Computed at read time and never stored. Scope: read, no owner flag needed.', inputSchema: { type: 'object', required: ['product'], properties: { product: S('Product id.'), days: { type: 'integer', minimum: 1, maximum: 90, description: 'The window for part 3, in days. Default 7.' } }, additionalProperties: false } },
76
73
  { 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 } },
@@ -1,189 +0,0 @@
1
- // Atlas M1 slice L5: the one-time sweep.
2
- // Design: the Atlas M1 study, D5 slice L5, design
3
- // finding R7, and the frozen L5 paragraph of the lock block.
4
- //
5
- // The frozen text asks for two things and nothing else:
6
- // 1. The owner's one-time sweep, written as a decision record that lists the
7
- // active items. That record is the oracle (R7). It is written by the
8
- // decision_record verb of slice L2 with kind "sweep"; this module adds no
9
- // second way to write a decision.
10
- // 2. The "In flight" view equal to that list. sweep_check reads the view of
11
- // slice L3 and compares it with the record. It writes nothing.
12
- //
13
- // One write verb stands between the two: item_adopt. The sweep gives an item a
14
- // product, and until now no verb could. item_propose and item_lock name a
15
- // product when they make or lock an item; items made before the registry
16
- // existed carry no product at all. Both L3 and L4 views say so on every read.
17
- import { refuse, text, oneOf, stringList } from './verb-fields.mjs';
18
- import { oneLine, scrubSecrets, ROW_LINE_MAX } from './questions.mjs';
19
- import { listLine } from './delivery.mjs';
20
-
21
- export const SWEEP_WRITE_VERBS = Object.freeze(['item_adopt']);
22
- export const SWEEP_READ_VERBS = Object.freeze(['sweep_check']);
23
- // The kind that marks a decision record as a sweep. `kind` is a free string in
24
- // the manifest, so this costs no manifest version (the M2 lane holds it).
25
- export const SWEEP_KIND = 'sweep';
26
-
27
- // A sweep record can hold at most this many item ids, because decision_record
28
- // verifies each one. The same number bounds what the check reads back, so the
29
- // two can never disagree (architect review, finding 5).
30
- export const MAX_SWEPT = 200;
31
- const MAX_SWEEPS = 300;
32
- const TITLES_FETCHED = 25;
33
-
34
- export function createSweepVerbs(core) {
35
- const { client, now, typeNamed, registry, requireProduct, requireRepos, requireItem, requireOwner, patch, alreadyPatched, inFlight, stages, tiers, drivers } = core;
36
-
37
- const verbs = {
38
- // Give an existing item its product and repos. Owner present, because the
39
- // sweep is the owner's own list (R7) and a product is a name the owner owns.
40
- async item_adopt(args, requestId, meta) {
41
- requireOwner('item adopt');
42
- const item = await requireItem(args.item_id);
43
- const fields = item.fields ?? {};
44
- if (alreadyPatched(item, requestId, meta)) {
45
- return { verb: 'item_adopt', item_id: item.id, version: item.version, product: fields.product ?? null, repos: fields.repos ?? [], lifecycle: fields.lifecycle ?? null, stage: fields.stage ?? null, replayed_from_graph: true };
46
- }
47
- const reg = await registry();
48
- const product = await requireProduct(args.product, reg);
49
- const productId = product.fields.registry_id;
50
- const named = typeof fields.product === 'string' ? fields.product : '';
51
- // An item is named once. Moving it between registered products is not a
52
- // sweep, and no verb of M1 does it: the reader of any view would see the
53
- // work change product with nothing on the record to say why.
54
- if (named && named !== productId && reg.products.has(named)) refuse(`item ${item.id} already names the registered product ${named}, not ${productId}; the sweep names an item once`);
55
- // `repos` is required, and an empty list is how a caller says "this item
56
- // has no repo" — a container root is the real case. Leaving the argument
57
- // out used to fall back to whatever survived the product filter, which
58
- // could silently empty the field and leave an item no other verb could
59
- // have written (architect review, finding 4).
60
- if (args.repos === undefined) {
61
- const kept = (Array.isArray(fields.repos) ? fields.repos : []).filter((id) => reg.repos.get(id)?.fields?.product === productId);
62
- refuse(`repos is required: name the repo ids under ${productId}, or pass an empty list for an item with no repo.${kept.length ? ` This item already names ${kept.join(', ')}.` : ''}`);
63
- }
64
- const asked = stringList(args.repos, 'repos', { max: 16 });
65
- const repos = asked.length ? await requireRepos(asked, reg, productId) : [];
66
- const dropped = (Array.isArray(fields.repos) ? fields.repos : []).filter((id) => !repos.includes(id));
67
- const change = { product: productId, repos };
68
- if (args.tier !== undefined) change.tier = oneOf(args.tier, 'tier', tiers);
69
- if (args.driver !== undefined) change.driver = oneOf(args.driver, 'driver', drivers);
70
- if (args.next_step !== undefined) change.next_step = text(args.next_step, 'next_step', { max: 1000 });
71
- if (args.stage !== undefined) {
72
- const stage = oneOf(args.stage, 'stage', stages);
73
- if (fields.lifecycle !== 'active') refuse(`item ${item.id} is ${fields.lifecycle ?? 'unknown'}; only an active item carries a stage`);
74
- change.stage = stage;
75
- }
76
- const same = named === productId && [...repos].sort().join(' ') === [...(Array.isArray(fields.repos) ? fields.repos : [])].sort().join(' ');
77
- if (same && Object.keys(change).length === 2) return { verb: 'item_adopt', item_id: item.id, version: item.version, product: productId, repos, lifecycle: fields.lifecycle ?? null, stage: fields.stage ?? null, unchanged: true };
78
- // The audit stamps say when the sweep NAMED this item and what it came
79
- // from. A later call that only sets a stage or a next step must not move
80
- // them, or "adopted_from: engram" ends up on an item already in engram
81
- // and the sweep's own date is lost (architect review, finding 3).
82
- const stamps = named === productId ? {} : { adopted_at: now(), adopted_from: named || null };
83
- const entity = await patch(item, 'item_adopt', requestId, { ...change, ...stamps }, {
84
- digest: meta?.digest,
85
- tags: [`product:${productId}`, ...repos.map((id) => `repo:${id}`), ...(change.tier ? [`tier:${change.tier}`] : [])],
86
- });
87
- return { verb: 'item_adopt', item_id: entity.id, version: entity.version, product: productId, repos, lifecycle: entity.fields?.lifecycle ?? null, stage: entity.fields?.stage ?? null, previous_product: named || null, ...(dropped.length ? { dropped_repos: dropped } : {}) };
88
- },
89
-
90
- // R7 made mechanical: the sweep record is the oracle, so the check reads
91
- // the record and the view and names every difference. Read only.
92
- async sweep_check(args = {}) {
93
- const reg = await registry();
94
- let only = null;
95
- if (args.product !== undefined) only = (await requireProduct(args.product, reg)).fields.registry_id;
96
- const type = await typeNamed('decision');
97
- const decisions = await client.listAllEntities({ type: type.name, state: 'active' });
98
- // Filter to sweeps BEFORE bounding. Every answered question writes a
99
- // decision, so a count over all decisions would one day refuse the verb
100
- // with no argument that helps (architect review, finding 6).
101
- const sweeps = decisions.filter((row) => row.fields?.kind === SWEEP_KIND && typeof row.fields?.product === 'string' && row.fields.product && (!only || row.fields.product === only));
102
- if (sweeps.length > MAX_SWEEPS) refuse(`${sweeps.length} sweep records are on record; the check reads at most ${MAX_SWEEPS}. Narrow it with a product id.`);
103
- // A sweep for a product the registry does not hold cannot be checked: the
104
- // view shows rows only for registered products, so every listed item
105
- // would read as missing for ever (architect review, finding 7).
106
- const orphaned = [...new Set(sweeps.filter((row) => !reg.products.has(row.fields.product)).map((row) => row.fields.product))].sort();
107
- // One sweep stands per product. A sweep that names another with
108
- // `replaces` supersedes it outright; otherwise the newest by the owner's
109
- // date wins, then by write time, then by id so a tie is never decided by
110
- // list order (architect review, findings 12 and 13).
111
- const replaced = new Set(sweeps.map((row) => row.fields?.replaces).filter(Boolean));
112
- const latest = new Map();
113
- for (const row of sweeps) {
114
- if (!reg.products.has(row.fields.product)) continue;
115
- if (replaced.has(row.id)) continue;
116
- const at = `${String(row.fields.date ?? '')} ${String(row.created_at ?? row.updated_at ?? '')} ${row.id}`;
117
- const held = latest.get(row.fields.product);
118
- if (!held || at > held.at) latest.set(row.fields.product, { row, at, superseded: held ? [...held.superseded, held.row.id] : [] });
119
- else held.superseded.push(row.id);
120
- }
121
- for (const [product, held] of latest) held.superseded = [...new Set([...held.superseded, ...sweeps.filter((row) => row.fields.product === product && replaced.has(row.id)).map((row) => row.id)])];
122
- const view = await inFlight(only ? { product: only } : {});
123
- const inView = new Map(view.products.map((group) => [group.product, group.rows.map((row) => row.item)]));
124
- const titles = new Map(view.products.flatMap((group) => group.rows.map((row) => [row.item, row.folded?.title ?? row.lines[0]])));
125
-
126
- const checks = [];
127
- const unreadable = [];
128
- for (const productId of [...latest.keys()].sort()) {
129
- const { row, superseded } = latest.get(productId);
130
- // A malformed record must not make a read verb refuse: it is reported
131
- // as unreadable and the other products still answer (finding 12).
132
- let listed;
133
- try { listed = [...new Set(stringList(row.fields.items ?? [], 'items', { max: MAX_SWEPT }))]; }
134
- catch (error) { unreadable.push({ product: productId, decision: row.id, reason: error?.message ?? 'items is not a list of ids' }); continue; }
135
- const seen = inView.get(productId) ?? [];
136
- const missing = listed.filter((id) => !seen.includes(id));
137
- const extra = seen.filter((id) => !listed.includes(id));
138
- // A missing id is not in the view, so the view carries no title for it.
139
- // The owner reads titles, not ids, so the check fetches the few it needs.
140
- for (const [index, id] of missing.entries()) {
141
- if (titles.has(id)) continue;
142
- if (index >= TITLES_FETCHED) { titles.set(id, 'title not read: past the fetch bound of this check'); continue; }
143
- try { const entity = await client.getEntity(id); titles.set(id, entity?.title ?? ''); }
144
- catch { titles.set(id, 'the graph did not answer for this id'); }
145
- }
146
- checks.push({
147
- product: productId, decision: row.id, date: row.fields.date ?? null, source: row.fields.source ?? '',
148
- listed: listed.length, in_view: seen.length,
149
- missing_from_view: missing.map((id) => ({ item: id, title: oneLine(titles.get(id) ?? '', 120) })),
150
- extra_in_view: extra.map((id) => ({ item: id, title: oneLine(titles.get(id) ?? '', 120) })),
151
- matches: !missing.length && !extra.length, items: listed, ...(superseded.length ? { superseded } : {}),
152
- });
153
- }
154
- const notSwept = [...reg.products.keys()].filter((id) => (!only || id === only) && !latest.has(id)).sort();
155
- // `matches` answers one narrow question: does each product's view hold
156
- // exactly what its sweep lists? `complete` answers the wider one the
157
- // owner actually asked: is every item in the tenant on a registered
158
- // product? An item nobody adopted is missing from the record AND from
159
- // the view, so the two sides agree by omission, and a headline reading
160
- // "equal" over a fifth of the corpus would be a lie (architect review,
161
- // finding 2). The two are reported apart, and the text says both.
162
- const matches = checks.length > 0 && !notSwept.length && !unreadable.length && !orphaned.length && checks.every((check) => check.matches);
163
- const outside = view.unregistered_total ?? 0;
164
- const complete = matches && outside === 0;
165
-
166
- const block = [
167
- `Sweep check — ${only ?? 'every registered product'} · read ${String(view.read_at).slice(0, 19)}Z · ${matches ? 'the view equals the sweep' : 'the view and the sweep differ'}${matches ? (complete ? ', and every item in the tenant is on a registered product' : `, but ${outside} item${outside === 1 ? '' : 's'} sit outside every product, so the sweep is not complete`) : ''}`,
168
- ...checks.map((check) => [
169
- `${check.product} — sweep ${String(check.date ?? 'no date')} lists ${check.listed}, the view shows ${check.in_view} · ${check.matches ? 'equal' : 'not equal'}`,
170
- ` decision ${check.decision}`,
171
- ...check.missing_from_view.map((row) => listLine(`listed, not in flight: ${row.title}`, row.item)),
172
- ...check.extra_in_view.map((row) => listLine(`in flight, not listed: ${row.title}`, row.item)),
173
- ].join('\n')),
174
- ...(notSwept.length ? [`no sweep on record (${notSwept.length})\n${notSwept.map((id) => ` ${id}`).join('\n')}`] : []),
175
- ...(orphaned.length ? [`a sweep names a product the registry does not hold (${orphaned.length})\n${orphaned.map((id) => ` ${id} — the view can show no row for it, so its items would read as missing for ever`).join('\n')}`] : []),
176
- ...(unreadable.length ? [`unreadable sweep record (${unreadable.length})\n${unreadable.map((row) => ` ${row.product} · ${row.decision} — ${row.reason}`).join('\n')}`] : []),
177
- ...(outside ? [oneLine(`${outside} item${outside === 1 ? '' : 's'} still name no registered product, so the sweep is not complete. Each one is invisible to both sides of this check.`, ROW_LINE_MAX)] : []),
178
- ...((view.repo_errors ?? []).length ? [`GitHub did not answer for ${view.repo_errors.length} repo${view.repo_errors.length === 1 ? '' : 's'}; the movement half of the view came from the graph alone\n${view.repo_errors.map((row) => ` ${row.repo} — ${row.reason}`).join('\n')}`] : []),
179
- ].join('\n\n');
180
- const rendered = scrubSecrets(block);
181
- return {
182
- verb: 'sweep_check', read_at: view.read_at, product: only, matches, complete, checks, not_swept: notSwept,
183
- ...(orphaned.length ? { product_not_registered: orphaned } : {}), ...(unreadable.length ? { unreadable } : {}),
184
- unregistered_total: outside, repo_errors: view.repo_errors ?? [], redactions: rendered.redactions, text: rendered.text,
185
- };
186
- },
187
- };
188
- return verbs;
189
- }