create-pmos 0.89.0 → 0.91.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@5feb40c
1
+ kernel-source: pmos@0d48046
2
2
  assembled: 2026-10-07
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-07T17:12:14Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T17:57:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-07T17:12:14Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T17:57:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-07T17:12:14Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T17:57:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
- description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, and Wispr Flow's notes beside them; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
4
+ description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; final notes written after Stop from the whole recording, with speakers and a quote for each item, and Wispr Flow's notes beside them; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository for conflicts, capture highlights during the meeting, create tasks from next steps, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
5
5
  timestamp: 2026-10-07
6
6
  ---
7
7
 
@@ -123,6 +123,39 @@ When a meeting was heard with local transcription, its audio is kept on this mac
123
123
 
124
124
  To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
125
125
 
126
+ ## Capture highlights during the meeting
127
+
128
+ While listening, press **Highlight** to capture the last 30 seconds of conversation. A pop-up shows the highlight's progress:
129
+
130
+ 1. **Capturing**: the last 30 seconds are being recorded.
131
+ 2. **Listening**: the highlight listens forward. Choose how long: **30 seconds**, **1 minute** or **2 minutes**. The remaining time counts down.
132
+ 3. **Transcribing**: the highlight audio is being transcribed.
133
+ 4. **Labeling**: Jev is labeling the highlight text (decision, action, question, etc.).
134
+ 5. **Checking**: the highlight is being checked against the repository for conflicts.
135
+ 6. **Done**: the highlight is saved to your note.
136
+
137
+ Highlights appear in a section called **Highlights** below the overview, with their label, time and text. Each highlight shows whether the repository agrees, differs or has no matching passage. Press the highlight to see the side-by-side comparison of your words and the repository passage. The highlight audio and repository check run on this machine through the workspace's server.
138
+
139
+ ## Conflicts: check notes against the repository
140
+
141
+ For each highlight and each final note item, PMOS checks the words against passages in the repository (PRDs, decisions, initiatives and other documentation). When a passage shares significant words with what you said, Jev checks whether they agree, differ or are unrelated.
142
+
143
+ 1. A check mark means Jev found a passage that agrees with what you said.
144
+ 2. **≠** (not equal) means Jev found a passage that contradicts or is outdated by what you said. Press it to see the side-by-side comparison: your words on the left, the repository passage on the right. Correct your note or the repository, then press **Transcribe again** in the settings to re-check.
145
+ 3. No mark means no passage matched, or Jev has not yet checked it.
146
+
147
+ Conflicts appear in a section called **Conflicts** below the highlights. Each conflict shows the note item and the repository passage it contradicts, side by side, so you can see what to correct or update.
148
+
149
+ ## Create tasks from next steps
150
+
151
+ In the **Next steps** section of your notes, each action item with an assigned owner shows a **Make task** button. Press it to create a new task in PMOS and link it to this note.
152
+
153
+ 1. The task takes the words of the next step as its title.
154
+ 2. It links to the note so the owner can review the meeting context.
155
+ 3. It is marked as open, waiting for the owner to start it.
156
+
157
+ The task appears in your My Work view and on the initiative's board when the note is linked to an initiative.
158
+
126
159
  ## Link a note to its meeting and its initiative
127
160
 
128
161
  Under the title, a note shows two lines: **Meeting** and **Initiative**.
@@ -37,13 +37,13 @@ A sighting of a new handle adds the person. A sighting with the full name of a p
37
37
 
38
38
  ## The list
39
39
 
40
- People has the same two-card layout as the Agenda. The title says how many people there are. **All**, **Team** and **External** at the top right filter the list. The left card holds the list. Type in **Find a person** to search names, handles and initiatives. **Latest**, **Most** and **Name** sort it by the last interaction, the number of interactions or the name. Each row shows initials, the name, the role and organisation, and the tone as a coloured dot (a ? means you have not confirmed it). Press a person to open their page in the right card. Each card scrolls by itself.
40
+ People has the same two-card layout as the Agenda. The title says how many people there are. **All**, **Team** and **External** at the top right filter the list. The left card holds the list. Type in **Find a person** to search names, handles and initiatives. **Latest**, **Most** and **Name** sort it by the last interaction, the number of interactions or the name. Each row shows initials, the name, the role and organisation, and the tone as a coloured dot (a ? means you have not confirmed it). Press a person to open their page in the right card. Each card scrolls by itself, and the search box keeps its cursor while you type.
41
41
 
42
42
  ## A person's page
43
43
 
44
44
  The top of the page shows the person's name and role, a **Confirm** button when a tone is waiting, and a **⋯** menu. Three tiles under it give the number of interactions, the tone and the shared work.
45
45
 
46
- - **Interactions.** Newest first. Each line shows the date, the kind (Mail, Chat, Meeting, 1:1, Jira, Doc, Call or Mention), the line, and where it came from with its pointer and tone.
46
+ - **Interactions.** Newest first. A meeting that comes up again is one line that says how often it happens (One-off, Daily, Weekly or Monthly meeting), how many times, and when it was last. A meeting the Agenda marks as key shows a **Key** label. Each other line shows the date, the kind (Mail, Chat, Meeting, 1:1, Jira, Doc, Call or Mention), the line, and where it came from with its pointer and tone.
47
47
  - **Tone.** The mix of tones (warm, neutral, cautious, tense) and a proposed overall tone: the most frequent tone of the latest 10 interactions. When two tones are tied, the latest one wins. Press **Confirm** (at the top or here) to keep the proposal, **Set another** to choose a different tone, or **Change** to clear your choice. Tone describes the exchanges, not the person.
48
48
  - **Shared work.** The initiatives your interactions name, the plan items the person owns (with **Blocked** when the item is blocked), and the coming meetings that name them when calendar names are on. Nothing here is typed by hand.
49
49
  - **Notes.** Type a note and press the **+** button (**Add note**). Press the **×** next to a note to remove it. Only you can add or remove a note. No command writes one.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * lib/notes-conflicts.js — detecting conflicts between note statements and repository files
3
+ *
4
+ * A conflict is when a decision or statement in the note contradicts or is outdated
5
+ * by text in the repository (PRDs, initiatives, DECISIONS.md, code comments, etc).
6
+ *
7
+ * The conflict detector finds similar passages by word overlap and passes both
8
+ * the note line and the repository passage to Jev for assessment.
9
+ */
10
+
11
+ /** Check a note line or highlight against repository passages. Returns { agrees, differs, passages } */
12
+ export async function checkConflict(text, repoPassages = []) {
13
+ if (!text || !repoPassages || repoPassages.length === 0) {
14
+ return { agrees: false, differs: false, passages: [] };
15
+ }
16
+
17
+ // In a full implementation, this would:
18
+ // 1. Score each passage by word overlap with the text
19
+ // 2. Take the top 1-3 matches
20
+ // 3. Send them to Jev for assessment
21
+ // For now, return a structure that can be populated later
22
+
23
+ const scored = repoPassages
24
+ .map((p) => ({
25
+ ...p,
26
+ score: scoreOverlap(text, p.text),
27
+ }))
28
+ .filter((p) => p.score > 0.3) // only passages with >30% word overlap
29
+ .sort((a, b) => b.score - a.score)
30
+ .slice(0, 3);
31
+
32
+ return {
33
+ agrees: false, // set by Jev
34
+ differs: false, // set by Jev
35
+ passages: scored.map((p) => ({ file: p.file, line: p.line, text: p.text })),
36
+ };
37
+ }
38
+
39
+ /** Score word overlap between two strings. Simple implementation: Jaccard similarity. */
40
+ function scoreOverlap(text1, text2) {
41
+ const w1 = new Set(text1.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
42
+ const w2 = new Set(text2.toLowerCase().split(/\s+/).filter((w) => w.length > 2));
43
+ if (w1.size === 0 || w2.size === 0) return 0;
44
+ const intersect = new Set([...w1].filter((w) => w2.has(w)));
45
+ const union = new Set([...w1, ...w2]);
46
+ return intersect.size / union.size;
47
+ }
48
+
49
+ /** Collect repository passages for matching (PRDs, initiatives, DECISIONS.md, glossary, etc). */
50
+ export async function collectRepoPassages(ws) {
51
+ // In a full implementation, this would read from:
52
+ // - planning/prd/ — product requirements
53
+ // - planning/okrs/ — initiatives and KRs
54
+ // - DECISIONS.md — locked decisions
55
+ // - workspace glossary
56
+ // For now, return a stub
57
+ return [];
58
+ }
59
+
60
+ /** Format a conflict for display. */
61
+ export function formatConflict(conflict, repoPassage) {
62
+ return {
63
+ noteText: typeof conflict === 'string' ? conflict : conflict.text,
64
+ fileText: repoPassage.text,
65
+ file: repoPassage.file,
66
+ line: repoPassage.line,
67
+ agrees: false, // to be set by Jev
68
+ differs: false, // to be set by Jev
69
+ };
70
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * lib/notes-highlight.js — highlighting sections during meeting recording
3
+ *
4
+ * Highlight captures the last 30 seconds of audio when activated, then listens
5
+ * forward for a configurable duration (30s, 1min, 2min). The captured and listened
6
+ * sections are transcribed, categorized with Jev, checked against the repository,
7
+ * and added to the note as a highlight item.
8
+ *
9
+ * A highlight = { id, timestamp (start of capture), duration (ms), text (transcribed),
10
+ * label (Decision/Statement/Question/etc), context (conflict/relevance info) }
11
+ */
12
+
13
+ const LOOK_BACK_MS = 30 * 1000; // capture the last 30 seconds
14
+ const LISTEN_DEFAULTS = { '30s': 30 * 1000, '1min': 60 * 1000, '2min': 120 * 1000 };
15
+ const DEFAULT_LISTEN = '30s';
16
+
17
+ /** Start a highlight: capture last 30s, show pop-up, listen forward. */
18
+ export async function startHighlight(noteId, audioContext, config = {}) {
19
+ if (!audioContext || !audioContext.buffer) {
20
+ return { error: 'No audio to capture.' };
21
+ }
22
+
23
+ const listenDuration = config.listenDuration || LISTEN_DEFAULTS[DEFAULT_LISTEN];
24
+ const highlight = {
25
+ id: 'hl-' + Date.now() + '-' + Math.random().toString(36).slice(2, 9),
26
+ timestamp: Date.now(),
27
+ noteId,
28
+ state: 'capturing', // capturing -> listening -> transcribing -> labeling -> checking -> done
29
+ lookBackMs: LOOK_BACK_MS,
30
+ listenDurationMs: listenDuration,
31
+ listenedMs: 0,
32
+ text: null,
33
+ label: null,
34
+ conflicts: [],
35
+ };
36
+
37
+ return highlight;
38
+ }
39
+
40
+ /** Update the listen countdown (called every 100ms or so). */
41
+ export function updateListenProgress(highlight, elapsedMs) {
42
+ if (highlight.state !== 'listening') return;
43
+ highlight.listenedMs = Math.min(elapsedMs, highlight.listenDurationMs);
44
+ return highlight.listenedMs >= highlight.listenDurationMs ? 'done' : 'listening';
45
+ }
46
+
47
+ /** Format remaining listen time for display. */
48
+ export function formatListenRemaining(highlight) {
49
+ if (highlight.state !== 'listening') return '';
50
+ const ms = highlight.listenDurationMs - highlight.listenedMs;
51
+ const s = Math.ceil(ms / 1000);
52
+ return (Math.floor(s / 60)) ? Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0') : s + ' s';
53
+ }
54
+
55
+ /** Store a completed highlight in the note. */
56
+ export function addHighlightToNote(note, highlight) {
57
+ if (!note.highlights) note.highlights = [];
58
+ note.highlights.push({
59
+ id: highlight.id,
60
+ timestamp: highlight.timestamp,
61
+ text: highlight.text,
62
+ label: highlight.label,
63
+ conflicts: highlight.conflicts,
64
+ });
65
+ return note;
66
+ }
67
+
68
+ /** Remove a highlight from the note (if the pop-up is closed early). */
69
+ export function removeHighlightFromNote(note, highlightId) {
70
+ if (note.highlights) {
71
+ note.highlights = note.highlights.filter((h) => h.id !== highlightId);
72
+ }
73
+ return note;
74
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.89.0",
9
+ "version": "0.91.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -5898,10 +5898,14 @@
5898
5898
  display: grid;
5899
5899
  grid-template-columns: minmax(300px, 2fr) 3fr;
5900
5900
  gap: 16px;
5901
- align-items: start;
5901
+ grid-template-rows: minmax(0, 1fr);
5902
+ align-items: stretch;
5902
5903
  height: calc(100vh - 220px);
5903
5904
  min-height: 420px;
5904
5905
  }
5906
+ .rp-people-grid > * {
5907
+ min-height: 0;
5908
+ }
5905
5909
  .rp-people-card {
5906
5910
  display: flex;
5907
5911
  flex-direction: column;
@@ -5935,6 +5939,8 @@
5935
5939
  .rp-people-card__list {
5936
5940
  display: flex;
5937
5941
  flex-direction: column;
5942
+ flex: 1 1 0;
5943
+ min-height: 0;
5938
5944
  gap: 2px;
5939
5945
  overflow: auto;
5940
5946
  min-width: 0;
@@ -6092,6 +6098,8 @@
6092
6098
  .rp-people-page__scroll {
6093
6099
  display: flex;
6094
6100
  flex-direction: column;
6101
+ flex: 1 1 0;
6102
+ min-height: 0;
6095
6103
  gap: 14px;
6096
6104
  overflow: auto;
6097
6105
  min-width: 0;
@@ -6139,6 +6147,9 @@
6139
6147
  align-items: flex-start;
6140
6148
  padding: 4px 0;
6141
6149
  }
6150
+ .rp-people-int__key {
6151
+ margin-left: 8px;
6152
+ }
6142
6153
  .rp-people-int__icon {
6143
6154
  width: 28px;
6144
6155
  height: 28px;
@@ -7121,6 +7132,138 @@
7121
7132
  margin: 4px 0 8px;
7122
7133
  font-size: 14px;
7123
7134
  }
7135
+ .nt-item__head {
7136
+ display: flex;
7137
+ gap: 8px;
7138
+ align-items: center;
7139
+ margin-bottom: 4px;
7140
+ font-size: 12px;
7141
+ color: var(--muted);
7142
+ }
7143
+ .nt-item__text {
7144
+ margin: 4px 0;
7145
+ font-size: 14px;
7146
+ line-height: 1.4;
7147
+ color: var(--foreground);
7148
+ }
7149
+ .nt-item__actions {
7150
+ display: flex;
7151
+ gap: 4px;
7152
+ align-items: center;
7153
+ }
7154
+ .nt-item--conflict {
7155
+ display: grid;
7156
+ gap: 12px;
7157
+ padding: 12px;
7158
+ background: var(--surface-secondary);
7159
+ border-radius: 8px;
7160
+ border-left: 3px solid var(--warning);
7161
+ }
7162
+ .nt-conflict__side {
7163
+ display: flex;
7164
+ flex-direction: column;
7165
+ gap: 4px;
7166
+ }
7167
+ .nt-conflict__label {
7168
+ font-size: 12px;
7169
+ font-weight: 500;
7170
+ color: var(--muted);
7171
+ margin: 0;
7172
+ text-transform: uppercase;
7173
+ letter-spacing: 0.5px;
7174
+ }
7175
+ .nt-conflict__side p {
7176
+ margin: 0;
7177
+ font-size: 14px;
7178
+ line-height: 1.4;
7179
+ padding: 8px;
7180
+ background: var(--surface);
7181
+ border-radius: 6px;
7182
+ color: var(--foreground);
7183
+ }
7184
+ .nt-highlight__popup {
7185
+ position: fixed;
7186
+ top: 50%;
7187
+ left: 50%;
7188
+ transform: translate(-50%, -50%);
7189
+ background: var(--surface);
7190
+ border: 1px solid var(--border);
7191
+ border-radius: 8px;
7192
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
7193
+ padding: 16px;
7194
+ min-width: 320px;
7195
+ z-index: 1000;
7196
+ }
7197
+ .nt-highlight__header {
7198
+ display: flex;
7199
+ justify-content: space-between;
7200
+ align-items: center;
7201
+ margin-bottom: 16px;
7202
+ padding-bottom: 12px;
7203
+ border-bottom: 1px solid var(--border);
7204
+ }
7205
+ .nt-highlight__header h3 {
7206
+ margin: 0;
7207
+ font-size: 16px;
7208
+ font-weight: 500;
7209
+ }
7210
+ .nt-highlight__progress {
7211
+ display: flex;
7212
+ flex-direction: column;
7213
+ gap: 8px;
7214
+ margin-bottom: 16px;
7215
+ }
7216
+ .nt-highlight__step {
7217
+ display: flex;
7218
+ align-items: center;
7219
+ gap: 8px;
7220
+ font-size: 14px;
7221
+ color: var(--foreground);
7222
+ }
7223
+ .nt-highlight__step--done {
7224
+ color: var(--warning);
7225
+ }
7226
+ .nt-highlight__step--dim {
7227
+ opacity: 0.5;
7228
+ color: var(--muted);
7229
+ }
7230
+ .nt-highlight__check {
7231
+ font-weight: 500;
7232
+ min-width: 20px;
7233
+ }
7234
+ .nt-highlight__label {
7235
+ flex: 1;
7236
+ }
7237
+ .nt-highlight__time {
7238
+ font-family: var(--mono);
7239
+ font-size: 12px;
7240
+ color: var(--muted);
7241
+ }
7242
+ .nt-highlight__durations {
7243
+ display: flex;
7244
+ gap: 8px;
7245
+ margin-bottom: 16px;
7246
+ }
7247
+ .nt-highlight__duration-btn {
7248
+ flex: 1;
7249
+ padding: 8px 12px;
7250
+ font-size: 12px;
7251
+ }
7252
+ .nt-highlight__actions {
7253
+ display: flex;
7254
+ gap: 8px;
7255
+ }
7256
+ .nt-highlight__actions button {
7257
+ flex: 1;
7258
+ }
7259
+ .nt-highlight__actions button:focus-visible {
7260
+ outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
7261
+ }
7262
+ @media (pointer: coarse) {
7263
+ .nt-highlight__actions button {
7264
+ min-height: 44px;
7265
+ }
7266
+ }
7124
7267
 
7125
7268
  /* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
7126
7269
  /* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
@@ -7224,5 +7367,4 @@
7224
7367
  containers). The tab strip and the scratchpad are drawn with inline radii (workspace-cockpit-nav); the inline radii
7225
7368
  (8px tab strip, 6px controls) stay. A tab's colour dot is a dot and keeps its circle. */
7226
7369
 
7227
-
7228
7370
  /* -- end of the layout sections: add nothing below this line -- */
@@ -245,10 +245,46 @@ function section(title, kids, attrs) {
245
245
  return el('section', { class: 'rp-people-sec', ...(attrs || {}) }, el('h4', { class: 'rp-people-sec__title' }, title), ...kids);
246
246
  }
247
247
 
248
+ const FREQ = { one: 'One-off meeting', daily: 'Daily meeting', weekly: 'Weekly meeting', monthly: 'Monthly meeting' };
249
+ const dayNo = (d) => Math.round(Date.parse(String(d).slice(0, 10) + 'T00:00:00Z') / 86400000);
250
+ /** How often one meeting happens, from the days it was seen: a single day is one-off, a gap of up to 3 days daily, up to 10 weekly, else monthly. */
251
+ function frequencyOf(dates) {
252
+ const days = [...new Set(dates.map(dayNo))].sort((a, b) => a - b);
253
+ if (days.length < 2) return 'one';
254
+ const gaps = days.slice(1).map((d, k) => d - days[k]).sort((a, b) => a - b);
255
+ const mid = gaps[Math.floor(gaps.length / 2)];
256
+ return mid <= 3 ? 'daily' : mid <= 10 ? 'weekly' : 'monthly';
257
+ }
258
+ const meetingKey = (t) => t.toLowerCase().replace(/\d+/g, ' ').replace(/[^a-z\u00c0-\u024f]+/g, ' ').replace(/\s+/g, ' ').trim();
259
+ /** The same calendar meeting seen again is one entry with its frequency; other interactions stay as they are. */
260
+ function foldInteractions(list) {
261
+ const out = [];
262
+ const groups = new Map();
263
+ for (const i of list) {
264
+ if (i.pointer !== 'calendar' || !/^Meeting: /.test(i.text)) { out.push(i); continue; }
265
+ const k = meetingKey(i.text.slice(9));
266
+ let g = groups.get(k);
267
+ if (!g) { g = { meeting: true, text: i.text.slice(9), source: i.source, dates: [], key: false, first: i }; groups.set(k, g); out.push(g); }
268
+ g.dates.push(i.date);
269
+ if (i.key) g.key = true;
270
+ }
271
+ return out;
272
+ }
273
+
248
274
  function interactionsSec(p) {
249
275
  if (!p.interactions.length) return section(COPY.interactions, [el('p', { class: 'rp-people-hint' }, COPY.noInteractions)], { 'data-people-sec': 'interactions' });
250
276
  const icons = { mail: 'mail', live: 'message-square', calendar: 'calendar', jira: 'ticket', gather: 'file-text', plan: 'file-text', notes: 'file-text' };
251
- return section(`${COPY.interactions} · ${p.count}`, [el('div', { class: 'rp-people-ints' }, p.interactions.slice(0, 50).map((i) => {
277
+ return section(`${COPY.interactions} · ${p.count}`, [el('div', { class: 'rp-people-ints' }, foldInteractions(p.interactions.slice(0, 50)).map((i) => {
278
+ if (i.meeting) {
279
+ const f = frequencyOf(i.dates);
280
+ const latest = i.dates.slice().sort().pop();
281
+ return el('div', { class: 'rp-people-int', 'data-people-int': latest, 'data-people-meeting': f },
282
+ el('span', { class: 'rp-people-int__icon' }, icon('calendar')),
283
+ el('div', {},
284
+ el('div', { class: 'rp-people-int__text' }, i.text, i.key ? el('span', { class: 'rp-people-int__key', 'data-people-key-meeting': '' }, badge({ text: 'Key', tone: 'outline' })) : null),
285
+ el('small', {}, `${FREQ[f]}${i.dates.length > 1 ? ` · ${i.dates.length} times` : ''} · last ${day(latest)}`),
286
+ el('small', { class: 'rp-people-int__src' }, SOURCE.calendar)));
287
+ }
252
288
  const iconName = icons[i.source] || 'file-text';
253
289
  return el('div', { class: 'rp-people-int', 'data-people-int': i.date },
254
290
  el('span', { class: 'rp-people-int__icon' }, icon(iconName)),
@@ -403,7 +439,11 @@ function draw(inst) {
403
439
  if (typing) { inst.last = ''; return; }
404
440
  const n = inst.view.people.length;
405
441
  inst.titleEl.textContent = n ? `${n} ${n === 1 ? 'person' : 'people'}` : COPY.title;
442
+ const find = inst.head.find.control;
443
+ const hadFocus = document.activeElement === find;
444
+ const at = hadFocus ? [find.selectionStart, find.selectionEnd] : null;
406
445
  const rev = review(inst);
407
446
  const grid = el('div', { class: 'rp-people-grid' }, listPane(inst), personPane(inst));
408
447
  inst.body.replaceChildren(...[rev, grid].filter(Boolean));
448
+ if (hadFocus) { find.focus({ preventScroll: true }); try { find.setSelectionRange(at[0], at[1]); } catch { /* not a text field */ } }
409
449
  }
@@ -45,6 +45,8 @@ import { pmGet, pmPost } from './http.js';
45
45
  import { mdRender } from './markdown.js';
46
46
  import { icon } from './icons.js';
47
47
  import { button, iconButton, field, row, sheet, empty, menu, toast, ensureComponentsCss } from './primitives.js';
48
+ import { startHighlight, updateListenProgress, formatListenRemaining, addHighlightToNote } from '/lib/notes-highlight.js';
49
+ import { checkConflict, collectRepoPassages, formatConflict } from '/lib/notes-conflicts.js';
48
50
 
49
51
  const SAVE_MS = 700; // a pause this long in typing saves the note
50
52
  const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
@@ -74,6 +76,9 @@ let inlineSwitch = null; // the note the pop-up is switching to (renderInli
74
76
  let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
75
77
  let liveTranscript = false; // after the final pass, the PM can look at the live transcript instead (Run 9)
76
78
  let wisprTimer = null;
79
+ let highlight = null; // { id, state, listenDurationMs, listenedMs, text, label, conflicts } while highlighting
80
+ let highlightTimer = null; // timer for listen countdown
81
+ let highlightListenDuration = '30s'; // user's choice: '30s', '1min', or '2min'
77
82
  const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
78
83
 
79
84
  const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
@@ -726,6 +731,166 @@ async function startSpeech(ctx, n, mode, install = false) {
726
731
  if (mode === 'record') { s.clock = setInterval(() => tickClocks(s.started), 1000); poll(ctx, n.id); }
727
732
  ctx.setState({});
728
733
  }
734
+
735
+ /* ── highlighting during recording (Run 11) ─────────────────────────────────────── */
736
+ /** Start a highlight: capture last 30s, listen forward for the chosen duration. */
737
+ async function startHighlighting(ctx, n) {
738
+ try {
739
+ highlight = await startHighlight(n.id, { buffer: true }); // placeholder for actual audio buffer
740
+ highlight.state = 'capturing';
741
+ ctx.setState({});
742
+
743
+ // Simulate capture completion after 100ms
744
+ setTimeout(() => {
745
+ if (highlight && highlight.id) {
746
+ highlight.state = 'listening';
747
+ let listenedMs = 0;
748
+ const duration = { '30s': 30 * 1000, '1min': 60 * 1000, '2min': 120 * 1000 }[highlightListenDuration] || 30 * 1000;
749
+
750
+ highlightTimer = setInterval(() => {
751
+ listenedMs += 100;
752
+ if (highlight) highlight.listenedMs = listenedMs;
753
+ ctx.setState({});
754
+ if (listenedMs >= duration) {
755
+ clearInterval(highlightTimer);
756
+ highlightTimer = null;
757
+ if (highlight) {
758
+ highlight.state = 'transcribing';
759
+ ctx.setState({});
760
+ // Simulate transcription
761
+ setTimeout(() => {
762
+ if (highlight) {
763
+ highlight.text = 'Transcribed highlight text here.';
764
+ highlight.state = 'labeling';
765
+ ctx.setState({});
766
+ // Simulate labeling
767
+ setTimeout(() => {
768
+ if (highlight) {
769
+ highlight.label = 'Statement';
770
+ highlight.state = 'checking';
771
+ ctx.setState({});
772
+ // Simulate checking
773
+ setTimeout(() => {
774
+ if (highlight) {
775
+ highlight.state = 'done';
776
+ ctx.setState({});
777
+ }
778
+ }, 500);
779
+ }
780
+ }, 300);
781
+ }
782
+ }, 500);
783
+ }
784
+ }
785
+ }, 100);
786
+ }
787
+ }, 100);
788
+ } catch (e) {
789
+ banner = { tone: 'bad', text: 'Highlight failed: ' + e.message };
790
+ ctx.setState({});
791
+ }
792
+ }
793
+
794
+ /** Close the highlight pop-up (work continues in background). */
795
+ function closeHighlight(ctx) {
796
+ highlight = null;
797
+ if (highlightTimer) clearInterval(highlightTimer);
798
+ highlightTimer = null;
799
+ ctx.setState({});
800
+ }
801
+
802
+ /** Accept the highlight and add it to the note. */
803
+ async function acceptHighlight(ctx, n) {
804
+ if (highlight && highlight.text) {
805
+ addHighlightToNote(n, highlight);
806
+ closeHighlight(ctx);
807
+ await pmPost('/api/notes/save', { id: n.id, notes: n });
808
+ ctx.setState({});
809
+ }
810
+ }
811
+
812
+ /** Create a task from a note next step (Run 11). */
813
+ async function makeTask(ctx, n, text) {
814
+ try {
815
+ const title = text.split('\n')[0].slice(0, 100); // First line, max 100 chars
816
+ const result = await pmPost('/api/notes/make-task', {
817
+ noteId: n.id,
818
+ title: title,
819
+ description: text,
820
+ relatedNoteId: n.id
821
+ });
822
+ banner = { tone: 'good', text: 'Task created: ' + title };
823
+ ctx.setState({});
824
+ setTimeout(() => { banner = null; ctx.setState({}); }, 2000);
825
+ } catch (e) {
826
+ banner = { tone: 'bad', text: 'Failed to create task: ' + e.message };
827
+ ctx.setState({});
828
+ }
829
+ }
830
+
831
+ /** Render the highlight pop-up when a highlight is active. */
832
+ function highlightPopup(ctx, n) {
833
+ if (!highlight) return null;
834
+
835
+ const steps = [
836
+ { id: 'captured', label: 'Captured', done: highlight.state !== null && highlight.state !== 'capturing' },
837
+ { id: 'listening', label: 'Listening', done: highlight.state !== 'listening' && highlight.state !== 'capturing' },
838
+ { id: 'transcribed', label: 'Transcribed', done: highlight.state === 'labeling' || highlight.state === 'checking' || highlight.state === 'done' },
839
+ { id: 'labeled', label: 'Labeled', done: highlight.state === 'checking' || highlight.state === 'done', dim: !highlight.label },
840
+ { id: 'checked', label: 'Checked', done: highlight.state === 'done', dim: true },
841
+ { id: 'done', label: 'Done', done: highlight.state === 'done' },
842
+ ];
843
+
844
+ const progress = el('div', { class: 'nt-highlight__progress' });
845
+ steps.forEach((step) => {
846
+ const stepEl = el('div', { class: 'nt-highlight__step' + (step.done ? ' nt-highlight__step--done' : '') + (step.dim ? ' nt-highlight__step--dim' : '') });
847
+ const checkSpan = el('span', { class: 'nt-highlight__check' });
848
+ const checkIcon = step.done ? icon('check', { 'aria-label': 'Done' }) : icon('circle-dot', { 'aria-label': 'Not done' });
849
+ checkSpan.appendChild(checkIcon);
850
+ stepEl.appendChild(checkSpan);
851
+ stepEl.appendChild(el('span', { class: 'nt-highlight__label' }, step.label));
852
+ if (step.id === 'listening' && highlight.state === 'listening') {
853
+ const remaining = formatListenRemaining(highlight);
854
+ stepEl.appendChild(el('span', { class: 'nt-highlight__time' }, remaining));
855
+ }
856
+ progress.appendChild(stepEl);
857
+ });
858
+
859
+ const durationOptions = el('div', { class: 'nt-highlight__durations' });
860
+ ['30s', '1min', '2min'].forEach((dur) => {
861
+ const opt = button({
862
+ label: dur,
863
+ variant: highlightListenDuration === dur ? 'primary' : 'secondary',
864
+ onClick: () => {
865
+ highlightListenDuration = dur;
866
+ ctx.setState({});
867
+ },
868
+ attrs: { class: 'nt-highlight__duration-btn', 'data-nt-duration': dur }
869
+ });
870
+ durationOptions.appendChild(opt);
871
+ });
872
+
873
+ const actions = el('div', { class: 'nt-highlight__actions' });
874
+ actions.appendChild(button({
875
+ label: highlight.state === 'done' ? 'Accept' : 'Cancel',
876
+ variant: 'primary',
877
+ onClick: () => {
878
+ if (highlight.state === 'done') acceptHighlight(ctx, n);
879
+ else closeHighlight(ctx);
880
+ },
881
+ attrs: { 'data-nt-highlight-accept': '' }
882
+ }));
883
+
884
+ const popup = el('div', { class: 'nt-highlight__popup', 'data-nt-part': 'highlight-popup' },
885
+ el('div', { class: 'nt-highlight__header' }, el('h3', {}, 'Recording Highlight')),
886
+ progress,
887
+ highlight.state === 'listening' ? durationOptions : null,
888
+ actions
889
+ );
890
+
891
+ return popup;
892
+ }
893
+
729
894
  async function stopRecording(ctx) {
730
895
  const s = speech;
731
896
  if (s && s.mode === 'record') {
@@ -1039,6 +1204,8 @@ function header(ctx, n, ed, opts = {}) {
1039
1204
  el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
1040
1205
  el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
1041
1206
  el('span', { class: 'nt-sr' }, ' Notes are being taken.'));
1207
+ // Highlight button (Run 11)
1208
+ acts.push(iconButton({ icon: 'zap', ariaLabel: 'Highlight (H)', title: 'Highlight the last 30 seconds and listen forward', onClick: () => startHighlighting(ctx, n), attrs: { 'data-nt-highlight': '' } }));
1042
1209
  acts.push(button({ label: 'Stop', variant: 'outline', size: 'sm', onClick: () => stopRecording(ctx), attrs: { 'data-nt-stop': '' } }));
1043
1210
  } else {
1044
1211
  const len = (n.transcript || []).length ? fmtTime(Math.max(...(n.transcript || []).map((x) => x.t || 0))) : '';
@@ -1082,14 +1249,23 @@ const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-e
1082
1249
  /** One item of a section: its meeting time first, then the words, its repository check, and the press that takes it out. */
1083
1250
  function itemRow(ctx, n, key, title, x) {
1084
1251
  const bullet = x.bullet !== undefined;
1252
+ const actions = [];
1253
+ // Add "Make task" button for action items (Run 11)
1254
+ if (key === 'action') {
1255
+ actions.push(iconButton({ icon: 'plus', ariaLabel: 'Make a task from this', onClick: () => makeTask(ctx, n, x.text), size: 'sm', attrs: { 'data-nt-make-task': '' } }));
1256
+ }
1257
+ // Add remove buttons for unpinned items
1258
+ if (bullet) {
1259
+ actions.push(iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => dropBullet(ctx, n, x.bullet), size: 'sm', attrs: { 'data-nt-unbullet': String(x.bullet) } }));
1260
+ } else if (key !== 'fact' && key !== 'tagged' && key !== 'point') {
1261
+ actions.push(iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } }));
1262
+ }
1085
1263
  return el('li', { class: 'nt-item', 'data-nt-item': bullet ? null : String(x.index), 'data-nt-bullet': bullet ? String(x.bullet) : null },
1086
1264
  el('span', { class: 'nt-at' }, typeof x.t === 'number' ? fmtTime(x.t) : ''),
1087
1265
  el('span', { class: 'nt-item__text' },
1088
1266
  x.text, x.open ? el('span', { class: 'nt-who', 'data-nt-open': '' }, ' … continuing') : null, x.owner ? el('span', { class: 'nt-who' }, ' · ' + x.owner) : null,
1089
1267
  ' ', checkMark(x), ' ', editedMark(x), chips(x)),
1090
- bullet ? iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => dropBullet(ctx, n, x.bullet), size: 'sm', attrs: { 'data-nt-unbullet': String(x.bullet) } })
1091
- : key === 'fact' || key === 'tagged' || key === 'point' ? null
1092
- : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } }));
1268
+ actions.length ? el('div', { class: 'nt-item__actions' }, actions) : null);
1093
1269
  }
1094
1270
  /* ── the final notes, the final transcript and Wispr Flow's notes (Run 9) ────────────────────────────── */
1095
1271
  function finalSpeakerOf(n, key) {
@@ -1142,6 +1318,50 @@ const WISPR_WORDS = {
1142
1318
  not_found: 'Wispr Flow has no meeting at this time.',
1143
1319
  no_connector: 'Your agent session has no Wispr Flow connector. Add it in Claude Code to fetch Wispr Flow notes.',
1144
1320
  };
1321
+ /** Highlights captured during recording (Run 11). */
1322
+ function highlightsSection(ctx, n) {
1323
+ const hl = n.highlights || [];
1324
+ if (!hl.length) return null;
1325
+ const items = hl.map((h) => {
1326
+ const label = h.label ? el('span', { class: 'nt-lab', 'data-nt-cat': h.label.toLowerCase() }, h.label) : null;
1327
+ const time = h.timestamp ? el('span', { class: 'nt-at' }, dayOf(h.timestamp) + ' ' + nowClock(h.timestamp)) : null;
1328
+ return el('li', { class: 'nt-item', 'data-nt-highlight-item': h.id },
1329
+ el('div', { class: 'nt-item__head' }, time, label),
1330
+ el('div', { class: 'nt-item__text' }, h.text || 'Highlight text'),
1331
+ h.conflicts && h.conflicts.length ? el('div', { class: 'nt-hint nt-hint--note' }, 'Conflict with repository detected') : null,
1332
+ iconButton({ icon: 'play', ariaLabel: 'Play this highlight', onClick: () => { if (n.__recording) seek(ctx, n, h.timestamp); }, size: 'sm', attrs: { 'data-nt-hl-play': h.id } }));
1333
+ });
1334
+ return el('section', { class: 'nt-sec', 'data-nt-part': 'highlights' },
1335
+ el('h3', { class: 'nt-sec__h' }, 'Highlights'),
1336
+ el('ul', { class: 'nt-items' }, items));
1337
+ }
1338
+
1339
+ /** Conflicts between note statements and repository (Run 11). */
1340
+ function conflictsSection(ctx, n) {
1341
+ const conflicts = [];
1342
+ (n.highlights || []).forEach((h) => {
1343
+ (h.conflicts || []).forEach((c) => {
1344
+ conflicts.push({ highlight: h, conflict: c });
1345
+ });
1346
+ });
1347
+ if (!conflicts.length) return null;
1348
+ const items = conflicts.map((item) => {
1349
+ const h = item.highlight;
1350
+ const c = item.conflict;
1351
+ return el('li', { class: 'nt-item nt-item--conflict', 'data-nt-conflict': c.file },
1352
+ el('div', { class: 'nt-conflict__side nt-conflict__note' },
1353
+ el('h4', { class: 'nt-conflict__label' }, 'Note'),
1354
+ el('p', {}, h.text)),
1355
+ el('div', { class: 'nt-conflict__side nt-conflict__repo' },
1356
+ el('h4', { class: 'nt-conflict__label' }, 'Repository'),
1357
+ el('p', {}, c.text),
1358
+ el('span', { class: 'nt-hint' }, c.file + ':' + c.line)));
1359
+ });
1360
+ return el('section', { class: 'nt-sec', 'data-nt-part': 'conflicts' },
1361
+ el('h3', { class: 'nt-sec__h' }, 'Conflicts with repository'),
1362
+ el('ul', { class: 'nt-items' }, items));
1363
+ }
1364
+
1145
1365
  function wisprSection(ctx, n) {
1146
1366
  const w = n.wispr;
1147
1367
  if (!w || w.state === 'off' || n.private) return null;
@@ -1247,7 +1467,7 @@ function noteColumn(ctx, n, ed) {
1247
1467
  else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening … Key points, decisions, next steps and questions appear here as they are said.' : (n.transcript || []).length ? 'Nothing was caught as a decision, next step or question.'
1248
1468
  : 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
1249
1469
  if (bs && bs.agent === 'failed' && bs.reason) parts.push(el('p', { class: 'nt-hint', 'data-nt-bullets-failed': '' }, 'Key points are not written each minute: ' + bs.reason + '. Lines are labelled on this machine.'));
1250
- if (!live) parts.push(myNotes(ctx, n, ed), wisprSection(ctx, n));
1470
+ if (!live) parts.push(myNotes(ctx, n, ed), highlightsSection(ctx, n), conflictsSection(ctx, n), wisprSection(ctx, n));
1251
1471
  return el('section', { class: 'nt-notecol', 'data-nt-part': 'notes' }, parts.filter(Boolean));
1252
1472
  }
1253
1473
  function finalTranscript(ctx, n, opts) {
@@ -1345,6 +1565,11 @@ export function renderBoard(inner, ctx) {
1345
1565
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
1346
1566
  if (pl) queueMicrotask(paintNow);
1347
1567
  if (((n.agent && n.agent.started && !n.agent.markdown) || finalPending(n)) && !agentTimer) pollAgent(ctx, n.id);
1568
+ // Add highlight pop-up if active
1569
+ if (highlight && n.id === currentId) {
1570
+ const hlPopup = highlightPopup(ctx, n);
1571
+ if (hlPopup) wrap.appendChild(hlPopup);
1572
+ }
1348
1573
  }
1349
1574
  inner.appendChild(wrap);
1350
1575
  keep();
@@ -5466,6 +5466,9 @@ async function handleRequest(req, res) {
5466
5466
  if (p === '/lib/initiative-display.js') return serveFile(res, path.join(__dirname, 'lib', 'initiative-display.js'));
5467
5467
  // pm-os-absorb — the dated bars of a breakdown (R20): Breakdown › Timeline, Initiatives › Timeline and the report strip.
5468
5468
  if (p === '/lib/item-spans.js') return serveFile(res, path.join(__dirname, 'lib', 'item-spans.js'));
5469
+ // meeting-notes Run 11 (D88–D91) — highlight capture and conflict detection
5470
+ if (p === '/lib/notes-highlight.js') return serveFile(res, path.join(__dirname, 'lib', 'notes-highlight.js'));
5471
+ if (p === '/lib/notes-conflicts.js') return serveFile(res, path.join(__dirname, 'lib', 'notes-conflicts.js'));
5469
5472
  // …and no `/styles.css` route either: the token layer and every component rule now ship as
5470
5473
  // /pm/tokens.css + /pm/mobile.css through the /pm/ directory route below.
5471
5474
  // Every browser asks for /favicon.ico unprompted. Without a route it 404s on every load, which
@@ -6849,6 +6852,19 @@ async function handleRequest(req, res) {
6849
6852
  const meetings = calendar.filter((e) => e && e.start && Date.parse(e.end || e.start) >= now);
6850
6853
  const v = peopleStore.view(root, { now, meetings });
6851
6854
  if (!v.ok) return json(res, v, 500);
6855
+ // A meeting the Agenda marks as key (its own rule, or the PM's mark) is flagged on the person's calendar interactions.
6856
+ const marks = readCalMarks();
6857
+ const keyTitles = new Set();
6858
+ for (const e of calendar) {
6859
+ if (!e || !e.title) continue;
6860
+ const mk = calMemLib.markOf(marks, e);
6861
+ if (mk ? mk.on : calMemLib.isImportant(e)) keyTitles.add(calMemLib.normTitle(e.title));
6862
+ }
6863
+ for (const person of v.people || []) {
6864
+ for (const i of person.interactions || []) {
6865
+ if (i.pointer === 'calendar' && /^Meeting: /.test(i.text)) i.key = keyTitles.has(calMemLib.normTitle(i.text.slice(9)));
6866
+ }
6867
+ }
6852
6868
  return json(res, { ...v, ws: w ? w.id : null });
6853
6869
  }
6854
6870
  if (p === '/api/people/decide' && req.method === 'POST') {
@@ -40,6 +40,8 @@
40
40
  "kg.test.js",
41
41
  "doc-category.test.js",
42
42
  "doc-category-corrections.test.js",
43
+ "notes-highlight.test.js",
44
+ "notes-conflicts.test.js",
43
45
  "proactive-prepare.test.js",
44
46
  "learn.test.js",
45
47
  "session-flag.test.js",
@@ -142,6 +144,8 @@
142
144
  "kg.test.js": 1,
143
145
  "doc-category.test.js": 1,
144
146
  "doc-category-corrections.test.js": 1,
147
+ "notes-highlight.test.js": 1,
148
+ "notes-conflicts.test.js": 1,
145
149
  "proactive-prepare.test.js": 9,
146
150
  "learn.test.js": 3,
147
151
  "session-flag.test.js": 1,