@arjunkhera/atlas 0.2.2 → 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.
@@ -30,27 +30,27 @@ checks and the transition log; each verb's own description says what it needs.
30
30
  | Record new work, with the owner's words | `item_propose`, then `item_stage` as it moves |
31
31
  | Freeze the design | `item_lock` |
32
32
  | Park it, or pick it up again | `item_park`, `item_resume` |
33
+ | Drop it, in the owner's words (`item_resume` undoes a drop) | `item_drop` |
34
+ | Keep its next step, its wait or its title true | `item_edit` |
33
35
  | Split it, or link it to other work | `item_split`, `item_link` |
34
36
  | Ask the owner something, or record the answer | `question_ask`, `question_answer` |
35
37
  | Record a decision in the owner's words | `decision_record` |
36
38
  | Mark it delivered, or reopen it | `item_done`, `item_reopen` |
37
39
  | Say where things stand | `where_are_we`, `in_flight`, `needs_me` |
38
40
 
39
- The repo's own facts come from `atlas.yaml` and `atlas/`. Its `kind` (`service`,
40
- `library`, `schema`) decides which procedures it has and what "delivered" means.
41
- Read it. Never assume a deployed service.
41
+ The repo's own facts come from `atlas.yaml` and `atlas/`. Its kinds (`service`,
42
+ `library`, `schema`, `cli`, `plugin`) decide which procedures it has and what
43
+ "delivered" means. Read them. Never assume a deployed service.
42
44
 
43
- A change to the process itself is not a task. It is an amendment to the
44
- frozen text, and the rule for one is short:
45
+ How to talk with the owner lives in the `atlas:lead` skill: one question at a
46
+ time, a page first with a worked example, plain words, push back. This skill
47
+ does not repeat those rules; follow the lead.
45
48
 
46
- 1. Say so at once. Never build against a sentence you know is wrong.
47
- 2. Write the amendment as its own dated file: what changes, why, the new
48
- frozen text in full, one new hash over the whole frozen section, and the old
49
- hash named as retired.
50
- 3. Never edit frozen text in place. A reviewer's finding is a normal reason to
51
- amend.
52
-
53
- (Source: the Atlas lock procedure, amendment A1.6, section 12.)
49
+ **A change after the owner approved a design** is not a new lock. Say so at
50
+ once, and never build against a sentence you know is wrong. Show the owner the
51
+ change in one screen. When the owner approves it, record the owner's words with
52
+ `decision_record` on the same item, and write the change into the design file
53
+ with the date. (Target design, flow 4: long frozen texts with hashes retire.)
54
54
 
55
55
  ## start
56
56
 
@@ -68,73 +68,62 @@ frozen text, and the rule for one is short:
68
68
  [`templates/design-doc.md`](templates/design-doc.md) to
69
69
  `docs/design-docs/<slug>.md`, fill Context and goal, and list it in
70
70
  `docs/index.md` in the commit that first lands it. Hotfix tier skips the
71
- doc: the acceptance criteria live on the work item, and a resume anchor
71
+ doc: the definition of done lives on the work item, and a resume anchor
72
72
  covers pauses.
73
73
  5. Enter the design loop.
74
74
 
75
75
  ## The design loop (between start and lock)
76
76
 
77
- - **One voice.** The owner talks to one colleague. Crews run behind the
78
- curtain as subagents and come back as digests — never as a committee
79
- surfacing question lists.
80
- - **Guided elicitation** (the owner's words: *"one on one
81
- conversation that flows… the purpose of agent is to do research and guide my
82
- thoughts into useful features"*). Research first, then **one pointed question
83
- at a time**, with a recommendation (the owner: *"Questions if posed
84
- should be one by one. Just think, if you say 5 things in one go, how do I
85
- respond."*). Keep the question queue in the design doc; surface only the
86
- next one. Record each question with `question_ask` and each answer with
87
- `question_answer`.
88
- - **Succinct chat; artifacts carry the content** (the owner's words:
89
- *"the content is very verbose… I won't read that much"* · *"artefacts should
90
- be the default method of communication… use as many diagrams and examples as
91
- possible"*). Chat is a few short sentences: the outcome, the artifact link,
92
- the one question.
93
- - **Lead with a worked example.** Real input, real process, real output, and a
94
- sequence diagram where order matters. The owner cannot read dense prose.
95
- - **Read the reason, not only the rule.** Before you build to a done line, read
96
- the owner's words behind it and ask whether the thing should exist at all. A
97
- done line is a measure, not the goal.
77
+ Talk to the owner by the `atlas:lead` skill. What this loop adds:
78
+
98
79
  - **Research before opinions.** Fan out `atlas:code-explorer` for how the system
99
- works today. Raw exploration never enters this session — digests only.
80
+ works today. Raw exploration never enters this session; digests only.
81
+ - **Read the reason, not only the rule.** Before you build to a definition of
82
+ done, read the owner's words behind it and ask whether the thing should exist
83
+ at all.
100
84
  - **When the task is a cluster of filed bugs, first ask whether the model
101
85
  they presume was ever decided.** A bug report says the code surprised
102
86
  someone; it does not say what the code should do.
87
+ - **User stories before the definition of done.** Never write checks for a
88
+ flow no story walks through.
103
89
  - **The doc accretes in real time.** Every resolved question, decision and
104
- approach lands in the design doc as it happens. The doc is the pause and
105
- resume state. Record each decision with `decision_record`.
106
- - **HTML is the owner's reading surface.** Any design, review or status the
107
- owner reads is a styled artifact, rendered by the `atlas:artifact-renderer`
108
- crew. Its treatment contract lives in that crew; do not restate it.
109
- - **No bare acronyms in front of the owner.** Spell a thing out on first
110
- mention. Ids are for the machine; names are for the human.
111
- - **User stories before acceptance criteria** (the owner's words:
112
- *"I need to see how a user would use the product, or like how the flow
113
- works"*). Never draft criteria for a flow no story walks through.
114
- - **Adversarial review before lock.** Standard tier: at least one persona.
115
- Initiative: the panel — `atlas:reviewer-pm`, `atlas:reviewer-architect`,
116
- `atlas:reviewer-security` — each briefed with the doc path, in parallel. Brief each
117
- one to ask whether the design is the right thing, not only whether it is
118
- consistent. An unresolved finding goes into the doc's open questions, and
119
- that blocks the lock.
90
+ approach lands in the design doc as it happens. Record each question with
91
+ `question_ask`, each answer with `question_answer`, each decision with
92
+ `decision_record`.
93
+ - **Adversarial review before the owner approves.** Standard tier: at least one
94
+ persona. Initiative: the panel, `atlas:reviewer-pm`, `atlas:reviewer-architect`
95
+ and `atlas:reviewer-security`, each briefed with the doc path, in parallel.
96
+ Brief each one to ask whether the design is the right thing. An unresolved
97
+ finding goes into the doc's open questions.
120
98
 
121
99
  ## lock
122
100
 
123
- Gate on `lifecycle.yaml` `lock.preconditions`, all four: the open questions
124
- are empty, the owner ratified the criteria, the approach is chosen, the tier
125
- is confirmed. Then:
126
-
127
- 1. Stamp the design doc's lock block: date, ratifier, tier, the sha256 of the
128
- acceptance criteria text, frozen interfaces, the work list. Commit.
129
- 2. Call `item_lock` with the frozen scope and its hash.
130
- 3. Build against the frozen text. **Tripwires** (`lifecycle.yaml`
131
- `tripwires`): a discovery after lock that touches the criteria, security,
132
- a schema or cost stops the build and goes back to the owner. If the frozen
133
- text is wrong, say so at once and write an amendment. Never build against a
134
- sentence you know is wrong.
135
- 4. Test with the repo's own `test` procedure, which `atlas.yaml` names. Open a
136
- pull request. Keep a separate acceptance reviewer, and close its findings
137
- before acceptance.
101
+ A lock is the owner's approval of a short design (target design, flow 4).
102
+
103
+ 1. **The design is short and sized to the work.** A feature gets a full
104
+ document; a small fix gets a one-screen card. It holds what
105
+ `lifecycle.yaml` `lock.holds` lists: the definition of done, written as checks; the kind of change, its impact and its undo; and
106
+ each existing test it expects to change. A change that cannot be undone
107
+ shows in bold at the top.
108
+ 2. **The owner approves it in the owner's own words.** Gate on
109
+ `lifecycle.yaml` `lock.preconditions`. Record the words with
110
+ `decision_record`, and mark the design doc "Approved <date>", with the words.
111
+ 3. Call `item_lock` with the definition of done as `scope.text` and the design
112
+ file as `scope.design_doc`. The verb keeps a fingerprint of that text for
113
+ the tools. No person reads or writes a hash, and no frozen text is copied
114
+ into the doc.
115
+ 4. Build against the approved design. **Tripwires** (`lifecycle.yaml`
116
+ `tripwires`): a discovery that touches the definition of done, security, a
117
+ schema or cost stops the build and goes back to the owner, as a change after
118
+ approval (above).
119
+ 5. Test with the repo's own `test` procedure, which `atlas.yaml` names. Open a
120
+ pull request. Its text holds the definition of done, the kind of change, the
121
+ undo, and the list of existing tests it changes or removes, and it links the
122
+ tracker item.
123
+ 6. **Proof before the merge.** Run the `atlas:verifier` agent on the pull
124
+ request. It proves each line of the definition of done from a fresh run and
125
+ posts its own proof comment. Never edit that comment. Ask the owner for the
126
+ merge only after the proof is there, and show the proof first.
138
127
 
139
128
  ## pause
140
129
 
@@ -142,6 +131,9 @@ is confirmed. Then:
142
131
  resume anchor from [`templates/resume-anchor.md`](templates/resume-anchor.md)
143
132
  (hotfix). The bar: a fresh session needs nothing else.
144
133
  2. Call `item_park` with the reason. A parked item keeps its stage.
134
+ 3. Before the session ends, keep each item you touched true: call
135
+ `item_edit` with its next step and what it waits on. If the owner retires
136
+ a piece of work, call `item_drop` with his words.
145
137
 
146
138
  ## resume
147
139
 
@@ -160,8 +152,9 @@ next action. One short paragraph.
160
152
  ## done
161
153
 
162
154
  1. Prove delivery the way the repo's `kind` requires, then call `item_done`. A
163
- `library` or `schema` repo proves it by merged pull requests and a green
164
- gate. A `service` repo also needs its live checks.
155
+ `library` repo proves it by merged pull requests and a green gate. A
156
+ `service` or `schema` repo also needs its live checks, because a schema
157
+ migration reaches the running node after the merge.
165
158
  2. If anything was a struggle, encode the fix where it stops a repeat — a lint,
166
159
  then a tool, then a skill, then a doc — and say which.
167
160
  3. Design doc status: `SHIPPED <date>`.
@@ -2,7 +2,9 @@
2
2
  #
3
3
  # Atlas is the package that sets how a repo is worked on, and this is its
4
4
  # lifecycle. It travels with the package, beside the skill that reads it, not
5
- # with any one repo. A verb reads these lists; it never hard-codes them.
5
+ # with any one repo. The skill reads these lists. The work verbs still keep
6
+ # their own copies (STAGES and LIVE_LIFECYCLES in work/lib), and the stage
7
+ # lists differ on ship; the register records that gap.
6
8
  #
7
9
  # The values came from the old ops pack, unchanged. That pack and every script
8
10
  # that read it are deleted.
@@ -12,11 +14,13 @@ authority: the Atlas lock procedure, amendment A1.9, section 5, slice L2 stage o
12
14
  lifecycle_states: [proposed, active, paused, done, dropped, archived]
13
15
  stages: [capture, spec, build, verify, ship, learn]
14
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.
15
19
  lock:
16
- freezes: [acceptance_criteria, interfaces, approach]
17
- 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]
18
22
 
19
- tripwires: [acceptance_criteria, security, schema_migrations, money_cost]
23
+ tripwires: [definition_of_done, security, schema_migrations, money_cost]
20
24
 
21
25
  tiers: [hotfix, standard, initiative]
22
26
  tier_escalators: [migrations, auth_rls_surface, new_external_dependency, money_cost]
@@ -1,4 +1,4 @@
1
- <!-- Living design document — template (lifecycle spec §7, LC-5).
1
+ <!-- Living design document — template (sdlc-task skill, "The design loop").
2
2
  Copy to docs/design-docs/<slug>.md at `sdlc-task start` (standard/initiative
3
3
  tiers). Sections may be trimmed at standard tier; never at initiative.
4
4
  The artifact projection republishes on change (D14: git is truth).
@@ -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 (LC-6). 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 |
@@ -1,16 +1,18 @@
1
- <!-- Resume anchor — template (lifecycle spec §8).
1
+ <!-- Resume anchor — template (sdlc-task skill, "pause").
2
2
  For work BELOW the design-doc threshold: hotfix tier, bug investigations,
3
- any interrupted queue work. Posted as a comment on the issue (queue lane)
4
- or the container thread (conversation lane) at every pause AND at stage
5
- transitions of long-running work.
6
- The bar (and the eval): a fresh session given only this anchor proceeds
7
- without asking the owner anything the anchor should have contained. -->
3
+ any interrupted work. Write it at every pause AND at stage transitions of
4
+ long-running work. Keep it where the work item can point at it: a file in
5
+ the repo or a comment on the issue, attached with `item_link`. Put its
6
+ next action in the item's next step with `item_edit`.
7
+ The bar: a fresh session given only this anchor proceeds without asking
8
+ the owner anything the anchor should have contained. No eval checks this
9
+ bar yet; the reviewer judges it. -->
8
10
 
9
11
  ## ⏸ Resume anchor — <date>
10
12
 
11
13
  **Goal:** <one line, owner-verbatim where possible>
12
14
 
13
- **State:** lifecycle `<active|paused>` · stage `<capture|spec|build|verify|ship|learn>`
15
+ **State:** lifecycle `<active|paused>` · stage `<capture|spec|build|verify|learn>`
14
16
 
15
17
  **Findings so far:** <POINTER to where they live — issue comment link, doc
16
18
  path, graph entity id. Never duplicate content inline.>
@@ -15,7 +15,7 @@
15
15
  // scrubs by pattern. The rendered answer is scrubbed once more before it
16
16
  // leaves the door (R12).
17
17
  import { refuse, text, isoDate, stringList } from './verb-fields.mjs';
18
- import { oneLine, scrubSecrets, ROW_LINE_MAX } from './questions.mjs';
18
+ import { oneLine, scrubSecrets, ROW_LINE_MAX, priorityOrderOf, byPriority, orderNote, waitsOnLine } from './questions.mjs';
19
19
  import { listLine, legacySealOf, marksByItem, movementOf, pullsOfItem, readPullsByRepo, correctionsOf, daysBetween } from './delivery.mjs';
20
20
  import { acceptCodeRead, codeReadBrief } from './code-read.mjs';
21
21
 
@@ -259,7 +259,7 @@ export function createCapabilityVerbs(core) {
259
259
  const born = fields.created_at ?? item.created_at ?? null;
260
260
  proposed.push({ ...base, age_days: born ? daysBetween(born, readAt) : null, proposed_at: born, driver: clean(fields.driver ?? '', 64) });
261
261
  } else if (lifecycle !== 'done' && !deliveries.length) {
262
- omitted.push({ item: item.id, title: clean(item.title, 120), reason: `the item is ${clean(lifecycle, 32)}; paused and archived items are backlog (D3 rule 1)` });
262
+ omitted.push({ item: item.id, title: clean(item.title, 120), reason: lifecycle === 'dropped' ? 'the owner dropped this item' : `the item is ${clean(lifecycle, 32)}; paused and archived items are backlog (D3 rule 1)` });
263
263
  }
264
264
  }
265
265
  inProgress.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
@@ -382,8 +382,10 @@ export function createCapabilityVerbs(core) {
382
382
  // does not carry: an item with none reads "unregistered", as the six-part
383
383
  // answer already renders it.
384
384
  const items = only ? rows.filter((row) => row.fields?.product === only) : rows;
385
- const unregisteredTotal = rows.filter((row) => !registered(row)).length;
386
- const unregisteredShown = items.filter((row) => !registered(row)).length;
385
+ // A dropped item is counted once, under "Dropped by you", never as unsorted.
386
+ const unsorted = (row) => !registered(row) && row.fields?.lifecycle !== 'dropped';
387
+ const unregisteredTotal = rows.filter(unsorted).length;
388
+ const unregisteredShown = items.filter(unsorted).length;
387
389
  const marks = await marksFor(new Set(items.map((row) => row.id)));
388
390
  const readAt = now();
389
391
  let redactions = 0;
@@ -415,7 +417,7 @@ export function createCapabilityVerbs(core) {
415
417
  // own, and it must not pass through the one-line flattener. The count is
416
418
  // read after the walk, so the walk's own finds are counted too (L1).
417
419
  const scrubbed = ({ text, ...rest }) => { const rows = cleanDeep(rest); return { ...rows, redactions: redactions + rendered.redactions, text }; };
418
- const built = [], building = [], pending = [], possible = [];
420
+ const built = [], building = [], pending = [], possible = [], dropped = [];
419
421
  for (const item of items) {
420
422
  const fields = item.fields ?? {};
421
423
  const { rows: deliveries, marks: own } = deliveriesOf(item, marks);
@@ -431,24 +433,24 @@ export function createCapabilityVerbs(core) {
431
433
  }
432
434
  const doneAt = fields.delivered_at ?? item.updated_at ?? item.created_at ?? null;
433
435
  if (fields.lifecycle === 'done' && !deliveries.length && !(since && String(doneAt ?? '') < since)) built.push({ ...base, mark_id: null, verdict: 'done, no delivery mark', delivered_at: doneAt, prs: [], source: 'no mark yet', note: 'the item record says done; the done verb writes the mark' });
434
- if (fields.lifecycle === 'active') building.push({ ...base, stage: clean(fields.stage ?? '', 64), driver: clean(fields.driver ?? '', 64), next_step: clean(fields.next_step ?? '', 200), days_since_last_change: days, last_change_source: clean(last?.source ?? 'nothing recorded', 120), delivery_state: fields.delivery_state ? clean(fields.delivery_state, 64) : null });
435
- else if (fields.lifecycle === 'paused') pending.push({ ...base, reason: clean(fields.pause_reason ?? 'no reason on record', 300), days_since_last_change: days, parked_at: fields.parked_at ?? null });
436
+ const waits = fields.waits_on && typeof fields.waits_on === 'object' ? { waits_on: fields.waits_on } : {};
437
+ if (fields.lifecycle === 'active') building.push({ ...base, ...waits, stage: clean(fields.stage ?? '', 64), driver: clean(fields.driver ?? '', 64), next_step: clean(fields.next_step ?? '', 200), days_since_last_change: days, last_change_source: clean(last?.source ?? 'nothing recorded', 120), delivery_state: fields.delivery_state ? clean(fields.delivery_state, 64) : null });
438
+ else if (fields.lifecycle === 'paused') pending.push({ ...base, ...waits, reason: clean(fields.pause_reason ?? 'no reason on record', 300), days_since_last_change: days, parked_at: fields.parked_at ?? null });
436
439
  else if (fields.lifecycle === 'proposed') {
437
440
  const born = fields.created_at ?? item.created_at ?? null;
438
- possible.push({ ...base, age_days: born ? daysBetween(born, readAt) : null, proposed_at: born, driver: clean(fields.driver ?? '', 64) });
439
- }
441
+ possible.push({ ...base, ...waits, age_days: born ? daysBetween(born, readAt) : null, proposed_at: born, driver: clean(fields.driver ?? '', 64) });
442
+ } else if (fields.lifecycle === 'dropped') dropped.push({ ...base, dropped_at: fields.dropped_at ?? null });
440
443
  }
441
- built.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
442
- building.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
443
- pending.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
444
- possible.sort((a, b) => (b.age_days ?? 0) - (a.age_days ?? 0));
445
-
446
444
  // The decisions that set the order. The owner's words are what ordered
447
- // this work, so the view ends with them, newest first.
445
+ // this work, so the view ends with them, newest first. Release 0.2.3:
446
+ // his newest priority decision also orders the rows above.
448
447
  const products = only ? [only] : [...new Set(items.filter(registered).map((row) => row.fields.product))];
449
448
  const order = [];
449
+ const orders = new Map();
450
450
  for (const productId of products) {
451
- for (const row of await decisionsFor(productId)) {
451
+ const decisions = await decisionsFor(productId);
452
+ orders.set(productId, priorityOrderOf(decisions, productId));
453
+ for (const row of decisions) {
452
454
  if (since && String(row.fields?.date ?? '') < since) continue;
453
455
  if (row.fields?.kind === 'priority' || (Array.isArray(row.fields?.priority_order) && row.fields.priority_order.length)) {
454
456
  order.push({ decision: row.id, product: productId, kind: row.fields?.kind ?? 'decision', date: row.fields?.date ?? '', text: clean(row.fields?.text ?? row.title, 400), priority_order: row.fields?.priority_order ?? [], citation: { decision: row.id, date: row.fields?.date ?? '', source: row.fields?.source ?? '' } });
@@ -456,6 +458,12 @@ export function createCapabilityVerbs(core) {
456
458
  }
457
459
  }
458
460
  order.sort((a, b) => String(b.date).localeCompare(String(a.date)));
461
+ const byDays = (a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item);
462
+ built.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
463
+ building.sort(byPriority(orders, byDays));
464
+ pending.sort(byPriority(orders, byDays));
465
+ possible.sort(byPriority(orders, (a, b) => (b.age_days ?? 0) - (a.age_days ?? 0)));
466
+ const waitsOf = (row) => (row.waits_on ? ` · ${waitsOnLine(row.waits_on)}` : '');
459
467
 
460
468
  // D3 rule 2: a row names its product. The board does not group, so every
461
469
  // line carries the id (review H6).
@@ -464,20 +472,23 @@ export function createCapabilityVerbs(core) {
464
472
  const block = [
465
473
  `Built, pending, possible — ${only ?? `every product${unregisteredShown ? `, and ${plural(unregisteredShown, 'item')} that name${unregisteredShown === 1 ? 's' : ''} none` : ''}`} · read ${readAt.slice(0, 19)}Z${since ? ` · since ${since}` : ''}`,
466
474
  section(since ? `Built since ${since}` : 'Built', built, (row) => listLine(row.title, `${where_(row)} · ${row.verdict} ${day(row.delivered_at)} · ${prsOf(row)} · ${row.item}${row.mark_id ? ` · mark ${row.mark_id.slice(0, 8)}` : ''}`)),
467
- section('Building', building, (row) => listLine(`${row.title} · ${row.stage || 'no stage'} · next: ${row.next_step || 'not set'}`, `${where_(row)} · ${row.item} · ${row.days_since_last_change === null ? 'no movement on record' : `${plural(row.days_since_last_change, 'day')} since ${row.last_change_source}`}`)),
468
- section('Pending, parked until someone returns to it', pending, (row) => listLine(`${row.title} — ${row.reason}`, `${where_(row)} · ${row.item}`)),
469
- section('Possible, proposed and waiting for a yes', possible, (row) => listLine(row.title, `${where_(row)} · ${row.age_days === null ? 'age unknown' : `${plural(row.age_days, 'day')} old`} · ${row.item}`)),
475
+ section('Building', building, (row) => listLine(`${row.title} · ${row.stage || 'no stage'}${waitsOf(row)} · next: ${row.next_step || 'not set'}`, `${where_(row)} · ${row.item} · ${row.days_since_last_change === null ? 'no movement on record' : `${plural(row.days_since_last_change, 'day')} since ${row.last_change_source}`}${orderNote(orders, row)}`)),
476
+ section('Pending, parked until someone returns to it', pending, (row) => listLine(`${row.title}${waitsOf(row)} — ${row.reason}`, `${where_(row)} · ${row.item}${orderNote(orders, row)}`)),
477
+ section('Possible, proposed and waiting for a yes', possible, (row) => listLine(`${row.title}${waitsOf(row)}`, `${where_(row)} · ${row.age_days === null ? 'age unknown' : `${plural(row.age_days, 'day')} old`} · ${row.item}${orderNote(orders, row)}`)),
470
478
  section('The decisions that set the order', order, (row) => listLine(`${row.kind}: ${row.text}`, `${row.product} · ${row.date} · ${row.decision}`)),
471
- ...(unregisteredTotal ? [[
472
- `Not in the registry (${unregisteredTotal}${only ? '' : `, ${unregisteredShown} of them in the rows above, named "unregistered"`})`,
479
+ ...(dropped.length ? [`Dropped by you (${dropped.length}): they stay in the graph, and no live list names them.`] : []),
480
+ // Release 0.2.3: a read narrowed to
481
+ // one product says nothing about items that name no product. The
482
+ // count stays in the result, as unregistered_total.
483
+ ...(unregisteredTotal && !only ? [[
484
+ `Not in the registry (${unregisteredTotal}, ${unregisteredShown} of them in the rows above, named "unregistered")`,
473
485
  ' The slice L5 sweep gives them a product.',
474
- ...(only ? [' A read narrowed to one product cannot show them: an item with no product belongs to none.'] : []),
475
486
  ].join('\n')] : []),
476
487
  ].join('\n\n');
477
488
  const rendered = scrubSecrets(block);
478
489
  return scrubbed({
479
490
  verb: 'built_pending_possible', read_at: readAt, product: only, since,
480
- built, building, pending, possible, order, unregistered_total: unregisteredTotal, unregistered_in_rows: unregisteredShown,
491
+ built, building, pending, possible, order, dropped_total: dropped.length, unregistered_total: unregisteredTotal, unregistered_in_rows: unregisteredShown,
481
492
  rows: built.length + building.length + pending.length + possible.length,
482
493
  redactions: redactions + rendered.redactions, text: rendered.text,
483
494
  });
@@ -12,8 +12,8 @@
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';
16
- import { oneLine, ROW_LINE_MAX } from './questions.mjs';
15
+ import { refuse, text, isoDate, url, stringList, sha256, repoKinds, LIVE_KINDS } from './verb-fields.mjs';
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']);
19
19
  export const DELIVERY_READ_VERBS = Object.freeze(['in_flight']);
@@ -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', {
@@ -545,6 +551,11 @@ export function createDeliveryVerbs(core) {
545
551
  if (repoIds.length > MAX_REPOS) refuse(`${repoIds.length} repos are registered for this view; it reads at most ${MAX_REPOS}. Narrow it with a product id.`);
546
552
  const { pullsByRepo, repoErrors } = await readPullsByRepo(repoIds, readGitHub);
547
553
 
554
+ // Release 0.2.3: the owner's priority decision orders the rows.
555
+ const decisionType = await typeNamed('decision');
556
+ const decisions = await client.listAllEntities({ type: decisionType.name, state: 'active' });
557
+ const orders = new Map([...new Set(items.map((row) => row.fields.product))].map((productId) => [productId, priorityOrderOf(decisions, productId)]));
558
+
548
559
  const readAt = now();
549
560
  const rows = [], delivered = [], notMarked = [];
550
561
  for (const item of items) {
@@ -583,21 +594,23 @@ export function createDeliveryVerbs(core) {
583
594
  // The id is what a reader types back, so the title gives way to it
584
595
  // inside the line budget, as the "Needs me" row does (review G6).
585
596
  oneLine(`${oneLine(item.title, Math.max(1, ROW_LINE_MAX - item.id.length - 3))} · ${item.id}`),
586
- oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'} · next: ${fields.next_step || 'not set'}`),
587
- oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}`),
597
+ oneLine(`${fields.product} · ${fields.stage ?? 'no stage'} · ${fields.driver ?? 'no driver'}${waitsOnLine(fields.waits_on) ? ` · ${waitsOnLine(fields.waits_on)}` : ''} · next: ${fields.next_step || 'not set'}`),
598
+ oneLine(`${days === null ? 'no movement on record' : `${days} day${days === 1 ? '' : 's'} since last change`} (${last?.source ?? 'nothing recorded'}) · ${mine.length} pull request${mine.length === 1 ? '' : 's'}${open.length ? ` · ${open.length} merged, not marked` : ''}${orderNote(orders, { product: fields.product, item: item.id })}`),
588
599
  ],
600
+ waits_on: fields.waits_on ?? null,
589
601
  folded: { title: item.title, next_step: fields.next_step ?? '', scope: fields.scope?.text ?? '', pieces: fields.split_children ?? [], prs: mine.map((pull) => ({ url: pull.url, state: pull.state, matched_by: pull.matched_by, merged_at: pull.merged_at, updated_at: pull.updated_at })), marks: own.map((mark) => ({ id: mark.id, kind: mark.fields.kind, verdict: mark.fields.verdict, at: mark.fields.marked_at })) },
590
602
  citation: { item: item.id, read_at: readAt, repos: [...new Set(mine.map((pull) => pull.repo))] },
591
603
  });
592
604
  }
593
605
  // The owner's rule: order by days since last change, latest on top, and
594
- // show the days on the row.
595
- rows.sort((a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item));
606
+ // show the days on the row. Release 0.2.3: the items his priority
607
+ // decision names come first, in his order.
608
+ rows.sort(byPriority(orders, (a, b) => (a.days_since_last_change ?? Number.MAX_SAFE_INTEGER) - (b.days_since_last_change ?? Number.MAX_SAFE_INTEGER) || a.item.localeCompare(b.item)));
596
609
  delivered.sort((a, b) => String(b.delivered_at ?? '').localeCompare(String(a.delivered_at ?? '')));
597
610
  notMarked.sort((a, b) => (a.days ?? 0) - (b.days ?? 0));
598
611
  const products = [...new Set(rows.map((row) => row.product))].sort();
599
612
  const byProduct = products.map((product) => ({ product, rows: rows.filter((row) => row.product === product) }));
600
- const block = byProduct.map((group) => [`${group.product} — in flight (${group.rows.length})`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n'));
613
+ const block = byProduct.map((group) => [`${group.product} — in flight (${group.rows.length})${orders.get(group.product) ? ` · in your order of ${orders.get(group.product).date}, then by the last change` : ''}`, ...group.rows.flatMap((row, index) => row.lines.map((line, position) => `${position === 0 ? `${index + 1}. ` : ' '}${line}`))].join('\n'));
601
614
  if (notMarked.length) block.push([`merged, not marked (${notMarked.length})`, ...notMarked.map((row) => listLine(row.title, `${row.pr} · ${row.item} · merged ${row.days} day${row.days === 1 ? '' : 's'} ago`))].join('\n'));
602
615
  if (repoErrors.length) block.push([`GitHub did not answer for ${repoErrors.length} repo${repoErrors.length === 1 ? '' : 's'}; movement below is from the graph only`, ...repoErrors.map((row) => oneLine(` ${row.repo} — ${row.reason}`, ROW_LINE_MAX))].join('\n'));
603
616
  if (unregisteredTotal && !only) {
@@ -615,6 +628,7 @@ export function createDeliveryVerbs(core) {
615
628
  if (since && delivered.length) block.push([`delivered since ${since} (${delivered.length})`, ...delivered.map((row) => listLine(row.item_title, `${row.verdict}${(row.gate_warnings ?? []).length ? `, gate warning on ${row.gate_warnings.map((warning) => warning.check).join(' ')}` : ''} · ${String(row.delivered_at ?? '').slice(0, 10)} · ${row.prs.join(' ') || 'no pull request on the mark'}`))].join('\n'));
616
629
  return {
617
630
  verb: 'in_flight', read_at: readAt, product: only, since, rows: rows.length, products: byProduct,
631
+ priority: Object.fromEntries([...orders].filter(([, order]) => order).map(([productId, order]) => [productId, { decision: order.decision, date: order.date }])),
618
632
  merged_not_marked: notMarked, delivered: since ? delivered : delivered.slice(0, 20), unregistered, unregistered_total: unregisteredTotal, repos_read: repoIds, repo_errors: repoErrors,
619
633
  text: block.join('\n\n') || 'Nothing is in flight: no active item on a registered product.',
620
634
  };