@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -8
- package/agents/artifact-renderer.md +6 -1
- package/agents/capability-reader.md +1 -1
- package/agents/code-explorer.md +1 -1
- package/agents/reviewer-architect.md +1 -1
- package/agents/reviewer-pm.md +1 -1
- package/agents/reviewer-security.md +1 -1
- package/agents/verifier.md +97 -0
- package/door/cli.mjs +33 -108
- package/door/lib/kind-drift.mjs +16 -6
- package/door/lib/privacy.mjs +61 -0
- package/door/lib/pull-request.mjs +55 -56
- package/door/lib/ste-words.json +51 -0
- package/door/lib/ste.mjs +199 -0
- package/door/lib/tooling.mjs +2 -2
- package/package.json +1 -1
- package/shape/check.mjs +33 -33
- package/shape/releases.json +5 -0
- package/skills/feedback/SKILL.md +39 -0
- package/skills/lead/SKILL.md +103 -0
- package/skills/learnings/SKILL.md +48 -0
- package/skills/repo-skills/SKILL.md +64 -0
- package/skills/sdlc-task/SKILL.md +54 -67
- package/skills/sdlc-task/lifecycle.yaml +5 -3
- package/skills/sdlc-task/templates/design-doc.md +22 -17
- package/work/lib/delivery.mjs +12 -6
- package/work/lib/verb-fields.mjs +31 -0
- package/work/lib/verbs.mjs +16 -15
- package/work/{manifest-0.5.0.json → manifest-0.6.0.json} +9 -3
- package/work/mcp.mjs +5 -4
- package/work/protected-paths.json +4 -2
- package/door/lib/helpers.mjs +0 -139
- package/door/lib/onboarding.mjs +0 -130
|
@@ -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
|
-
|
|
19
|
-
preconditions: [open_questions_empty,
|
|
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: [
|
|
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) |
|
|
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
|
-
|
|
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
|
|
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>, <
|
|
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
|
-
##
|
|
92
|
+
## Definition of done
|
|
93
93
|
|
|
94
|
-
<!--
|
|
95
|
-
|
|
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
|
-
##
|
|
99
|
+
## Kind of change, impact and undo
|
|
100
100
|
|
|
101
|
-
<!--
|
|
101
|
+
<!-- A change that cannot be undone goes in bold at the top of this doc. -->
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 (
|
|
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 |
|
package/work/lib/delivery.mjs
CHANGED
|
@@ -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
|
|
358
|
-
const
|
|
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
|
|
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(
|
|
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(
|
|
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', {
|
package/work/lib/verb-fields.mjs
CHANGED
|
@@ -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`);
|
package/work/lib/verbs.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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}.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
40
|
-
{ name: 'repo_edit', description: `Change a repo's
|
|
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: `
|
|
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"
|
package/door/lib/helpers.mjs
DELETED
|
@@ -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
|
-
}
|