create-pmos 0.511.0 → 0.512.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 +14 -16
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/changelog.json +13 -0
- package/workspace/fixtures/next-prompt-replay.json +40 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/next-prompt.md +62 -0
- package/workspace/guide/settings.md +3 -1
- package/workspace/lib/next-prompt.js +199 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-next-prompt.js +248 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-ev.css +2 -0
- package/workspace/public/pm/cozy-home.css +45 -1
- package/workspace/public/pm/cozy-settings.js +37 -2
- package/workspace/public/pm/home-chat.js +21 -4
- package/workspace/public/pm/next-prompt-ui.js +117 -0
- package/workspace/test-lists/next-prompt.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.512.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": {
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
"audit"
|
|
33
33
|
],
|
|
34
34
|
"pmosChanges": [
|
|
35
|
+
{
|
|
36
|
+
"v": "0.512.0",
|
|
37
|
+
"at": "2026-11-03T15:33:10.629Z",
|
|
38
|
+
"f": 4,
|
|
39
|
+
"prs": [
|
|
40
|
+
{
|
|
41
|
+
"n": 1204,
|
|
42
|
+
"t": "Suggest the next prompt and sharpen a rough one in the chat",
|
|
43
|
+
"s": "Suggest the next prompt and sharpen a rough one in the chat",
|
|
44
|
+
"k": "new"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
35
48
|
{
|
|
36
49
|
"v": "0.511.0",
|
|
37
50
|
"at": "2026-11-03T15:10:29.930Z",
|
|
@@ -551,21 +564,6 @@
|
|
|
551
564
|
"d": "The roadmap split work into Now, Next and Later. Now it is one ranked list, and each initiative's stage says where it is."
|
|
552
565
|
}
|
|
553
566
|
]
|
|
554
|
-
},
|
|
555
|
-
{
|
|
556
|
-
"v": "0.492.0",
|
|
557
|
-
"at": "2026-11-02T10:50:33.055Z",
|
|
558
|
-
"f": 4,
|
|
559
|
-
"prs": [
|
|
560
|
-
{
|
|
561
|
-
"n": 1155,
|
|
562
|
-
"t": "A new user's empty PMOS folder no longer skips the setup conversation",
|
|
563
|
-
"s": "A new user's empty PMOS folder no longer skips the setup conversation",
|
|
564
|
-
"k": "fix",
|
|
565
|
-
"h": "New users get the setup conversation",
|
|
566
|
-
"d": "A new user with an empty PMOS folder landed on a bare Home. Now the setup conversation starts as it should."
|
|
567
|
-
}
|
|
568
|
-
]
|
|
569
567
|
}
|
|
570
568
|
]
|
|
571
569
|
}
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-11-03T15:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T15:33:07Z"}
|
|
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-11-03T15:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T15:33:07Z"}
|
|
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-11-03T15:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T15:33:07Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "0.512.0",
|
|
4
|
+
"at": "2026-11-03T15:33:10.629Z",
|
|
5
|
+
"f": 4,
|
|
6
|
+
"prs": [
|
|
7
|
+
{
|
|
8
|
+
"n": 1204,
|
|
9
|
+
"t": "Suggest the next prompt and sharpen a rough one in the chat",
|
|
10
|
+
"s": "Suggest the next prompt and sharpen a rough one in the chat",
|
|
11
|
+
"k": "new"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"v": "0.511.0",
|
|
4
17
|
"at": "2026-11-03T15:10:29.930Z",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"about": "Recorded answers for the next-prompt and sharpen-prompt replay gates (lib/routes-next-prompt.js). Each answer was given by claude-sonnet-5-5 on a real reply from the PMOS project threads on 2026-11-03, except the cases named as made up, which test a drop. The replay never calls a model.",
|
|
3
|
+
"next": [
|
|
4
|
+
{ "name": "a reply that asks a question gets the answer as a follow card, citing the line it answers",
|
|
5
|
+
"reply": "#1198 (no initiative without an expected outcome) is merged, and kit-publish is publishing it.\nAll five approved features are now merged except the hindsight engine, which stays unbuilt because there are only 1 of the 10 needed prediction-and-outcome pairs. Do you want to swap it for another idea, such as a period-end KR debrief, or stay at four?",
|
|
6
|
+
"answer": { "cards": [{ "move": "follow", "text": "Yes, swap the hindsight engine for the period-end KR debrief.", "from": "Do you want to swap it for another idea, such as a period-end KR debrief, or stay at four?" }] },
|
|
7
|
+
"expect": { "filed": 1 } },
|
|
8
|
+
{ "name": "a retired thing in an initiative chat gets an ask-the-past card, citing the verb that answers it",
|
|
9
|
+
"place": { "initiative": "Fix OOH Digital missing from Board" },
|
|
10
|
+
"reply": "Found the cause: the Board hid initiatives on the Later horizon. Retiring Now/Next/Later everywhere is a bigger change, so I asked for a separate thread for it.",
|
|
11
|
+
"answer": { "cards": [{ "move": "past", "text": "Did we ever decide to retire the Now/Next/Later stage? Answer from the records with file:line sources, or say No record found." }] },
|
|
12
|
+
"expect": { "filed": 1, "cites": ["lib/pmos-verbs.js"] } },
|
|
13
|
+
{ "name": "a reply that needs nothing gets no card",
|
|
14
|
+
"reply": "First I'll find why OOH Digital is ticked in the product filter but has no rows on the Board.",
|
|
15
|
+
"answer": { "cards": [] },
|
|
16
|
+
"expect": { "none": true } },
|
|
17
|
+
{ "name": "made up: a move PMOS does not have is dropped",
|
|
18
|
+
"reply": "PR #1171 is merged.",
|
|
19
|
+
"answer": { "cards": [{ "move": "deploy", "text": "Deploy PR #1171 to production now." }] },
|
|
20
|
+
"expect": { "none": true } },
|
|
21
|
+
{ "name": "made up: a follow card that quotes a line the reply never said is dropped",
|
|
22
|
+
"reply": "PR #1171 is merged. Tell me if any task still sits under \"Not in a stage\".",
|
|
23
|
+
"answer": { "cards": [{ "move": "follow", "text": "Yes, merge it.", "from": "Should I merge PR #1171 now or wait?" }] },
|
|
24
|
+
"expect": { "none": true } },
|
|
25
|
+
{ "name": "made up: ask the past outside an initiative chat is dropped",
|
|
26
|
+
"reply": "Retiring Now/Next/Later everywhere is a bigger change.",
|
|
27
|
+
"answer": { "cards": [{ "move": "past", "text": "Why did we retire Now/Next/Later?" }] },
|
|
28
|
+
"expect": { "none": true } }
|
|
29
|
+
],
|
|
30
|
+
"sharpen": [
|
|
31
|
+
{ "name": "a rough date move becomes a rehearsal, citing what the PM typed",
|
|
32
|
+
"text": "move the legal review 3 days later",
|
|
33
|
+
"answer": { "text": "Rehearse moving the legal review task in Ooh-digital 3 days later: show what it pushes back and which dates or tasks shift, then wait for my yes before changing anything." },
|
|
34
|
+
"expect": { "filed": 1 } },
|
|
35
|
+
{ "name": "a sharper prompt that says the same is not shown",
|
|
36
|
+
"text": "Hotfix, why OOH digital product isn't shown here",
|
|
37
|
+
"answer": { "text": "Hotfix: why isn't the OOH digital product shown here?" },
|
|
38
|
+
"expect": { "none": true } }
|
|
39
|
+
]
|
|
40
|
+
}
|
package/workspace/guide/index.md
CHANGED
|
@@ -81,6 +81,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
81
81
|
- [Initiatives › Timeline](initiatives-timeline.md): when each initiative runs, one row per initiative, and how to reorder rows by their grip.
|
|
82
82
|
- [Workflows](workflows.md): Settings › Workflows, the stages each kind of initiative goes through, New workflow in the chat, and `pmos workflow`.
|
|
83
83
|
- [Expected outcomes](outcomes.md): every initiative says what should change once it ships and when to check it. Claude drafts each one from the PRD or the KR, you keep it with ✓, and `pmos outcomes`.
|
|
84
|
+
- [The next prompt, suggested and sharpened](next-prompt.md): after a reply, up to two prompts to send next; while you type a rough prompt, a sharper version in your style (Polish, Prompt engineer, Your own).
|
|
84
85
|
- [Rehearse a plan](rehearse.md): before you approve a date change or a cut, Claude shows what it breaks or helps in other initiatives, goals and program milestones, with the record each effect comes from, and `pmos rehearse`.
|
|
85
86
|
- [Forecasts](forecast.md): how a goal, a delivery, a program and a health budget are forecast, the states, the kept baseline, and `pmos forecast`.
|
|
86
87
|
- [Programs on the Board](programs.md): All products, programs first then products, By product, the program panel and its milestones, and the 60 % side panels.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "The next prompt, suggested and sharpened"
|
|
4
|
+
description: "After a reply, the chat suggests what to say next. While you type a rough prompt, Claude offers a sharper version. What each card means, the styles, the settings, and the guardrails."
|
|
5
|
+
timestamp: 2026-11-03
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The next prompt, suggested and sharpened
|
|
9
|
+
|
|
10
|
+
You do not have to remember phrases such as "grill this". The chat suggests them when they fit.
|
|
11
|
+
|
|
12
|
+
## After a reply
|
|
13
|
+
|
|
14
|
+
When Claude finishes a reply, up to two cards can show above the box. Usually there is one.
|
|
15
|
+
|
|
16
|
+
- The card Claude recommends is white, with a blue ring and a blue ✦. Another card is grey.
|
|
17
|
+
- Tap a card to put its text in the box. Nothing is sent. Change the text if you want to, then send it with ↑.
|
|
18
|
+
- The cards go away when you type, or when Claude starts a new reply. They do not time out.
|
|
19
|
+
- When there is no clear next step, no card shows.
|
|
20
|
+
|
|
21
|
+
A card suggests only something that works in PMOS today:
|
|
22
|
+
|
|
23
|
+
- an answer to the question or the offer at the end of the reply;
|
|
24
|
+
- "Grill this …" in an initiative's chat ([Claude proposes](claude-proposes.md));
|
|
25
|
+
- "Why did we …?" in an initiative's chat ([Ask about the past](ask-the-past.md));
|
|
26
|
+
- "Rehearse moving …" before a date change or a cut ([Rehearse a plan](rehearse.md));
|
|
27
|
+
- "Run the drift replay" when the weekly check waits for it ([What you might have missed](what-you-missed.md)).
|
|
28
|
+
|
|
29
|
+
Each card names only the initiatives, tasks and PRs that the reply or your message names.
|
|
30
|
+
|
|
31
|
+
## While you type
|
|
32
|
+
|
|
33
|
+
When you stop typing for about 1.5 seconds on a rough prompt of three or more words, a small spinner card shows while Claude sharpens it. Then one lit **Sharper** card shows. Tap it to put the sharper text in the box. ↶ in the box puts back what you wrote. When the sharper version says the same as yours, no card shows.
|
|
34
|
+
|
|
35
|
+
The sliders icon on the Sharper card sets how Claude sharpens:
|
|
36
|
+
|
|
37
|
+
- **Polish**: clearer and shorter. Your meaning is kept.
|
|
38
|
+
- **Prompt engineer** (the default): the best prompt for the job. It names the real initiative or task and says what a good answer holds. A date move or a cut becomes a rehearsal that waits for your yes.
|
|
39
|
+
- **Your own**: your own wording rules. You write them once in Settings.
|
|
40
|
+
|
|
41
|
+
The same menu has the **Sharpen as I type** switch. Off means no spinner and no Sharper card.
|
|
42
|
+
|
|
43
|
+
## Settings › Claude › Sharpen prompts
|
|
44
|
+
|
|
45
|
+
| Row | What it does |
|
|
46
|
+
|---|---|
|
|
47
|
+
| Suggest the next prompt | On by default. Off: no cards after a reply. |
|
|
48
|
+
| Sharpen as I type | On when Claude's runs use your API key (Settings › Claude › API key), because a sharper version then shows in about 2 seconds. Without a key, each one is a background Claude run that takes 10 seconds or more, so it starts off. You can turn it on anyway. |
|
|
49
|
+
| Sharpen as | Polish, Prompt engineer or Your own. |
|
|
50
|
+
| Your own rules | The rules for Your own, up to 1000 characters. |
|
|
51
|
+
|
|
52
|
+
## The guardrails
|
|
53
|
+
|
|
54
|
+
The suggestions and the sharper prompt go through the same guardrail as every AI job in PMOS:
|
|
55
|
+
|
|
56
|
+
- **Propose only.** A card is text you can put in the box. Nothing is sent, and nothing is written to a record, a session or a repository.
|
|
57
|
+
- **Proven only.** Each suggested move points to the line of PMOS code that makes it work, and PMOS checks that line before the card shows. An answer to the reply points to the line of the reply it answers. A card that PMOS cannot check is not shown.
|
|
58
|
+
- **Replay-tested.** A new version shows nothing until its recorded cases pass, and those include cases that must be dropped.
|
|
59
|
+
- **Audited.** Each run writes a line to the AI audit, with no text of your conversation in it.
|
|
60
|
+
- **Off switch.** `pmos ai off next-prompt` stops the suggestions after a reply. `pmos ai off sharpen-prompt` stops the Sharper card. `pmos ai off` stops every AI job.
|
|
61
|
+
|
|
62
|
+
The reply and the words a card refers to are kept in your PMOS state folder (`next-prompt/`, the newest 30 files), outside every repository. Your choices for these rows are kept there too, in `next-prompt/prefs.json`.
|
|
@@ -35,7 +35,7 @@ Settings is not a tab. While it is open, no tab is selected. Press a tab to go b
|
|
|
35
35
|
| Claude sessions | What a new session starts with: the CLI, the model, the Claude profile and the launch flags for sessions you start, and the flags for automatic sessions. The session limits. Validate consent for this workspace. |
|
|
36
36
|
| Privacy and data | A list of what leaves this computer and when. Whether a TypeSafe key is saved, the local model that answers when there is no key (Off, Winnow or Laya), and the data folder. |
|
|
37
37
|
| Appearance | The theme (Same as system, Light or Dark) and the page that opens first. |
|
|
38
|
-
| Claude | The Claude profile that new Claude sessions start with,
|
|
38
|
+
| Claude | The Claude profile that new Claude sessions start with, the Anthropic API key for Claude's own runs on the API, and Sharpen prompts (see below). |
|
|
39
39
|
| Claude's checks | When Claude grills an initiative by itself (Full, Only at stage exits, or Off) and the monthly credit for its checks ($5, $10, $20 or $50), with what this month used. See below. |
|
|
40
40
|
| Updates | The version of the app and of each workspace's kit, the check for updates, and what changed in each week's kits (what's new). See below. |
|
|
41
41
|
|
|
@@ -85,6 +85,8 @@ Under the update card, **Updates** shows what changed in the published kits. **T
|
|
|
85
85
|
|
|
86
86
|
**API key.** Some runs of Claude, for example Grill, can use the Claude API and not a Claude session. Paste an Anthropic API key and press **Save** to turn this on. The key stays on this computer, in a file only you can read, outside every repository. PMOS never shows the key again: the row only says that a key is set. Press **Remove** to delete it. A key saved here is used before `ANTHROPIC_API_KEY`. A command that a run starts does not get the key. Only the computer that runs the workspace can change it. Set `PMOS_GRILL_API=off` to stop runs on the API.
|
|
87
87
|
|
|
88
|
+
**Sharpen prompts.** Suggest the next prompt (on by default) shows up to two prompts above the chat box after a reply. Sharpen as I type offers a sharper version of a rough prompt. It is on when an API key is set, and off without one, because a background run is too slow to follow your typing. Sharpen as picks Polish, Prompt engineer (the default) or Your own, and Your own rules holds the rules for Your own. The same style and switch are in the menu on the chat's Sharper card. See [The next prompt, suggested and sharpened](next-prompt.md).
|
|
89
|
+
|
|
88
90
|
## Claude's checks: when Claude grills, and what it may spend
|
|
89
91
|
|
|
90
92
|
Claude grills an initiative by itself and files what it finds under the initiative's ✦ (see the Claude proposes guide). There is no button to start it. This section is the only control.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* next-prompt.js — Claude suggests the PM's next prompt, already sharpened (batch 26 item 2, design B on the main canvas: NpB, NpBw).
|
|
3
|
+
*
|
|
4
|
+
* Two jobs, both short Claude calls whose answer is a suggestion and never an act:
|
|
5
|
+
* next after a reply ends: up to two prompts the PM could send next, the first one recommended. A tap puts the text in the box; nothing sends.
|
|
6
|
+
* sharpen while the PM types a rough prompt: one sharper version in the PM's chosen style (Polish, Prompt engineer, Your own).
|
|
7
|
+
*
|
|
8
|
+
* PROVEN ONLY. A `next` card is one of MOVES, and a move is offered only while the code that makes it work is on disk: each move names the file
|
|
9
|
+
* and the text that prove it (lib/pmos-verbs.js for rehearse, ask the past and the drift replay; lib/routes-briefing-premium.js for "grill
|
|
10
|
+
* this"). movesOf(root) finds that line, and the card cites it, so lib/ai-guard.js check() verifies the quote at its line before the card shows.
|
|
11
|
+
* A `follow` card answers the question or the offer the reply ends with; it cites the line of the reply it answers (state:next-prompt/…).
|
|
12
|
+
* A sharpened prompt cites the PM's own words. A card with no verified source is dropped and never shown.
|
|
13
|
+
*
|
|
14
|
+
* MOVES · STYLES · JOB_NEXT · JOB_SHARPEN
|
|
15
|
+
* movesOf(root) → [{ id, where, use, ref, quote }] the moves whose proving line is on disk
|
|
16
|
+
* nextPrompt({ pm, reply, place, style, rules, moves }) → string the prompt for the `next` call
|
|
17
|
+
* sharpenPrompt({ text, reply, place, style, rules }) → string the prompt for the `sharpen` call
|
|
18
|
+
* parseNext(text, { moves, place }) → [{ move, text, from? }] the cards the answer holds, unknown moves and bad shapes left out (≤ 2)
|
|
19
|
+
* parseSharpen(text, rough) → string | null the sharper prompt, or null (no answer, or it says the same)
|
|
20
|
+
* same(a, b) → bool two prompts that say the same thing (≥ 90% the same words)
|
|
21
|
+
* proposalsOfNext(cards, { moves, replyRef }) → [ai-guard proposal] replyRef(from) → { ref, quote } | null
|
|
22
|
+
* proposalOfSharpen(text, roughRef) → ai-guard proposal
|
|
23
|
+
* rough(text) → bool 3 or more words and 12 or more characters, not a slash command
|
|
24
|
+
*
|
|
25
|
+
* Pure: no I/O apart from movesOf reading the proving files. lib/routes-next-prompt.js makes the call and files the cards through the guard. */
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
|
|
30
|
+
const JOB_NEXT = 'next-prompt';
|
|
31
|
+
const JOB_SHARPEN = 'sharpen-prompt';
|
|
32
|
+
const MAX_CARDS = 2;
|
|
33
|
+
const MAX_TEXT = 400;
|
|
34
|
+
const REPLY_MAX = 3000;
|
|
35
|
+
const GRILL_RE = /^\s*(please\s+)?(grill|re-?grill)\b/i; // the same test lib/routes-briefing-premium.js grillAsk makes
|
|
36
|
+
|
|
37
|
+
/* where: 'any' chat, or only an initiative's chat (its key starts init:). `find` is the text that proves the move works today. */
|
|
38
|
+
const MOVES = Object.freeze([
|
|
39
|
+
{ id: 'follow', where: 'any', file: null,
|
|
40
|
+
use: 'Answer the question the reply asks, or take up the offer it makes, in the PM\'s words (for example "Yes, swap it for the period-end KR debrief."). Only when the reply asks or offers something. Put the exact line of the reply you answer in "from".' },
|
|
41
|
+
{ id: 'grill', where: 'initiative', file: 'lib/routes-briefing-premium.js', find: 'const GRILL_RE = ',
|
|
42
|
+
use: 'Ask Claude to grill this initiative\'s plan. The text MUST start with "Grill this" and then say what to test it against (earlier decisions, scope, dates). Only when the reply is about this initiative\'s plan, scope, dates or a decision on it.' },
|
|
43
|
+
{ id: 'past', where: 'initiative', file: 'lib/pmos-verbs.js', find: "past: Object.freeze({ module: 'past-cli.js'",
|
|
44
|
+
use: 'Ask why something was decided or dropped: "Why did we …?" or "Did we ever decide …?", answered from the records with file:line sources or "No record found". Only when the reply names an earlier decision, or something dropped, retired or replaced.' },
|
|
45
|
+
{ id: 'rehearse', where: 'any', file: 'lib/pmos-verbs.js', find: "rehearse: Object.freeze({ module: 'rehearse-cli.js'",
|
|
46
|
+
use: 'Ask Claude to rehearse a change before making it: moving a task, changing an initiative\'s end date, or cutting a task or an initiative. It shows what breaks, helps and moves, then waits for the PM\'s yes. Only when the reply or the PM\'s message talks about such a change, naming the real task or initiative.' },
|
|
47
|
+
{ id: 'drift', where: 'any', file: 'lib/pmos-verbs.js', find: "drift: Object.freeze({ module: 'drift-cli.js'",
|
|
48
|
+
use: 'Ask Claude to run the weekly drift check\'s replay on the PM\'s records ("Run the drift replay"). Only when the reply says the weekly "what you might have missed" check waits for its replay.' },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const STYLES = Object.freeze({
|
|
52
|
+
polish: 'Polish: make it clearer and shorter. Keep the PM\'s meaning, words and order where they work. Add nothing the PM did not ask for.',
|
|
53
|
+
engineer: 'Prompt engineer: build the best prompt for the job. Say the goal in the first words, name the real things (initiative, task, product, PR) from the context, and say what a good answer holds. Add no new request, step or condition the PM did not ask for. One or two short sentences, plain words, no role-play preamble, no markdown, no lists.',
|
|
54
|
+
own: 'The PM\'s own rules, below. Follow them; where they say nothing, keep the PM\'s meaning and make it clear.',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const one = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
58
|
+
const norm = (s) => String(s == null ? '' : s).toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
59
|
+
const words = (s) => norm(s).split(' ').filter(Boolean);
|
|
60
|
+
const rough = (t) => { const s = String(t || '').trim(); return !s.startsWith('/') && s.length >= 12 && words(s).length >= 3; };
|
|
61
|
+
|
|
62
|
+
/** The moves whose proving line is on disk now, each with its ref (path:line under root) and the line as its quote. */
|
|
63
|
+
function movesOf(root) {
|
|
64
|
+
const out = [], cache = new Map();
|
|
65
|
+
for (const m of MOVES) {
|
|
66
|
+
if (!m.file) { out.push({ id: m.id, where: m.where, use: m.use, ref: null, quote: null }); continue; }
|
|
67
|
+
if (!cache.has(m.file)) { let L = null; try { L = fs.readFileSync(path.join(root, m.file), 'utf8').split('\n'); } catch { L = null; } cache.set(m.file, L); }
|
|
68
|
+
const L = cache.get(m.file); if (!L) continue;
|
|
69
|
+
const i = L.findIndex((l) => l.includes(m.find)); if (i < 0) continue;
|
|
70
|
+
out.push({ id: m.id, where: m.where, use: m.use, ref: `${m.file}:${i + 1}`, quote: one(L[i], 200) });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const inInitiative = (place) => !!(place && place.initiative);
|
|
75
|
+
const usable = (moves, place) => (moves || []).filter((m) => m.where === 'any' || inInitiative(place));
|
|
76
|
+
|
|
77
|
+
function placeLines(place) {
|
|
78
|
+
if (!place) return 'The chat: Home (not tied to one initiative).';
|
|
79
|
+
const bits = [];
|
|
80
|
+
if (place.initiative) bits.push(`The chat: the initiative "${one(place.initiative, 120)}"${place.stage ? `, stage ${one(place.stage, 40)}` : ''}${place.product ? `, product ${one(place.product, 80)}` : ''}.`);
|
|
81
|
+
else bits.push(`The chat: ${one(place.title || 'Home', 120)} (not tied to one initiative).`);
|
|
82
|
+
return bits.join('\n');
|
|
83
|
+
}
|
|
84
|
+
const styleLines = (style, rules) => (STYLES[style] || STYLES.engineer) + (style === 'own' && rules ? '\nThe PM\'s rules:\n' + one(rules, 1000) : '');
|
|
85
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).trim(); return t.length > n ? t.slice(0, n) + ' …' : t; };
|
|
86
|
+
|
|
87
|
+
function nextPrompt({ pm, reply, place, style = 'engineer', rules = '', moves = [] } = {}) {
|
|
88
|
+
const ok = usable(moves, place);
|
|
89
|
+
return [
|
|
90
|
+
'You suggest the next message a product manager (the PM) could send in a PMOS chat with Claude. You write it in the PM\'s voice, ready to send. You do not answer the PM, and you run nothing.',
|
|
91
|
+
'',
|
|
92
|
+
placeLines(place),
|
|
93
|
+
'',
|
|
94
|
+
'The PM\'s last message:', '<pm>', clip(pm, 1200) || '(none)', '</pm>',
|
|
95
|
+
'',
|
|
96
|
+
'Claude\'s reply:', '<reply>', clip(reply, REPLY_MAX), '</reply>',
|
|
97
|
+
'',
|
|
98
|
+
'You may suggest only these moves. Each one works in PMOS today:',
|
|
99
|
+
...ok.map((m) => `- ${m.id}: ${m.use}`),
|
|
100
|
+
'',
|
|
101
|
+
'How to write each suggestion:',
|
|
102
|
+
styleLines(style, rules),
|
|
103
|
+
'',
|
|
104
|
+
'Rules:',
|
|
105
|
+
'- At most 2 suggestions, the best first. Usually 1. Return none when the reply needs nothing from the PM or no move clearly fits: an empty list is a good answer.',
|
|
106
|
+
'- Use only names, numbers and dates that appear above. Never invent them.',
|
|
107
|
+
'- Never suggest something the reply says is already done, or that only repeats the reply.',
|
|
108
|
+
`- Each text is at most ${MAX_TEXT} characters.`,
|
|
109
|
+
'',
|
|
110
|
+
'Answer with JSON only, no other text: {"cards":[{"move":"<id>","text":"<the prompt>","from":"<follow only: the exact line of the reply it answers>"}]}',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sharpenPrompt({ text, reply, place, style = 'engineer', rules = '' } = {}) {
|
|
115
|
+
return [
|
|
116
|
+
'A product manager (the PM) is typing a message to Claude in a PMOS chat. Rewrite it into a sharper prompt, ready to send. You do not answer it, and you run nothing.',
|
|
117
|
+
'',
|
|
118
|
+
placeLines(place),
|
|
119
|
+
'',
|
|
120
|
+
reply ? 'Claude\'s last reply, for context:\n<reply>\n' + clip(reply, 1500) + '\n</reply>\n' : '',
|
|
121
|
+
'What the PM typed:', '<draft>', clip(text, 1500), '</draft>',
|
|
122
|
+
'',
|
|
123
|
+
'Style:',
|
|
124
|
+
styleLines(style, rules),
|
|
125
|
+
'',
|
|
126
|
+
'Rules:',
|
|
127
|
+
'- Keep what the PM asks for. Do not add a new request or step, and do not answer it. Say nothing about how Claude should read or check data.',
|
|
128
|
+
'- When the draft asks to move a task, change an end date or cut work, ask Claude to rehearse it first ("Rehearse moving …") and wait for the PM\'s yes: PMOS does that today.',
|
|
129
|
+
'- Use only names, numbers and dates from the draft and the context above. Never invent them.',
|
|
130
|
+
'- Write in the PM\'s voice, as the message the PM sends. Do not guess what "here" or "this" points at when the context does not say.',
|
|
131
|
+
`- At most ${MAX_TEXT} characters.`,
|
|
132
|
+
'- If the draft is already as sharp as it can be in this style, say so.',
|
|
133
|
+
'',
|
|
134
|
+
'Answer with JSON only, no other text: {"text":"<the sharper prompt>"} or {"same":true}',
|
|
135
|
+
].join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The first JSON object in a model's answer (it may wrap it in a code fence or a sentence). */
|
|
139
|
+
function jsonOf(text) {
|
|
140
|
+
const s = String(text == null ? '' : text);
|
|
141
|
+
const a = s.indexOf('{'), b = s.lastIndexOf('}');
|
|
142
|
+
if (a < 0 || b <= a) return null;
|
|
143
|
+
try { return JSON.parse(s.slice(a, b + 1)); } catch { return null; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseNext(text, { moves = [], place = null } = {}) {
|
|
147
|
+
const o = jsonOf(text); if (!o || !Array.isArray(o.cards)) return [];
|
|
148
|
+
const ok = new Map(usable(moves, place).map((m) => [m.id, m]));
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const c of o.cards) {
|
|
151
|
+
if (out.length >= MAX_CARDS) break;
|
|
152
|
+
if (!c || typeof c !== 'object') continue;
|
|
153
|
+
const move = String(c.move || ''), t = one(c.text, MAX_TEXT + 1);
|
|
154
|
+
if (!ok.has(move) || !t || t.length > MAX_TEXT) continue;
|
|
155
|
+
if (move === 'grill' && !GRILL_RE.test(t)) continue; // the chat starts a grill run only on a message that starts with grill
|
|
156
|
+
if (move === 'follow' && !one(c.from, 400)) continue;
|
|
157
|
+
if (out.some((x) => same(x.text, t))) continue;
|
|
158
|
+
out.push({ move, text: t, ...(move === 'follow' ? { from: one(c.from, 400) } : {}) });
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Two prompts that say the same: the same words, or at least 90% of the words of both shared. */
|
|
164
|
+
function same(a, b) {
|
|
165
|
+
const x = words(a), y = words(b);
|
|
166
|
+
if (!x.length || !y.length) return x.length === y.length;
|
|
167
|
+
if (x.join(' ') === y.join(' ')) return true;
|
|
168
|
+
const X = new Set(x), Y = new Set(y);
|
|
169
|
+
let both = 0; for (const w of X) if (Y.has(w)) both++;
|
|
170
|
+
return both / Math.max(X.size, Y.size) >= 0.9;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseSharpen(text, roughText) {
|
|
174
|
+
const o = jsonOf(text); if (!o || o.same === true) return null;
|
|
175
|
+
const t = one(o.text, MAX_TEXT + 1);
|
|
176
|
+
if (!t || t.length > MAX_TEXT || same(t, roughText)) return null;
|
|
177
|
+
return t;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const idOf = (prefix, s) => prefix + ':' + crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 12);
|
|
181
|
+
|
|
182
|
+
function proposalsOfNext(cards, { moves = [], replyRef = () => null } = {}) {
|
|
183
|
+
const by = new Map(moves.map((m) => [m.id, m]));
|
|
184
|
+
return (cards || []).map((c, i) => {
|
|
185
|
+
const m = by.get(c.move) || {};
|
|
186
|
+
const src = c.move === 'follow' ? replyRef(c.from) : (m.ref ? { ref: m.ref, quote: m.quote } : null);
|
|
187
|
+
return {
|
|
188
|
+
id: idOf('np', c.move + '\n' + c.text), title: c.move, say: c.text, confidence: 'medium',
|
|
189
|
+
claims: [{ text: c.move === 'follow' ? 'It answers the reply' : 'This move works in PMOS today', sources: src ? [{ label: c.move === 'follow' ? 'the reply' : m.file || c.move, ref: src.ref, quote: src.quote }] : [] }],
|
|
190
|
+
data: { move: c.move, rec: i === 0 },
|
|
191
|
+
};
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function proposalOfSharpen(text, src) {
|
|
195
|
+
return { id: idOf('sh', text), title: 'sharper', say: text, confidence: 'medium',
|
|
196
|
+
claims: [{ text: 'It keeps what the PM typed', sources: src ? [{ label: 'what you typed', ref: src.ref, quote: src.quote }] : [] }], data: { kind: 'sharper' } };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = { JOB_NEXT, JOB_SHARPEN, MAX_CARDS, MAX_TEXT, MOVES, STYLES, GRILL_RE, movesOf, nextPrompt, sharpenPrompt, parseNext, parseSharpen, same, rough, jsonOf, proposalsOfNext, proposalOfSharpen };
|
|
@@ -114,6 +114,7 @@ const MODULES = Object.freeze([
|
|
|
114
114
|
'routes-goal-status', // batch 16 item 6: the goals' one status from readings (product page), notify choice, recipe, Read now, test read, CSV readings
|
|
115
115
|
'routes-refresh', // data-refresh-settings: Settings › Data refresh: what refreshes, when, the refresh log, Refresh now
|
|
116
116
|
'routes-impact-views', // batch 16 item 7 (Your impact, Phase 4): the chapter card's read, a stage that closed this week (boards.jsonl; read-only)
|
|
117
|
+
'routes-next-prompt', // batch 26 item 2: the chat's suggested next prompt and Sharper card (lib/next-prompt.js, through lib/ai-guard.js)
|
|
117
118
|
'routes-drift', // weekly-drift-check: What would I have missed, Home's weekly ✦ and its ⋯ drop and Undo (runtime drift/drift.jsonl; page-press POST)
|
|
118
119
|
]);
|
|
119
120
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-next-prompt.js — the chat's suggested next prompt and its Sharper card over HTTP (batch 26 item 2, design B; lib/next-prompt.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/next-prompt/prefs → { ok, next, sharpen, sharpenSet, style, rules, api }
|
|
5
|
+
* next: suggestions after a reply are on. sharpen: Sharpen as I type is on now ('auto' is on when Claude's runs go to the API, where a
|
|
6
|
+
* call takes about 2 s; a background `claude -p` run takes 10 s or more, too slow to follow typing). sharpenSet: 'auto'|'on'|'off'.
|
|
7
|
+
* style: 'polish'|'engineer'|'own'. rules: whether Your own rules are written. api: a key for the Claude API is set.
|
|
8
|
+
* POST /api/next-prompt/prefs { key: 'next'|'sharpen'|'style'|'rules', value } → { ok } (saved in <runtime>/next-prompt/prefs.json)
|
|
9
|
+
* POST /api/next-prompt/next { key, asked, reply, initiative?, stage?, product?, title? } → { ok, on, cards: [{ text, move, rec }] }
|
|
10
|
+
* POST /api/next-prompt/sharpen { key, text, reply?, initiative?, title? } → { ok, on, text | null }
|
|
11
|
+
*
|
|
12
|
+
* Both POSTs are page presses (lib/pm-guard.js requirePagePress; an agent session header is refused) and run ONE short Claude call:
|
|
13
|
+
* on the API with the PM's key when one is set (lib/anthropic-key.js, model PMOS_NEXT_PROMPT_MODEL or claude-sonnet-5-5), else a headless
|
|
14
|
+
* read-only run (lib/headless.js kind 'ask', origin briefing-ask:next-prompt:…). The answer goes through the shared door, lib/ai-guard.js file():
|
|
15
|
+
* the kill switch (`pmos ai off next-prompt`, `pmos ai off sharpen-prompt`, PMOS_AI=off), the replay gate (fixtures/next-prompt-replay.json,
|
|
16
|
+
* recorded answers, never a model), the citation check and the audit (<runtime>/ai-audit.jsonl). A card with no verified source is never
|
|
17
|
+
* shown. The reply and the typed text a card cites are kept in <runtime>/next-prompt/ (the newest 30 files), outside every repository.
|
|
18
|
+
* Nothing here writes to a session, a record or a repository: a card is text the PM may put in the box.
|
|
19
|
+
*
|
|
20
|
+
* Tests inject ctx.askClaude(prompt, { job, key }) → Promise<string|null> instead of a real call. */
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const guard = require('./pm-guard.js');
|
|
25
|
+
const AG = require('./ai-guard.js');
|
|
26
|
+
const NP = require('./next-prompt.js');
|
|
27
|
+
|
|
28
|
+
const VERSION = '1';
|
|
29
|
+
const ROOT = path.join(__dirname, '..'); // the workspace package: the files that prove a move works today
|
|
30
|
+
const FIXTURE = path.join(ROOT, 'fixtures/next-prompt-replay.json');
|
|
31
|
+
const KEEP = 30;
|
|
32
|
+
const TIMEOUT_MS = 90000;
|
|
33
|
+
const DEFAULT_MODEL = 'claude-sonnet-5-5';
|
|
34
|
+
|
|
35
|
+
const text = (v, n) => (typeof v === 'string' ? v.slice(0, n) : '');
|
|
36
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
37
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'next-prompt');
|
|
38
|
+
|
|
39
|
+
/* The PM's choices live in <runtime>/next-prompt/prefs.json, owned by this route (Settings › Claude and the sliders menu write them here). */
|
|
40
|
+
const PREFS = {
|
|
41
|
+
next: (v) => typeof v === 'boolean',
|
|
42
|
+
sharpen: (v) => v === 'auto' || v === 'on' || v === 'off',
|
|
43
|
+
style: (v) => Object.prototype.hasOwnProperty.call(NP.STYLES, v),
|
|
44
|
+
rules: (v) => typeof v === 'string' && v.length <= 1000,
|
|
45
|
+
};
|
|
46
|
+
const prefsFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'prefs.json');
|
|
47
|
+
function readPrefs(runtimeDir) {
|
|
48
|
+
try { const o = JSON.parse(fs.readFileSync(prefsFile(runtimeDir), 'utf8')); return o && typeof o === 'object' ? o : {}; } catch { return {}; }
|
|
49
|
+
}
|
|
50
|
+
/** Save one choice → { ok } or { ok:false, error }. */
|
|
51
|
+
function setPref(runtimeDir, key, value) {
|
|
52
|
+
if (!Object.prototype.hasOwnProperty.call(PREFS, key)) return { ok: false, error: 'no such choice: ' + String(key).slice(0, 40) };
|
|
53
|
+
if (!PREFS[key](value)) return { ok: false, error: 'that value does not fit ' + key };
|
|
54
|
+
const o = readPrefs(runtimeDir); o[key] = value;
|
|
55
|
+
fs.mkdirSync(dirOf(runtimeDir), { recursive: true, mode: 0o700 });
|
|
56
|
+
const f = prefsFile(runtimeDir), tmp = f + '.' + process.pid + '.tmp';
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(o, null, 2) + '\n', { mode: 0o600 });
|
|
58
|
+
fs.renameSync(tmp, f);
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
const get = (ctx, k) => { const v = readPrefs(ctx.runtimeDir)[k]; return PREFS[k](v) ? v : undefined; };
|
|
62
|
+
const envOf = (ctx) => ctx.env || process.env;
|
|
63
|
+
|
|
64
|
+
function apiKey(ctx) {
|
|
65
|
+
const env = envOf(ctx);
|
|
66
|
+
if (/^(off|0|false|no)$/i.test(String(env.PMOS_NEXT_PROMPT_API || ''))) return null;
|
|
67
|
+
try { return require('./anthropic-key.js').getKey(ctx.runtimeDir, env); } catch { return null; }
|
|
68
|
+
}
|
|
69
|
+
function prefs(ctx) {
|
|
70
|
+
const set = get(ctx, 'sharpen') || 'auto', api = !!apiKey(ctx), style = get(ctx, 'style') || 'engineer', rules = String(get(ctx, 'rules') || '');
|
|
71
|
+
return { next: get(ctx, 'next') !== false, sharpen: set === 'on' || (set === 'auto' && api), sharpenSet: set, style, rules: !!rules.trim(), rulesText: rules, api };
|
|
72
|
+
}
|
|
73
|
+
function offWhy(ctx, job) {
|
|
74
|
+
const why = AG.off(job, { env: envOf(ctx), settingsDir: ctx.runtimeDir });
|
|
75
|
+
if (why) return why;
|
|
76
|
+
const P = prefs(ctx);
|
|
77
|
+
if (job === NP.JOB_NEXT && !P.next) return 'Suggestions after a reply are off (Settings › Claude)';
|
|
78
|
+
if (job === NP.JOB_SHARPEN && !P.sharpen) return 'Sharpen as I type is off (Settings › Claude)';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ── the call ────────────────────────────────────────────────────────────────────────────── */
|
|
83
|
+
|
|
84
|
+
async function viaApi(key, prompt, env) {
|
|
85
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
86
|
+
const api = new Anthropic({ apiKey: key });
|
|
87
|
+
const res = await api.messages.create({ model: String(env.PMOS_NEXT_PROMPT_MODEL || '').trim() || DEFAULT_MODEL, max_tokens: 800, messages: [{ role: 'user', content: prompt }] });
|
|
88
|
+
return (res.content || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n');
|
|
89
|
+
}
|
|
90
|
+
function viaHeadless(ctx, prompt, origin) {
|
|
91
|
+
const headless = require('./headless.js');
|
|
92
|
+
if (typeof ctx.spawnSession !== 'function' || headless.kindFor({ agent: 'claude', origin }) !== 'ask') return Promise.resolve(null);
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let settled = false; const done = (v) => { if (!settled) { settled = true; clearTimeout(t); resolve(v); } };
|
|
95
|
+
const t = setTimeout(() => done(null), TIMEOUT_MS);
|
|
96
|
+
let s = null;
|
|
97
|
+
try {
|
|
98
|
+
s = ctx.spawnSession({ name: 'chat: next prompt', agent: 'claude', origin, initialInput: prompt,
|
|
99
|
+
onDone: (d) => { const r = d && d.result; done(r && r.ok && !r.isError ? String(r.text || '') : null); } });
|
|
100
|
+
} catch { s = null; }
|
|
101
|
+
if (!s || s.state === 'exited') done(null);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function ask(ctx, prompt, { job, key }) {
|
|
105
|
+
if (typeof ctx.askClaude === 'function') return ctx.askClaude(prompt, { job, key });
|
|
106
|
+
const k = apiKey(ctx);
|
|
107
|
+
if (k) { try { return await viaApi(k, prompt, envOf(ctx)); } catch { return null; } }
|
|
108
|
+
return viaHeadless(ctx, prompt, `briefing-ask:next-prompt:${job}:${crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 10)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ── what a card cites ───────────────────────────────────────────────────────────────────── */
|
|
112
|
+
|
|
113
|
+
function prune(dir) {
|
|
114
|
+
let fsx = []; try { fsx = fs.readdirSync(dir).filter((f) => f.endsWith('.txt')).map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })); } catch { return; }
|
|
115
|
+
fsx.sort((a, b) => b.t - a.t);
|
|
116
|
+
for (const x of fsx.slice(KEEP)) { try { fs.unlinkSync(path.join(dir, x.f)); } catch { /* gone already */ } }
|
|
117
|
+
}
|
|
118
|
+
/** Keep a text where the guard can read it at its lines; → its name under the state folder. */
|
|
119
|
+
function keep(runtimeDir, prefix, body) {
|
|
120
|
+
const dir = dirOf(runtimeDir);
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
const name = prefix + '-' + crypto.createHash('sha1').update(body).digest('hex').slice(0, 16) + '.txt';
|
|
123
|
+
fs.writeFileSync(path.join(dir, name), body, { mode: 0o600 });
|
|
124
|
+
prune(dir);
|
|
125
|
+
return 'next-prompt/' + name;
|
|
126
|
+
}
|
|
127
|
+
const flat = (s) => String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
128
|
+
/** The line of the kept reply that holds `from` (spacing and case ignored) → { ref, quote } for the guard, or null. */
|
|
129
|
+
function lineRef(rel, body, from) {
|
|
130
|
+
const want = flat(from); if (want.length < AG.QUOTE_MIN) return null;
|
|
131
|
+
const L = body.split('\n');
|
|
132
|
+
for (let i = 0; i < L.length; i++) if (flat(L.slice(Math.max(0, i - 2), i + 3).join(' ')).includes(want)) return { ref: `state:${rel}:${i + 1}`, quote: from };
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── the guard ───────────────────────────────────────────────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
const MOVES = () => NP.movesOf(ROOT);
|
|
139
|
+
function replayCases(runtimeDir, job = null) {
|
|
140
|
+
let F; try { F = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); } catch { return []; }
|
|
141
|
+
const moves = MOVES();
|
|
142
|
+
const N = job === NP.JOB_SHARPEN ? [] : F.next || [], H = job === NP.JOB_NEXT ? [] : F.sharpen || [];
|
|
143
|
+
return N.map((c) => ({ name: 'next: ' + c.name, expect: c.expect, run: async () => {
|
|
144
|
+
const reply = String(c.reply || ''), rel = keep(runtimeDir, 'replay', reply);
|
|
145
|
+
return NP.proposalsOfNext(NP.parseNext(JSON.stringify(c.answer), { moves, place: c.place || null }), { moves, replyRef: (from) => lineRef(rel, reply, from) });
|
|
146
|
+
} })).concat(H.map((c) => ({ name: 'sharpen: ' + c.name, expect: c.expect, run: async () => {
|
|
147
|
+
const out = NP.parseSharpen(JSON.stringify(c.answer), c.text);
|
|
148
|
+
if (!out) return [];
|
|
149
|
+
const rel = keep(runtimeDir, 'replay-typed', String(c.text));
|
|
150
|
+
return [NP.proposalOfSharpen(out, c.cite === false ? null : { ref: `state:${rel}:1`, quote: String(c.text).split('\n')[0] })];
|
|
151
|
+
} })));
|
|
152
|
+
}
|
|
153
|
+
async function gate(ctx, job) {
|
|
154
|
+
if (AG.ready(ctx.runtimeDir, job, VERSION)) return true;
|
|
155
|
+
const rp = await AG.replay(job, VERSION, replayCases(ctx.runtimeDir, job), { root: ROOT, stateDir: ctx.runtimeDir, max: NP.MAX_CARDS });
|
|
156
|
+
return rp.ok;
|
|
157
|
+
}
|
|
158
|
+
async function through(ctx, job, run, proposals) {
|
|
159
|
+
let shown = [];
|
|
160
|
+
const got = await AG.file({ job, version: VERSION, run, root: ROOT, stateDir: ctx.runtimeDir, settingsDir: ctx.runtimeDir, env: envOf(ctx), max: NP.MAX_CARDS }, proposals, async (filed) => { shown = filed; });
|
|
161
|
+
return got.off || got.held ? [] : shown;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── the two calls ───────────────────────────────────────────────────────────────────────── */
|
|
165
|
+
|
|
166
|
+
const placeOf = (b) => ({ initiative: text(b.initiative, 160) || null, stage: text(b.stage, 60) || null, product: text(b.product, 120) || null, title: text(b.title, 160) || null });
|
|
167
|
+
const inflight = new Map(); // the same reply asked twice (two tabs, a re-read) shares one call
|
|
168
|
+
function once(k, fn) {
|
|
169
|
+
if (inflight.has(k)) return inflight.get(k);
|
|
170
|
+
const p = fn().finally(() => setTimeout(() => inflight.delete(k), 60000));
|
|
171
|
+
inflight.set(k, p);
|
|
172
|
+
if (inflight.size > 100) inflight.delete(inflight.keys().next().value);
|
|
173
|
+
return p;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function next(ctx, b) {
|
|
177
|
+
const reply = text(b.reply, 6000).trim(), key = text(b.key, 200);
|
|
178
|
+
if (!reply) return { ok: true, on: true, cards: [] };
|
|
179
|
+
const job = NP.JOB_NEXT, run = 'np-' + crypto.randomBytes(4).toString('hex');
|
|
180
|
+
const P = prefs(ctx), place = placeOf(b), moves = MOVES();
|
|
181
|
+
if (!(await gate(ctx, job))) return { ok: true, on: true, held: true, cards: [] };
|
|
182
|
+
const k = 'n:' + key + ':' + crypto.createHash('sha1').update(reply + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
183
|
+
return once(k, async () => {
|
|
184
|
+
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
185
|
+
const answer = await ask(ctx, NP.nextPrompt({ pm: text(b.asked, 2000), reply, place, style: P.style, rules: P.rulesText, moves }), { job, key });
|
|
186
|
+
const cards = NP.parseNext(answer, { moves, place });
|
|
187
|
+
if (!cards.length) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : {}) }); return { ok: true, on: true, cards: [] }; }
|
|
188
|
+
const rel = keep(ctx.runtimeDir, 'reply', reply);
|
|
189
|
+
const shown = await through(ctx, job, run, NP.proposalsOfNext(cards, { moves, replyRef: (from) => lineRef(rel, reply, from) }));
|
|
190
|
+
return { ok: true, on: true, cards: shown.map((p, i) => ({ text: p.say, move: p.data.move, rec: i === 0 })) };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function sharpen(ctx, b) {
|
|
195
|
+
const typed = text(b.text, 2000).trim(), key = text(b.key, 200);
|
|
196
|
+
if (!NP.rough(typed)) return { ok: true, on: true, text: null };
|
|
197
|
+
const job = NP.JOB_SHARPEN, run = 'sh-' + crypto.randomBytes(4).toString('hex');
|
|
198
|
+
const P = prefs(ctx);
|
|
199
|
+
if (!(await gate(ctx, job))) return { ok: true, on: true, held: true, text: null };
|
|
200
|
+
const k = 's:' + key + ':' + crypto.createHash('sha1').update(typed + '\n' + P.style + '\n' + P.rulesText).digest('hex');
|
|
201
|
+
return once(k, async () => {
|
|
202
|
+
AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'start' });
|
|
203
|
+
const answer = await ask(ctx, NP.sharpenPrompt({ text: typed, reply: text(b.reply, 3000), place: placeOf(b), style: P.style, rules: P.rulesText }), { job, key });
|
|
204
|
+
const out = NP.parseSharpen(answer, typed);
|
|
205
|
+
if (!out) { AG.audit(ctx.runtimeDir, { job, version: VERSION, run, event: 'end', filed: 0, dropped: 0, ...(answer == null ? { error: 'no answer' } : { same: true }) }); return { ok: true, on: true, text: null }; }
|
|
206
|
+
const first = typed.split('\n')[0], rel = keep(ctx.runtimeDir, 'typed', typed);
|
|
207
|
+
const shown = await through(ctx, job, run, [NP.proposalOfSharpen(out, { ref: `state:${rel}:1`, quote: first })]);
|
|
208
|
+
return { ok: true, on: true, text: shown.length ? shown[0].say : null };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function handle(req, res, url, ctx) {
|
|
213
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
214
|
+
const what = url.pathname.replace(/^\/api\/next-prompt\/?/, '');
|
|
215
|
+
if (what === 'prefs' && req.method === 'POST') {
|
|
216
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only the page changes these choices.'));
|
|
217
|
+
const rd = await ctx.readJson(req);
|
|
218
|
+
if (!rd.ok) return send(bad(400, 'the body is not JSON'));
|
|
219
|
+
const body = rd.body || {};
|
|
220
|
+
const g = guard.requirePagePress(req, body);
|
|
221
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
222
|
+
if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
|
|
223
|
+
const r = setPref(ctx.runtimeDir, body.key, body.value);
|
|
224
|
+
return send(r.ok ? { status: 200, body: { ok: true } } : bad(400, r.error));
|
|
225
|
+
}
|
|
226
|
+
if (what === 'prefs') {
|
|
227
|
+
if (req.method !== 'GET') return send(bad(405, 'GET or POST only'));
|
|
228
|
+
const P = prefs(ctx), env = envOf(ctx);
|
|
229
|
+
const next = P.next && !AG.off(NP.JOB_NEXT, { env, settingsDir: ctx.runtimeDir }), sh = P.sharpen && !AG.off(NP.JOB_SHARPEN, { env, settingsDir: ctx.runtimeDir });
|
|
230
|
+
return send({ status: 200, body: { ok: true, next, nextSet: P.next, sharpen: sh, sharpenSet: P.sharpenSet, style: P.style, rules: P.rules, rulesText: P.rulesText, api: P.api } });
|
|
231
|
+
}
|
|
232
|
+
if (what !== 'next' && what !== 'sharpen') return send(bad(404, 'no such next-prompt route'));
|
|
233
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
234
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only the page asks for a suggestion.'));
|
|
235
|
+
const rd = await ctx.readJson(req);
|
|
236
|
+
if (!rd.ok) return send(bad(400, 'the body is not JSON'));
|
|
237
|
+
const body = rd.body || {};
|
|
238
|
+
const g = guard.requirePagePress(req, body);
|
|
239
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
240
|
+
if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
|
|
241
|
+
const job = what === 'next' ? NP.JOB_NEXT : NP.JOB_SHARPEN;
|
|
242
|
+
const why = offWhy(ctx, job);
|
|
243
|
+
if (why) return send({ status: 200, body: { ok: true, on: false, why, ...(what === 'next' ? { cards: [] } : { text: null }) } });
|
|
244
|
+
try { return send({ status: 200, body: what === 'next' ? await next(ctx, body) : await sharpen(ctx, body) }); }
|
|
245
|
+
catch (e) { return send(bad(500, 'The suggestion failed: ' + String((e && e.message) || e).slice(0, 200))); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { prefixes: ['/api/next-prompt'], handle, VERSION, FIXTURE, prefs, setPref, lineRef, replayCases, next, sharpen };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.512.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.512.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -379,3 +379,5 @@
|
|
|
379
379
|
.ev-st [data-claude-setup] .cs-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
380
380
|
.ev-st [data-claude-setup] .cs-term { width: 100%; min-height: 280px; border-radius: 14px; overflow: hidden; }
|
|
381
381
|
@media (max-width: 760px) { .ev-st [data-claude-setup] .wl-btn, .ev-st [data-claude-setup] select { min-height: 44px; } .ev-st [data-claude-setup] .cs-label { flex-wrap: wrap; } }
|
|
382
|
+
/* Settings › Claude › Your own rules (batch 26 item 2) */
|
|
383
|
+
.ev-st textarea.ev-rules { display: block; width: 100%; box-sizing: border-box; margin-top: 8px; min-height: 72px; padding: 10px 12px; border: 0; border-radius: 14px; background: var(--cz-well); color: var(--cz-ink); font: inherit; font-size: var(--fs-3); line-height: 1.45; resize: vertical; }
|
|
@@ -20,7 +20,8 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
|
|
|
20
20
|
* ground, 16px apart, the list 360px wide (cozy-today.css) */
|
|
21
21
|
@media (min-width: 761px) {
|
|
22
22
|
body.cz-home .hm-desk { inset: 0; padding: 16px; gap: 16px; background: none; box-shadow: none; border-radius: 0; }
|
|
23
|
-
|
|
23
|
+
/* batch 26 item 2 (NpBw): the chat sits on the same soft gradient as on a phone */
|
|
24
|
+
body.cz-home .hm-chat { background: var(--cz-ground); border-radius: 24px; padding: 16px 24px 20px; box-shadow: 0 10px 30px -24px var(--cz-c-9da3ac); }
|
|
24
25
|
body.cz-home .hm-vw .cz-vw { flex-basis: min(500px, max(380px, calc(100vw - 852px))); width: min(500px, max(380px, calc(100vw - 852px))); }
|
|
25
26
|
body.cz-home.hm-folded:not(.cz-vw-rail) .hm-desk { padding-left: 56px; }
|
|
26
27
|
body.cz-home .hm-reopen { top: 12px; right: 12px; }
|
|
@@ -791,3 +792,46 @@ form.hm-box textarea { min-height: 48px; border: 0; background: none; resize: no
|
|
|
791
792
|
.hm-chat .ctxmore[open] > summary { display: none; }
|
|
792
793
|
.hm-chat .ctxmore > .ctxc { display: contents; }
|
|
793
794
|
.hm-chat .ctxmore:not([open]) > .ctxc { display: none; }
|
|
795
|
+
|
|
796
|
+
/* ── the suggested next prompt and the Sharper card (batch 26 item 2, canvas NpB, NpBw; pm/next-prompt-ui.js) ──
|
|
797
|
+
* Above the box: up to two cards, the recommended one lit (card white, blue ring, blue ✦), the other grey. A tap fills the box. */
|
|
798
|
+
.np { display: flex; flex-direction: column; gap: 8px; padding-top: 8px; }
|
|
799
|
+
.np[hidden] { display: none; }
|
|
800
|
+
.np-list { display: flex; flex-direction: column; gap: 8px; }
|
|
801
|
+
.np-card { position: relative; display: flex; align-items: flex-start; gap: 10px; width: 100%; box-sizing: border-box; min-height: 44px; padding: 11px 14px; border-radius: 16px;
|
|
802
|
+
border: 1px solid transparent; background: var(--cz-well); color: var(--cz-ink); font: inherit; font-size: var(--fs-3); line-height: 1.5; text-align: left; cursor: pointer; }
|
|
803
|
+
.np-card .np-star { flex: none; margin-top: 3px; fill: currentColor; stroke: currentColor; color: var(--cz-ink3); }
|
|
804
|
+
.np-card.rec { background: var(--cz-card); border-color: var(--cz-blue); box-shadow: 0 0 0 3px var(--cz-bluebg); }
|
|
805
|
+
.np-card.rec .np-star { color: var(--cz-blue); }
|
|
806
|
+
.np-card:hover { border-color: var(--cz-blue); }
|
|
807
|
+
.np-card:focus-visible, .np-use:focus-visible, .np-ib:focus-visible, .np-mi:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
|
|
808
|
+
.np-t { flex: 1; min-width: 0; }
|
|
809
|
+
.np-sh { padding: 0 6px 0 0; cursor: default; align-items: center; }
|
|
810
|
+
.np-use { display: flex; align-items: flex-start; gap: 10px; flex: 1; min-width: 0; padding: 11px 0 11px 14px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
811
|
+
.np-ib { flex: none; width: 36px; height: 36px; display: inline-grid; place-items: center; border: 0; border-radius: 999px; background: none; color: var(--cz-ink2); cursor: pointer; }
|
|
812
|
+
.np-ib:hover, .np-ib[aria-expanded="true"] { background: var(--cz-well); }
|
|
813
|
+
.np-spin { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; background: var(--cz-bluebg); color: var(--cz-blue); }
|
|
814
|
+
.np-sp { display: inline-flex; animation: np-rot 1.1s linear infinite; }
|
|
815
|
+
@keyframes np-rot { to { transform: rotate(360deg); } }
|
|
816
|
+
@media (prefers-reduced-motion: reduce) { .np-sp { animation: none; } }
|
|
817
|
+
.np-sk { flex: 1; display: flex; flex-direction: column; gap: 8px; }
|
|
818
|
+
.np-sk i { display: block; height: 12px; border-radius: 6px; background: var(--cz-bluebg); }
|
|
819
|
+
.np-sk i:first-child { width: 88%; } .np-sk i:last-child { width: 60%; }
|
|
820
|
+
.np-menu { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; width: 310px; max-width: calc(100vw - 32px); box-sizing: border-box; padding: 8px; border-radius: 20px;
|
|
821
|
+
background: var(--cz-card); box-shadow: 0 24px 60px -24px rgba(42,46,53,.45), 0 0 0 1px var(--cz-hair); display: flex; flex-direction: column; gap: 2px; }
|
|
822
|
+
.np-mh { margin: 0; padding: 8px 12px 4px; font-size: var(--fs-1); font-weight: 800; color: var(--cz-ink2); }
|
|
823
|
+
.np-mi { display: flex; align-items: flex-start; gap: 10px; min-height: 44px; padding: 8px 12px; border: 0; border-radius: 12px; background: none; color: var(--cz-ink); font: inherit; text-align: left; cursor: pointer; }
|
|
824
|
+
.np-mi:hover, .np-mi[aria-checked="true"]:not(.np-swr) { background: var(--cz-well); }
|
|
825
|
+
.np-mi b { display: block; font-size: var(--fs-3); font-weight: 600; }
|
|
826
|
+
.np-mi[aria-checked="true"] b { font-weight: 700; }
|
|
827
|
+
.np-mi em { display: block; font-style: normal; font-size: var(--fs-1); color: var(--cz-ink2); }
|
|
828
|
+
.np-ck { flex: none; width: 18px; margin-top: 2px; color: var(--cz-blue); display: inline-flex; }
|
|
829
|
+
.np-sep { height: 1px; margin: 6px 8px; background: var(--cz-hair); }
|
|
830
|
+
.np-swr { align-items: center; }
|
|
831
|
+
.np-swr b { flex: 1; }
|
|
832
|
+
.np-sw { flex: none; width: 40px; height: 24px; border-radius: 12px; background: var(--cz-well2); display: inline-flex; align-items: center; padding: 0 3px; box-sizing: border-box; }
|
|
833
|
+
.np-sw i { width: 18px; height: 18px; border-radius: 50%; background: var(--cz-card); box-shadow: 0 1px 2px rgba(0,0,0,.2); }
|
|
834
|
+
.np-sw.on { background: var(--cz-solid); justify-content: flex-end; }
|
|
835
|
+
@media (max-width: 760px) {
|
|
836
|
+
.np-t { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } /* a phone shows 3 lines; a tap still fills the whole text */
|
|
837
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* (this device), Product (which products Home reads: the Products card), Your day (when the end-of-day card shows:
|
|
4
4
|
* time.workEnd, POST /api/settings), Your impact (impact.on, POST /api/settings; canvas GmP1Switch, batch 16 item 7: the one switch
|
|
5
5
|
* for Adler's leaf, the impact log and the weekly check; the page hears `pmos:impact`), Agents write to Jira (fixed: only after Commit)
|
|
6
|
+
* Claude the Claude profile, the API key, and Sharpen prompts: suggestions after a reply, Sharpen as I type, the style and Your own rules
|
|
7
|
+
* (GET/POST /api/next-prompt/prefs; batch 26 item 2)
|
|
6
8
|
* Claude’s checks Grill by itself (Full, Only at stage exits, Off) and the monthly credit for checks with what this month used (grill.mode,
|
|
7
9
|
* grill.cap, POST /api/settings; GET /api/grill; canvas GrillEntry). At the cap Claude pauses and says so once here.
|
|
8
10
|
* Jev and Laya the TypeSafe key, Laya on this computer and the per-repository switches (pm/cozy-jl.js; canvas JlA1Empty–JlA4Phone)
|
|
@@ -262,7 +264,35 @@ function claudeSec() {
|
|
|
262
264
|
const keyRight = `<span class="ev-dr-pl"><label class="ev-vh" for="evKey">Anthropic API key</label><input id="evKey" class="ev-inp" type="password" autocomplete="off" spellcheck="false" placeholder="${k.present ? 'Replace the key' : 'sk-ant-\u2026'}" data-ev-key><button type="button" class="ev-btn sm pri" data-ev-keysave>Save</button>${k.source === 'settings' ? '<button type="button" class="ev-btn sm" data-ev-keyclear>Remove</button>' : ''}</span>`;
|
|
263
265
|
return '<h4>Claude login and profile</h4><div data-claude-setup></div>'
|
|
264
266
|
+ trow('API key', keySub, keyRight)
|
|
265
|
-
+ (k.present && C.off ? trow('Runs on the API', 'Off: PMOS_GRILL_API is off in the environment.', '<span class="ev-pill">Off</span>') : '')
|
|
267
|
+
+ (k.present && C.off ? trow('Runs on the API', 'Off: PMOS_GRILL_API is off in the environment.', '<span class="ev-pill">Off</span>') : '')
|
|
268
|
+
+ sharpenRows(k.present);
|
|
269
|
+
}
|
|
270
|
+
/* Settings › Claude › Sharpen prompts (batch 26 item 2; pm/next-prompt-ui.js, lib/routes-next-prompt.js): the suggestions after a reply, Sharpen as I
|
|
271
|
+
* type ('auto' is on when Claude runs on the API key, where a sharpen takes about 2 s), the style, and the PM's own rules for Your own.
|
|
272
|
+
* The same style and switch are in the chat's Sharper card menu; a save here tells the chat (the event pmos:prompt-prefs). */
|
|
273
|
+
const NPK = { 'prompt.next': ['nextSet', 'next'], 'prompt.sharpen': ['sharpenSet', 'sharpen'], 'prompt.style': ['style', 'style'], 'prompt.rules': ['rulesText', 'rules'] }; // row key → [GET field, POST key]
|
|
274
|
+
const setVal = (k, d) => { const v = T.np ? T.np[NPK[k][0]] : undefined; return v !== undefined && v !== null ? v : d; };
|
|
275
|
+
function sharpenRows(api) {
|
|
276
|
+
const next = setVal('prompt.next', true) !== false, sh = setVal('prompt.sharpen', 'auto'), style = setVal('prompt.style', 'engineer'), rules = String(setVal('prompt.rules', '') || '');
|
|
277
|
+
const on = sh === 'on' || (sh === 'auto' && api);
|
|
278
|
+
const shSub = sh === 'auto' ? (api ? 'On, because Claude runs on your API key: the sharper version shows in about 2 seconds.' : 'Off for now: without an API key each one takes 10 seconds or more. Turn it on to use it anyway.')
|
|
279
|
+
: on ? 'When you pause on a rough prompt, a sharper version shows above the box.' : 'Off. No spinner and no Sharper card.';
|
|
280
|
+
const styleSub = style === 'polish' ? 'Clearer and shorter, your meaning kept.' : style === 'own' ? (rules.trim() ? 'Your rules below.' : 'Write your rules below first.') : 'Builds the best prompt for the job.';
|
|
281
|
+
return '<h4>Sharpen prompts</h4>'
|
|
282
|
+
+ trow('Suggest the next prompt', 'After a reply, up to two prompts above the box. A tap puts one in the box. Nothing sends.', sw(next, 'data-ev-np="prompt.next"', 'Suggest the next prompt'))
|
|
283
|
+
+ trow('Sharpen as I type', shSub, sw(on, 'data-ev-np="prompt.sharpen"', 'Sharpen as I type'))
|
|
284
|
+
+ trow('Sharpen as', styleSub, seg('Sharpen as', 'prompt.style', style, [['polish', 'Polish'], ['engineer', 'Prompt engineer'], ['own', 'Your own']]))
|
|
285
|
+
+ `<div class="ev-trow"><div class="ev-tx"><label for="evRules"><b>Your own rules</b></label><p class="ev-small ev-mut">For Your own. For example: short, always name the initiative, ask for sources.</p>`
|
|
286
|
+
+ `<textarea id="evRules" class="ev-inp ev-rules" rows="3" maxlength="1000" spellcheck="true">${esc(rules)}</textarea></div><button type="button" class="ev-btn sm pri" data-ev-rulesave>Save</button></div>`;
|
|
287
|
+
}
|
|
288
|
+
async function savePrompt(key, value, said) {
|
|
289
|
+
const r = await pmPost('/api/next-prompt/prefs', { key: NPK[key][1], value });
|
|
290
|
+
if (r && r.ok) {
|
|
291
|
+
T.np = { ...(T.np || {}), [NPK[key][0]]: value };
|
|
292
|
+
document.dispatchEvent(new CustomEvent('pmos:prompt-prefs', { detail: { key, value } }));
|
|
293
|
+
}
|
|
294
|
+
T.msg = r && r.ok ? said : (r && r.error) || 'Not saved.';
|
|
295
|
+
draw();
|
|
266
296
|
}
|
|
267
297
|
/* Settings > Claude's checks (GrillEntry): Claude grills by itself, so this is the only control: when, and how much it may spend a month */
|
|
268
298
|
const CAPS = [5, 10, 20, 50];
|
|
@@ -343,6 +373,10 @@ async function press(e) {
|
|
|
343
373
|
T.msg = r && r.ok ? (key === 'grill.mode' ? (value === 'off' ? 'Saved. Claude no longer grills by itself.' : value === 'exits' ? 'Saved. Claude grills only before a stage ends.' : 'Saved. Claude grills by itself when it matters.') : 'Saved. Claude’s checks spend at most ' + usd(value) + ' a month.') : (r && r.error) || 'Not saved.';
|
|
344
374
|
draw(); return;
|
|
345
375
|
}
|
|
376
|
+
if (t.dataset.evSeg === 'prompt.style') { await savePrompt('prompt.style', t.dataset.v, 'Saved. Sharper prompts follow this style.'); return; }
|
|
377
|
+
if (t.dataset.evNp === 'prompt.next') { const on = setVal('prompt.next', true) === false; await savePrompt('prompt.next', on, on ? 'Saved. Claude suggests the next prompt after a reply.' : 'Saved. No suggestions after a reply.'); return; }
|
|
378
|
+
if (t.dataset.evNp === 'prompt.sharpen') { const api = !!(T.cl && T.cl.key && T.cl.key.present), sh = setVal('prompt.sharpen', 'auto'), was = sh === 'on' || (sh === 'auto' && api); await savePrompt('prompt.sharpen', was ? 'off' : 'on', was ? 'Saved. Sharpen as I type is off.' : 'Saved. Sharpen as I type is on.'); return; }
|
|
379
|
+
if ('evRulesave' in t.dataset) { const ta = T.el.querySelector('#evRules'); await savePrompt('prompt.rules', ta ? ta.value.slice(0, 1000) : '', 'Saved. Your own rules are used when Sharpen as is Your own.'); return; }
|
|
346
380
|
if (t.dataset.evSeg && t.dataset.evSeg.startsWith('adler.')) { const r = await AD.savePref(pmPost, t.dataset.evSeg, t.dataset.v); T.msg = r && r.ok ? 'Saved.' : (r && r.error) || 'Not saved.'; draw(); return; }
|
|
347
381
|
if (t.dataset.evDress) {
|
|
348
382
|
const a = t.dataset.evDress;
|
|
@@ -459,6 +493,7 @@ async function readAgain(id) {
|
|
|
459
493
|
async function load() {
|
|
460
494
|
readFresh();
|
|
461
495
|
pmGet('/api/grill').then((g) => { T.gr = g && g.ok ? g : { ok: false, error: (g && g.error) || '' }; draw(); }).catch(() => { T.gr = { ok: false }; draw(); });
|
|
496
|
+
pmGet('/api/next-prompt/prefs').then((n) => { T.np = n && n.ok ? n : null; draw(); }).catch(() => { T.np = null; });
|
|
462
497
|
pmGet('/api/profiles').then(async (pr) => { const k = await pmGet('/api/anthropic'); T.cl = { profiles: pr && Array.isArray(pr.profiles) ? pr.profiles : [], prefs: (pr && pr.prefs) || {}, perr: pr && pr.ok === false ? (pr.error || 'the server did not answer') : '', key: k && k.ok ? k.key : { present: false, source: null }, off: !!(k && k.off) }; draw(); }).catch(() => { T.cl = { profiles: [], prefs: {}, perr: 'the server did not answer', key: { present: false, source: null } }; draw(); });
|
|
463
498
|
const [w, p, s, b] = await Promise.all([pmGet('/api/workspaces'), pmGet('/api/proactive/settings'), pmGet('/api/settings'), pmGet('/api/briefing-premium')]);
|
|
464
499
|
T.ws = w && Array.isArray(w.workspaces) ? w.workspaces : [];
|
|
@@ -477,7 +512,7 @@ async function load() {
|
|
|
477
512
|
export function openSettings(section) {
|
|
478
513
|
const el = mount();
|
|
479
514
|
if (section === 'dress') { T.sec = 'adler'; T.dress = true; } else if (section && BODY[section]) { T.sec = section; T.dress = false; }
|
|
480
|
-
if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.wkMore = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; JL.reset(); }
|
|
515
|
+
if (el.hidden) { const ae = document.activeElement; T.back = ae && ae !== document.body ? ae : null; T.ws = null; T.src = null; T.msg = ''; T.phase = ''; T.auto = false; T.wkOpen = new Set(); T.wkMore = new Set(); T.arc = false; T.fresh = null; T.note = {}; T.dset = {}; T.cl = null; T.gr = null; T.np = null; JL.reset(); }
|
|
481
516
|
el.hidden = false; document.getElementById('evStScrim').hidden = false; el.setAttribute('data-esc-layer', '');
|
|
482
517
|
draw();
|
|
483
518
|
const f = el.querySelector('[aria-current="page"]'); if (f) f.focus();
|
|
@@ -34,6 +34,7 @@ import * as VWC from './cozy-viewer.js';
|
|
|
34
34
|
import { canDictate, dictate } from './home-dictate.js';
|
|
35
35
|
import { adler } from './cozy-adler.js';
|
|
36
36
|
import { snapOf, turnEnded } from './chat-turn-end.js'; // a finished turn re-reads what the Viewer shows (batch 25 item 3)
|
|
37
|
+
import { nextPromptUi } from './next-prompt-ui.js'; // the suggested next prompt and the Sharper card above the box (batch 26 item 2)
|
|
37
38
|
/* The views a chat opens go in the Viewer: on a page with no dialog the old viewer's views draw in the cozy card (premium-viewer.js). */
|
|
38
39
|
const views = {
|
|
39
40
|
ctx: () => import('./premium-vw-ctx.js'), file: () => import('./premium-vw-file.js'), attach: () => import('./premium-attach.js'),
|
|
@@ -78,6 +79,18 @@ export function mountChat(el, host) {
|
|
|
78
79
|
const role = () => (C.t && C.t.role) || roleOf(C.sess || null, host.roles(), firstUser());
|
|
79
80
|
const firstUser = () => { const m = ((C.conv && C.conv.messages) || []).find((x) => x.role === 'user'); return m ? m.text : ''; };
|
|
80
81
|
const running = () => !!C.conv && (C.conv.turn === 'agent' || C.conv.turn === 'tool') && C.conv.state !== 'ended';
|
|
82
|
+
// the suggested next prompt (batch 26 item 2): cards above the box after a reply, a Sharper card while the PM types a rough prompt
|
|
83
|
+
const NPU = nextPromptUi({ el, host, box: () => foot.querySelector('textarea'), redrawFoot: () => { if (C.t) drawFoot({ role: role() }); } });
|
|
84
|
+
el.addEventListener('np-grow', (e) => grow(e.target), true);
|
|
85
|
+
const plain = (s) => String(s || '').replace(/```pmos-[\s\S]*?```/g, '').trim();
|
|
86
|
+
function npCtx() {
|
|
87
|
+
const t = C.t, ms = (C.conv && C.conv.messages) || [];
|
|
88
|
+
let ai = -1; for (let i = ms.length - 1; i >= 0; i--) if (ms[i] && ms[i].role !== 'user' && plain(ms[i].text)) { ai = i; break; }
|
|
89
|
+
let pi = -1; for (let i = ai - 1; i >= 0; i--) if (ms[i] && ms[i].role === 'user') { pi = i; break; }
|
|
90
|
+
const ini = /^init:/.test(String(t.key || ''));
|
|
91
|
+
return { key: t.key || t.sid || '', reply: ai >= 0 ? plain(ms[ai].text).slice(0, 6000) : '', asked: pi >= 0 ? shownUser(String(ms[pi].text || '')).slice(0, 2000) : '',
|
|
92
|
+
initiative: ini ? String(t.title || '') : '', stage: ini ? String(t.sub || '') : '', title: String(t.title || '') };
|
|
93
|
+
}
|
|
81
94
|
|
|
82
95
|
/* ── reading ── */
|
|
83
96
|
async function read() {
|
|
@@ -98,6 +111,8 @@ export function mountChat(el, host) {
|
|
|
98
111
|
const ended = turnEnded(C.turn, snap); C.turn = snap;
|
|
99
112
|
draw();
|
|
100
113
|
if (ended && host.turnEnded) host.turnEnded(t);
|
|
114
|
+
NPU.read(snap.busy);
|
|
115
|
+
if (ended && !(C.conv && C.conv.ask)) NPU.turnEnded(npCtx());
|
|
101
116
|
clearTimeout(C.timer);
|
|
102
117
|
const live = (C.th && (C.th.session || C.th.working)) || (t.sid && C.conv && C.conv.state !== 'ended');
|
|
103
118
|
C.timer = setTimeout(() => { if (!document.hidden) read(); else C.timer = setTimeout(read, IDLE_POLL); }, live ? POLL : IDLE_POLL);
|
|
@@ -429,7 +444,7 @@ export function mountChat(el, host) {
|
|
|
429
444
|
const html = `${also}<form class="hm-box${on ? ' ut-on' : ''}" id="hmForm">${dot}<textarea id="hmIn" rows="1" aria-label="A message for ${esc(R.role.name)}" placeholder="${esc(ph)}"></textarea>`
|
|
430
445
|
+ `<div class="hm-tb">${add}`
|
|
431
446
|
+ to + wait + scope + `<span class="sp"></span>${host.viewerClosed() ? '<span class="hm-key">Enter sends · Shift+Enter for a new line</span>' : ''}`
|
|
432
|
-
+ `${mic}<button type="submit" class="hm-send" aria-label="Send" title="Send">${SVG(UP, 18)}</button></div>`
|
|
447
|
+
+ `${NPU.undoHtml()}${mic}<button type="submit" class="hm-send" aria-label="Send" title="Send">${SVG(UP, 18)}</button></div>`
|
|
433
448
|
+ filesRow()
|
|
434
449
|
+ '</form>';
|
|
435
450
|
if (foot.__h !== html) {
|
|
@@ -511,6 +526,7 @@ export function mountChat(el, host) {
|
|
|
511
526
|
return;
|
|
512
527
|
}
|
|
513
528
|
const b = e.target.closest('button, [data-file], [data-att], [data-ctx]'); if (!b || !el.contains(b)) return;
|
|
529
|
+
if (NPU.click(b)) return;
|
|
514
530
|
// ✓ types /compact (and hides the ✦ until the pie moves); ✕ hides it until the session is 10 points fuller
|
|
515
531
|
if (b.dataset.hmCpt && C.cpt) { if (b.dataset.hmCpt === 'ok') host.compact(sid()); else host.dismissCompact(sid(), C.cpt.pct); C.cpt = null; sug.hidden = true; put(sug, ''); return; }
|
|
516
532
|
if (b.dataset.hmSug && C.sug) { const g = C.sug; if (b.dataset.hmSug === 'link') host.linkTo(C.t.sid, g.id); else { host.dismissLink(C.t.sid); drawSug(); } return; }
|
|
@@ -572,7 +588,7 @@ export function mountChat(el, host) {
|
|
|
572
588
|
el.addEventListener('submit', (e) => { e.preventDefault(); const ta = foot.querySelector('textarea'); const v = ta.value; if (GU.submit(v)) { ta.value = ''; grow(ta); return; }
|
|
573
589
|
// the box empties at once and the bubble shows (batch 24 item 2); a send that fails puts the words back, unless the PM typed again
|
|
574
590
|
if (!v.trim() && !C.files.length) return;
|
|
575
|
-
ta.value = ''; grow(ta);
|
|
591
|
+
ta.value = ''; grow(ta); NPU.sent();
|
|
576
592
|
send(v).then((ok) => { const box = foot.querySelector('textarea'); if (!ok && box && !box.value) { box.value = v; grow(box); } }); });
|
|
577
593
|
const shutAdd = (focus) => { if (!C.addOpen) return; C.addOpen = false; drawFoot({ role: role() }); if (focus) { const a = foot.querySelector('#hmAdd'); if (a) a.focus(); } };
|
|
578
594
|
document.addEventListener('click', (e) => { if (C.addOpen && e.target.isConnected && !e.target.closest('.hm-addw')) shutAdd(false); }, true);
|
|
@@ -580,6 +596,7 @@ export function mountChat(el, host) {
|
|
|
580
596
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !e.defaultPrevented && el.isConnected && !el.contains(e.target) && !document.querySelector('[data-esc-layer]') && GU.escape()) e.preventDefault(); });
|
|
581
597
|
el.addEventListener('keydown', (e) => {
|
|
582
598
|
if (e.key === 'Escape' && C.addOpen) { e.preventDefault(); e.stopPropagation(); shutAdd(true); return; }
|
|
599
|
+
if (e.key === 'Escape' && NPU.escape()) { e.preventDefault(); e.stopPropagation(); return; }
|
|
583
600
|
if (e.key === 'Escape' && GU.escape()) { e.preventDefault(); e.stopPropagation(); return; }
|
|
584
601
|
if (e.target.id !== 'hmIn') { if (e.key === 'Escape' && running() && !document.querySelector('[data-esc-layer]') && !(window.czViewer && window.czViewer.isOpen())) press('esc'); return; }
|
|
585
602
|
if (e.isComposing || e.keyCode === 229) return;
|
|
@@ -592,7 +609,7 @@ export function mountChat(el, host) {
|
|
|
592
609
|
el.addEventListener('input', (e) => {
|
|
593
610
|
const card = C.t && C.t.intro && C.t.intro.card;
|
|
594
611
|
if (card && card.input && e.target.closest('.hm-icard')) { card.input(e.target, e.target.closest('.hm-icard')); return; }
|
|
595
|
-
if (e.target.id === 'hmIn') { grow(e.target); C.slashAt = 0; drawSlash(); }
|
|
612
|
+
if (e.target.id === 'hmIn') { grow(e.target); C.slashAt = 0; drawSlash(); NPU.input(e.target.value); }
|
|
596
613
|
});
|
|
597
614
|
/* ── the mic ── dictation into the box only (HmMic). A desk shows the blue dot in the box; a phone shows the Speak to Claude sheet. */
|
|
598
615
|
const box = () => foot.querySelector('textarea');
|
|
@@ -671,7 +688,7 @@ export function mountChat(el, host) {
|
|
|
671
688
|
if (notesM) notesM.then((M) => setTimeout(() => M.syncNotes(), 0)); // the Notes open in the Viewer belonged to the conversation before
|
|
672
689
|
const same = C.t && t && C.t.key === t.key && C.t.sid === t.sid;
|
|
673
690
|
C.t = t;
|
|
674
|
-
if (!same) { C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.pend = []; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
|
|
691
|
+
if (!same) { NPU.open(); C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.pend = []; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
|
|
675
692
|
draw(); read();
|
|
676
693
|
},
|
|
677
694
|
refresh() { if (C.t) draw(); },
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* pm/next-prompt-ui.js — the chat's suggested next prompt and its Sharper card (batch 26 item 2, design B: canvas NpB, NpBw).
|
|
2
|
+
*
|
|
3
|
+
* After a reply ends: up to two cards above the box, the one Claude recommends lit (white, blue ring, blue ✦), the other grey. A tap puts its
|
|
4
|
+
* text in the box; nothing sends, and the PM sends with the box's own ↑. No ↵, edit or dismiss icon. No card when there is no clear next step.
|
|
5
|
+
* The cards hide when the PM types or a new reply starts; they have no timeout.
|
|
6
|
+
* While the PM types a rough prompt (3 or more words), after a 1.5 s pause: a spinner card while Claude sharpens it, then one lit Sharper card.
|
|
7
|
+
* Its sliders icon opens the style menu: Sharpen as Polish, Prompt engineer (the default) or Your own, and the Sharpen as I type switch.
|
|
8
|
+
* After a tap on the Sharper card, ↶ in the box puts back what the PM wrote (NpB 5).
|
|
9
|
+
* Server: GET /api/next-prompt/prefs, POST /api/next-prompt/next and /sharpen (lib/routes-next-prompt.js, through lib/ai-guard.js); the style
|
|
10
|
+
* and the switch are saved with POST /api/next-prompt/prefs (style, sharpen). Settings › Claude › Sharpen prompts holds the same.
|
|
11
|
+
*
|
|
12
|
+
* nextPromptUi({ el, host, box, redrawFoot }) → { turnEnded(ctx), read(busy), input(value), sent(), open(), undoHtml(), click(target) → bool }
|
|
13
|
+
* ctx: { key, asked, reply, initiative?, stage?, title? } box() → the textarea redrawFoot() draws the box again (for ↶) */
|
|
14
|
+
const STAR = '<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" class="np-star"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/></svg>';
|
|
15
|
+
const SLIDERS = 'M4 7h9M17 7h3M4 17h3M11 17h9M13 4v6M7 14v6', CHECK = 'M5 12l5 5L20 7', UNDO = 'M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3', SPIN = 'M12 3a9 9 0 1 1-9 9';
|
|
16
|
+
const ic = (d, w = 16) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
|
17
|
+
const STYLES = [['polish', 'Polish', 'Clearer and shorter, your meaning kept'], ['engineer', 'Prompt engineer', 'Builds the best prompt for the job'], ['own', 'Your own', 'Your wording rules, written once in Settings']];
|
|
18
|
+
const PAUSE = 1500;
|
|
19
|
+
export const roughText = (t) => { const s = String(t || '').trim(); return !s.startsWith('/') && s.length >= 12 && s.split(/\s+/).filter(Boolean).length >= 3; };
|
|
20
|
+
|
|
21
|
+
export function nextPromptUi({ el, host, box, redrawFoot }) {
|
|
22
|
+
const esc = host.esc;
|
|
23
|
+
const N = { mode: null, cards: [], sharp: null, menu: false, prefs: null, seq: 0, timer: null, ctx: null, orig: null, filled: null, sent: null };
|
|
24
|
+
const wrap = document.createElement('div');
|
|
25
|
+
wrap.className = 'np'; wrap.id = 'hmNp'; wrap.hidden = true;
|
|
26
|
+
const foot = el.querySelector('#hmFoot');
|
|
27
|
+
foot.parentNode.insertBefore(wrap, foot);
|
|
28
|
+
|
|
29
|
+
const loadPrefs = () => host.pmGet('/api/next-prompt/prefs').then((r) => { if (r && r.ok) { N.prefs = r; if (!r.sharpen && N.mode !== 'cards') clear(); else draw(); } }).catch(() => {});
|
|
30
|
+
loadPrefs();
|
|
31
|
+
document.addEventListener('pmos:prompt-prefs', loadPrefs); // Settings › Claude changed them
|
|
32
|
+
|
|
33
|
+
function clear() { N.seq++; clearTimeout(N.timer); N.mode = null; N.cards = []; N.sharp = null; N.menu = false; draw(); }
|
|
34
|
+
function draw() {
|
|
35
|
+
let html = '';
|
|
36
|
+
if (N.mode === 'cards' && N.cards.length) {
|
|
37
|
+
html = `<div class="np-list" role="group" aria-label="Suggested next prompts">${N.cards.map((c, i) => `<button type="button" class="np-card${c.rec ? ' rec' : ''}" data-np-use="${i}" aria-label="Use this prompt: ${esc(c.text)}"${c.rec ? ' title="Claude recommends this one"' : ''}>${STAR}<span class="np-t">${esc(c.text)}</span></button>`).join('')}</div>`;
|
|
38
|
+
} else if (N.mode === 'spin') {
|
|
39
|
+
html = `<div class="np-spin" role="status" aria-label="Claude is sharpening your prompt"><span class="np-sp">${ic(SPIN, 20)}</span><span class="np-sk"><i></i><i></i></span></div>`;
|
|
40
|
+
} else if (N.mode === 'sharp' && N.sharp) {
|
|
41
|
+
const P = N.prefs || {}, st = P.style || 'engineer';
|
|
42
|
+
const menu = N.menu ? `<div class="np-menu" role="menu" aria-label="Style of sharper prompts" data-esc-layer><p class="np-mh">Sharpen as</p>${STYLES.map(([k, l, d]) => `<button type="button" role="menuitemradio" aria-checked="${st === k}" class="np-mi" data-np-voice="${k}"><span class="np-ck">${st === k ? ic(CHECK) : ''}</span><span><b>${esc(l)}</b><em>${esc(k === 'own' && !P.rules ? 'Write your rules in Settings first' : d)}</em></span></button>`).join('')}`
|
|
43
|
+
+ `<span class="np-sep" role="separator"></span><button type="button" role="menuitemcheckbox" class="np-mi np-swr" aria-checked="${!!P.sharpen}" data-np-sw><b>Sharpen as I type</b><span class="np-sw${P.sharpen ? ' on' : ''}" aria-hidden="true"><i></i></span></button></div>` : '';
|
|
44
|
+
html = `<div class="np-card rec np-sh"><button type="button" class="np-use" data-np-sharp aria-label="Use the sharper prompt: ${esc(N.sharp)}" title="Claude sharpened your prompt">${STAR}<span class="np-t">${esc(N.sharp)}</span></button>`
|
|
45
|
+
+ `<button type="button" class="np-ib" data-np-menu aria-haspopup="menu" aria-expanded="${N.menu}" aria-label="Style of sharper prompts" title="Style of sharper prompts">${ic(SLIDERS)}</button>${menu}</div>`;
|
|
46
|
+
}
|
|
47
|
+
if (wrap.__h !== html) { wrap.__h = html; wrap.innerHTML = html; }
|
|
48
|
+
wrap.hidden = !html;
|
|
49
|
+
}
|
|
50
|
+
function fill(text, keepOrig) {
|
|
51
|
+
const ta = box(); if (!ta) return;
|
|
52
|
+
N.orig = keepOrig && ta.value.trim() && ta.value !== text ? ta.value : null;
|
|
53
|
+
N.filled = text; ta.value = text;
|
|
54
|
+
clear(); redrawFoot();
|
|
55
|
+
const t2 = box(); if (t2) { t2.dispatchEvent(new Event('np-grow')); t2.focus(); t2.setSelectionRange(t2.value.length, t2.value.length); }
|
|
56
|
+
}
|
|
57
|
+
async function sharpen(value) {
|
|
58
|
+
const my = ++N.seq;
|
|
59
|
+
N.mode = 'spin'; N.sharp = null; draw();
|
|
60
|
+
const c = N.ctx || {};
|
|
61
|
+
const r = await host.pmPost('/api/next-prompt/sharpen', { key: c.key || '', text: value, reply: c.reply || '', initiative: c.initiative || '', title: c.title || '' }).catch(() => null);
|
|
62
|
+
const ta = box();
|
|
63
|
+
if (my !== N.seq || !ta || ta.value !== value) return; // the PM typed on, or sent: this answer is for words no longer in the box
|
|
64
|
+
if (r && r.ok && r.text) { N.mode = 'sharp'; N.sharp = r.text; } else { N.mode = null; }
|
|
65
|
+
draw();
|
|
66
|
+
}
|
|
67
|
+
async function save(key, value) {
|
|
68
|
+
const r = await host.pmPost('/api/next-prompt/prefs', { key, value }).catch(() => null);
|
|
69
|
+
if (!(r && r.ok)) { host.toast((r && r.error) || 'Not saved.'); return; }
|
|
70
|
+
await loadPrefs();
|
|
71
|
+
document.dispatchEvent(new CustomEvent('pmos:prompt-prefs-saved', { detail: { key, value } }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
/** A reply ended: ask for the next prompts, unless the PM is already typing. */
|
|
76
|
+
async turnEnded(ctx) {
|
|
77
|
+
N.ctx = ctx;
|
|
78
|
+
const ta = box();
|
|
79
|
+
if (!N.prefs || !N.prefs.next || !ctx || !ctx.reply || (ta && ta.value.trim())) return;
|
|
80
|
+
const my = ++N.seq; clearTimeout(N.timer);
|
|
81
|
+
const r = await host.pmPost('/api/next-prompt/next', ctx).catch(() => null);
|
|
82
|
+
const t2 = box();
|
|
83
|
+
if (my !== N.seq || (t2 && t2.value.trim())) return;
|
|
84
|
+
N.cards = r && r.ok && Array.isArray(r.cards) ? r.cards.slice(0, 2) : [];
|
|
85
|
+
N.mode = N.cards.length ? 'cards' : null; N.menu = false; draw();
|
|
86
|
+
},
|
|
87
|
+
/** Each read of the conversation: a new turn at work hides the cards. */
|
|
88
|
+
read(busy, ctx) { if (ctx) N.ctx = { ...(N.ctx || {}), ...ctx }; if (busy && N.mode) clear(); },
|
|
89
|
+
input(value) {
|
|
90
|
+
if (N.orig != null && !value.trim()) { N.orig = null; redrawFoot(); }
|
|
91
|
+
if (N.mode === 'cards' || N.mode === 'sharp' || N.mode === 'spin') { N.seq++; N.mode = null; N.menu = false; draw(); }
|
|
92
|
+
clearTimeout(N.timer);
|
|
93
|
+
if (!N.prefs || !N.prefs.sharpen || value === N.filled || !roughText(value)) return;
|
|
94
|
+
N.timer = setTimeout(() => { const ta = box(); if (ta && ta.value === value) sharpen(value); }, PAUSE);
|
|
95
|
+
},
|
|
96
|
+
sent() { N.orig = null; N.filled = null; clear(); },
|
|
97
|
+
open() { N.ctx = null; N.orig = null; N.filled = null; clear(); },
|
|
98
|
+
undoHtml: () => (N.orig != null ? `<button type="button" class="hm-tool np-undo" data-np-undo aria-label="Back to what I wrote" title="Back to what I wrote">${ic(UNDO)}</button>` : ''),
|
|
99
|
+
/** A click inside the chat: true when it was one of ours. */
|
|
100
|
+
click(b) {
|
|
101
|
+
if (!b) return false;
|
|
102
|
+
if ('npUse' in b.dataset) { const c = N.cards[Number(b.dataset.npUse)]; if (c) fill(c.text, false); return true; }
|
|
103
|
+
if ('npSharp' in b.dataset) { if (N.sharp) fill(N.sharp, true); return true; }
|
|
104
|
+
if ('npUndo' in b.dataset) { const ta = box(); if (ta && N.orig != null) { const o = N.orig; N.orig = null; N.filled = null; ta.value = o; redrawFoot(); const t2 = box(); if (t2) { t2.dispatchEvent(new Event('np-grow')); t2.focus(); } } return true; }
|
|
105
|
+
if ('npMenu' in b.dataset) { N.menu = !N.menu; draw(); if (N.menu) { const f = wrap.querySelector('.np-mi'); if (f) f.focus(); } return true; }
|
|
106
|
+
if (b.dataset.npVoice) {
|
|
107
|
+
const k = b.dataset.npVoice;
|
|
108
|
+
if (k === 'own' && !(N.prefs && N.prefs.rules)) { N.menu = false; draw(); import('./cozy-settings.js').then((M) => M.openSettings('claude')).catch(() => {}); return true; }
|
|
109
|
+
N.menu = false; save('style', k).then(() => { const ta = box(); if (ta && roughText(ta.value)) sharpen(ta.value); });
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
if ('npSw' in b.dataset) { const on = !(N.prefs && N.prefs.sharpen); save('sharpen', on ? 'on' : 'off').then(() => { if (!on) clear(); else draw(); }); return true; }
|
|
113
|
+
return false;
|
|
114
|
+
},
|
|
115
|
+
escape() { if (!N.menu) return false; N.menu = false; draw(); const m = wrap.querySelector('[data-np-menu]'); if (m) m.focus(); return true; },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "The chat's suggested next prompt and Sharper card (batch 26 item 2): the engine, the route with a recorded answer in place of Claude, the shared guard's replay, citation check and kill switch, and the Settings keys.",
|
|
3
|
+
"modules": ["next-prompt.test.js"],
|
|
4
|
+
"seconds": { "next-prompt.test.js": 2 }
|
|
5
|
+
}
|