@arjunkhera/atlas 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,11 +14,13 @@ authority: the Atlas lock procedure, amendment A1.9, section 5, slice L2 stage o
14
14
  lifecycle_states: [proposed, active, paused, done, dropped, archived]
15
15
  stages: [capture, spec, build, verify, ship, learn]
16
16
 
17
+ # A lock is the owner's approval of a short design (target design, flow 4).
18
+ # Long frozen texts with hashes retired in stage 1.
17
19
  lock:
18
- freezes: [acceptance_criteria, interfaces, approach]
19
- preconditions: [open_questions_empty, ac_ratified, approach_chosen, tier_confirmed]
20
+ holds: [definition_of_done, kind_of_change, impact, undo, tests_it_changes]
21
+ preconditions: [open_questions_empty, owner_approved_in_own_words, approach_chosen, tier_confirmed]
20
22
 
21
- tripwires: [acceptance_criteria, security, schema_migrations, money_cost]
23
+ tripwires: [definition_of_done, security, schema_migrations, money_cost]
22
24
 
23
25
  tiers: [hotfix, standard, initiative]
24
26
  tier_escalators: [migrations, auth_rls_surface, new_external_dependency, money_cost]
@@ -14,7 +14,7 @@
14
14
 
15
15
  # <Feature name> — design
16
16
 
17
- > Status: DRAFT (in design loop) | LOCKED <date> | SHIPPED <date>
17
+ > Status: DRAFT (in design loop) | APPROVED <date> | SHIPPED <date>
18
18
  > Work item: <work item id> · Tier: hotfix | standard | initiative
19
19
  > Artifact: <claude.ai artifact url once published>
20
20
 
@@ -46,7 +46,7 @@ and the one-line goal. Agent interpretation kept separate.>
46
46
  what the system does, what they see — written as narrative the owner can
47
47
  react to, each tagged to the slice that ships it. Illustrative dialogue
48
48
  is fine; mark invented values as illustrative. Draft these BEFORE
49
- acceptance criteria — the AC formalize what the stories show. -->
49
+ the definition of done — its checks formalize what the stories show. -->
50
50
 
51
51
  - **US-1 — <scene name>** *(slice)*: …
52
52
 
@@ -67,7 +67,7 @@ Standard tier: may collapse to "approach + rejected alternative, one line".>
67
67
  ## Open questions
68
68
 
69
69
  <!-- The lock precondition: this checklist must be EMPTY (all items moved to
70
- Resolved) before `sdlc-task lock` will stamp the lock block. -->
70
+ Resolved) before the owner is asked to approve the design. -->
71
71
 
72
72
  - [ ] OQ-1 —
73
73
 
@@ -77,7 +77,7 @@ Standard tier: may collapse to "approach + rejected alternative, one line".>
77
77
 
78
78
  ## Decision log
79
79
 
80
- - D-1 — <decision> — <rationale> (<date>, <ratifier>)
80
+ - D-1 — <decision> — <rationale> (<date>, <who decided>)
81
81
 
82
82
  ## Risks & failure modes
83
83
 
@@ -89,29 +89,34 @@ Standard tier: may collapse to "approach + rejected alternative, one line".>
89
89
  <!-- How each slice proves itself: suites, scenarios, eval questions, prod
90
90
  verification. The red spec's table of contents. Trimmable at standard. -->
91
91
 
92
- ## Acceptance criteria
92
+ ## Definition of done
93
93
 
94
- <!-- These freeze at lock (`lifecycle.yaml` `lock.freezes`). Write them testable — the red spec derives
95
- from this list. -->
94
+ <!-- Written as checks, before the build. The verifier proves each line from a
95
+ fresh run. Name each existing test this work expects to change. -->
96
96
 
97
97
  1. …
98
98
 
99
- ## Lock block
99
+ ## Kind of change, impact and undo
100
100
 
101
- <!-- Stamped by `sdlc-task lock`; never hand-edited afterwards. -->
101
+ <!-- A change that cannot be undone goes in bold at the top of this doc. -->
102
102
 
103
- ```
104
- LOCKED: <date> by <ratifier>
105
- Tier: <tier>
106
- Frozen: AC (sha256 of the AC section), interfaces: <list>, approach: <chosen>
107
- Tripwires in force: AC / security / schema / cost (kernel defaults)
108
- Emitted: <child containers / issues created at lock>
109
- ```
103
+ - Kind: …
104
+ - Impact: …
105
+ - Undo: …
106
+ - Existing tests changed or removed: …
107
+
108
+ ## Approval
109
+
110
+ <!-- The owner's approval of this short design is the lock (target design,
111
+ flow 4). Record the owner's words here and with decision_record. A change
112
+ after approval is a new approval, dated, in the same place. -->
113
+
114
+ Approved: <date>. The owner's words: "<words>".
110
115
 
111
116
  ## Deviation log (post-lock)
112
117
 
113
118
  <!-- Every post-lock divergence from this document. Review checks this list.
114
- A deviation touching a tripwire (AC, security, schema, cost) must halt
119
+ A deviation touching a tripwire (definition of done, security, schema, cost) must halt
115
120
  and return to the owner instead of landing here. -->
116
121
 
117
122
  | # | What changed vs the doc | Why | Tripwire? | Reviewed |
@@ -12,7 +12,7 @@
12
12
  // reopen points at the delivery it corrects with the corrects edge (R9).
13
13
  // 3. Movement is read, never stored. The view computes it at question time
14
14
  // from the marks and from the repos' pull requests (D3 rule 5, R1).
15
- import { refuse, text, isoDate, url, stringList, sha256 } from './verb-fields.mjs';
15
+ import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS } from './verb-fields.mjs';
16
16
  import { oneLine, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
17
17
 
18
18
  export const DELIVERY_WRITE_VERBS = Object.freeze(['item_done', 'item_reopen']);
@@ -353,14 +353,20 @@ export function createDeliveryVerbs(core) {
353
353
  // or a repo whose kind was never set, keeps the three checks. Nothing
354
354
  // weakens by omission, and a mixed-kind item takes the strict path
355
355
  // because one of its repos demands it.
356
+ //
357
+ // Stage 1 reads a list of kinds. The strict path applies when any kind
358
+ // of any named repo is live (service or schema). A cli and a plugin
359
+ // prove delivery like a library until the trial install of stage 2.
356
360
  const named = (Array.isArray(fields.repos) ? fields.repos : []).map((id) => reg.repos.get(id)).filter(Boolean);
357
- const unset = named.filter((repo) => !repo.fields?.kind).map((repo) => repo.fields.registry_id);
358
- const strictKinds = named.filter((repo) => repo.fields?.kind && repo.fields.kind !== 'library').map((repo) => `${repo.fields.registry_id} is a ${repo.fields.kind}`);
361
+ const kindsOf = (repo) => repoKinds(repo.fields);
362
+ const said = (repo) => `${repo.fields.registry_id} is a ${kindsOf(repo).join(' and ')}`;
363
+ const unset = named.filter((repo) => !kindsOf(repo).length).map((repo) => repo.fields.registry_id);
364
+ const strictKinds = named.filter((repo) => kindsOf(repo).some((kind) => LIVE_KINDS.includes(kind))).map(said);
359
365
  const live = !named.length || unset.length > 0 || strictKinds.length > 0;
360
366
  const healthUrl = text(product.fields?.health_url, `product ${product.fields.registry_id} health_url`, { optional: true, max: 2000 });
361
367
  if (live && !healthUrl) {
362
368
  const why = !named.length ? 'the item names no registered repo' : (unset.length ? `repo ${unset.join(', ')} carries no kind` : `${strictKinds.join(', ')}`);
363
- refuse(`product ${product.fields.registry_id} has no health_url, and the live checks apply because ${why}; the owner sets it with product edit, or sets the repo kind to library, which is the one kind whose merge is its delivery`);
369
+ refuse(`product ${product.fields.registry_id} has no health_url, and the live checks apply because ${why}; the owner sets it with product edit, or sets the repo kinds with repo edit; a library, a cli and a plugin are proved by the merge`);
364
370
  }
365
371
  // Which pull requests deliver this item. The caller names them, always.
366
372
  // An earlier draft fell back to the item's own pr links, and the first
@@ -373,7 +379,7 @@ export function createDeliveryVerbs(core) {
373
379
  if (!given.length) refuse('prs must name at least one merged pull request');
374
380
  let spec = null;
375
381
  if (live) { spec = functionalSpec(args.functional); allowedPath(spec, product, healthUrl); }
376
- else if (args.functional !== undefined) refuse(`functional is not accepted for this delivery: ${named.map((repo) => `${repo.fields.registry_id} is a ${repo.fields.kind}`).join(', ')}, so there is no deployed page to read. The merge and the gate prove it.`);
382
+ else if (args.functional !== undefined) refuse(`functional is not accepted for this delivery: ${named.map(said).join(', ')}, so there is no deployed page to read. The merge and the gate prove it.`);
377
383
  const note = text(args.note, 'note', { optional: true, max: 2000 }) ?? '';
378
384
 
379
385
  const repos = new Set(Array.isArray(fields.repos) ? fields.repos : []);
@@ -434,7 +440,7 @@ export function createDeliveryVerbs(core) {
434
440
  const checks = live ? [contains, health, functional] : [{
435
441
  name: 'every named pull request is merged',
436
442
  result: 'pass',
437
- detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map((repo) => `${repo.fields.registry_id} is a ${repo.fields.kind}`).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7).`,
443
+ detail: `${pulls.length} pull request${pulls.length === 1 ? '' : 's'} merged on GitHub: ${pulls.map((pull) => `${pull.url} at ${pull.merge_commit_sha.slice(0, 7)}`).join(', ')}. No live check ran: ${named.map(said).join(', ')}, so the product has no deployed endpoint to read (amendment A1.7).`,
438
444
  checked_at: now(),
439
445
  }];
440
446
  const mark = await create(type, requestId, 'item_done', {
@@ -48,6 +48,37 @@ export function url(value, name) {
48
48
  if (!['http:', 'https:'].includes(parsed.protocol)) refuse(`${name} must be an http or https URL`);
49
49
  return value;
50
50
  }
51
+ // What a repo is (stage 1 of the target design). A record holds a list,
52
+ // because a monorepo or a plugin that is also a library is more than one
53
+ // thing. Manifest 0.5.0 held one scalar kind, and an old record still has
54
+ // only that, so every reader goes through repoKinds.
55
+ export const REPO_KINDS = Object.freeze(['service', 'library', 'schema', 'cli', 'plugin']);
56
+ // The kinds whose merge is not their delivery. A service runs after the
57
+ // merge, and a schema repo applies its migration after the merge, so both
58
+ // keep the live checks of item_done.
59
+ export const LIVE_KINDS = Object.freeze(['service', 'schema']);
60
+
61
+ export function repoKinds(fields) {
62
+ if (Array.isArray(fields?.kinds) && fields.kinds.length) return [...fields.kinds];
63
+ return typeof fields?.kind === 'string' && fields.kind ? [fields.kind] : [];
64
+ }
65
+
66
+ // The kinds a repo_add or repo_edit call gives, from `kinds` or the older
67
+ // `kind`. Returns null when the call names neither. The scalar written beside
68
+ // the list is the first live kind, else the first kind, so a reader of 0.5.0
69
+ // still takes the strict path when the list needs it.
70
+ export function kindsFromArgs(args) {
71
+ if (args.kinds !== undefined && args.kind !== undefined) refuse('give kinds or kind, not both');
72
+ if (args.kinds !== undefined) {
73
+ const list = stringList(args.kinds, 'kinds', { max: REPO_KINDS.length });
74
+ if (!list.length) refuse('kinds must name at least one kind');
75
+ for (const one of list) oneOf(one, 'each of kinds', REPO_KINDS);
76
+ return { kinds: list, kind: list.find((one) => LIVE_KINDS.includes(one)) ?? list[0] };
77
+ }
78
+ if (args.kind !== undefined) { const kind = oneOf(args.kind, 'kind', REPO_KINDS); return { kinds: [kind], kind }; }
79
+ return null;
80
+ }
81
+
51
82
  export function stringList(value, name, { max = 64 } = {}) {
52
83
  if (!Array.isArray(value)) refuse(`${name} must be an array of strings`);
53
84
  if (value.length > max) refuse(`${name} holds more than ${max} entries`);
@@ -11,24 +11,22 @@
11
11
  // lock block L1, L2 and L3. Every write carries request_id and writer. The
12
12
  // server, not the caller, supplies the session. Unknown ids are refused.
13
13
  // M2 slice L1 adds the repo kind in 0.5.0, which carries 0.4.0 whole.
14
- import manifestCurrent from '../manifest-0.5.0.json' with { type: 'json' };
14
+ // Stage 1 of the target design adds kinds, a list, in 0.6.0.
15
+ import manifestCurrent from '../manifest-0.6.0.json' with { type: 'json' };
15
16
  import { EngramHttpError } from './client.mjs';
16
- import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList, LIVE_LIFECYCLES } from './verb-fields.mjs';
17
+ import { VerbError, refuse, canonicalJson, sha256, text, oneOf, origin, url, stringList, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds, kindsFromArgs } from './verb-fields.mjs';
17
18
  import { createQuestionVerbs, COSTS, QUESTION_WRITE_VERBS, QUESTION_READ_VERBS } from './questions.mjs';
18
19
  import { createDeliveryVerbs, DELIVERY_WRITE_VERBS, DELIVERY_READ_VERBS } from './delivery.mjs';
19
20
  import { createCapabilityVerbs, CAPABILITY_READ_VERBS } from './capability.mjs';
20
21
  import { createSweepVerbs, SWEEP_WRITE_VERBS, SWEEP_READ_VERBS } from './sweep.mjs';
21
22
 
22
- export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES };
23
- export const MANIFEST_VERSION = '0.5.0';
23
+ export { VerbError, canonicalJson, sha256, COSTS, LIVE_LIFECYCLES, REPO_KINDS, LIVE_KINDS, repoKinds };
24
+ export const MANIFEST_VERSION = '0.6.0';
24
25
  export const PRODUCT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
25
26
  export const REPO_ID = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}$/;
26
27
  export const STAGES = Object.freeze(['capture', 'spec', 'build', 'verify', 'learn']);
27
28
  export const TIERS = Object.freeze(['hotfix', 'standard', 'initiative']);
28
29
  export const DRIVERS = Object.freeze(['owner-led', 'agent']);
29
- // The repo kind of M2 amendment A1.5. The repo-shape check reads it to decide
30
- // which of the seven files a repo needs.
31
- export const REPO_KINDS = Object.freeze(['service', 'library', 'schema']);
32
30
  // What an item waits on (release 0.2.3). One line on the item, shown on its
33
31
  // rows. "item" names another work item by id.
34
32
  export const WAITS_ON = Object.freeze(['owner-decision', 'owner-action', 'item', 'gate', 'nothing']);
@@ -240,7 +238,7 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
240
238
  async registry_list() {
241
239
  const reg = await registry();
242
240
  const products = [...reg.products.values()].map((row) => ({ registry_id: row.fields.registry_id, name: row.fields.name, entity_id: row.id, project_id: row.fields.project_id ?? null, notes: row.fields.notes ?? '',
243
- repos: [...reg.repos.values()].filter((repo) => repo.fields.product === row.fields.registry_id).map((repo) => ({ registry_id: repo.fields.registry_id, entity_id: repo.id, kind: repo.fields.kind ?? null, default_branch: repo.fields.default_branch, notes: repo.fields.notes ?? '' })) }));
241
+ repos: [...reg.repos.values()].filter((repo) => repo.fields.product === row.fields.registry_id).map((repo) => ({ registry_id: repo.fields.registry_id, entity_id: repo.id, kind: repo.fields.kind ?? null, kinds: repoKinds(repo.fields), default_branch: repo.fields.default_branch, notes: repo.fields.notes ?? '' })) }));
244
242
  return { verb: 'registry_list', products, read_at: now() };
245
243
  },
246
244
 
@@ -298,7 +296,9 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
298
296
  if (typeof readGitHub !== 'function') refuse('repo add needs a GitHub reader for the push-rights check');
299
297
  const registryId = text(args.registry_id, 'registry_id', { max: 200 });
300
298
  if (!REPO_ID.test(registryId)) refuse('registry_id must be a GitHub "owner/repo" name');
301
- const kind = oneOf(args.kind, 'kind', REPO_KINDS);
299
+ const given = kindsFromArgs(args);
300
+ if (!given) refuse('kind is required: give kinds, a list of what the repo is');
301
+ const { kinds, kind } = given;
302
302
  const notes = text(args.notes, 'notes', { optional: true, max: 4000 }) ?? '';
303
303
  const reg = await registry();
304
304
  const product = await requireProduct(args.product, reg);
@@ -310,10 +310,10 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
310
310
  const defaultBranch = text(github.default_branch, 'GitHub default_branch', { max: 200 });
311
311
  const type = await typeNamed('repo');
312
312
  const prior = await landed(type, requestId, meta);
313
- if (prior) { await edge(prior, product, 'part_of', requestId); return { verb: 'repo_add', registry_id: prior.fields.registry_id, product: prior.fields.product, kind: prior.fields.kind ?? null, default_branch: prior.fields.default_branch, entity_id: prior.id, version: prior.version, push_rights: true, replayed_from_graph: true }; }
314
- const entity = await create(type, requestId, 'repo_add', { digest: meta?.digest, title: registryId, body: `Atlas repo record for ${registryId}, product ${product.fields.registry_id}. Kind: ${kind}. Default branch: ${defaultBranch}.`, fields: { registry_id: registryId, product: product.fields.registry_id, kind, default_branch: defaultBranch, notes, github_url: github.html_url ?? `https://github.com/${registryId}` }, tags: ['atlas-registry', `product:${product.fields.registry_id}`, `repo:${registryId}`] });
313
+ if (prior) { await edge(prior, product, 'part_of', requestId); return { verb: 'repo_add', registry_id: prior.fields.registry_id, product: prior.fields.product, kind: prior.fields.kind ?? null, kinds: repoKinds(prior.fields), default_branch: prior.fields.default_branch, entity_id: prior.id, version: prior.version, push_rights: true, replayed_from_graph: true }; }
314
+ const entity = await create(type, requestId, 'repo_add', { digest: meta?.digest, title: registryId, body: `Atlas repo record for ${registryId}, product ${product.fields.registry_id}. Kinds: ${kinds.join(', ')}. Default branch: ${defaultBranch}.`, fields: { registry_id: registryId, product: product.fields.registry_id, kind, kinds, default_branch: defaultBranch, notes, github_url: github.html_url ?? `https://github.com/${registryId}` }, tags: ['atlas-registry', `product:${product.fields.registry_id}`, `repo:${registryId}`] });
315
315
  await edge(entity, product, 'part_of', requestId);
316
- return { verb: 'repo_add', registry_id: registryId, product: product.fields.registry_id, kind, default_branch: defaultBranch, entity_id: entity.id, version: entity.version, push_rights: true };
316
+ return { verb: 'repo_add', registry_id: registryId, product: product.fields.registry_id, kind, kinds, default_branch: defaultBranch, entity_id: entity.id, version: entity.version, push_rights: true };
317
317
  },
318
318
 
319
319
  async repo_edit(args, requestId, meta) {
@@ -325,7 +325,8 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
325
325
  await requireProduct(record.fields.product, reg);
326
326
  const fields = {};
327
327
  if (args.notes !== undefined) fields.notes = text(args.notes, 'notes', { max: 4000 });
328
- if (args.kind !== undefined) fields.kind = oneOf(args.kind, 'kind', REPO_KINDS);
328
+ const given = kindsFromArgs(args);
329
+ if (given) { fields.kind = given.kind; fields.kinds = given.kinds; }
329
330
  if (args.refresh_default_branch === true) {
330
331
  if (typeof readGitHub !== 'function') refuse('refreshing the default branch needs a GitHub reader');
331
332
  const github = await readGitHub(`repos/${registryId}`);
@@ -333,10 +334,10 @@ export function createVerbs({ client, store, writer, session, agent = 'lead', bo
333
334
  fields.default_branch = text(github.default_branch, 'GitHub default_branch', { max: 200 });
334
335
  }
335
336
  if (alreadyPatched(record, requestId, meta)) return { verb: 'repo_edit', registry_id: registryId, entity_id: record.id, version: record.version, changed: [], replayed_from_graph: true };
336
- if (!Object.keys(fields).length) refuse('repo edit needs kind, notes or refresh_default_branch; registry_id and product are immutable');
337
+ if (!Object.keys(fields).length) refuse('repo edit needs kind, notes or refresh_default_branch (kind may be given as kinds, a list); registry_id and product are immutable');
337
338
  // The kind is a field, not a tag: repo edit may change it, and a tag cannot be dropped.
338
339
  const entity = await patch(record, 'repo_edit', requestId, fields, { digest: meta?.digest });
339
- return { verb: 'repo_edit', registry_id: registryId, entity_id: entity.id, version: entity.version, kind: entity.fields.kind ?? null, changed: Object.keys(fields) };
340
+ return { verb: 'repo_edit', registry_id: registryId, entity_id: entity.id, version: entity.version, kind: entity.fields.kind ?? null, kinds: repoKinds(entity.fields), changed: Object.keys(fields) };
340
341
  },
341
342
 
342
343
  async item_propose(args, requestId, meta) {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "atlas-work",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "kind": "extension",
5
- "description": "Atlas work records: products and repos as the registry, work items, questions, decisions, receipts, delivery and reopen marks, and their relations. Version 0.4.0 adds mark, the delivery fields on the item and the health endpoint on the product (M1 slice L3, 2026-09-17). Version 0.3.0 added question and receipt. Version 0.2.0 dropped atlas_key and write_class.",
5
+ "description": "Atlas work records: products and repos as the registry, work items, questions, decisions, receipts, delivery and reopen marks, and their relations. Version 0.6.0 adds kinds, a list, on the repo. Version 0.5.0 added the repo kind. Version 0.4.0 adds mark, the delivery fields on the item and the health endpoint on the product (M1 slice L3, 2026-09-17). Version 0.3.0 added question and receipt. Version 0.2.0 dropped atlas_key and write_class.",
6
6
  "capability": {
7
7
  "semantics_version": ">=1.0.0 <2.0.0"
8
8
  },
@@ -93,6 +93,9 @@
93
93
  "kind": {
94
94
  "type": "string"
95
95
  },
96
+ "kinds": {
97
+ "type": "array"
98
+ },
96
99
  "notes": {
97
100
  "type": "string",
98
101
  "default": ""
@@ -591,7 +594,7 @@
591
594
  ],
592
595
  "0.3.0": [
593
596
  "Adds question and receipt. A question holds its text, item, product, asker, date, cost of waiting and the context to answer; a receipt holds the rendered answer the owner acted on and the decision it belongs to.",
594
- "Re-declares decision with the fields of design page D1: the owner\u2019s words, date, source, the question it answers, the items it touches, the priority order, and what it replaces. $required is unchanged, so an older decision record still validates.",
597
+ "Re-declares decision with the fields of design page D1: the owner’s words, date, source, the question it answers, the items it touches, the priority order, and what it replaces. $required is unchanged, so an older decision record still validates.",
595
598
  "No new edge type. Questions, decisions and receipts point at their records by field and carry one references edge each."
596
599
  ],
597
600
  "0.4.0": [
@@ -604,6 +607,9 @@
604
607
  "kind is not a required field, so every repo record written under 0.2.0, 0.3.0 and 0.4.0 still validates. repo edit fills it.",
605
608
  "Nothing else changes. 0.4.0 and its mark record are carried over whole.",
606
609
  "Why 0.5.0 and not 0.4.0: M1 slice L3 published 0.4.0 for the mark record on 2026-09-17, in a session that ran beside this one. A published version never changes, so this slice took the next number. Owner decision, 2026-09-17."
610
+ ],
611
+ "0.6.0": [
612
+ "Added: kinds on the repo record, a list of what the repo is (service, library, schema, cli, plugin). A monorepo or a plugin that is also a library holds more than one. The scalar kind stays, so a record written under 0.5.0 still validates and an older reader still reads it. Stage 1 of the target design."
607
613
  ]
608
614
  }
609
615
  }
package/work/mcp.mjs CHANGED
@@ -25,6 +25,7 @@ const ATLAS_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.
25
25
  const S = (description, extra = {}) => ({ type: 'string', description, ...extra });
26
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
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 KINDS = { type: 'array', items: { type: 'string', enum: [...REPO_KINDS] }, description: `What the repo is, as a list: one or more of ${REPO_KINDS.join(', ')}. A service or a schema repo keeps the live checks of item_done; a library, a cli and a plugin are proved by the merge.` };
28
29
  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
 
30
31
  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.') } };
@@ -33,13 +34,13 @@ const CODE_DIGEST = { type: 'object', description: 'One sub-agent digest for one
33
34
 
34
35
  export const TOOLS = [
35
36
  { 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: 'setup', description: `Publish atlas-work ${MANIFEST_VERSION} to the tenant's Forge and register its types (product, repo with its kinds, 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
38
  { 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
39
  { 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 } },
40
+ { 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 } },
41
+ { 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 } },
41
42
  { 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_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 } },
43
44
  { 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
45
  { 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 } },
45
46
  { 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 } },
@@ -8,7 +8,8 @@
8
8
  ".stack/",
9
9
  ".atlas/",
10
10
  ".mcp.json",
11
- ".sdlc/"
11
+ ".sdlc/",
12
+ ".dev.vars*"
12
13
  ],
13
14
  "never_quote": [
14
15
  "fixtures",
@@ -30,7 +31,8 @@
30
31
  "agents/reviewer-architect.md",
31
32
  "agents/reviewer-pm.md",
32
33
  "agents/reviewer-security.md",
33
- "agents/artifact-renderer.md"
34
+ "agents/artifact-renderer.md",
35
+ "agents/verifier.md"
34
36
  ],
35
37
  "sub_agents_that_write_files": [
36
38
  "agents/artifact-renderer.md"
@@ -1,139 +0,0 @@
1
- // The five helpers, and the entry verb that routes to them.
2
- //
3
- // Milestone M2, slice L2 stage one. Authority:
4
- // Atlas amendment A1.6, section 5, the L2 stage
5
- // one paragraph; the Atlas architecture record, section 5.1.
6
- //
7
- // One helper per class of file. You never pick one. You say what changed, and
8
- // the entry verb routes it. "We now deploy prod on Fly" reaches Facts, which
9
- // edits atlas/deploy.yaml, and Procedures, which updates the deploy skill. One
10
- // request, two helpers, one pull request.
11
- //
12
- // What a helper does here: it says which files it owns, it refuses a change to
13
- // a file it does not own, and it carries the change into one pull request with
14
- // the fixed label and per-change provenance. It does not draft. An agent in a
15
- // session drafts, because drafting needs a model and a read of the repository.
16
- // The tooling helper is the exception: both the files it owns are written from
17
- // the package, so it needs no model at all.
18
- import { posix } from 'node:path';
19
-
20
- // R13, from the M2 design study's review panel: agent-invoked helper pull
21
- // requests carried no provenance. Every change now names where it came from,
22
- // and the set is closed.
23
- export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
24
-
25
- // The one label every helper pull request carries. A person filters on it, and
26
- // slice L4's merge actor reads it.
27
- export const HELPER_LABEL = 'atlas-helper';
28
-
29
- const under = (...prefixes) => (path) => prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
30
- const isOneOf = (...paths) => (path) => paths.includes(path);
31
-
32
- export const HELPERS = Object.freeze([
33
- {
34
- id: 'entry-map',
35
- file: 1,
36
- title: 'Entry map',
37
- owns: isOneOf('CLAUDE.md', 'AGENTS.md'),
38
- ownsText: 'CLAUDE.md, AGENTS.md',
39
- forWhat: 'Keeps the front door true: what the repo is, how to build it, where things are.',
40
- youMightSay: 'the modules moved, refresh the map',
41
- words: ['entry map', 'claude.md', 'agents.md', 'front door', 'layout', 'module', 'modules', 'readme', 'structure', 'moved'],
42
- },
43
- {
44
- id: 'rules',
45
- file: 2,
46
- title: 'Path rules',
47
- owns: under('.claude/rules'),
48
- ownsText: '.claude/rules/*.md',
49
- forWhat: 'Records a constraint that holds under one path only.',
50
- youMightSay: 'migrations are append-only under migrations/',
51
- words: ['rule', 'rules', 'constraint', 'never', 'append-only', 'forbidden', 'must not', 'policy'],
52
- },
53
- {
54
- id: 'procedures',
55
- file: 3,
56
- title: 'Procedures',
57
- owns: (path) => path.startsWith('.claude/skills/') && path.endsWith('/SKILL.md'),
58
- ownsText: '.claude/skills/<name>/SKILL.md',
59
- forWhat: 'Keeps the how-to for running, testing, deploying and releasing.',
60
- youMightSay: 'we changed how we deploy',
61
- words: ['skill', 'procedure', 'how to', 'run locally', 'test', 'tests', 'deploy', 'release', 'ship', 'runbook'],
62
- },
63
- {
64
- id: 'facts',
65
- file: 4,
66
- title: 'Repo facts',
67
- owns: (path) => path === 'atlas.yaml' || under('atlas')(path),
68
- ownsText: 'atlas.yaml, atlas/',
69
- forWhat: 'Holds where it runs, where the logs are, and every credential by name.',
70
- youMightSay: 'add a staging environment on Fly',
71
- words: ['fact', 'facts', 'atlas.yaml', 'environment', 'staging', 'prod', 'logs', 'metrics', 'credential', 'credentials', 'dependency', 'dependencies', 'kind', 'deploy target', 'fly', 'region'],
72
- },
73
- {
74
- id: 'tooling',
75
- file: 7,
76
- title: 'Tooling',
77
- owns: (path) => path === '.github/atlas/check.mjs' || path === '.github/workflows/atlas-check.yml',
78
- ownsText: '.github/atlas/check.mjs, .github/workflows/atlas-check.yml',
79
- forWhat: 'Keeps the automatic checks this repo runs.',
80
- youMightSay: 'refresh the Atlas check',
81
- words: ['check', 'workflow', 'ci', 'lint', 'linter', 'action', 'tooling', 'refresh the check', 'atlas check'],
82
- },
83
- ]);
84
-
85
- export const helperById = (id) => HELPERS.find((helper) => helper.id === id) ?? null;
86
-
87
- // Which helper owns a path. null means no helper owns it, and no helper may
88
- // write it.
89
- export function ownerOf(path) {
90
- const clean = posix.normalize(String(path ?? '')).replace(/^\.\//, '');
91
- return HELPERS.find((helper) => helper.owns(clean)) ?? null;
92
- }
93
-
94
- // The entry verb's routing. You say what changed; this says which helpers it
95
- // reaches, in file order. An empty list is an honest answer: it means the
96
- // sentence names nothing any helper owns, and the caller should say more.
97
- export function route(say) {
98
- const text = String(say ?? '').toLowerCase();
99
- if (!text.trim()) return [];
100
- return HELPERS.filter((helper) => helper.words.some((word) => text.includes(word)));
101
- }
102
-
103
- // First setup is the same verb. It runs all five, in file order. There is no
104
- // separate first-run path to maintain.
105
- export const setupOrder = () => [...HELPERS];
106
-
107
- export class HelperError extends Error {}
108
-
109
- // Every change a helper carries, checked before anything is written.
110
- //
111
- // Two rules, and both are about who may write what. A helper writes only the
112
- // files it owns, so one request cannot quietly rewrite another class of file.
113
- // Every change names its source, so a reader of the pull request can tell an
114
- // owner decision from a guess.
115
- export function checkChanges(helperId, changes) {
116
- const helper = helperById(helperId);
117
- if (!helper) throw new HelperError(`there is no helper "${helperId}"; the helpers are ${HELPERS.map((one) => one.id).join(', ')}`);
118
- if (!Array.isArray(changes) || !changes.length) throw new HelperError('a helper needs at least one change to carry');
119
- for (const change of changes) {
120
- const path = String(change?.path ?? '');
121
- if (!path) throw new HelperError('a change has no path');
122
- if (path.startsWith('/') || path.includes('..')) throw new HelperError(`"${path}" leaves the repo; a helper writes inside one repo only`);
123
- if (!helper.owns(path)) {
124
- const other = ownerOf(path);
125
- throw new HelperError(other
126
- ? `the ${helper.id} helper does not own "${path}"; the ${other.id} helper does. Route the change there.`
127
- : `no helper owns "${path}"; the five helpers own ${HELPERS.map((one) => one.ownsText).join('; ')}`);
128
- }
129
- if (typeof change.contents !== 'string') throw new HelperError(`the change to "${path}" carries no contents`);
130
- const source = change?.provenance?.source;
131
- if (!PROVENANCE_SOURCES.includes(source)) {
132
- throw new HelperError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')} (review finding R13)`);
133
- }
134
- if (!String(change?.provenance?.detail ?? '').trim()) {
135
- throw new HelperError(`the change to "${path}" names the source "${source}" and says nothing about it; give the words, the file read, or the task`);
136
- }
137
- }
138
- return helper;
139
- }